aicp-cli 0.6.0__tar.gz → 0.7.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.
Files changed (55) hide show
  1. {aicp_cli-0.6.0 → aicp_cli-0.7.0}/CHANGELOG.md +14 -0
  2. {aicp_cli-0.6.0 → aicp_cli-0.7.0}/PKG-INFO +51 -40
  3. {aicp_cli-0.6.0 → aicp_cli-0.7.0}/README.md +50 -39
  4. {aicp_cli-0.6.0 → aicp_cli-0.7.0}/pyproject.toml +1 -1
  5. aicp_cli-0.7.0/src/aicp/agentcfg.py +168 -0
  6. {aicp_cli-0.6.0 → aicp_cli-0.7.0}/src/aicp/agents.py +70 -6
  7. {aicp_cli-0.6.0 → aicp_cli-0.7.0}/src/aicp/cli.py +90 -2
  8. {aicp_cli-0.6.0 → aicp_cli-0.7.0}/src/aicp/config.py +5 -1
  9. {aicp_cli-0.6.0 → aicp_cli-0.7.0}/src/aicp/i18n.py +25 -0
  10. {aicp_cli-0.6.0 → aicp_cli-0.7.0}/src/aicp/menu.py +226 -7
  11. aicp_cli-0.7.0/tests/test_agentcfg.py +246 -0
  12. {aicp_cli-0.6.0 → aicp_cli-0.7.0}/tests/test_menu.py +5 -4
  13. {aicp_cli-0.6.0 → aicp_cli-0.7.0}/tests/test_menu_skills.py +91 -10
  14. {aicp_cli-0.6.0 → aicp_cli-0.7.0}/uv.lock +1 -1
  15. {aicp_cli-0.6.0 → aicp_cli-0.7.0}/.github/workflows/ci.yml +0 -0
  16. {aicp_cli-0.6.0 → aicp_cli-0.7.0}/.github/workflows/release.yml +0 -0
  17. {aicp_cli-0.6.0 → aicp_cli-0.7.0}/.gitignore +0 -0
  18. {aicp_cli-0.6.0 → aicp_cli-0.7.0}/CLAUDE.md +0 -0
  19. {aicp_cli-0.6.0 → aicp_cli-0.7.0}/LICENSE +0 -0
  20. {aicp_cli-0.6.0 → aicp_cli-0.7.0}/TODO.md +0 -0
  21. {aicp_cli-0.6.0 → aicp_cli-0.7.0}/config.example.json +0 -0
  22. {aicp_cli-0.6.0 → aicp_cli-0.7.0}/docs/images/aicp-run.png +0 -0
  23. {aicp_cli-0.6.0 → aicp_cli-0.7.0}/skills/commit.md +0 -0
  24. {aicp_cli-0.6.0 → aicp_cli-0.7.0}/skills/safe-git-push/SKILL.md +0 -0
  25. {aicp_cli-0.6.0 → aicp_cli-0.7.0}/skills/safe-git-push/scripts/safe_push.py +0 -0
  26. {aicp_cli-0.6.0 → aicp_cli-0.7.0}/src/aicp/__init__.py +0 -0
  27. {aicp_cli-0.6.0 → aicp_cli-0.7.0}/src/aicp/_keyreader.py +0 -0
  28. {aicp_cli-0.6.0 → aicp_cli-0.7.0}/src/aicp/_utils.py +0 -0
  29. {aicp_cli-0.6.0 → aicp_cli-0.7.0}/src/aicp/agents.json +0 -0
  30. {aicp_cli-0.6.0 → aicp_cli-0.7.0}/src/aicp/budget.py +0 -0
  31. {aicp_cli-0.6.0 → aicp_cli-0.7.0}/src/aicp/contracts.py +0 -0
  32. {aicp_cli-0.6.0 → aicp_cli-0.7.0}/src/aicp/gitflow.py +0 -0
  33. {aicp_cli-0.6.0 → aicp_cli-0.7.0}/src/aicp/notify.py +0 -0
  34. {aicp_cli-0.6.0 → aicp_cli-0.7.0}/src/aicp/present.py +0 -0
  35. {aicp_cli-0.6.0 → aicp_cli-0.7.0}/src/aicp/quota.py +0 -0
  36. {aicp_cli-0.6.0 → aicp_cli-0.7.0}/src/aicp/runner.py +0 -0
  37. {aicp_cli-0.6.0 → aicp_cli-0.7.0}/src/aicp/secrets.py +0 -0
  38. {aicp_cli-0.6.0 → aicp_cli-0.7.0}/src/aicp/skills.py +0 -0
  39. {aicp_cli-0.6.0 → aicp_cli-0.7.0}/src/aicp/timing.py +0 -0
  40. {aicp_cli-0.6.0 → aicp_cli-0.7.0}/tests/conftest.py +0 -0
  41. {aicp_cli-0.6.0 → aicp_cli-0.7.0}/tests/test_agents.py +0 -0
  42. {aicp_cli-0.6.0 → aicp_cli-0.7.0}/tests/test_budget.py +0 -0
  43. {aicp_cli-0.6.0 → aicp_cli-0.7.0}/tests/test_cli.py +0 -0
  44. {aicp_cli-0.6.0 → aicp_cli-0.7.0}/tests/test_config.py +0 -0
  45. {aicp_cli-0.6.0 → aicp_cli-0.7.0}/tests/test_cross_platform.py +0 -0
  46. {aicp_cli-0.6.0 → aicp_cli-0.7.0}/tests/test_gitflow.py +0 -0
  47. {aicp_cli-0.6.0 → aicp_cli-0.7.0}/tests/test_i18n.py +0 -0
  48. {aicp_cli-0.6.0 → aicp_cli-0.7.0}/tests/test_notify.py +0 -0
  49. {aicp_cli-0.6.0 → aicp_cli-0.7.0}/tests/test_present.py +0 -0
  50. {aicp_cli-0.6.0 → aicp_cli-0.7.0}/tests/test_quota.py +0 -0
  51. {aicp_cli-0.6.0 → aicp_cli-0.7.0}/tests/test_runner.py +0 -0
  52. {aicp_cli-0.6.0 → aicp_cli-0.7.0}/tests/test_secrets.py +0 -0
  53. {aicp_cli-0.6.0 → aicp_cli-0.7.0}/tests/test_skills.py +0 -0
  54. {aicp_cli-0.6.0 → aicp_cli-0.7.0}/tests/test_timing.py +0 -0
  55. {aicp_cli-0.6.0 → aicp_cli-0.7.0}/tests/test_undo.py +0 -0
