aicp-cli 0.4.0__tar.gz → 0.6.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 (54) hide show
  1. {aicp_cli-0.4.0 → aicp_cli-0.6.0}/CHANGELOG.md +38 -0
  2. {aicp_cli-0.4.0 → aicp_cli-0.6.0}/PKG-INFO +94 -19
  3. {aicp_cli-0.4.0 → aicp_cli-0.6.0}/README.md +93 -18
  4. {aicp_cli-0.4.0 → aicp_cli-0.6.0}/TODO.md +12 -0
  5. {aicp_cli-0.4.0 → aicp_cli-0.6.0}/pyproject.toml +1 -1
  6. {aicp_cli-0.4.0 → aicp_cli-0.6.0}/skills/commit.md +6 -2
  7. aicp_cli-0.6.0/src/aicp/agents.py +135 -0
  8. {aicp_cli-0.4.0 → aicp_cli-0.6.0}/src/aicp/cli.py +27 -2
  9. {aicp_cli-0.4.0 → aicp_cli-0.6.0}/src/aicp/i18n.py +1 -0
  10. {aicp_cli-0.4.0 → aicp_cli-0.6.0}/src/aicp/secrets.py +13 -5
  11. {aicp_cli-0.4.0 → aicp_cli-0.6.0}/tests/test_agents.py +101 -1
  12. {aicp_cli-0.4.0 → aicp_cli-0.6.0}/tests/test_cli.py +27 -0
  13. {aicp_cli-0.4.0 → aicp_cli-0.6.0}/tests/test_secrets.py +9 -0
  14. {aicp_cli-0.4.0 → aicp_cli-0.6.0}/uv.lock +1 -1
  15. aicp_cli-0.4.0/src/aicp/agents.py +0 -83
  16. {aicp_cli-0.4.0 → aicp_cli-0.6.0}/.github/workflows/ci.yml +0 -0
  17. {aicp_cli-0.4.0 → aicp_cli-0.6.0}/.github/workflows/release.yml +0 -0
  18. {aicp_cli-0.4.0 → aicp_cli-0.6.0}/.gitignore +0 -0
  19. {aicp_cli-0.4.0 → aicp_cli-0.6.0}/CLAUDE.md +0 -0
  20. {aicp_cli-0.4.0 → aicp_cli-0.6.0}/LICENSE +0 -0
  21. {aicp_cli-0.4.0 → aicp_cli-0.6.0}/config.example.json +0 -0
  22. {aicp_cli-0.4.0 → aicp_cli-0.6.0}/docs/images/aicp-run.png +0 -0
  23. {aicp_cli-0.4.0 → aicp_cli-0.6.0}/skills/safe-git-push/SKILL.md +0 -0
  24. {aicp_cli-0.4.0 → aicp_cli-0.6.0}/skills/safe-git-push/scripts/safe_push.py +0 -0
  25. {aicp_cli-0.4.0 → aicp_cli-0.6.0}/src/aicp/__init__.py +0 -0
  26. {aicp_cli-0.4.0 → aicp_cli-0.6.0}/src/aicp/_keyreader.py +0 -0
  27. {aicp_cli-0.4.0 → aicp_cli-0.6.0}/src/aicp/_utils.py +0 -0
  28. {aicp_cli-0.4.0 → aicp_cli-0.6.0}/src/aicp/agents.json +0 -0
  29. {aicp_cli-0.4.0 → aicp_cli-0.6.0}/src/aicp/budget.py +0 -0
  30. {aicp_cli-0.4.0 → aicp_cli-0.6.0}/src/aicp/config.py +0 -0
  31. {aicp_cli-0.4.0 → aicp_cli-0.6.0}/src/aicp/contracts.py +0 -0
  32. {aicp_cli-0.4.0 → aicp_cli-0.6.0}/src/aicp/gitflow.py +0 -0
  33. {aicp_cli-0.4.0 → aicp_cli-0.6.0}/src/aicp/menu.py +0 -0
  34. {aicp_cli-0.4.0 → aicp_cli-0.6.0}/src/aicp/notify.py +0 -0
  35. {aicp_cli-0.4.0 → aicp_cli-0.6.0}/src/aicp/present.py +0 -0
  36. {aicp_cli-0.4.0 → aicp_cli-0.6.0}/src/aicp/quota.py +0 -0
  37. {aicp_cli-0.4.0 → aicp_cli-0.6.0}/src/aicp/runner.py +0 -0
  38. {aicp_cli-0.4.0 → aicp_cli-0.6.0}/src/aicp/skills.py +0 -0
  39. {aicp_cli-0.4.0 → aicp_cli-0.6.0}/src/aicp/timing.py +0 -0
  40. {aicp_cli-0.4.0 → aicp_cli-0.6.0}/tests/conftest.py +0 -0
  41. {aicp_cli-0.4.0 → aicp_cli-0.6.0}/tests/test_budget.py +0 -0
  42. {aicp_cli-0.4.0 → aicp_cli-0.6.0}/tests/test_config.py +0 -0
  43. {aicp_cli-0.4.0 → aicp_cli-0.6.0}/tests/test_cross_platform.py +0 -0
  44. {aicp_cli-0.4.0 → aicp_cli-0.6.0}/tests/test_gitflow.py +0 -0
  45. {aicp_cli-0.4.0 → aicp_cli-0.6.0}/tests/test_i18n.py +0 -0
  46. {aicp_cli-0.4.0 → aicp_cli-0.6.0}/tests/test_menu.py +0 -0
  47. {aicp_cli-0.4.0 → aicp_cli-0.6.0}/tests/test_menu_skills.py +0 -0
  48. {aicp_cli-0.4.0 → aicp_cli-0.6.0}/tests/test_notify.py +0 -0
  49. {aicp_cli-0.4.0 → aicp_cli-0.6.0}/tests/test_present.py +0 -0
  50. {aicp_cli-0.4.0 → aicp_cli-0.6.0}/tests/test_quota.py +0 -0
  51. {aicp_cli-0.4.0 → aicp_cli-0.6.0}/tests/test_runner.py +0 -0
  52. {aicp_cli-0.4.0 → aicp_cli-0.6.0}/tests/test_skills.py +0 -0
  53. {aicp_cli-0.4.0 → aicp_cli-0.6.0}/tests/test_timing.py +0 -0
  54. {aicp_cli-0.4.0 → aicp_cli-0.6.0}/tests/test_undo.py +0 -0
