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.
- {aicp_cli-0.6.0 → aicp_cli-0.7.0}/CHANGELOG.md +14 -0
- {aicp_cli-0.6.0 → aicp_cli-0.7.0}/PKG-INFO +51 -40
- {aicp_cli-0.6.0 → aicp_cli-0.7.0}/README.md +50 -39
- {aicp_cli-0.6.0 → aicp_cli-0.7.0}/pyproject.toml +1 -1
- aicp_cli-0.7.0/src/aicp/agentcfg.py +168 -0
- {aicp_cli-0.6.0 → aicp_cli-0.7.0}/src/aicp/agents.py +70 -6
- {aicp_cli-0.6.0 → aicp_cli-0.7.0}/src/aicp/cli.py +90 -2
- {aicp_cli-0.6.0 → aicp_cli-0.7.0}/src/aicp/config.py +5 -1
- {aicp_cli-0.6.0 → aicp_cli-0.7.0}/src/aicp/i18n.py +25 -0
- {aicp_cli-0.6.0 → aicp_cli-0.7.0}/src/aicp/menu.py +226 -7
- aicp_cli-0.7.0/tests/test_agentcfg.py +246 -0
- {aicp_cli-0.6.0 → aicp_cli-0.7.0}/tests/test_menu.py +5 -4
- {aicp_cli-0.6.0 → aicp_cli-0.7.0}/tests/test_menu_skills.py +91 -10
- {aicp_cli-0.6.0 → aicp_cli-0.7.0}/uv.lock +1 -1
- {aicp_cli-0.6.0 → aicp_cli-0.7.0}/.github/workflows/ci.yml +0 -0
- {aicp_cli-0.6.0 → aicp_cli-0.7.0}/.github/workflows/release.yml +0 -0
- {aicp_cli-0.6.0 → aicp_cli-0.7.0}/.gitignore +0 -0
- {aicp_cli-0.6.0 → aicp_cli-0.7.0}/CLAUDE.md +0 -0
- {aicp_cli-0.6.0 → aicp_cli-0.7.0}/LICENSE +0 -0
- {aicp_cli-0.6.0 → aicp_cli-0.7.0}/TODO.md +0 -0
- {aicp_cli-0.6.0 → aicp_cli-0.7.0}/config.example.json +0 -0
- {aicp_cli-0.6.0 → aicp_cli-0.7.0}/docs/images/aicp-run.png +0 -0
- {aicp_cli-0.6.0 → aicp_cli-0.7.0}/skills/commit.md +0 -0
- {aicp_cli-0.6.0 → aicp_cli-0.7.0}/skills/safe-git-push/SKILL.md +0 -0
- {aicp_cli-0.6.0 → aicp_cli-0.7.0}/skills/safe-git-push/scripts/safe_push.py +0 -0
- {aicp_cli-0.6.0 → aicp_cli-0.7.0}/src/aicp/__init__.py +0 -0
- {aicp_cli-0.6.0 → aicp_cli-0.7.0}/src/aicp/_keyreader.py +0 -0
- {aicp_cli-0.6.0 → aicp_cli-0.7.0}/src/aicp/_utils.py +0 -0
- {aicp_cli-0.6.0 → aicp_cli-0.7.0}/src/aicp/agents.json +0 -0
- {aicp_cli-0.6.0 → aicp_cli-0.7.0}/src/aicp/budget.py +0 -0
- {aicp_cli-0.6.0 → aicp_cli-0.7.0}/src/aicp/contracts.py +0 -0
- {aicp_cli-0.6.0 → aicp_cli-0.7.0}/src/aicp/gitflow.py +0 -0
- {aicp_cli-0.6.0 → aicp_cli-0.7.0}/src/aicp/notify.py +0 -0
- {aicp_cli-0.6.0 → aicp_cli-0.7.0}/src/aicp/present.py +0 -0
- {aicp_cli-0.6.0 → aicp_cli-0.7.0}/src/aicp/quota.py +0 -0
- {aicp_cli-0.6.0 → aicp_cli-0.7.0}/src/aicp/runner.py +0 -0
- {aicp_cli-0.6.0 → aicp_cli-0.7.0}/src/aicp/secrets.py +0 -0
- {aicp_cli-0.6.0 → aicp_cli-0.7.0}/src/aicp/skills.py +0 -0
- {aicp_cli-0.6.0 → aicp_cli-0.7.0}/src/aicp/timing.py +0 -0
- {aicp_cli-0.6.0 → aicp_cli-0.7.0}/tests/conftest.py +0 -0
- {aicp_cli-0.6.0 → aicp_cli-0.7.0}/tests/test_agents.py +0 -0
- {aicp_cli-0.6.0 → aicp_cli-0.7.0}/tests/test_budget.py +0 -0
- {aicp_cli-0.6.0 → aicp_cli-0.7.0}/tests/test_cli.py +0 -0
- {aicp_cli-0.6.0 → aicp_cli-0.7.0}/tests/test_config.py +0 -0
- {aicp_cli-0.6.0 → aicp_cli-0.7.0}/tests/test_cross_platform.py +0 -0
- {aicp_cli-0.6.0 → aicp_cli-0.7.0}/tests/test_gitflow.py +0 -0
- {aicp_cli-0.6.0 → aicp_cli-0.7.0}/tests/test_i18n.py +0 -0
- {aicp_cli-0.6.0 → aicp_cli-0.7.0}/tests/test_notify.py +0 -0
- {aicp_cli-0.6.0 → aicp_cli-0.7.0}/tests/test_present.py +0 -0
- {aicp_cli-0.6.0 → aicp_cli-0.7.0}/tests/test_quota.py +0 -0
- {aicp_cli-0.6.0 → aicp_cli-0.7.0}/tests/test_runner.py +0 -0
- {aicp_cli-0.6.0 → aicp_cli-0.7.0}/tests/test_secrets.py +0 -0
- {aicp_cli-0.6.0 → aicp_cli-0.7.0}/tests/test_skills.py +0 -0
- {aicp_cli-0.6.0 → aicp_cli-0.7.0}/tests/test_timing.py +0 -0
- {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.
|
|
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).
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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).
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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.
|
|
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
|
|
105
|
-
|
|
106
|
-
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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
|
-
|
|
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:
|