Coverage for astrocyte/cli.py: 95%
151 statements
« prev ^ index » next coverage.py v7.15.0, created at 2026-10-29 22:10 +0000
« prev ^ index » next coverage.py v7.15.0, created at 2026-10-29 22:10 +0000
1"""Astrocyte CLI — local setup and operator tooling.
3Commands:
4 astrocyte setup [--claude ...] Create a local memory store and wire it into coding agents.
5 astrocyte doctor [--fix] Check the local install end to end; repair what it can.
6 astrocyte uninstall [--claude ...] Remove Astrocyte from agent harnesses (keeps memories).
7 astrocyte memory [search|forget|banks] See, search and remove what agents remember (this project).
8 astrocyte mip lint <path> Validate a mip.yaml, printing errors and warnings.
9 astrocyte mip explain <path> ... Show which rule fires for a hypothetical input.
11The CLI is intentionally minimal: argparse-based, no external dependencies, and
12reuses the same loader / router code that runtime uses. It is the recommended
13way to verify pipeline overrides (chunker, dedup, rerank, reflect) before
14deploying a rule change.
15"""
17from __future__ import annotations
19import argparse
20import dataclasses
21import os
22import sys
23import warnings
24from pathlib import Path
25from typing import TYPE_CHECKING, Sequence
27from astrocyte.errors import ConfigError
29# The `mip` commands import routing where they run: every agent hook enters
30# through this module (`python -m astrocyte.cli hook …`) and must not pay for it.
31if TYPE_CHECKING:
32 from astrocyte.mip.schema import ForgetSpec, PipelineSpec
33 from astrocyte.types import MetadataValue
35# ---------------------------------------------------------------------------
36# astrocyte mip lint
37# ---------------------------------------------------------------------------
40def _cmd_mip_lint(args: argparse.Namespace) -> int:
41 """Load and validate a mip.yaml. Returns 0 if clean, 1 on any error."""
42 from astrocyte.mip import load_mip_config
44 path = Path(args.path)
45 print(f"Linting MIP config: {path}")
47 captured: list[warnings.WarningMessage] = []
48 try:
49 with warnings.catch_warnings(record=True) as caught:
50 warnings.simplefilter("always")
51 config = load_mip_config(path)
52 captured = list(caught)
53 except ConfigError as exc:
54 print(f" error: {exc}", file=sys.stderr)
55 return 1
56 except FileNotFoundError as exc:
57 print(f" error: {exc}", file=sys.stderr)
58 return 1
60 rule_count = len(config.rules or [])
61 bank_count = len(config.banks or [])
62 print(f" ok: {rule_count} rule(s), {bank_count} bank(s)")
64 if captured:
65 print(f" {len(captured)} warning(s):")
66 for w in captured:
67 print(f" - {w.message}")
68 return 0
71# ---------------------------------------------------------------------------
72# astrocyte mip explain
73# ---------------------------------------------------------------------------
76def _parse_kv(items: Sequence[str] | None) -> dict[str, MetadataValue]:
77 """Parse a list of ``key=value`` strings into a metadata dict.
79 Numeric and boolean values are coerced; everything else stays as a string.
80 """
81 out: dict[str, MetadataValue] = {}
82 if not items:
83 return out
84 for raw in items:
85 if "=" not in raw:
86 raise SystemExit(f"Invalid --metadata entry (expected KEY=VALUE): {raw!r}")
87 key, _, val = raw.partition("=")
88 key = key.strip()
89 val = val.strip()
90 coerced: MetadataValue
91 if val.lower() in ("true", "false"):
92 coerced = val.lower() == "true"
93 else:
94 try:
95 coerced = int(val)
96 except ValueError:
97 try:
98 coerced = float(val)
99 except ValueError:
100 coerced = val
101 out[key] = coerced
102 return out
105def _format_pipeline(pipeline: PipelineSpec | None) -> list[str]:
106 """Render a PipelineSpec as a list of indented printable lines."""
107 if pipeline is None:
108 return []
109 lines: list[str] = [" pipeline:"]
110 if pipeline.version is not None:
111 lines.append(f" version: {pipeline.version}")
112 for field in ("chunker", "dedup", "rerank", "reflect"):
113 spec = getattr(pipeline, field)
114 if spec is None:
115 continue
116 # Show only fields that are set
117 set_fields = {
118 f.name: getattr(spec, f.name) for f in dataclasses.fields(spec) if getattr(spec, f.name) is not None
119 }
120 if set_fields:
121 lines.append(f" {field}: {set_fields}")
122 return lines
125def _format_forget(forget: ForgetSpec | None) -> list[str]:
126 """Render a ForgetSpec as a list of indented printable lines."""
127 if forget is None:
128 return []
129 lines: list[str] = [" forget:"]
130 set_fields = {
131 f.name: getattr(forget, f.name) for f in dataclasses.fields(forget) if getattr(forget, f.name) is not None
132 }
133 for k, v in set_fields.items():
134 lines.append(f" {k}: {v}")
135 return lines
138def _cmd_mip_explain(args: argparse.Namespace) -> int:
139 """Show which rule(s) match a hypothetical input and the resulting decision."""
140 from astrocyte.mip import MipRouter, load_mip_config
141 from astrocyte.mip.rule_engine import RuleEngineInput, evaluate_rules
143 path = Path(args.path)
144 try:
145 config = load_mip_config(path)
146 except (ConfigError, FileNotFoundError) as exc:
147 print(f"error: {exc}", file=sys.stderr)
148 return 1
150 metadata = _parse_kv(args.metadata)
151 rule_input = RuleEngineInput(
152 content=args.content or "",
153 content_type=args.content_type,
154 metadata=metadata or None,
155 tags=list(args.tag) if args.tag else None,
156 pii_detected=args.pii_detected,
157 source=args.source,
158 )
160 print("Input:")
161 print(f" content_type: {rule_input.content_type!r}")
162 print(f" metadata: {rule_input.metadata}")
163 print(f" tags: {rule_input.tags}")
164 print(f" pii_detected: {rule_input.pii_detected}")
165 print(f" source: {rule_input.source!r}")
167 # 1. Show every matching rule (mechanical eval, before escalation policy)
168 sorted_rules = sorted(config.rules or [], key=lambda r: r.priority)
169 matches = evaluate_rules(sorted_rules, rule_input)
170 print(f"\nMatched rules ({len(matches)}):")
171 if not matches:
172 print(" (none)")
173 for m in matches:
174 print(f" - {m.rule.name} (priority={m.rule.priority}, override={m.rule.override}, confidence={m.confidence})")
176 # 2. Show what the synchronous router would actually return
177 router = MipRouter(config)
178 decision = router.route_sync(rule_input)
179 print("\nDecision (sync routing):")
180 if decision is None:
181 print(" → escalation required (would call intent layer at runtime)")
182 return 0
184 print(f" resolved_by: {decision.resolved_by}")
185 print(f" rule_name: {decision.rule_name}")
186 print(f" bank_id: {decision.bank_id}")
187 print(f" tags: {decision.tags}")
188 print(f" retain_policy: {decision.retain_policy}")
189 print(f" confidence: {decision.confidence}")
190 for line in _format_pipeline(decision.pipeline):
191 print(line)
192 for line in _format_forget(decision.forget):
193 print(line)
194 return 0
197# ---------------------------------------------------------------------------
198# argparse plumbing
199# ---------------------------------------------------------------------------
202def _build_parser() -> argparse.ArgumentParser:
203 parser = argparse.ArgumentParser(prog="astrocyte", description="Astrocyte operator CLI")
204 sub = parser.add_subparsers(dest="command", required=True)
206 mip = sub.add_parser("mip", help="MIP (Memory Intent Protocol) tools")
207 mip_sub = mip.add_subparsers(dest="mip_command", required=True)
209 lint = mip_sub.add_parser("lint", help="Validate a mip.yaml file")
210 lint.add_argument("path", help="Path to mip.yaml")
211 lint.set_defaults(func=_cmd_mip_lint)
213 explain = mip_sub.add_parser(
214 "explain",
215 help="Show which rule fires for a hypothetical retain input",
216 )
217 explain.add_argument("path", help="Path to mip.yaml")
218 explain.add_argument("--content", default="", help="Inbound content (text body)")
219 explain.add_argument("--content-type", default=None, help="Content type, e.g. text|conversation|document")
220 explain.add_argument("--metadata", action="append", default=[], help="Metadata KEY=VALUE (repeatable)")
221 explain.add_argument("--tag", action="append", default=[], help="Tag (repeatable)")
222 explain.add_argument("--source", default=None, help="Source identifier")
223 explain.add_argument("--pii-detected", action="store_true", help="Mark PII as already detected")
224 explain.set_defaults(func=_cmd_mip_explain)
226 from astrocyte.harness.commands import register as register_harness_commands
228 register_harness_commands(sub)
229 return parser
232def _utf8_stdio(windows: bool = os.name == "nt") -> None:
233 """Speak UTF-8 on Windows. Output piped there (an agent running the CLI,
234 CI, a redirect) is encoded with the ANSI code page, cp1252, which has no
235 ✓ or ─: every status line would raise UnicodeEncodeError. And hook
236 payloads arrive on stdin as UTF-8 JSON."""
237 if not windows:
238 return
239 for stream in (sys.stdin, sys.stdout, sys.stderr):
240 reconfigure = getattr(stream, "reconfigure", None)
241 if reconfigure is not None:
242 reconfigure(encoding="utf-8", errors="replace")
245def main(argv: Sequence[str] | None = None) -> int:
246 _utf8_stdio()
247 parser = _build_parser()
248 args = parser.parse_args(argv)
249 return int(args.func(args) or 0)
252if __name__ == "__main__":
253 raise SystemExit(main())