@@ -1,8 +1,46 @@
1
+ ## [0.5.1] - 2026-09-17
2
+
3
+ ### 🐛 Bug Fixes
4
+
5
+ - **cli:** Remove unused noqa directive
6
+
7
+ ### 📚 Documentation
8
+
9
+ - **changelog:** Release v0.5.1
10
+ ## [0.5.0] - 2026-09-17
11
+
12
+ ### 🚀 Features
13
+
14
+ - **cli:** Allow flags without their leading --
15
+
16
+ ### 🐛 Bug Fixes
17
+
18
+ - **cli:** Remove duplicate commit handler row
19
+ - **secret-scan:** Ignore url path slugs
20
+
21
+ ### 📚 Documentation
22
+
23
+ - Clarify skill installation ownership
24
+ - **changelog:** Release v0.5.0
25
+
26
+ ### ⚙️ Miscellaneous Tasks
27
+
28
+ - **commit:** Remove model metadata
29
+ - **release:** Add scripted release flow
30
+ - **release:** Remove project release.sh, use global template
1
31
  ## [0.4.0] - 2026-09-14
2
32
 
33
+ ### 🐛 Bug Fixes
34
+
35
+ - **release:** Sync uv.lock for v0.4.0
36
+
3
37
  ### 🚜 Refactor
4
38
 
5
39
  - **agents:** Centralize agent definitions
40
+
41
+ ### ⚙️ Miscellaneous Tasks
42
+
43
+ - **release:** Bump version to 0.4.0
6
44
  ## [0.3.1] - 2026-09-14
7
45
 
8
46
  ### 🐛 Bug Fixes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: aicp-cli
3
- Version: 0.4.0
3
+ Version: 0.6.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
@@ -30,7 +30,7 @@ push that never reached the remote.
30
30
 