@@ -1,3 +1,17 @@
1
+ ## [0.7.0] - 2026-09-17
2
+
3
+ ### 🚀 Features
4
+
5
+ - **agents:** Add cli and menu editing surface
6
+
7
+ ### 🐛 Bug Fixes
8
+
9
+ - **menu:** Size panels to their own content
10
+ ## [0.6.0] - 2026-09-17
11
+
12
+ ### 📚 Documentation
13
+
14
+ - **changelog:** Release v0.6.0
1
15
  ## [0.5.1] - 2026-09-17
2
16
 
3
17
  ### 🐛 Bug Fixes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: aicp-cli
3
- Version: 0.6.0
3
+ Version: 0.7.0
4
4
  Summary: AI commit + push, with a git-verified result summary
5
5
  Project-URL: Repository, https://github.com/weskao/aicp
6
6
  Project-URL: Issues, https://github.com/weskao/aicp/issues
@@ -168,6 +168,7 @@ use is the two commands above.
168
168
  aicp --doctor --json # skill-install status for every configured CLI, as JSON
169
169
  aicp --install-skills --yes # install what's missing, upgrade what's outdated
170
170
  aicp --install-skills --force # also replace files aicp doesn't recognize
171
+ aicp --agents # the agent registry; see Custom agents for its verbs
171
172
  ```
172
173
 
173
174
  `--doctor --json` reports, per CLI and per skill: the target path and whether
@@ -242,12 +243,46 @@ preferences continue to use the config file below.
242
243
 
243
244
  You can add agents that are not in the built-in registry — or suppress
244
245
  built-in ones — without touching the bundled `agents.json` (which package
245
- upgrades replace). Create `~/.aicp/agents.json` with the same `version: 1`
246
- / `agents` envelope. aicp merges it over the built-in registry at startup:
247
- user-defined entries win on conflict, and an entry with `"disabled": true`
248
- removes that agent from the chain entirely.
246
+ upgrades replace). Your changes live in `~/.aicp/agents.json`, which aicp
247
+ merges over the built-in registry at startup: your entries win on conflict,
248
+ and an entry with `"disabled": true` removes that agent from the chain.
249
249
 
250
- **Adding a new agent** — example: [MiniMax](https://www.minimaxi.com/) CLI:
250
+ You don't have to write that file by hand. `aicp --agents` edits it for you,
251
+ and so does the **Agents** row of `aicp --config`:
252
+
253
+ ```sh
254
+ aicp --agents # list every agent and where it came from
255
+ aicp --agents disable grok # drop one from the chain
256
+ aicp --agents enable grok # put it back
257
+ aicp --agents set claude executable=/opt/claude # override one field
258
+ aicp --agents reset claude # forget your override
259
+ ```
260
+
261
+ Like every other flag, the leading `--` is optional: `aicp agents disable grok`.
262
+
263
+ Nothing is written unless the resulting registry passes the same validation
264
+ the next run does, so a typo is refused with the file left exactly as it was —
265
+ `aicp` can never be edited into a state where it won't start. Disabling an
266
+ agent also removes it from a saved `aicp_cli_order`, because an order naming
267
+ an agent that no longer resolves is rejected *in full*, which would silently
268
+ reset a fallback order you picked on purpose. The last remaining agent can't
269
+ be disabled.
270
+
271
+ **Adding a new agent** — example: [MiniMax](https://www.minimaxi.com/) CLI.
272
+ Every field is required for a name the built-in registry has never heard of:
273
+
274
+ ```sh
275
+ aicp --agents set minimax \
276
+ executable=minimax \
277
+ config_dir='~/.minimax' \
278
+ memory_file=AGENTS.md \
279
+ skills_dir=skills \
280
+ skills=safe-git-push \
281
+ 'args=--prompt,{prompt},--yes'
282
+ ```
283
+
284
+ `skills` and `args` are comma-separated; a value that needs a literal comma
285
+ has to go into the file by hand. The equivalent written out:
251
286
 
252
287
  ```json
