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.
- {aicp_cli-0.4.0 → aicp_cli-0.6.0}/CHANGELOG.md +38 -0
- {aicp_cli-0.4.0 → aicp_cli-0.6.0}/PKG-INFO +94 -19
- {aicp_cli-0.4.0 → aicp_cli-0.6.0}/README.md +93 -18
- {aicp_cli-0.4.0 → aicp_cli-0.6.0}/TODO.md +12 -0
- {aicp_cli-0.4.0 → aicp_cli-0.6.0}/pyproject.toml +1 -1
- {aicp_cli-0.4.0 → aicp_cli-0.6.0}/skills/commit.md +6 -2
- aicp_cli-0.6.0/src/aicp/agents.py +135 -0
- {aicp_cli-0.4.0 → aicp_cli-0.6.0}/src/aicp/cli.py +27 -2
- {aicp_cli-0.4.0 → aicp_cli-0.6.0}/src/aicp/i18n.py +1 -0
- {aicp_cli-0.4.0 → aicp_cli-0.6.0}/src/aicp/secrets.py +13 -5
- {aicp_cli-0.4.0 → aicp_cli-0.6.0}/tests/test_agents.py +101 -1
- {aicp_cli-0.4.0 → aicp_cli-0.6.0}/tests/test_cli.py +27 -0
- {aicp_cli-0.4.0 → aicp_cli-0.6.0}/tests/test_secrets.py +9 -0
- {aicp_cli-0.4.0 → aicp_cli-0.6.0}/uv.lock +1 -1
- aicp_cli-0.4.0/src/aicp/agents.py +0 -83
- {aicp_cli-0.4.0 → aicp_cli-0.6.0}/.github/workflows/ci.yml +0 -0
- {aicp_cli-0.4.0 → aicp_cli-0.6.0}/.github/workflows/release.yml +0 -0
- {aicp_cli-0.4.0 → aicp_cli-0.6.0}/.gitignore +0 -0
- {aicp_cli-0.4.0 → aicp_cli-0.6.0}/CLAUDE.md +0 -0
- {aicp_cli-0.4.0 → aicp_cli-0.6.0}/LICENSE +0 -0
- {aicp_cli-0.4.0 → aicp_cli-0.6.0}/config.example.json +0 -0
- {aicp_cli-0.4.0 → aicp_cli-0.6.0}/docs/images/aicp-run.png +0 -0
- {aicp_cli-0.4.0 → aicp_cli-0.6.0}/skills/safe-git-push/SKILL.md +0 -0
- {aicp_cli-0.4.0 → aicp_cli-0.6.0}/skills/safe-git-push/scripts/safe_push.py +0 -0
- {aicp_cli-0.4.0 → aicp_cli-0.6.0}/src/aicp/__init__.py +0 -0
- {aicp_cli-0.4.0 → aicp_cli-0.6.0}/src/aicp/_keyreader.py +0 -0
- {aicp_cli-0.4.0 → aicp_cli-0.6.0}/src/aicp/_utils.py +0 -0
- {aicp_cli-0.4.0 → aicp_cli-0.6.0}/src/aicp/agents.json +0 -0
- {aicp_cli-0.4.0 → aicp_cli-0.6.0}/src/aicp/budget.py +0 -0
- {aicp_cli-0.4.0 → aicp_cli-0.6.0}/src/aicp/config.py +0 -0
- {aicp_cli-0.4.0 → aicp_cli-0.6.0}/src/aicp/contracts.py +0 -0
- {aicp_cli-0.4.0 → aicp_cli-0.6.0}/src/aicp/gitflow.py +0 -0
- {aicp_cli-0.4.0 → aicp_cli-0.6.0}/src/aicp/menu.py +0 -0
- {aicp_cli-0.4.0 → aicp_cli-0.6.0}/src/aicp/notify.py +0 -0
- {aicp_cli-0.4.0 → aicp_cli-0.6.0}/src/aicp/present.py +0 -0
- {aicp_cli-0.4.0 → aicp_cli-0.6.0}/src/aicp/quota.py +0 -0
- {aicp_cli-0.4.0 → aicp_cli-0.6.0}/src/aicp/runner.py +0 -0
- {aicp_cli-0.4.0 → aicp_cli-0.6.0}/src/aicp/skills.py +0 -0
- {aicp_cli-0.4.0 → aicp_cli-0.6.0}/src/aicp/timing.py +0 -0
- {aicp_cli-0.4.0 → aicp_cli-0.6.0}/tests/conftest.py +0 -0
- {aicp_cli-0.4.0 → aicp_cli-0.6.0}/tests/test_budget.py +0 -0
- {aicp_cli-0.4.0 → aicp_cli-0.6.0}/tests/test_config.py +0 -0
- {aicp_cli-0.4.0 → aicp_cli-0.6.0}/tests/test_cross_platform.py +0 -0
- {aicp_cli-0.4.0 → aicp_cli-0.6.0}/tests/test_gitflow.py +0 -0
- {aicp_cli-0.4.0 → aicp_cli-0.6.0}/tests/test_i18n.py +0 -0
- {aicp_cli-0.4.0 → aicp_cli-0.6.0}/tests/test_menu.py +0 -0
- {aicp_cli-0.4.0 → aicp_cli-0.6.0}/tests/test_menu_skills.py +0 -0
- {aicp_cli-0.4.0 → aicp_cli-0.6.0}/tests/test_notify.py +0 -0
- {aicp_cli-0.4.0 → aicp_cli-0.6.0}/tests/test_present.py +0 -0
- {aicp_cli-0.4.0 → aicp_cli-0.6.0}/tests/test_quota.py +0 -0
- {aicp_cli-0.4.0 → aicp_cli-0.6.0}/tests/test_runner.py +0 -0
- {aicp_cli-0.4.0 → aicp_cli-0.6.0}/tests/test_skills.py +0 -0
- {aicp_cli-0.4.0 → aicp_cli-0.6.0}/tests/test_timing.py +0 -0
- {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.
|
|
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
|
-
`/
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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` |
|
|
84
|
-
| `copilot` | `~/.copilot` |
|
|
85
|
-
| `agy` (Gemini CLI) | `~/.gemini` |
|
|
86
|
-
| `vibe` | `~/.vibe` |
|
|
87
|
-
| `grok` | `$GROK_HOME` when set, otherwise `~/.grok` |
|
|
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
|
-
`
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|

|
|
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:
|
|
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
|
-
`/
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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` |
|
|
72
|
-
| `copilot` | `~/.copilot` |
|
|
73
|
-
| `agy` (Gemini CLI) | `~/.gemini` |
|
|
74
|
-
| `vibe` | `~/.vibe` |
|
|
75
|
-
| `grok` | `$GROK_HOME` when set, otherwise `~/.grok` |
|
|
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
|
-
`
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|

|
|
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:
|
|
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.
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
-
(
|
|
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
|
-
|
|
81
|
-
|
|
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
|
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|