31
31
  [Install](#install) · [Set up skills](#set-up-skills) · [Run it](#run-it) ·
32
32
  [`--undo`](#--undo) · [Automation / CI](#automation--ci) ·
33
- [Configuration](#configuration) · [Safety](#safety) ·
33
+ [Configuration](#configuration) · [Custom agents](#custom-agents) · [Safety](#safety) ·
34
34
  [Platform support](#platform-support)
35
35
 
36
36
  ## Requirements
@@ -66,13 +66,12 @@ uv tool uninstall aicp-cli
66
66
  aicp --config # -> Skills
67
67
  ```
68
68
 
69
- `/commit` and `/safe-git-push` only mean something if a skill by that exact
70
- name exists in the CLI's own config directory — otherwise the CLI receives a
71
- slash command it has never heard of and improvises. aicp ships byte-identical
72
- copies of both skills and installs them for you, but it never overwrites a
73
- skill you already have: a file with no aicp version marker next to it is
74
- treated as yours and left alone. If you already have a better `/commit` for
75
- this repo, it stays.
69
+ `/safe-git-push` only means something if a skill by that exact name exists in
70
+ the CLI's own config directory — otherwise the CLI receives a slash command it
71
+ has never heard of and improvises. aicp ships and installs that skill for you,
72
+ but it never overwrites a skill you already have: a file with no aicp version
73
+ marker next to it is treated as yours and left alone. `/commit` is always left
74
+ to your existing definition.
76
75
 
77
76
  Targets follow each CLI's own config directory, not its binary name — `agy`
78
77
  (this project's name for the Gemini CLI) reads `~/.gemini`, not `~/.agy`:
@@ -80,16 +79,16 @@ Targets follow each CLI's own config directory, not its binary name — `agy`
80
79
  | CLI | Config dir | `/commit` installed? | `/safe-git-push` installed? |
81
80
  | --- | --- | --- | --- |
82
81
  | `claude` | `~/.claude` | No — keeps your existing `commands/commit.md` | Yes |
83
- | `codex` | `~/.codex` | Yes | Yes |
84
- | `copilot` | `~/.copilot` | Yes | Yes |
85
- | `agy` (Gemini CLI) | `~/.gemini` | Yes | Yes |
86
- | `vibe` | `~/.vibe` | Yes | Yes |
87
- | `grok` | `$GROK_HOME` when set, otherwise `~/.grok` | Yes | Yes |
82
+ | `codex` | `~/.codex` | No — keeps your existing `/commit` | Yes |
83
+ | `copilot` | `~/.copilot` | No — keeps your existing `/commit` | Yes |
84
+ | `agy` (Gemini CLI) | `~/.gemini` | No — keeps your existing `/commit` | Yes |
85
+ | `vibe` | `~/.vibe` | No — keeps your existing `/commit` | Yes |
86
+ | `grok` | `$GROK_HOME` when set, otherwise `~/.grok` | No — keeps your existing `/commit` | Yes |
88
87
 
89
- `claude` is the one exception: it already resolves `/commit` from its own
90
- `commands/commit.md`, so aicp never installs a competing definition under
91
- `skills/` there — installing one would just shadow the one Claude already
92
- uses. Every other CLI gets both skills.
88
+ `/commit` is never installed: aicp keeps each CLI's existing command or skill
89
+ definition. Claude resolves that definition from `commands/commit.md`; the
90
+ other CLIs keep their existing `/commit` as well. Every configured CLI gets
91
+ `/safe-git-push`.
93
92
 
94
93
  A CLI whose config directory doesn't exist at all is skipped, never created —
95
94
  aicp only ever installs into a CLI you've actually set up.
@@ -133,6 +132,9 @@ through the fallback chain, then `/safe-git-push` the same way, then prints
133
132
  the git-verified result table described above. `-v`/`--verbose` streams each
134
133
  CLI's raw output live instead of showing a spinner.
135
134
 
135
+ Every long flag also works without its leading `--` — `aicp config` is the
136
+ same as `aicp --config`.
137
+
136
138
  ![aicp run: commit, push, and the git-verified result table](docs/images/aicp-run.png)
137
139
 
138
140
  `aicp --config` is one menu for everything that isn't "commit and push
@@ -220,7 +222,7 @@ integration tests and installed-wheel checks on all three operating systems.
220
222
  | `config_dir` | Absolute path or `~/`-relative directory; home is resolved when used. |
221
223
  | `config_dir_env` | Optional environment variable overriding that directory (currently `GROK_HOME`). |
222
224
  | `skills_dir` | Relative directory inside `config_dir` where skills are installed. |
223
- | `skills` | Vendored skills to install: `commit` and/or `safe-git-push`. Claude keeps its existing `/commit`. |
225
+ | `skills` | Vendored skills to install: `/safe-git-push`. Every CLI keeps its existing `/commit`. |
224
226
  | `memory_file` | Instruction filename used when adapting vendored skill text. Project directory references use the basename of `config_dir`. |
225
227
  | `args` | Argument array containing exactly one standalone `{prompt}`, replaced with the complete prompt as one argument. No shell evaluation. |
226
228
 
@@ -236,6 +238,79 @@ upgrades replace it; neither repository-local files nor
236
238
  `~/.aicp/config.json` can override agent execution settings. Ordinary user
237
239
  preferences continue to use the config file below.
238
240
 
241
+ ### Custom agents
242
+
243
+ You can add agents that are not in the built-in registry — or suppress
244
+ 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.
249
+
250
+ **Adding a new agent** — example: [MiniMax](https://www.minimaxi.com/) CLI:
251
+
252
+ ```json
253
+ {
254
+ "version": 1,
255
+ "agents": {
256
+ "minimax": {
257
+ "executable": "minimax",
258
+ "config_dir": "~/.minimax",
259
+ "memory_file": "AGENTS.md",
260
+ "skills_dir": "skills",
261
+ "skills": ["safe-git-push"],
262
+ "args": ["--prompt", "{prompt}", "--yes"]
263
+ }
264
+ }
265
+ }
266
+ ```
267
+
268
+ Then add `minimax` anywhere in your preferred fallback order:
269
+
270
+ ```sh
271
+ # ~/.aicp/config.json
272
+ { "aicp_cli_order": "minimax copilot agy codex claude vibe grok" }
273
+ ```
274
+
275
+ Or set it as an environment variable for one run:
276
+
277
+ ```sh
278
+ AICP_CLI_ORDER="minimax claude" aicp
279
+ ```
280
+
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).
313
+
239
314
  The config file lives at `~/.aicp/config.json` (override the path itself
240
315
  with `AICP_CONFIG`, which — being the thing that names the file — can only be
241
316
  set as a real environment variable). Precedence everywhere is
@@ -18,7 +18,7 @@ push that never reached the remote.
18
18
 
19
19
  [Install](#install) · [Set up skills](#set-up-skills) · [Run it](#run-it) ·
20
20
  [`--undo`](#--undo) · [Automation / CI](#automation--ci) ·
21
- [Configuration](#configuration) · [Safety](#safety) ·
21
+ [Configuration](#configuration) · [Custom agents](#custom-agents) · [Safety](#safety) ·
22
22
  [Platform support](#platform-support)
23
23
 
24
24
  ## Requirements
@@ -54,13 +54,12 @@ uv tool uninstall aicp-cli
54
54
  aicp --config # -> Skills
55
55
  ```
56
56
 
57
- `/commit` and `/safe-git-push` only mean something if a skill by that exact
58
- name exists in the CLI's own config directory — otherwise the CLI receives a
59
- slash command it has never heard of and improvises. aicp ships byte-identical
60
- copies of both skills and installs them for you, but it never overwrites a
61
- skill you already have: a file with no aicp version marker next to it is
62
- treated as yours and left alone. If you already have a better `/commit` for
63
- this repo, it stays.
57
+ `/safe-git-push` only means something if a skill by that exact name exists in
58
+ the CLI's own config directory — otherwise the CLI receives a slash command it
59
+ has never heard of and improvises. aicp ships and installs that skill for you,
60
+ but it never overwrites a skill you already have: a file with no aicp version
61
+ marker next to it is treated as yours and left alone. `/commit` is always left
62
+ to your existing definition.
64
63
 
65
64
  Targets follow each CLI's own config directory, not its binary name — `agy`
66
65
  (this project's name for the Gemini CLI) reads `~/.gemini`, not `~/.agy`:
@@ -68,16 +67,16 @@ Targets follow each CLI's own config directory, not its binary name — `agy`
68
67
  | CLI | Config dir | `/commit` installed? | `/safe-git-push` installed? |
69
68
  | --- | --- | --- | --- |
70
69
  | `claude` | `~/.claude` | No — keeps your existing `commands/commit.md` | Yes |
71
- | `codex` | `~/.codex` | Yes | Yes |
72
- | `copilot` | `~/.copilot` | Yes | Yes |
73
- | `agy` (Gemini CLI) | `~/.gemini` | Yes | Yes |
74
- | `vibe` | `~/.vibe` | Yes | Yes |
75
- | `grok` | `$GROK_HOME` when set, otherwise `~/.grok` | Yes | Yes |
70
+ | `codex` | `~/.codex` | No — keeps your existing `/commit` | Yes |
71
+ | `copilot` | `~/.copilot` | No — keeps your existing `/commit` | Yes |
72
+ | `agy` (Gemini CLI) | `~/.gemini` | No — keeps your existing `/commit` | Yes |
73
+ | `vibe` | `~/.vibe` | No — keeps your existing `/commit` | Yes |
74
+ | `grok` | `$GROK_HOME` when set, otherwise `~/.grok` | No — keeps your existing `/commit` | Yes |
76
75
 
77
- `claude` is the one exception: it already resolves `/commit` from its own
78
- `commands/commit.md`, so aicp never installs a competing definition under
79
- `skills/` there — installing one would just shadow the one Claude already
80
- uses. Every other CLI gets both skills.
76
+ `/commit` is never installed: aicp keeps each CLI's existing command or skill
77
+ definition. Claude resolves that definition from `commands/commit.md`; the
78
+ other CLIs keep their existing `/commit` as well. Every configured CLI gets
79
+ `/safe-git-push`.
81
80
 
82
81
  A CLI whose config directory doesn't exist at all is skipped, never created —
83
82
  aicp only ever installs into a CLI you've actually set up.
@@ -121,6 +120,9 @@ through the fallback chain, then `/safe-git-push` the same way, then prints
121
120
  the git-verified result table described above. `-v`/`--verbose` streams each
122
121
  CLI's raw output live instead of showing a spinner.
123
122
 
123
+ Every long flag also works without its leading `--` — `aicp config` is the
124
+ same as `aicp --config`.
125
+
124
126
  ![aicp run: commit, push, and the git-verified result table](docs/images/aicp-run.png)
125
127
 
126
128
  `aicp --config` is one menu for everything that isn't "commit and push
@@ -208,7 +210,7 @@ integration tests and installed-wheel checks on all three operating systems.
208
210
  | `config_dir` | Absolute path or `~/`-relative directory; home is resolved when used. |
209
211
  | `config_dir_env` | Optional environment variable overriding that directory (currently `GROK_HOME`). |
210
212
  | `skills_dir` | Relative directory inside `config_dir` where skills are installed. |
211
- | `skills` | Vendored skills to install: `commit` and/or `safe-git-push`. Claude keeps its existing `/commit`. |
213
+ | `skills` | Vendored skills to install: `/safe-git-push`. Every CLI keeps its existing `/commit`. |
212
214
  | `memory_file` | Instruction filename used when adapting vendored skill text. Project directory references use the basename of `config_dir`. |
213
215
  | `args` | Argument array containing exactly one standalone `{prompt}`, replaced with the complete prompt as one argument. No shell evaluation. |
214
216
 
@@ -224,6 +226,79 @@ upgrades replace it; neither repository-local files nor
224
226
  `~/.aicp/config.json` can override agent execution settings. Ordinary user
225
227
  preferences continue to use the config file below.
226
228
 
229
+ ### Custom agents
230
+
231
+ You can add agents that are not in the built-in registry — or suppress
232
+ 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.
237
+
238
+ **Adding a new agent** — example: [MiniMax](https://www.minimaxi.com/) CLI:
239
+
240
+ ```json
241
+ {
242
+ "version": 1,
243
+ "agents": {
244
+ "minimax": {
245
+ "executable": "minimax",
246
+ "config_dir": "~/.minimax",
247
+ "memory_file": "AGENTS.md",
248
+ "skills_dir": "skills",
249
+ "skills": ["safe-git-push"],
250
+ "args": ["--prompt", "{prompt}", "--yes"]
251
+ }
252
+ }
253
+ }
254
+ ```
255
+
256
+ Then add `minimax` anywhere in your preferred fallback order:
257
+
258
+ ```sh
259
+ # ~/.aicp/config.json
260
+ { "aicp_cli_order": "minimax copilot agy codex claude vibe grok" }
261
+ ```
262
+
263
+ Or set it as an environment variable for one run:
264
+
265
+ ```sh
266
+ AICP_CLI_ORDER="minimax claude" aicp
267
+ ```
268
+
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).
301
+
227
302
  The config file lives at `~/.aicp/config.json` (override the path itself
228
303
  with `AICP_CONFIG`, which — being the thing that names the file — can only be
229
304
  set as a real environment variable). Precedence everywhere is
@@ -37,3 +37,15 @@ reliably, so it was avoided in the safe git push stage afterward — find
37
37
  and fix cases like this where a slow/unreliable step gets silently
38
38
  bypassed instead of fixed. Audit other stages for similar slowness or
39
39
  failure-avoidance patterns.
40
+
41
+ ## Support "devin" harness (open)
42
+
43
+ Detect and support the Devin AI agent environment (`~/.devin`).
44
+ Add a `devin` harness so `aicp` can install skills, rules, and config
45
+ into the paths and formats that Devin expects.
46
+
47
+ ## Support "opencode" harness (open)
48
+
49
+ Detect and support the OpenCode agent environment (`~/.opencode`).
50
+ Add an `opencode` harness so `aicp` can install skills, rules, and config
51
+ into the paths and formats that OpenCode expects.
@@ -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.4.0"
5
+ version = "0.6.0"
6
6
  description = "AI commit + push, with a git-verified result summary"
7
7
  readme = "README.md"
8
8
  requires-python = ">=3.10"
@@ -1,13 +1,17 @@
1
1
  ---
2
2
  description: Stage and commit all changes, grouped into logical Conventional Commits batches.
3
- model: sonnet
4
- effort: medium
5
3
  ---
6
4
 
7
5
  # Commit
8
6
 
9
7
  Git commit current changes, split into logical batches if needed.
10
8
 
9
+ ## Secret-scan behavior
10
+
11
+ The pre-command aicp gate ignores key-like text only in URL paths, not in
12
+ ordinary text or URL query values. This prevents hyphenated article slugs from
13
+ blocking `/commit` without weakening sensitive-string detection.
14
+
11
15
  ## Project override (check FIRST)
12
16
 
13
17
  Skills/custom commands resolve **personal-over-project** on a name clash, so
@@ -0,0 +1,135 @@
1
+ """Bundled agent definitions shared by execution and skill management.
2
+
3
+ The package's ``agents.json`` is the base registry. ``~/.aicp/agents.json``,
4
+ if present, is merged over it at import time: an entry for a name already in
5
+ the base registry overrides only the fields it lists (unspecified fields
6
+ keep the built-in value); an entry for a new name must supply every field,
7
+ same as the base registry; ``{"disabled": true}`` drops that name from the
8
+ chain entirely. A malformed user file is warned about on stderr and ignored
9
+ rather than crashing every invocation — see :func:`_load_user_overrides`.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import os
16
+ import sys
17
+ from dataclasses import dataclass
18
+ from pathlib import Path
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class Agent:
23
+ executable: str
24
+ config_dir: str
25
+ memory_file: str
26
+ skills_dir: str
27
+ skills: tuple[str, ...]
28
+ args: tuple[str, ...]
29
+ config_dir_env: str = ""
30
+
31
+ def config_root(self, home: Path | None = None) -> Path:
32
+ if home is None and (override := os.environ.get(self.config_dir_env)):
33
+ return Path(override).expanduser()
34
+ if self.config_dir.startswith("~/"):
35
+ return (Path.home() if home is None else home) / self.config_dir[2:]
36
+ return Path(self.config_dir)
37
+
38
+
39
+ def _read_registry(path: Path) -> dict[str, dict]:
40
+ data = json.loads(path.read_text(encoding="utf-8"))
41
+ if not isinstance(data, dict) or data.get("version") != 1:
42
+ raise ValueError(f"{path}: unsupported agent registry version")
43
+ entries = data.get("agents")
44
+ if not isinstance(entries, dict) or not entries:
45
+ raise ValueError(f"{path}: expected a nonempty agents object")
46
+ return entries
47
+
48
+
49
+ def _build_agent(path: Path, name: str, entry: dict, base: Agent | None) -> Agent:
50
+ """Validate *entry*, falling back to *base*'s fields for anything it omits.
51
+
52
+ ``base=None`` (the built-in registry, or a brand-new name) requires every
53
+ field to be present, same as before this function existed.
54
+ """
55
+ if not isinstance(entry, dict):
56
+ raise TypeError(f"{path}: {name}: expected an agent object")
57
+ merged: dict[str, object] = {
58
+ "executable": base.executable if base else None,
59
+ "config_dir": base.config_dir if base else None,
60
+ "memory_file": base.memory_file if base else None,
61
+ "skills_dir": base.skills_dir if base else None,
62
+ "skills": list(base.skills) if base else None,
63
+ "args": list(base.args) if base else None,
64
+ "config_dir_env": base.config_dir_env if base else "",
65
+ }
66
+ merged.update({k: v for k, v in entry.items() if k != "disabled"})
67
+ for key in ["executable", "config_dir", "memory_file", "skills_dir"]:
68
+ value = merged.get(key)
69
+ if not isinstance(value, str) or not value.strip() or "\0" in value:
70
+ raise ValueError(f"{path}: {name}: invalid {key}")
71
+ for key in ("args", "skills"):
72
+ value = merged.get(key)
73
+ if not isinstance(value, list) or not all(
74
+ isinstance(item, str) and "\0" not in item for item in value
75
+ ):
76
+ raise ValueError(f"{path}: {name}: invalid {key}")
77
+ if merged["args"].count("{prompt}") != 1:
78
+ raise ValueError(f"{path}: {name}: args must contain one {{prompt}} argument")
79
+ if not merged["config_dir"].startswith("~/") and not Path(merged["config_dir"]).is_absolute():
80
+ raise ValueError(f"{path}: {name}: config_dir must be absolute or start with ~/")
81
+ skills_dir = Path(merged["skills_dir"])
82
+ if skills_dir.anchor or ".." in skills_dir.parts or skills_dir == Path("."):
83
+ raise ValueError(f"{path}: {name}: skills_dir must stay inside config_dir")
84
+ env = merged.get("config_dir_env", "")
85
+ if not isinstance(env, str) or (env and not env.isidentifier()):
86
+ raise ValueError(f"{path}: {name}: invalid config_dir_env")
87
+ return Agent(
88
+ executable=merged["executable"],
89
+ config_dir=merged["config_dir"],
90
+ memory_file=merged["memory_file"],
91
+ skills_dir=merged["skills_dir"],
92
+ skills=tuple(merged["skills"]),
93
+ args=tuple(merged["args"]),
94
+ config_dir_env=env,
95
+ )
96
+
97
+
98
+ def load_agents(path: Path) -> dict[str, Agent]:
99
+ """Validate a full (built-in shaped) registry file."""
100
+ entries = _read_registry(path)
101
+ return {name: _build_agent(path, name, entry, base=None) for name, entry in entries.items()}
102
+
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."""
107
+ merged = dict(agents)
108
+ for name, entry in _read_registry(path).items():
109
+ if not isinstance(entry, dict):
110
+ raise TypeError(f"{path}: {name}: expected an agent object")
111
+ if entry.get("disabled") is True:
112
+ merged.pop(name, None)
113
+ continue
114
+ merged[name] = _build_agent(path, name, entry, base=merged.get(name))
115
+ return merged
116
+
117
+
118
+ def _load_user_overrides(agents: dict[str, Agent], home: Path | None = None) -> dict[str, Agent]:
119
+ path = (home or Path.home()) / ".aicp" / "agents.json"
120
+ if not path.is_file():
121
+ return agents
122
+ try:
123
+ return merge_user_agents(agents, path)
124
+ except (OSError, ValueError, TypeError, json.JSONDecodeError) as exc:
125
+ print(f"aicp: ignoring {path} ({exc}) — using built-in agents only", file=sys.stderr)
126
+ return agents
127
+
128
+
129
+ AGENTS = _load_user_overrides(load_agents(Path(__file__).with_name("agents.json")))
130
+
131
+
132
+ def executable(name: str) -> str:
133
+ """Resolve a roster ID to its binary; leave non-agent commands alone."""
134
+ agent = AGENTS.get(name)
135
+ return agent.executable if agent else name
@@ -185,6 +185,25 @@ class _Parser(argparse.ArgumentParser):
185
185
  raise SystemExit(1)
186
186
 
187
187
 
188
+ def _normalize_argv(argv: Sequence[str], parser: argparse.ArgumentParser) -> list[str]:
189
+ """Let a long flag's ``--`` be omitted: ``aicp config`` == ``aicp --config``.
190
+
191
+ The allowed bare words are read off *parser* itself (every ``--xxx`` it
192
+ defines), so a flag added to :func:`build_parser` gains this for free with
193
+ no second list to keep in sync. A word already starting with ``-`` is
194
+ left alone — this only fills in a *missing* prefix, never reinterprets
195
+ one that is already there (``--conf`` still isn't ``--config``: that's
196
+ ``allow_abbrev=False``'s job, untouched by this).
197
+ """
198
+ long_flags = {
199
+ opt[2:]
200
+ for action in parser._actions
201
+ for opt in action.option_strings
202
+ if opt.startswith("--")
203
+ }
204
+ return [f"--{arg}" if arg in long_flags else arg for arg in argv]
205
+
206
+
188
207
  def build_parser() -> argparse.ArgumentParser:
189
208
  parser = _Parser(
190
209
  prog="aicp",
@@ -192,6 +211,11 @@ def build_parser() -> argparse.ArgumentParser:
192
211
  # English: it is argparse's, not aicp's, and gettext-patching a stdlib
193
212
  # module to translate four words is not worth the surprise.
194
213
  description=t("help_description", "AI commit + push, with a git-verified result summary."),
214
+ epilog=t(
215
+ "help_epilog",
216
+ "every flag above also works without its leading `--` "
217
+ "(`aicp config` is the same as `aicp --config`).",
218
+ ),
195
219
  # No abbreviation matching: `--conf` must not silently become
196
220
  # `--config` (which runs the settings menu instead of a commit).
197
221
  allow_abbrev=False,
@@ -318,7 +342,7 @@ def _commit_step(
318
342
  print()
319
343
  _echo(
320
344
  present.render(
321
- [(t("commit_handler", "Commit handler"), result.winner or "—"), *rows],
345
+ rows,
322
346
  f'{t("commits_panel_title", "NEW COMMITS")} · {gitflow.tz_label()}',
323
347
  mode="panel",
324
348
  accent=GREEN,
@@ -576,8 +600,9 @@ def _dispatch(args: argparse.Namespace) -> int:
576
600
 
577
601
  def main(argv: list[str] | None = None) -> int:
578
602
  parser = build_parser()
603
+ raw = sys.argv[1:] if argv is None else argv
579
604
  try:
580
- args = parser.parse_args(argv)
605
+ args = parser.parse_args(_normalize_argv(raw, parser))
581
606
  _reject_orphan_sub_flags(parser, args)
582
607
  except SystemExit as exc: # --help/--version (0), or a bad flag (1)
583
608
  return int(exc.code or 0)
@@ -213,6 +213,7 @@ CATALOG: dict[str, str] = {
213
213
  "help_install_skills": "把內建的 skills 安裝到每個已設定的 CLI",
214
214
  "help_yes": "--install-skills:實際寫入(不加就只是預覽)",
215
215
  "help_force": "--install-skills:覆蓋別人的同名檔案,並留下 .bak",
216
+ "help_epilog": "以上任一 flag 都可以省略開頭的 `--`(`aicp config` 等同 `aicp --config`)。",
216
217
  }
217
218
 
218
219
 
@@ -40,10 +40,12 @@ from .i18n import t
40
40
 
41
41
  __all__ = ["PATTERNS", "ScanResult", "report_lines", "scan"]
42
42
 
43
- # Prefix/format checks only, copied verbatim from AICP_SECRET_PATTERNS in
44
- # bin/aicp. The human name is the only half ever printed.
43
+ # Prefix/format checks only. The human name is the only half ever printed.
45
44
  PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = (
46
- (re.compile(r"sk-(proj-)?[A-Za-z0-9_-]{20,}"), "OpenAI-style API key (sk-…)"),
45
+ (
46
+ re.compile(r"sk-(proj-)?[A-Za-z0-9_-]{20,}"),
47
+ "OpenAI-style API key (sk-…)",
48
+ ),
47
49
  (re.compile(r"ghp_[A-Za-z0-9]{20,}"), "GitHub personal access token (ghp_…)"),
48
50
  (re.compile(r"ghs_[A-Za-z0-9]{20,}"), "GitHub App/server token (ghs_…)"),
49
51
  (re.compile(r"github_pat_[A-Za-z0-9_]{20,}"), "GitHub fine-grained PAT (github_pat_…)"),
@@ -52,6 +54,8 @@ PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = (
52
54
  (re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY( BLOCK)?-----"), "private key block"),
53
55
  )
54
56
 
57
+ _URL_PATH = re.compile(r"https?://[^/\s?#]+(?P<path>/[^\s?#]*)")
58
+
55
59
  #: How much of a file is inspected for a NUL byte before calling it binary —
56
60
  #: the same "first block decides" rule `grep -I` applies.
57
61
  _BINARY_SNIFF_BYTES = 8192
@@ -77,8 +81,12 @@ def _hit(file: str, lineno: int, content: str) -> str | None:
77
81
  """The redaction boundary: *content* is matched and discarded, and only
78
82
  the location plus the pattern's name survive into the returned string."""
79
83
  for pattern, name in PATTERNS:
80
- if pattern.search(content):
81
- return f"{file}:{lineno} looks like {name}"
84
+ for match in pattern.finditer(content):
85
+ if not any(
86
+ url.start("path") <= match.start() and match.end() <= url.end("path")
87
+ for url in _URL_PATH.finditer(content)
88
+ ):
89
+ return f"{file}:{lineno} looks like {name}"
82
90
  return None
83
91
 
84
92
 
@@ -10,7 +10,7 @@ from pathlib import Path
10
10
  import pytest
11
11
 
12
12
  import aicp
13
- from aicp.agents import load_agents
13
+ from aicp.agents import _load_user_overrides, load_agents, merge_user_agents
14
14
 
15
15
 
16
16
  @pytest.mark.parametrize("location", ["home", "absolute", "environment"])
@@ -99,6 +99,106 @@ def test_rejects_invalid_registry_fields(tmp_path, field, value):
99
99
  load_agents(path)
100
100
 
101
101
 
102
+ def _write(path: Path, agents: dict) -> Path:
103
+ path.write_text(json.dumps({"version": 1, "agents": agents}))
104
+ return path
105
+
106
+
107
+ def test_merge_user_agents_adds_a_new_agent(tmp_path):
108
+ base = load_agents(Path(aicp.__file__).with_name("agents.json"))
109
+ override = _write(
110
+ tmp_path / "agents.json",
111
+ {
112
+ "minimax": {
113
+ "executable": "minimax",
114
+ "config_dir": "~/.minimax",
115
+ "memory_file": "AGENTS.md",
116
+ "skills_dir": "skills",
117
+ "skills": ["safe-git-push"],
118
+ "args": ["--prompt", "{prompt}", "--yes"],
119
+ }
120
+ },
121
+ )
122
+ merged = merge_user_agents(base, override)
123
+ assert set(merged) == set(base) | {"minimax"}
124
+ assert merged["minimax"].executable == "minimax"
125
+
126
+
127
+ def test_merge_user_agents_overrides_only_listed_fields(tmp_path):
128
+ base = load_agents(Path(aicp.__file__).with_name("agents.json"))
129
+ override = _write(tmp_path / "agents.json", {"claude": {"executable": "/opt/claude"}})
130
+ merged = merge_user_agents(base, override)
131
+ assert merged["claude"].executable == "/opt/claude"
132
+ assert merged["claude"].config_dir == base["claude"].config_dir
133
+ assert merged["claude"].args == base["claude"].args
134
+
135
+
136
+ def test_merge_user_agents_disabled_drops_the_agent(tmp_path):
137
+ base = load_agents(Path(aicp.__file__).with_name("agents.json"))
138
+ override = _write(tmp_path / "agents.json", {"grok": {"disabled": True}})
139
+ merged = merge_user_agents(base, override)
140
+ assert "grok" not in merged
141
+ assert set(merged) == set(base) - {"grok"}
142
+
143
+
144
+ def test_merge_user_agents_new_agent_requires_every_field(tmp_path):
145
+ base = load_agents(Path(aicp.__file__).with_name("agents.json"))
146
+ override = _write(tmp_path / "agents.json", {"minimax": {"executable": "minimax"}})
147
+ with pytest.raises(ValueError, match="config_dir"):
148
+ merge_user_agents(base, override)
149
+
150
+
151
+ def test_load_user_overrides_warns_and_falls_back_on_malformed_file(tmp_path, capsys):
152
+ base = load_agents(Path(aicp.__file__).with_name("agents.json"))
153
+ (tmp_path / ".aicp").mkdir()
154
+ (tmp_path / ".aicp" / "agents.json").write_text("not json")
155
+ result = _load_user_overrides(base, home=tmp_path)
156
+ assert result == base
157
+ assert "ignoring" in capsys.readouterr().err
158
+
159
+
160
+ def test_load_user_overrides_is_a_noop_with_no_file(tmp_path):
161
+ base = load_agents(Path(aicp.__file__).with_name("agents.json"))
162
+ assert _load_user_overrides(base, home=tmp_path) == base
163
+
164
+
165
+ def test_user_agents_json_reaches_roster_and_cli_order(tmp_path):
166
+ home = tmp_path / "home"
167
+ (home / ".aicp").mkdir(parents=True)
168
+ _write(
169
+ home / ".aicp" / "agents.json",
170
+ {
171
+ "grok": {"disabled": True},
172
+ "minimax": {
173
+ "executable": "minimax",
174
+ "config_dir": "~/.minimax",
175
+ "memory_file": "AGENTS.md",
176
+ "skills_dir": "skills",
177
+ "skills": ["safe-git-push"],
178
+ "args": ["--prompt", "{prompt}", "--yes"],
179
+ },
180
+ },
181
+ )
182
+ result = subprocess.run(
183
+ [sys.executable, "-c", """
184
+ from aicp.contracts import ROSTER
185
+ from aicp.config import resolve_cli_chain
186
+ names = [c.name for c in ROSTER]
187
+ assert 'grok' not in names, names
188
+ assert 'minimax' in names, names
189
+ assert resolve_cli_chain('minimax claude')[0] == 'minimax'
190
+ print('ok')
191
+ """],
192
+ env={**os.environ, "HOME": str(home), "PYTHONUTF8": "1"},
193
+ capture_output=True,
194
+ text=True,
195
+ encoding="utf-8",
196
+ check=False,
197
+ )
198
+ assert result.returncode == 0, result.stdout + result.stderr
199
+ assert "ok" in result.stdout
200
+
201
+
102
202
  def test_config_root_preserves_absolute_paths_and_live_env(tmp_path, monkeypatch):
103
203
  from dataclasses import replace
104
204
 
@@ -174,6 +174,32 @@ def test_flags_are_not_abbreviation_matched(run, aicprc, git_repo, stub_cli, cal
174
174
  assert calls(call_log) == []
175
175
 
176
176
 
177
+ def test_a_bare_flag_name_works_the_same_as_its_dashed_form(
178
+ run, aicprc, git_repo, stub_cli, call_log, monkeypatch
179
+ ):
180
+ """``aicp config`` == ``aicp --config``."""
181
+ stub_cli()
182
+ opened: list[int] = []
183
+ monkeypatch.setattr(cli.menu, "config_menu", lambda **_kw: opened.append(1) or 0)
184
+ assert run(git_repo, "config") == 0
185
+ assert len(opened) == 1
186
+ assert calls(call_log) == []
187
+
188
+
189
+ def test_a_bare_word_that_is_not_a_known_flag_still_fails(
190
+ run, aicprc, git_repo, stub_cli, call_log
191
+ ):
192
+ stub_cli()
193
+ assert run(git_repo, "not-a-flag") != 0
194
+ assert calls(call_log) == []
195
+
196
+
197
+ def test_help_mentions_the_optional_dashes(run, aicprc, git_repo, stub_cli, call_log, capsys):
198
+ stub_cli()
199
+ assert run(git_repo, "--help") == 0
200
+ assert "aicp config" in capsys.readouterr().out
201
+
202
+
177
203
  # ── repo / branch guards ─────────────────────────────────────────────────────
178
204
 
179
205
 
@@ -297,6 +323,7 @@ def test_result_presents_the_winning_handler_for_each_attempt(
297
323
  out = capsys.readouterr().out
298
324
  assert "Commit handler" in out and "writer" in out
299
325
  assert "Push handler" in out and "pusher" in out
326
+ assert out.count("Commit handler") == 1
300
327
 
301
328
 
302
329
  # ── the git-verified RESULT table ────────────────────────────────────────────
@@ -32,6 +32,7 @@ SAMPLES: tuple[tuple[str, str], ...] = (
32
32
  ("aws-asia", "ASIAABCDEFGHIJKLMNOP"),
33
33
  ("bearer", "Bearer abcdefghijklmnopqrstuvwxyz0123"),
34
34
  ("private-key", "-----BEGIN RSA PRIVATE KEY-----"),
35
+ ("openai-url-safe", "sk-abcde_fghij-klmno_pqrstuvwxyz0123"),
35
36
  )
36
37
 
37
38
  # The entropy heuristic was deliberately rejected upstream (see the comment
@@ -39,6 +40,7 @@ SAMPLES: tuple[tuple[str, str], ...] = (
39
40
  # source, and this repo's own strings would trip it.
40
41
  BENIGN: tuple[str, ...] = (
41
42
  "sk-automation",
43
+ "https://example.com/elon-musk-authorized-biography",
42
44
  "Bearer short",
43
45
  "AKIAZ demo",
44
46
  "ghp_short",
@@ -95,6 +97,13 @@ def test_benign_lookalikes_are_not_flagged(repo: Path, line: str) -> None:
95
97
  assert result.ok
96
98
 
97
99
 
100
+ def test_sensitive_value_in_a_url_query_is_flagged(repo: Path) -> None:
101
+ (repo / "notes.txt").write_text(
102
+ f"https://example.com/?api_key={SAMPLES[-1][1]}\n", encoding="utf-8"
103
+ )
104
+ assert scan(repo).hits
105
+
106
+
98
107
  # ── scan surface ─────────────────────────────────────────────────────────────
99
108
 
100
109
 
@@ -4,7 +4,7 @@ requires-python = ">=3.10"
4
4
 
5
5
  [[package]]
6
6
  name = "aicp-cli"
7
- version = "0.4.0"
7
+ version = "0.6.0"
8
8
  source = { editable = "." }
9
9
 
10
10
  [package.dev-dependencies]
@@ -1,83 +0,0 @@
1
- """Bundled agent definitions shared by execution and skill management.
2
-
3
- Only the package's agents.json is read; working-directory and user config
4
- files cannot supply executable paths or arguments through this registry.
5
- """
6
-
7
- from __future__ import annotations
8
-
9
- import json
10
- import os
11
- from dataclasses import dataclass
12
- from pathlib import Path
13
-
14
-
15
- @dataclass(frozen=True)
16
- class Agent:
17
- executable: str
18
- config_dir: str
19
- memory_file: str
20
- skills_dir: str
21
- skills: tuple[str, ...]
22
- args: tuple[str, ...]
23
- config_dir_env: str = ""
24
-
25
- def config_root(self, home: Path | None = None) -> Path:
26
- if home is None and (override := os.environ.get(self.config_dir_env)):
27
- return Path(override).expanduser()
28
- if self.config_dir.startswith("~/"):
29
- return (Path.home() if home is None else home) / self.config_dir[2:]
30
- return Path(self.config_dir)
31
-
32
-
33
- def load_agents(path: Path) -> dict[str, Agent]:
34
- """Validate packaged data and preserve its default fallback order."""
35
- data = json.loads(path.read_text(encoding="utf-8"))
36
- if not isinstance(data, dict) or data.get("version") != 1:
37
- raise ValueError(f"{path}: unsupported agent registry version")
38
- entries = data.get("agents")
39
- if not isinstance(entries, dict) or not entries:
40
- raise ValueError(f"{path}: expected a nonempty agents object")
41
- agents = {}
42
- for name, entry in entries.items():
43
- if not isinstance(entry, dict):
44
- raise TypeError(f"{path}: {name}: expected an agent object")
45
- for key in ["executable", "config_dir", "memory_file", "skills_dir"]:
46
- value = entry.get(key)
47
- if not isinstance(value, str) or not value.strip() or "\0" in value:
48
- raise ValueError(f"{path}: {name}: invalid {key}")
49
- for key in ("args", "skills"):
50
- value = entry.get(key)
51
- if not isinstance(value, list) or not all(
52
- isinstance(item, str) and "\0" not in item for item in value
53
- ):
54
- raise ValueError(f"{path}: {name}: invalid {key}")
55
- if entry["args"].count("{prompt}") != 1:
56
- raise ValueError(f"{path}: {name}: args must contain one {{prompt}} argument")
57
- if not entry["config_dir"].startswith("~/") and not Path(entry["config_dir"]).is_absolute():
58
- raise ValueError(f"{path}: {name}: config_dir must be absolute or start with ~/")
59
- skills_dir = Path(entry["skills_dir"])
60
- if skills_dir.anchor or ".." in skills_dir.parts or skills_dir == Path("."):
61
- raise ValueError(f"{path}: {name}: skills_dir must stay inside config_dir")
62
- env = entry.get("config_dir_env", "")
63
- if not isinstance(env, str) or (env and not env.isidentifier()):
64
- raise ValueError(f"{path}: {name}: invalid config_dir_env")
65
- agents[name] = Agent(
66
- executable=entry["executable"],
67
- config_dir=entry["config_dir"],
68
- memory_file=entry["memory_file"],
69
- skills_dir=entry["skills_dir"],
70
- skills=tuple(entry["skills"]),
71
- args=tuple(entry["args"]),
72
- config_dir_env=env,
73
- )
74
- return agents
75
-
76
-
77
- AGENTS = load_agents(Path(__file__).with_name("agents.json"))
78
-
79
-
80
- def executable(name: str) -> str:
81
- """Resolve a roster ID to its binary; leave non-agent commands alone."""
82
- agent = AGENTS.get(name)
83
- return agent.executable if agent else name
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes