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.
- kctl_agent-0.2.0/.gitignore +39 -0
- kctl_agent-0.2.0/PKG-INFO +11 -0
- kctl_agent-0.2.0/README.md +121 -0
- kctl_agent-0.2.0/pyproject.toml +40 -0
- kctl_agent-0.2.0/src/kctl_agent/__init__.py +5 -0
- kctl_agent-0.2.0/src/kctl_agent/__main__.py +4 -0
- kctl_agent-0.2.0/src/kctl_agent/cli.py +95 -0
- kctl_agent-0.2.0/src/kctl_agent/commands/__init__.py +0 -0
- kctl_agent-0.2.0/src/kctl_agent/commands/_shared.py +122 -0
- kctl_agent-0.2.0/src/kctl_agent/commands/doctor_cmd.py +146 -0
- kctl_agent-0.2.0/src/kctl_agent/commands/eval_cmd.py +116 -0
- kctl_agent-0.2.0/src/kctl_agent/commands/knowledge_cmd.py +93 -0
- kctl_agent-0.2.0/src/kctl_agent/commands/lint_cmd.py +77 -0
- kctl_agent-0.2.0/src/kctl_agent/commands/manifest_cmd.py +226 -0
- kctl_agent-0.2.0/src/kctl_agent/commands/reconcile_cmd.py +117 -0
- kctl_agent-0.2.0/src/kctl_agent/commands/vendor_cmd.py +79 -0
- kctl_agent-0.2.0/src/kctl_agent/core/__init__.py +0 -0
- kctl_agent-0.2.0/src/kctl_agent/core/callbacks.py +20 -0
- kctl_agent-0.2.0/src/kctl_agent/core/config.py +19 -0
- kctl_agent-0.2.0/src/kctl_agent/core/exceptions.py +19 -0
- kctl_agent-0.2.0/src/kctl_agent/evals.py +135 -0
- kctl_agent-0.2.0/src/kctl_agent/health.py +196 -0
- kctl_agent-0.2.0/src/kctl_agent/hoist.py +55 -0
- kctl_agent-0.2.0/src/kctl_agent/hooks.py +72 -0
- kctl_agent-0.2.0/src/kctl_agent/importer.py +341 -0
- kctl_agent-0.2.0/src/kctl_agent/knowledge.py +90 -0
- kctl_agent-0.2.0/src/kctl_agent/lint.py +47 -0
- kctl_agent-0.2.0/src/kctl_agent/lockfile.py +122 -0
- kctl_agent-0.2.0/src/kctl_agent/manifest.py +271 -0
- kctl_agent-0.2.0/src/kctl_agent/model.py +258 -0
- kctl_agent-0.2.0/src/kctl_agent/parser.py +117 -0
- kctl_agent-0.2.0/src/kctl_agent/reconcile.py +189 -0
- kctl_agent-0.2.0/src/kctl_agent/registry.py +127 -0
- kctl_agent-0.2.0/src/kctl_agent/renderers/__init__.py +76 -0
- kctl_agent-0.2.0/src/kctl_agent/renderers/copytree.py +113 -0
- kctl_agent-0.2.0/src/kctl_agent/renderers/jsonmerge.py +277 -0
- kctl_agent-0.2.0/src/kctl_agent/renderers/plugin.py +129 -0
- kctl_agent-0.2.0/src/kctl_agent/renderers/symlink.py +180 -0
- kctl_agent-0.2.0/src/kctl_agent/rules/__init__.py +12 -0
- kctl_agent-0.2.0/src/kctl_agent/rules/_shared.py +88 -0
- kctl_agent-0.2.0/src/kctl_agent/rules/commands.py +126 -0
- kctl_agent-0.2.0/src/kctl_agent/rules/evals.py +85 -0
- kctl_agent-0.2.0/src/kctl_agent/rules/external.py +158 -0
- kctl_agent-0.2.0/src/kctl_agent/rules/hooks.py +141 -0
- kctl_agent-0.2.0/src/kctl_agent/rules/knowledge.py +113 -0
- kctl_agent-0.2.0/src/kctl_agent/rules/mcp.py +162 -0
- kctl_agent-0.2.0/src/kctl_agent/rules/registry.py +58 -0
- kctl_agent-0.2.0/src/kctl_agent/rules/rules.py +110 -0
- kctl_agent-0.2.0/src/kctl_agent/rules/subagents.py +176 -0
- kctl_agent-0.2.0/src/kctl_agent/selection.py +37 -0
- kctl_agent-0.2.0/src/kctl_agent/state.py +142 -0
- kctl_agent-0.2.0/src/kctl_agent/vendor.py +139 -0
- kctl_agent-0.2.0/tests/conftest.py +103 -0
- kctl_agent-0.2.0/tests/test_cli.py +194 -0
- kctl_agent-0.2.0/tests/test_evals.py +102 -0
- kctl_agent-0.2.0/tests/test_health.py +174 -0
- kctl_agent-0.2.0/tests/test_hook_scripts.py +174 -0
- kctl_agent-0.2.0/tests/test_hooks_and_knowledge.py +82 -0
- kctl_agent-0.2.0/tests/test_importer.py +231 -0
- kctl_agent-0.2.0/tests/test_manifest.py +85 -0
- kctl_agent-0.2.0/tests/test_reconcile.py +133 -0
- kctl_agent-0.2.0/tests/test_registry.py +75 -0
- kctl_agent-0.2.0/tests/test_renderers.py +257 -0
- kctl_agent-0.2.0/tests/test_rules.py +190 -0
- kctl_agent-0.2.0/tests/test_rules_external.py +126 -0
- kctl_agent-0.2.0/tests/test_rules_knowledge.py +66 -0
- kctl_agent-0.2.0/tests/test_selection.py +52 -0
- kctl_agent-0.2.0/tests/test_standard.py +47 -0
- kctl_agent-0.2.0/tests/test_state.py +127 -0
- 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,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
|
+
)
|