kctl-agent 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of kctl-agent might be problematic. Click here for more details.

Files changed (70) hide show
  1. kctl_agent-0.2.0/.gitignore +39 -0
  2. kctl_agent-0.2.0/PKG-INFO +11 -0
  3. kctl_agent-0.2.0/README.md +121 -0
  4. kctl_agent-0.2.0/pyproject.toml +40 -0
  5. kctl_agent-0.2.0/src/kctl_agent/__init__.py +5 -0
  6. kctl_agent-0.2.0/src/kctl_agent/__main__.py +4 -0
  7. kctl_agent-0.2.0/src/kctl_agent/cli.py +95 -0
  8. kctl_agent-0.2.0/src/kctl_agent/commands/__init__.py +0 -0
  9. kctl_agent-0.2.0/src/kctl_agent/commands/_shared.py +122 -0
  10. kctl_agent-0.2.0/src/kctl_agent/commands/doctor_cmd.py +146 -0
  11. kctl_agent-0.2.0/src/kctl_agent/commands/eval_cmd.py +116 -0
  12. kctl_agent-0.2.0/src/kctl_agent/commands/knowledge_cmd.py +93 -0
  13. kctl_agent-0.2.0/src/kctl_agent/commands/lint_cmd.py +77 -0
  14. kctl_agent-0.2.0/src/kctl_agent/commands/manifest_cmd.py +226 -0
  15. kctl_agent-0.2.0/src/kctl_agent/commands/reconcile_cmd.py +117 -0
  16. kctl_agent-0.2.0/src/kctl_agent/commands/vendor_cmd.py +79 -0
  17. kctl_agent-0.2.0/src/kctl_agent/core/__init__.py +0 -0
  18. kctl_agent-0.2.0/src/kctl_agent/core/callbacks.py +20 -0
  19. kctl_agent-0.2.0/src/kctl_agent/core/config.py +19 -0
  20. kctl_agent-0.2.0/src/kctl_agent/core/exceptions.py +19 -0
  21. kctl_agent-0.2.0/src/kctl_agent/evals.py +135 -0
  22. kctl_agent-0.2.0/src/kctl_agent/health.py +196 -0
  23. kctl_agent-0.2.0/src/kctl_agent/hoist.py +55 -0
  24. kctl_agent-0.2.0/src/kctl_agent/hooks.py +72 -0
  25. kctl_agent-0.2.0/src/kctl_agent/importer.py +341 -0
  26. kctl_agent-0.2.0/src/kctl_agent/knowledge.py +90 -0
  27. kctl_agent-0.2.0/src/kctl_agent/lint.py +47 -0
  28. kctl_agent-0.2.0/src/kctl_agent/lockfile.py +122 -0
  29. kctl_agent-0.2.0/src/kctl_agent/manifest.py +271 -0
  30. kctl_agent-0.2.0/src/kctl_agent/model.py +258 -0
  31. kctl_agent-0.2.0/src/kctl_agent/parser.py +117 -0
  32. kctl_agent-0.2.0/src/kctl_agent/reconcile.py +189 -0
  33. kctl_agent-0.2.0/src/kctl_agent/registry.py +127 -0
  34. kctl_agent-0.2.0/src/kctl_agent/renderers/__init__.py +76 -0
  35. kctl_agent-0.2.0/src/kctl_agent/renderers/copytree.py +113 -0
  36. kctl_agent-0.2.0/src/kctl_agent/renderers/jsonmerge.py +277 -0
  37. kctl_agent-0.2.0/src/kctl_agent/renderers/plugin.py +129 -0
  38. kctl_agent-0.2.0/src/kctl_agent/renderers/symlink.py +180 -0
  39. kctl_agent-0.2.0/src/kctl_agent/rules/__init__.py +12 -0
  40. kctl_agent-0.2.0/src/kctl_agent/rules/_shared.py +88 -0
  41. kctl_agent-0.2.0/src/kctl_agent/rules/commands.py +126 -0
  42. kctl_agent-0.2.0/src/kctl_agent/rules/evals.py +85 -0
  43. kctl_agent-0.2.0/src/kctl_agent/rules/external.py +158 -0
  44. kctl_agent-0.2.0/src/kctl_agent/rules/hooks.py +141 -0
  45. kctl_agent-0.2.0/src/kctl_agent/rules/knowledge.py +113 -0
  46. kctl_agent-0.2.0/src/kctl_agent/rules/mcp.py +162 -0
  47. kctl_agent-0.2.0/src/kctl_agent/rules/registry.py +58 -0
  48. kctl_agent-0.2.0/src/kctl_agent/rules/rules.py +110 -0
  49. kctl_agent-0.2.0/src/kctl_agent/rules/subagents.py +176 -0
  50. kctl_agent-0.2.0/src/kctl_agent/selection.py +37 -0
  51. kctl_agent-0.2.0/src/kctl_agent/state.py +142 -0
  52. kctl_agent-0.2.0/src/kctl_agent/vendor.py +139 -0
  53. kctl_agent-0.2.0/tests/conftest.py +103 -0
  54. kctl_agent-0.2.0/tests/test_cli.py +194 -0
  55. kctl_agent-0.2.0/tests/test_evals.py +102 -0
  56. kctl_agent-0.2.0/tests/test_health.py +174 -0
  57. kctl_agent-0.2.0/tests/test_hook_scripts.py +174 -0
  58. kctl_agent-0.2.0/tests/test_hooks_and_knowledge.py +82 -0
  59. kctl_agent-0.2.0/tests/test_importer.py +231 -0
  60. kctl_agent-0.2.0/tests/test_manifest.py +85 -0
  61. kctl_agent-0.2.0/tests/test_reconcile.py +133 -0
  62. kctl_agent-0.2.0/tests/test_registry.py +75 -0
  63. kctl_agent-0.2.0/tests/test_renderers.py +257 -0
  64. kctl_agent-0.2.0/tests/test_rules.py +190 -0
  65. kctl_agent-0.2.0/tests/test_rules_external.py +126 -0
  66. kctl_agent-0.2.0/tests/test_rules_knowledge.py +66 -0
  67. kctl_agent-0.2.0/tests/test_selection.py +52 -0
  68. kctl_agent-0.2.0/tests/test_standard.py +47 -0
  69. kctl_agent-0.2.0/tests/test_state.py +127 -0
  70. kctl_agent-0.2.0/tests/test_vendor.py +142 -0
@@ -0,0 +1,39 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ *.egg
6
+ dist/
7
+ build/
8
+ .eggs/
9
+
10
+ # Virtual environments
11
+ .venv/
12
+ venv/
13
+
14
+ # IDE
15
+ .idea/
16
+ .vscode/
17
+ *.swp
18
+ *.swo
19
+
20
+ # Testing
21
+ .pytest_cache/
22
+ .coverage
23
+ htmlcov/
24
+ .mypy_cache/
25
+ .ruff_cache/
26
+
27
+ # OS
28
+ .DS_Store
29
+ Thumbs.db
30
+
31
+ # Environment
32
+ .env
33
+ .env.local
34
+
35
+ # Agent memory (claude-mem regenerates AGENTS.md locally; not a committed guide)
36
+ AGENTS.md
37
+
38
+ # kctl-agent container render output (derived from agents.toml)
39
+ out/
@@ -0,0 +1,11 @@
1
+ Metadata-Version: 2.5
2
+ Name: kctl-agent
3
+ Version: 0.2.0
4
+ Summary: Manifest, registry and reconciler for every agent asset in the Kodemeio workspace
5
+ Requires-Python: >=3.12
6
+ Requires-Dist: kctl-lib>=0.14.0
7
+ Requires-Dist: rich>=13.0
8
+ Requires-Dist: tomli-w>=1.0
9
+ Requires-Dist: typer>=0.9.0
10
+ Provides-Extra: eval
11
+ Requires-Dist: anthropic>=0.40; extra == 'eval'
@@ -0,0 +1,121 @@
1
+ # kctl-agent
2
+
3
+ The manifest, registry and reconciler for **every agent asset** in this workspace —
4
+ skills, slash commands, subagents, hooks, MCP servers, rules and knowledge — first-party
5
+ and third-party alike.
6
+
7
+ `kctl-agent` succeeds `kctl-claude`, which promised to "manage local and remote Claude Code
8
+ environments" but delegated `env`, `sync`, `setup`, `backup` and `verify` to five shell
9
+ scripts in a repository that no longer exists. Those ten call sites are replaced here with
10
+ real implementations, and the scope widens from one runtime to every runtime this workspace
11
+ targets.
12
+
13
+ See the design: [`docs/superpowers/specs/2026-09-05-agent-platform-design.md`](../../docs/superpowers/specs/2026-09-05-agent-platform-design.md).
14
+
15
+ ## The idea
16
+
17
+ Your machine's agent state should be **derived**, not hand-maintained. `agents.toml`
18
+ declares intent; `kctl-agent apply` makes `~/.claude`, `~/.agents` and a container render
19
+ tree match it. A new laptop, a CI runner and a chat agent on a server all reconcile from
20
+ the same file.
21
+
22
+ First-party assets are **discovered** from the declared roots, never enumerated — a
23
+ directory walk already knows the tree, and restating it would create a second source of
24
+ truth that drifts. Only external assets are declared, because the tree cannot see them.
25
+
26
+ ## Install / run
27
+
28
+ ```bash
29
+ uv sync --all-extras --all-packages
30
+ uv run kctl-agent --help
31
+ ```
32
+
33
+ Like `kctl-conform` and `kctl-skill`, this is a `kind = "meta"` developer tool: no service
34
+ to talk to, no kctl config profile. Its `-p/--profile` names a **manifest** profile — which
35
+ assets a given environment gets.
36
+
37
+ ## Commands
38
+
39
+ | Command | Purpose |
40
+ |---------|---------|
41
+ | `kctl-agent import [--write]` | Snapshot this machine into `agents.toml` + `agents.lock`. Dry-run by default. |
42
+ | `kctl-agent registry [--kind K]` | Every asset this repo governs: first-party, vendored, external. |
43
+ | `kctl-agent lock [--write]` | Resolve declared external assets and refresh `agents.lock`. |
44
+ | `kctl-agent -p P plan [--prune]` | What `apply` would change. Always read-only. |
45
+ | `kctl-agent -p P apply [--explain\|--adopt\|--force]` | Reconcile every target this profile names. |
46
+ | `kctl-agent -p P diff` | Report drift. Exits 1 when anything differs. |
47
+ | `kctl-agent doctor [--skip-network]` | Asset counts, MCP reachability, lock coverage, drift. |
48
+ | `kctl-agent doctor ai-summary` | One-call JSON health summary for agents. |
49
+ | `kctl-agent knowledge list\|search\|show` | The agent-facing knowledge layer. |
50
+
51
+ ## Getting started
52
+
53
+ ```bash
54
+ kctl-agent import # see what is on this machine — writes nothing
55
+ kctl-agent import --write # create agents.toml and agents.lock
56
+ kctl-agent registry # what the repo now governs
57
+ kctl-agent -p laptop plan # what reconciling would change
58
+ kctl-agent -p laptop apply # make it so
59
+ ```
60
+
61
+ ## Safety
62
+
63
+ Reconciliation is a **three-way merge**, never an overwrite. `~/.claude/.kctl-agent-state.json`
64
+ records the hash of everything `kctl-agent` last wrote, and every apply compares
65
+ *last-written · current-on-disk · desired*:
66
+
67
+ | Situation | Outcome |
68
+ |---|---|
69
+ | absent | `create` |
70
+ | matches the manifest | `ok` |
71
+ | matches what we last wrote, manifest changed | `update` |
72
+ | differs from what we last wrote | **`conflict` — apply stops** |
73
+ | present but never written by us | **`conflict` — apply stops** |
74
+ | adopted | `unmanaged` — the machine's version is kept |
75
+
76
+ Two escape hatches, with different meanings:
77
+
78
+ - `--adopt` keeps the machine's version and stops flagging it. Adoptions are recorded
79
+ distinctly from managed writes, so a later apply will not quietly overwrite them.
80
+ Adoption does **not** copy values back into the manifest; `import` does that, with review.
81
+ - `--force` clears adoptions and overwrites. This is the only path on which `kctl-agent`
82
+ destroys local data, and it still takes the pre-apply backup first.
83
+
84
+ Other invariants:
85
+
86
+ - Nothing unmanaged is ever touched. A symlink resolving outside the repo is reported,
87
+ never removed.
88
+ - `plan` and `--explain` never write.
89
+ - The first apply tars every file it will touch into `~/.claude/backups/`.
90
+ - `--prune` removes only links `kctl-agent` created.
91
+
92
+ ## Targets
93
+
94
+ | Target | Renderer | Notes |
95
+ |---|---|---|
96
+ | `claude-code` | symlink + json-merge + plugin | `~/.claude/{skills,commands,agents}`, `settings.json`, `~/.claude.json#mcpServers` |
97
+ | `codex` | symlink | `~/.agents/skills` — the cross-runtime convention |
98
+ | `container` | copy | a real tree; Docker `COPY` cannot follow symlinks out of build context |
99
+
100
+ ## Hooks describe themselves
101
+
102
+ A hook is a script that carries its own wiring, so the logic and the matcher cannot drift
103
+ apart:
104
+
105
+ ```bash
106
+ #!/usr/bin/env bash
107
+ # kctl-agent:event = PreToolUse
108
+ # kctl-agent:matcher = Edit|Write
109
+ # kctl-agent:description = Ask before editing a sensitive file
110
+ ```
111
+
112
+ `kctl-agent` merges these into `settings.json` as entries it owns, tagged so that foreign
113
+ entries keep their position and are never dropped.
114
+
115
+ ## Testing
116
+
117
+ ```bash
118
+ uv run pytest packages/kctl-agent/tests/ -v
119
+ ```
120
+
121
+ Every test runs against a `tmp_path` home. Nothing touches the real `~/.claude`.
@@ -0,0 +1,40 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "kctl-agent"
7
+ version = "0.2.0"
8
+ description = "Manifest, registry and reconciler for every agent asset in the Kodemeio workspace"
9
+ requires-python = ">=3.12"
10
+ dependencies = [
11
+ "kctl-lib>=0.14.0",
12
+ "typer>=0.9.0",
13
+ "rich>=13.0",
14
+ "tomli-w>=1.0",
15
+ ]
16
+
17
+ [project.optional-dependencies]
18
+ eval = ["anthropic>=0.40"]
19
+
20
+ [project.scripts]
21
+ kctl-agent = "kctl_agent.cli:_run"
22
+
23
+ [tool.uv.sources]
24
+ kctl-lib = { workspace = true }
25
+
26
+ [tool.hatch.build.targets.wheel]
27
+ packages = ["src/kctl_agent"]
28
+
29
+ [tool.kctl-conform]
30
+ kind = "meta"
31
+ # Rules genuinely N/A for a local reconciler with no external service:
32
+ # AFFORD-001 = --profile here selects a MANIFEST profile, not a kctl config
33
+ # profile, so the shared global-option check does not apply.
34
+ # AFFORD-004 = `doctor` reports local state; there is no service to probe.
35
+ # LAYOUT-006 = no core/client.py; the filesystem is the substrate.
36
+ exempt = [
37
+ "AFFORD-001",
38
+ "AFFORD-004",
39
+ "LAYOUT-006",
40
+ ]
@@ -0,0 +1,5 @@
1
+ """kctl-agent — the manifest, registry and reconciler for agent assets."""
2
+
3
+ __version__ = "0.2.0"
4
+
5
+ __all__ = ["__version__"]
@@ -0,0 +1,4 @@
1
+ from .cli import app
2
+
3
+ if __name__ == "__main__":
4
+ app()
@@ -0,0 +1,95 @@
1
+ """kctl-agent — the manifest, registry and reconciler for every agent asset."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+ from typing import Annotated
8
+
9
+ import typer
10
+ from kctl_lib import cli_entrypoint, register_introspection_commands
11
+
12
+ from . import __version__
13
+ from .commands import (
14
+ doctor_cmd,
15
+ eval_cmd,
16
+ knowledge_cmd,
17
+ lint_cmd,
18
+ manifest_cmd,
19
+ reconcile_cmd,
20
+ vendor_cmd,
21
+ )
22
+ from .core.callbacks import AppContext
23
+
24
+ PROFILE_ENV = "KCTL_AGENT_PROFILE"
25
+ HOME_ENV = "KCTL_AGENT_HOME"
26
+
27
+ app = typer.Typer(
28
+ name="kctl-agent",
29
+ help="Declare, govern and reconcile every agent asset in this workspace.",
30
+ no_args_is_help=True,
31
+ )
32
+
33
+
34
+ def _version_callback(value: bool) -> None:
35
+ if value:
36
+ typer.echo(f"kctl-agent {__version__}")
37
+ raise typer.Exit()
38
+
39
+
40
+ @app.callback()
41
+ def main(
42
+ ctx: typer.Context,
43
+ profile: Annotated[
44
+ str,
45
+ typer.Option("--profile", "-p", help="Manifest profile: which assets this environment gets."),
46
+ ] = "",
47
+ json_output: Annotated[bool, typer.Option("--json", help="Emit JSON.")] = False,
48
+ quiet: Annotated[bool, typer.Option("--quiet", "-q", help="Suppress non-essential output.")] = False,
49
+ output_format: Annotated[str, typer.Option("--format", "-f", help="pretty | json | csv | yaml")] = "pretty",
50
+ no_header: Annotated[bool, typer.Option("--no-header", help="Omit table headers.")] = False,
51
+ home: Annotated[str, typer.Option("--home", help="Override the home directory (testing, containers).")] = "",
52
+ version: Annotated[
53
+ bool,
54
+ typer.Option("--version", "-V", callback=_version_callback, is_eager=True, help="Show version."),
55
+ ] = False,
56
+ ) -> None:
57
+ """Root callback: build the app context every command reads."""
58
+ home_raw = home or os.environ.get(HOME_ENV, "")
59
+ ctx.obj = AppContext(
60
+ json_mode=json_output,
61
+ quiet=quiet,
62
+ format="json" if json_output else output_format,
63
+ no_header=no_header,
64
+ profile=profile or os.environ.get(PROFILE_ENV, ""),
65
+ home=Path(home_raw).expanduser() if home_raw else Path.home(),
66
+ )
67
+
68
+
69
+ app.add_typer(manifest_cmd.app, name="manifest")
70
+ app.add_typer(reconcile_cmd.app, name="reconcile")
71
+ app.add_typer(knowledge_cmd.app, name="knowledge")
72
+ app.add_typer(doctor_cmd.app, name="doctor")
73
+ app.add_typer(lint_cmd.app, name="lint")
74
+ app.add_typer(vendor_cmd.app, name="vendor")
75
+ app.add_typer(eval_cmd.app, name="eval")
76
+
77
+ # The three verbs used constantly are also top-level, because
78
+ # `kctl-agent plan` reads better than `kctl-agent reconcile plan`.
79
+ app.command("plan")(reconcile_cmd.plan_cmd)
80
+ app.command("apply")(reconcile_cmd.apply_cmd)
81
+ app.command("diff")(reconcile_cmd.diff_cmd)
82
+ app.command("import")(manifest_cmd.import_cmd)
83
+ app.command("lock")(manifest_cmd.lock_cmd)
84
+ app.command("registry")(manifest_cmd.registry_cmd)
85
+ app.command("hoist")(manifest_cmd.hoist_cmd)
86
+
87
+ # register_introspection_commands and cli_entrypoint must remain the last two
88
+ # statements: introspection needs every command registered first, and
89
+ # cli_entrypoint wraps the fully-assembled app for error handling.
90
+ register_introspection_commands(app)
91
+ app = cli_entrypoint(app)
92
+
93
+
94
+ def _run() -> None:
95
+ app()
File without changes
@@ -0,0 +1,122 @@
1
+ """Helpers shared by the command groups: context access and consistent output."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+
8
+ import typer
9
+
10
+ from ..core.callbacks import AppContext
11
+ from ..core.config import find_repo_root
12
+ from ..manifest import load_manifest
13
+ from ..model import ACTION_CONFLICT, ACTION_OK, Manifest, Plan
14
+ from ..reconcile import ReconcileRequest, resolve_profile
15
+
16
+ PROFILE_ENV = "KCTL_AGENT_PROFILE"
17
+
18
+ #: Console glyphs per action, so a plan reads at a glance.
19
+ _GLYPH = {
20
+ "create": "+",
21
+ "update": "~",
22
+ "remove": "-",
23
+ "ok": "=",
24
+ "conflict": "!",
25
+ "unmanaged": "?",
26
+ }
27
+
28
+
29
+ def context(ctx: typer.Context) -> AppContext:
30
+ actx: AppContext = ctx.obj
31
+ if actx.root is None:
32
+ actx.root = find_repo_root()
33
+ return actx
34
+
35
+
36
+ def manifest_of(actx: AppContext) -> Manifest:
37
+ assert actx.root is not None
38
+ return load_manifest(actx.root)
39
+
40
+
41
+ def request(actx: AppContext, manifest: Manifest, prune: bool = False) -> ReconcileRequest:
42
+ assert actx.root is not None
43
+ name = actx.profile or os.environ.get(PROFILE_ENV, "")
44
+ return ReconcileRequest(
45
+ root=actx.root,
46
+ home=actx.home,
47
+ manifest=manifest,
48
+ profile=resolve_profile(manifest, name),
49
+ prune=prune,
50
+ )
51
+
52
+
53
+ def note(actx: AppContext, message: str, level: str = "info") -> None:
54
+ """Human-readable line, suppressed under --json.
55
+
56
+ kctl_lib.Output.info/success/warn print to stdout regardless of json_mode, so
57
+ an unguarded call corrupts the JSON document a caller is trying to parse.
58
+ """
59
+ if actx.json_mode:
60
+ return
61
+ getattr(actx.output, level)(message)
62
+
63
+
64
+ def print_plan(actx: AppContext, plan: Plan, show_ok: bool = False) -> None:
65
+ """Render a plan. Unchanged items are hidden unless asked for -- the signal is what moves."""
66
+ if actx.json_mode:
67
+ return
68
+ out = actx.output
69
+ rows = [a for a in plan.actions if show_ok or a.action != ACTION_OK]
70
+ for action in rows:
71
+ glyph = _GLYPH.get(action.action, " ")
72
+ out.info(f"{glyph} {action.action:<9} {action.target:<12} {action.path}")
73
+ out.info(f" {action.detail}")
74
+ if not rows:
75
+ out.success("nothing to do — every target already matches the manifest")
76
+
77
+
78
+ def plan_payload(plan: Plan) -> dict[str, object]:
79
+ return {
80
+ "counts": plan.counts(),
81
+ "actions": [
82
+ {
83
+ "action": a.action,
84
+ "target": a.target,
85
+ "renderer": a.renderer,
86
+ "path": str(a.path),
87
+ "detail": a.detail,
88
+ "state_key": a.state_key,
89
+ }
90
+ for a in plan.actions
91
+ ],
92
+ }
93
+
94
+
95
+ def conflict_hint(plan: Plan) -> str | None:
96
+ count = sum(1 for a in plan.actions if a.action == ACTION_CONFLICT)
97
+ if not count:
98
+ return None
99
+ return (
100
+ f"{count} conflict(s): items changed outside kctl-agent. "
101
+ "Run `kctl-agent apply --adopt` to keep the machine's version, "
102
+ "or `--force` to overwrite."
103
+ )
104
+
105
+
106
+ def rel(path: Path, root: Path) -> str:
107
+ try:
108
+ return path.relative_to(root).as_posix()
109
+ except ValueError:
110
+ return str(path)
111
+
112
+
113
+ def _note(actx: AppContext, message: str) -> None:
114
+ note(actx, message, "info")
115
+
116
+
117
+ def _ok(actx: AppContext, message: str) -> None:
118
+ note(actx, message, "success")
119
+
120
+
121
+ def _warn(actx: AppContext, message: str) -> None:
122
+ note(actx, message, "warn")
@@ -0,0 +1,146 @@
1
+ """`kctl-agent doctor` — is this workspace's agent tooling actually working?"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated
6
+
7
+ import typer
8
+
9
+ from ..health import check_all, machine_env, missing_env
10
+ from ..lockfile import load_lock
11
+ from ..model import ACTION_CONFLICT, ACTION_OK, AssetKind
12
+ from ..reconcile import build_plan
13
+ from ..registry import discover
14
+ from ._shared import _note, _ok, _warn, context, manifest_of, request
15
+
16
+ app = typer.Typer(help="Diagnose the agent platform.", no_args_is_help=False, invoke_without_command=True)
17
+
18
+
19
+ @app.callback(invoke_without_command=True)
20
+ def doctor(
21
+ ctx: typer.Context,
22
+ timeout: Annotated[float, typer.Option("--timeout", help="Per-server health timeout, seconds.")] = 5.0,
23
+ skip_network: Annotated[bool, typer.Option("--skip-network", help="Do not probe MCP servers.")] = False,
24
+ deep: Annotated[
25
+ bool,
26
+ typer.Option("--deep", help="Launch each stdio server and complete an MCP handshake."),
27
+ ] = False,
28
+ ) -> None:
29
+ """Report asset counts, MCP reachability, lock coverage, and drift.
30
+
31
+ Without --deep a stdio server is only checked for a resolvable command, which
32
+ says nothing about whether it answers. --deep spawns each one, so it is opt-in.
33
+ """
34
+ if ctx.invoked_subcommand:
35
+ return
36
+ actx = context(ctx)
37
+ out = actx.output
38
+ assert actx.root is not None
39
+
40
+ manifest = manifest_of(actx)
41
+ assets = discover(actx.root, manifest)
42
+ lock = load_lock(actx.root)
43
+
44
+ counts: dict[str, int] = {}
45
+ for asset in assets:
46
+ counts[asset.kind.value] = counts.get(asset.kind.value, 0) + 1
47
+
48
+ health = (
49
+ []
50
+ if skip_network
51
+ else check_all(manifest.mcp, timeout=timeout, deep=deep, env_by_server=machine_env(actx.home))
52
+ )
53
+ failing = [h for h in health if h.failing]
54
+ warning = [h for h in health if h.warning]
55
+
56
+ absent_env = missing_env(manifest.mcp)
57
+ declared = len(manifest.marketplaces) + len(manifest.plugins) + len(manifest.skills) + len(manifest.mcp)
58
+ unlocked = max(declared - len(lock.entries), 0)
59
+
60
+ drift = 0
61
+ conflicts = 0
62
+ if actx.profile:
63
+ plan = build_plan(request(actx, manifest))
64
+ drift = sum(1 for a in plan.actions if a.action != ACTION_OK)
65
+ conflicts = sum(1 for a in plan.actions if a.action == ACTION_CONFLICT)
66
+
67
+ if actx.json_mode:
68
+ out.raw_json(
69
+ {
70
+ "assets": counts,
71
+ "assets_total": len(assets),
72
+ "external_declared": declared,
73
+ "lock_entries": len(lock.entries),
74
+ "unlocked": unlocked,
75
+ "mcp": [{"name": h.name, "status": h.status, "health": h.health, "detail": h.detail} for h in health],
76
+ "mcp_failing": len(failing),
77
+ "mcp_warning": len(warning),
78
+ "mcp_missing_env": absent_env,
79
+ "drift": drift,
80
+ "conflicts": conflicts,
81
+ "ok": not failing,
82
+ }
83
+ )
84
+ else:
85
+ columns = [("kind", "cyan"), ("count", "white")]
86
+ rows = [[kind.value, str(counts.get(kind.value, 0))] for kind in AssetKind]
87
+ out.table("Assets", columns, rows)
88
+
89
+ if health:
90
+ hcols = [("server", "cyan"), ("status", "white"), ("class", "dim"), ("detail", "dim")]
91
+ hrows = [[h.name, h.status, h.health, h.detail] for h in health]
92
+ out.table("MCP servers", hcols, hrows)
93
+
94
+ _note(actx, f"external declared: {declared} lock entries: {len(lock.entries)} unlocked: {unlocked}")
95
+ if actx.profile:
96
+ _note(actx, f"profile {actx.profile}: {drift} item(s) drifted, {conflicts} conflict(s)")
97
+ else:
98
+ _note(actx, "pass -p/--profile to include drift in this report")
99
+
100
+ for name, keys in sorted(absent_env.items()):
101
+ _warn(actx, f"MCP {name}: env not set — {', '.join(keys)}")
102
+ if failing:
103
+ out.error(f"{len(failing)} required MCP server(s) unreachable")
104
+ if warning:
105
+ _warn(actx, f"{len(warning)} optional MCP server(s) unreachable")
106
+ if not failing and not warning:
107
+ _ok(actx, "all declared MCP servers reachable")
108
+
109
+ if failing:
110
+ raise typer.Exit(code=1)
111
+
112
+
113
+ @app.command("ai-summary")
114
+ def ai_summary(ctx: typer.Context) -> None:
115
+ """One-call machine-readable health summary for agents."""
116
+ actx = context(ctx)
117
+ out = actx.output
118
+ assert actx.root is not None
119
+
120
+ manifest = manifest_of(actx)
121
+ assets = discover(actx.root, manifest)
122
+ health = check_all(manifest.mcp)
123
+ failing = [h.name for h in health if h.failing]
124
+ warning = [h.name for h in health if h.warning]
125
+
126
+ counts: dict[str, int] = {}
127
+ for asset in assets:
128
+ counts[asset.kind.value] = counts.get(asset.kind.value, 0) + 1
129
+
130
+ out.raw_json(
131
+ {
132
+ "workspace": manifest.workspace,
133
+ "assets": counts,
134
+ "assets_total": len(assets),
135
+ "profiles": sorted(manifest.profiles),
136
+ "targets": sorted(manifest.targets),
137
+ "mcp_failing": failing,
138
+ "mcp_warning": warning,
139
+ "healthy": not failing,
140
+ "next_commands": [
141
+ "kctl-agent registry --kind skills",
142
+ "kctl-agent -p laptop plan",
143
+ "kctl-agent doctor",
144
+ ],
145
+ }
146
+ )