253
288
  {
@@ -265,51 +300,27 @@ removes that agent from the chain entirely.
265
300
  }
266
301
  ```
267
302
 
268
- Then add `minimax` anywhere in your preferred fallback order:
303
+ A new agent joins the end of the fallback chain automatically. To put it
304
+ first, use `aicp --swap-ai`, the AI CLI order row of `aicp --config`, or set
305
+ the order yourself:
269
306
 
270
307
  ```sh
271
308
  # ~/.aicp/config.json
272
309
  { "aicp_cli_order": "minimax copilot agy codex claude vibe grok" }
273
310
  ```
274
311
 
275
- Or set it as an environment variable for one run:
312
+ Or as an environment variable, for one run:
276
313
 
277
314
  ```sh
278
315
  AICP_CLI_ORDER="minimax claude" aicp
279
316
  ```
280
317
 
281
- **Disabling a built-in agent** — example: remove `grok` from the chain:
282
-
283
- ```json
284
- {
285
- "version": 1,
286
- "agents": {
287
- "grok": { "disabled": true }
288
- }
289
- }
290
- ```
291
-
292
- **Overriding a built-in agent's settings** — example: change `claude`'s
293
- executable path:
294
-
295
- ```json
296
- {
297
- "version": 1,
298
- "agents": {
299
- "claude": {
300
- "executable": "/opt/homebrew/bin/claude"
301
- }
302
- }
303
- }
304
- ```
305
-
306
- Only the fields you provide are overridden; unspecified fields keep the
307
- built-in values. The same agent-field table above applies to user-defined
308
- entries. Running the chain only needs the `executable` on `PATH`, same as any
309
- built-in agent (see [Fallback results](#fallback-results-and-quota-limits));
310
- `config_dir` matters for skill installation — a newly added agent whose
311
- `config_dir` doesn't exist yet is skipped there until you set it up (as
312
- above).
318
+ For an agent that already exists, only the fields you provide are overridden;
319
+ unspecified fields keep the built-in values. Running the chain only needs the
320
+ `executable` on `PATH`, same as any built-in agent (see
321
+ [Fallback results](#fallback-results-and-quota-limits)); `config_dir` matters
322
+ for skill installation — a newly added agent whose `config_dir` doesn't exist
323
+ yet is skipped there until you set it up.
313
324
 
314
325
  The config file lives at `~/.aicp/config.json` (override the path itself
315
326
  with `AICP_CONFIG`, which — being the thing that names the file — can only be
@@ -156,6 +156,7 @@ use is the two commands above.
156
156
  aicp --doctor --json # skill-install status for every configured CLI, as JSON
157
157
  aicp --install-skills --yes # install what's missing, upgrade what's outdated
158
158
  aicp --install-skills --force # also replace files aicp doesn't recognize
159
+ aicp --agents # the agent registry; see Custom agents for its verbs
159
160
  ```
160
161
 
161
162
  `--doctor --json` reports, per CLI and per skill: the target path and whether
@@ -230,12 +231,46 @@ preferences continue to use the config file below.
230
231
 
231
232
  You can add agents that are not in the built-in registry — or suppress
232
233
  built-in ones — without touching the bundled `agents.json` (which package
233
- upgrades replace). Create `~/.aicp/agents.json` with the same `version: 1`
234
- / `agents` envelope. aicp merges it over the built-in registry at startup:
235
- user-defined entries win on conflict, and an entry with `"disabled": true`
236
- removes that agent from the chain entirely.
234
+ upgrades replace). Your changes live in `~/.aicp/agents.json`, which aicp
235
+ merges over the built-in registry at startup: your entries win on conflict,
236
+ and an entry with `"disabled": true` removes that agent from the chain.
237
237
 
238
- **Adding a new agent** — example: [MiniMax](https://www.minimaxi.com/) CLI:
238
+ You don't have to write that file by hand. `aicp --agents` edits it for you,
239
+ and so does the **Agents** row of `aicp --config`:
240
+
241
+ ```sh
242
+ aicp --agents # list every agent and where it came from
243
+ aicp --agents disable grok # drop one from the chain
244
+ aicp --agents enable grok # put it back
245
+ aicp --agents set claude executable=/opt/claude # override one field
246
+ aicp --agents reset claude # forget your override
247
+ ```
248
+
249
+ Like every other flag, the leading `--` is optional: `aicp agents disable grok`.
250
+
251
+ Nothing is written unless the resulting registry passes the same validation
252
+ the next run does, so a typo is refused with the file left exactly as it was —
253
+ `aicp` can never be edited into a state where it won't start. Disabling an
254
+ agent also removes it from a saved `aicp_cli_order`, because an order naming
255
+ an agent that no longer resolves is rejected *in full*, which would silently
256
+ reset a fallback order you picked on purpose. The last remaining agent can't
257
+ be disabled.
258
+
259
+ **Adding a new agent** — example: [MiniMax](https://www.minimaxi.com/) CLI.
260
+ Every field is required for a name the built-in registry has never heard of:
261
+
262
+ ```sh
263
+ aicp --agents set minimax \
264
+ executable=minimax \
265
+ config_dir='~/.minimax' \
266
+ memory_file=AGENTS.md \
267
+ skills_dir=skills \
268
+ skills=safe-git-push \
269
+ 'args=--prompt,{prompt},--yes'
270
+ ```
271
+
272
+ `skills` and `args` are comma-separated; a value that needs a literal comma
273
+ has to go into the file by hand. The equivalent written out:
239
274
 
240
275
  ```json
241
276
  {
@@ -253,51 +288,27 @@ removes that agent from the chain entirely.
253
288
  }
254
289
  ```
255
290
 
256
- Then add `minimax` anywhere in your preferred fallback order:
291
+ A new agent joins the end of the fallback chain automatically. To put it
292
+ first, use `aicp --swap-ai`, the AI CLI order row of `aicp --config`, or set
293
+ the order yourself:
257
294
 
258
295
  ```sh
259
296
  # ~/.aicp/config.json
260
297
  { "aicp_cli_order": "minimax copilot agy codex claude vibe grok" }
261
298
  ```
262
299
 
263
- Or set it as an environment variable for one run:
300
+ Or as an environment variable, for one run:
264
301
 
265
302
  ```sh
266
303
  AICP_CLI_ORDER="minimax claude" aicp
267
304
  ```
268
305
 
269
- **Disabling a built-in agent** — example: remove `grok` from the chain:
270
-
271
- ```json
272
- {
273
- "version": 1,
274
- "agents": {
275
- "grok": { "disabled": true }
276
- }
277
- }
278
- ```
279
-
280
- **Overriding a built-in agent's settings** — example: change `claude`'s
281
- executable path:
282
-
283
- ```json
284
- {
285
- "version": 1,
286
- "agents": {
287
- "claude": {
288
- "executable": "/opt/homebrew/bin/claude"
289
- }
290
- }
291
- }
292
- ```
293
-
294
- Only the fields you provide are overridden; unspecified fields keep the
295
- built-in values. The same agent-field table above applies to user-defined
296
- entries. Running the chain only needs the `executable` on `PATH`, same as any
297
- built-in agent (see [Fallback results](#fallback-results-and-quota-limits));
298
- `config_dir` matters for skill installation — a newly added agent whose
299
- `config_dir` doesn't exist yet is skipped there until you set it up (as
300
- above).
306
+ For an agent that already exists, only the fields you provide are overridden;
307
+ unspecified fields keep the built-in values. Running the chain only needs the
308
+ `executable` on `PATH`, same as any built-in agent (see
309
+ [Fallback results](#fallback-results-and-quota-limits)); `config_dir` matters
310
+ for skill installation — a newly added agent whose `config_dir` doesn't exist
311
+ yet is skipped there until you set it up.
301
312
 
302
313
  The config file lives at `~/.aicp/config.json` (override the path itself
303
314
  with `AICP_CONFIG`, which — being the thing that names the file — can only be
@@ -2,7 +2,7 @@
2
2
  # PyPI distribution name only — the `aicp` name was taken in 2021 by an
3
3
  # unrelated project. The command, import path and repo stay `aicp`.
4
4
  name = "aicp-cli"
5
- version = "0.6.0"
5
+ version = "0.7.0"
6
6
  description = "AI commit + push, with a git-verified result summary"
7
7
  readme = "README.md"
8
8
  requires-python = ">=3.10"
@@ -0,0 +1,168 @@
1
+ """Editing ``~/.aicp/agents.json`` — the write side of :mod:`aicp.agents`.
2
+
3
+ Kept out of that module on purpose. Every aicp run imports ``agents`` (the
4
+ roster is built from it at import time) and nothing here runs unless
5
+ ``--agents`` or the config menu's Agents row is used; separating them also
6
+ keeps ``agents`` free of an :mod:`aicp.config` import, which would be a cycle
7
+ — config's roster comes from agents.
8
+
9
+ **Nothing reaches disk until the whole candidate registry has passed the same
10
+ validation the next run will do.** A refused edit leaves the previous file
11
+ byte-identical, so a typo can never leave aicp unable to start.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ from collections.abc import Sequence
18
+ from dataclasses import dataclass, fields
19
+ from pathlib import Path
20
+
21
+ from . import agents, config
22
+
23
+ __all__ = ["EDITABLE", "STATE_LABELS", "VERBS", "AgentEditError", "Change", "apply"]
24
+
25
+
26
+ class AgentEditError(ValueError):
27
+ """A refused edit. Nothing was written."""
28
+
29
+
30
+ #: Which of the settable fields are comma-separated lists.
31
+ LIST_FIELDS = ("skills", "args")
32
+ #: Fields ``set`` accepts — read off :class:`~aicp.agents.Agent` rather than
33
+ #: listed again, so a field added there is settable the same day. ``disabled``
34
+ #: is not one of them: it is what the enable/disable verbs write.
35
+ EDITABLE = tuple(field.name for field in fields(agents.Agent))
36
+
37
+ VERBS = ("list", "enable", "disable", "set", "reset")
38
+
39
+ #: Display text per :func:`aicp.agents.inventory` state, as ``(msgid,
40
+ #: english)`` — the menu translates with its live language, the CLI with the
41
+ #: resolved one, both off this single table.
42
+ STATE_LABELS = {
43
+ agents.BUILT_IN: ("agents_state_builtin", "built-in"),
44
+ agents.OVERRIDDEN: ("agents_state_overridden", "overridden"),
45
+ agents.ADDED: ("agents_state_added", "added"),
46
+ agents.DISABLED: ("agents_state_disabled", "disabled"),
47
+ }
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class Change:
52
+ """What :func:`apply` did."""
53
+
54
+ name: str
55
+ #: ``enabled`` / ``disabled`` / ``updated`` / ``reset`` / ``removed``.
56
+ #: ``reset`` reverts to a built-in definition; ``removed`` is the same
57
+ #: verb applied to an agent that only ever existed in the user file.
58
+ verb: str
59
+ #: The registry's names after the change, in resolution order.
60
+ names: tuple[str, ...]
61
+ #: Names dropped from a saved ``AICP_CLI_ORDER`` to keep it loadable.
62
+ pruned: tuple[str, ...]
63
+
64
+
65
+ def parse_fields(assignments: Sequence[str]) -> dict[str, object]:
66
+ """``["executable=x", "args=--p,{prompt}"]`` -> a registry entry fragment."""
67
+ parsed: dict[str, object] = {}
68
+ for item in assignments:
69
+ key, sep, raw = item.partition("=")
70
+ if not sep or key not in EDITABLE:
71
+ raise AgentEditError(
72
+ f"expected key=value, key one of {', '.join(EDITABLE)} — got: {item}"
73
+ )
74
+ # A list field splits on commas, so a value that needs a literal comma
75
+ # has to be written into the file by hand. Nothing in a CLI invocation
76
+ # or a skill name has ever needed one.
77
+ parsed[key] = [part for part in raw.split(",") if part] if key in LIST_FIELDS else raw
78
+ return parsed
79
+
80
+
81
+ def _prune_chain(names: tuple[str, ...], config_path: Path | str | None) -> tuple[str, ...]:
82
+ """Drop names the registry no longer has from a saved ``AICP_CLI_ORDER``.
83
+
84
+ Without this, disabling an agent leaves an unknown name in the saved
85
+ order and :func:`aicp.config.resolve_cli_chain` refuses the whole line —
86
+ silently resetting a hand-picked fallback order to the default roster.
87
+ """
88
+ saved = config.load_config(config_path).get("AICP_CLI_ORDER", "")
89
+ listed = saved.split()
90
+ dropped = tuple(name for name in listed if name not in names)
91
+ if dropped:
92
+ config.persist_key(
93
+ "AICP_CLI_ORDER", " ".join(n for n in listed if n in names), config_path
94
+ )
95
+ return dropped
96
+
97
+
98
+ def apply(
99
+ verb: str,
100
+ name: str,
101
+ assignments: Sequence[str] = (),
102
+ *,
103
+ home: Path | None = None,
104
+ config_path: Path | str | None = None,
105
+ ) -> Change:
106
+ """Run *verb* against ``~/.aicp/agents.json`` and report what changed.
107
+
108
+ ``enable`` clears a ``disabled`` flag, ``disable`` sets one, ``set``
109
+ merges ``key=value`` fields, ``reset`` drops the whole user entry.
110
+ """
111
+ if verb not in VERBS or verb == "list":
112
+ raise AgentEditError(f"unknown agents verb: {verb}")
113
+ path = agents.user_registry_path(home)
114
+ try:
115
+ entries = agents.read_user_entries(path)
116
+ except (OSError, ValueError, TypeError, json.JSONDecodeError) as exc:
117
+ raise AgentEditError(f"{path} is unreadable ({exc}) — fix or delete it first") from exc
118
+
119
+ builtin = agents.load_agents(agents.BUILTIN_PATH)
120
+ known = {*builtin, *entries}
121
+ if verb != "set" and name not in known:
122
+ raise AgentEditError(f"unknown agent: {name} (known: {' '.join(sorted(known))})")
123
+
124
+ candidate = {key: dict(value) for key, value in entries.items()}
125
+ entry = candidate.setdefault(name, {})
126
+ if verb == "disable":
127
+ entry["disabled"] = True
128
+ elif verb == "enable":
129
+ entry.pop("disabled", None)
130
+ elif verb == "set":
131
+ if not assignments:
132
+ raise AgentEditError(f"set needs at least one key=value for {name}")
133
+ # Editing a disabled agent's fields would otherwise look like it did
134
+ # nothing: `disabled` drops the name before any of them are read.
135
+ if entry.get("disabled") is True:
136
+ raise AgentEditError(f"{name} is disabled — enable it first, then set its fields")
137
+ entry.update(parse_fields(assignments))
138
+ else: # reset
139
+ candidate.pop(name)
140
+ # An entry that says nothing is not an override; leaving it behind would
141
+ # also crash the loader, which requires a full definition for a name the
142
+ # built-in registry has never heard of.
143
+ if not candidate.get(name, {"x": 1}):
144
+ candidate.pop(name)
145
+
146
+ try:
147
+ merged = agents.merge_entries(builtin, candidate, path)
148
+ except (ValueError, TypeError) as exc:
149
+ raise AgentEditError(str(exc)) from exc
150
+ if not merged:
151
+ raise AgentEditError(f"refusing to disable {name}: it is the last agent left")
152
+
153
+ if candidate:
154
+ if not config._write_json_private(path, {"version": 1, "agents": candidate}):
155
+ raise AgentEditError(f"failed to write {path}")
156
+ else:
157
+ # An empty `agents` object is rejected on read, so "no overrides left"
158
+ # has to be spelled as "no file" rather than an empty one.
159
+ path.unlink(missing_ok=True)
160
+
161
+ names = tuple(merged)
162
+ if verb == "reset":
163
+ applied = "reset" if name in merged else "removed"
164
+ elif verb == "set":
165
+ applied = "updated" if name in known else "added"
166
+ else:
167
+ applied = {"enable": "enabled", "disable": "disabled"}[verb]
168
+ return Change(name, applied, names, _prune_chain(names, config_path))
@@ -101,11 +101,19 @@ def load_agents(path: Path) -> dict[str, Agent]:
101
101
  return {name: _build_agent(path, name, entry, base=None) for name, entry in entries.items()}
102
102
 
103
103
 
104
- def merge_user_agents(agents: dict[str, Agent], path: Path) -> dict[str, Agent]:
105
- """Overlay ``path``'s entries onto *agents*: partial overrides for known
106
- names, full entries for new ones, ``disabled: true`` to drop a name."""
104
+ def merge_entries(
105
+ agents: dict[str, Agent], entries: dict[str, dict], path: Path
106
+ ) -> dict[str, Agent]:
107
+ """Overlay raw *entries* onto *agents*: partial overrides for known names,
108
+ full entries for new ones, ``disabled: true`` to drop a name.
109
+
110
+ Takes the entries rather than a file so :mod:`aicp.agentcfg` can validate
111
+ a candidate registry it has not written yet — the whole reason an edit can
112
+ be refused without touching what is already on disk. *path* only names the
113
+ source in error messages.
114
+ """
107
115
  merged = dict(agents)
108
- for name, entry in _read_registry(path).items():
116
+ for name, entry in entries.items():
109
117
  if not isinstance(entry, dict):
110
118
  raise TypeError(f"{path}: {name}: expected an agent object")
111
119
  if entry.get("disabled") is True:
@@ -115,8 +123,64 @@ def merge_user_agents(agents: dict[str, Agent], path: Path) -> dict[str, Agent]:
115
123
  return merged
116
124
 
117
125
 
126
+ def merge_user_agents(agents: dict[str, Agent], path: Path) -> dict[str, Agent]:
127
+ """:func:`merge_entries` over the registry file at *path*."""
128
+ return merge_entries(agents, _read_registry(path), path)
129
+
130
+
131
+ BUILTIN_PATH = Path(__file__).with_name("agents.json")
132
+
133
+
134
+ def user_registry_path(home: Path | None = None) -> Path:
135
+ """Where a user's own overrides live. One place, so the reader, the writer
136
+ and the tests can never disagree about it."""
137
+ return (home or Path.home()) / ".aicp" / "agents.json"
138
+
139
+
140
+ def read_user_entries(path: Path) -> dict[str, dict]:
141
+ """The user file's raw entries, or ``{}`` when there is no file."""
142
+ return _read_registry(path) if path.is_file() else {}
143
+
144
+
145
+ #: What :func:`inventory` says about a name. Stable ids, not display text —
146
+ #: ``agentcfg.STATE_LABELS`` maps them to translatable strings.
147
+ BUILT_IN = "builtin"
148
+ OVERRIDDEN = "overridden"
149
+ ADDED = "added"
150
+ DISABLED = "disabled"
151
+
152
+
153
+ @dataclass(frozen=True)
154
+ class AgentRow:
155
+ """One line of :func:`inventory`."""
156
+
157
+ name: str
158
+ executable: str
159
+ state: str
160
+
161
+
162
+ def inventory(home: Path | None = None) -> list[AgentRow]:
163
+ """Every agent name aicp knows of, built-in order first, and where it
164
+ came from. Disabled names are listed too — hiding them is how someone
165
+ loses the ability to turn one back on."""
166
+ path = user_registry_path(home)
167
+ builtin = load_agents(BUILTIN_PATH)
168
+ entries = read_user_entries(path)
169
+ merged = merge_entries(builtin, entries, path)
170
+ rows = []
171
+ for name in [*builtin, *(n for n in entries if n not in builtin)]:
172
+ entry = entries.get(name)
173
+ if entry is not None and entry.get("disabled") is True:
174
+ base = builtin.get(name)
175
+ rows.append(AgentRow(name, base.executable if base else "—", DISABLED))
176
+ else:
177
+ state = BUILT_IN if entry is None else (OVERRIDDEN if name in builtin else ADDED)
178
+ rows.append(AgentRow(name, merged[name].executable, state))
179
+ return rows
180
+
181
+
118
182
  def _load_user_overrides(agents: dict[str, Agent], home: Path | None = None) -> dict[str, Agent]:
119
- path = (home or Path.home()) / ".aicp" / "agents.json"
183
+ path = user_registry_path(home)
120
184
  if not path.is_file():
121
185
  return agents
122
186
  try:
@@ -126,7 +190,7 @@ def _load_user_overrides(agents: dict[str, Agent], home: Path | None = None) ->
126
190
  return agents
127
191
 
128
192
 
129
- AGENTS = _load_user_overrides(load_agents(Path(__file__).with_name("agents.json")))
193
+ AGENTS = _load_user_overrides(load_agents(BUILTIN_PATH))
130
194
 
131
195
 
132
196
  def executable(name: str) -> str:
@@ -36,7 +36,19 @@ from collections.abc import Sequence
36
36
  from pathlib import Path
37
37
  from typing import NoReturn
38
38
 
39
- from . import __version__, config, gitflow, i18n, menu, notify, present, runner, skills
39
+ from . import (
40
+ __version__,
41
+ agentcfg,
42
+ agents,
43
+ config,
44
+ gitflow,
45
+ i18n,
46
+ menu,
47
+ notify,
48
+ present,
49
+ runner,
50
+ skills,
51
+ )
40
52
  from ._utils import BLUE, BOLD, CYAN, DIM, GREEN, MAGENTA, RED, RESET, YELLOW, have
41
53
  from .agents import executable
42
54
  from .contracts import ROSTER
@@ -201,7 +213,19 @@ def _normalize_argv(argv: Sequence[str], parser: argparse.ArgumentParser) -> lis
201
213
  for opt in action.option_strings
202
214
  if opt.startswith("--")
203
215
  }
204
- return [f"--{arg}" if arg in long_flags else arg for arg in argv]
216
+ normalized: list[str] = []
217
+ for i, arg in enumerate(argv):
218
+ if arg in long_flags:
219
+ normalized.append(f"--{arg}")
220
+ elif arg.startswith("-"):
221
+ normalized.append(arg)
222
+ else:
223
+ # The first bare word that names no flag begins the operands
224
+ # (`aicp agents disable <name>`), and an agent that happens to be
225
+ # called "json" must not be rewritten into --json.
226
+ normalized.extend(argv[i:])
227
+ break
228
+ return normalized
205
229
 
206
230
 
207
231
  def build_parser() -> argparse.ArgumentParser:
@@ -245,6 +269,13 @@ def build_parser() -> argparse.ArgumentParser:
245
269
  "--doctor", action="store_true",
246
270
  help=t("help_doctor", "report where each vendored skill stands for each CLI"),
247
271
  )
272
+ parser.add_argument(
273
+ "--agents", nargs="*", metavar="ARG",
274
+ help=t(
275
+ "help_agents",
276
+ "list the agent registry; or enable/disable/set/reset one in ~/.aicp/agents.json",
277
+ ),
278
+ )
248
279
  parser.add_argument("--json", action="store_true", help=t("help_json", "--doctor as JSON"))
249
280
  parser.add_argument(
250
281
  "--install-skills", action="store_true",
@@ -273,6 +304,61 @@ def _doctor(*, as_json: bool) -> int:
273
304
  return 0
274
305
 
275
306
 
307
+ def _agents_list() -> int:
308
+ rows = [
309
+ (row.name, f"{row.executable} {DIM}· {t(*agentcfg.STATE_LABELS[row.state])}{RESET}")
310
+ for row in agents.inventory()
311
+ ]
312
+ _echo(present.render(rows, t("agents_title", "AGENTS")))
313
+ _dim(t("agents_hint", " aicp --agents set <name> executable=… · disable <name> · reset <name>"))
314
+ return 0
315
+
316
+
317
+ def _agents(argv: Sequence[str]) -> int:
318
+ """``--agents`` and its verbs. Writes nothing on any error path."""
319
+ verb, rest = (argv[0] if argv else "list"), list(argv[1:])
320
+ if verb == "list":
321
+ return _agents_list()
322
+ if verb not in agentcfg.VERBS:
323
+ print(
324
+ f"{RED}"
325
+ + t("agents_bad_verb", "✗ unknown agents verb: %s (use: %s)", verb, " ".join(agentcfg.VERBS))
326
+ + RESET,
327
+ file=sys.stderr,
328
+ )
329
+ return 1
330
+ if not rest:
331
+ print(
332
+ f"{RED}" + t("agents_needs_name", "✗ %s needs an agent name", verb) + RESET,
333
+ file=sys.stderr,
334
+ )
335
+ return 1
336
+ try:
337
+ change = agentcfg.apply(verb, rest[0], rest[1:])
338
+ except agentcfg.AgentEditError as exc:
339
+ print(f"{RED}✗ {exc}{RESET}", file=sys.stderr)
340
+ return 1
341
+ done = {
342
+ "enabled": ("agents_done_enabled", "✓ %s enabled"),
343
+ "disabled": ("agents_done_disabled", "✓ %s disabled"),
344
+ "updated": ("agents_done_updated", "✓ %s updated"),
345
+ "added": ("agents_done_added", "✓ %s added"),
346
+ "reset": ("agents_done_reset", "✓ %s reset to its built-in definition"),
347
+ "removed": ("agents_done_removed", "✓ %s removed"),
348
+ }[change.verb]
349
+ print(f"{GREEN}{t(*done, change.name)}{RESET}")
350
+ if change.pruned:
351
+ _dim(
352
+ t(
353
+ "agents_pruned",
354
+ " dropped from the saved CLI order: %s",
355
+ " ".join(change.pruned),
356
+ )
357
+ )
358
+ _dim(t("agents_next_run", " the next aicp run uses it"))
359
+ return 0
360
+
361
+
276
362
  def _install_skills(*, yes: bool, force: bool) -> int:
277
363
  if not yes:
278
364
  rows = [(f"{s.cli} · {s.skill}", s.state) for s in skills.full_status()]
@@ -576,6 +662,8 @@ def _dispatch(args: argparse.Namespace) -> int:
576
662
 
577
663
  if args.doctor:
578
664
  return _doctor(as_json=args.json)
665
+ if args.agents is not None:
666
+ return _agents(args.agents)
579
667
  if args.install_skills:
580
668
  return _install_skills(yes=args.yes, force=args.force)
581
669
  if args.config: