aicp-cli 0.3.0__tar.gz → 0.4.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 (53) hide show
  1. {aicp_cli-0.3.0 → aicp_cli-0.4.0}/CHANGELOG.md +22 -0
  2. aicp_cli-0.4.0/CLAUDE.md +7 -0
  3. {aicp_cli-0.3.0 → aicp_cli-0.4.0}/PKG-INFO +54 -20
  4. {aicp_cli-0.3.0 → aicp_cli-0.4.0}/README.md +53 -19
  5. {aicp_cli-0.3.0 → aicp_cli-0.4.0}/pyproject.toml +1 -1
  6. aicp_cli-0.4.0/src/aicp/agents.json +54 -0
  7. aicp_cli-0.4.0/src/aicp/agents.py +83 -0
  8. {aicp_cli-0.3.0 → aicp_cli-0.4.0}/src/aicp/cli.py +2 -1
  9. {aicp_cli-0.3.0 → aicp_cli-0.4.0}/src/aicp/contracts.py +7 -8
  10. {aicp_cli-0.3.0 → aicp_cli-0.4.0}/src/aicp/gitflow.py +2 -1
  11. {aicp_cli-0.3.0 → aicp_cli-0.4.0}/src/aicp/menu.py +10 -1
  12. {aicp_cli-0.3.0 → aicp_cli-0.4.0}/src/aicp/runner.py +7 -27
  13. {aicp_cli-0.3.0 → aicp_cli-0.4.0}/src/aicp/skills.py +19 -25
  14. aicp_cli-0.4.0/tests/test_agents.py +112 -0
  15. {aicp_cli-0.3.0 → aicp_cli-0.4.0}/tests/test_menu.py +10 -0
  16. {aicp_cli-0.3.0 → aicp_cli-0.4.0}/uv.lock +1 -1
  17. {aicp_cli-0.3.0 → aicp_cli-0.4.0}/.github/workflows/ci.yml +0 -0
  18. {aicp_cli-0.3.0 → aicp_cli-0.4.0}/.github/workflows/release.yml +0 -0
  19. {aicp_cli-0.3.0 → aicp_cli-0.4.0}/.gitignore +0 -0
  20. {aicp_cli-0.3.0 → aicp_cli-0.4.0}/LICENSE +0 -0
  21. {aicp_cli-0.3.0 → aicp_cli-0.4.0}/TODO.md +0 -0
  22. {aicp_cli-0.3.0 → aicp_cli-0.4.0}/config.example.json +0 -0
  23. {aicp_cli-0.3.0 → aicp_cli-0.4.0}/docs/images/aicp-run.png +0 -0
  24. {aicp_cli-0.3.0 → aicp_cli-0.4.0}/skills/commit.md +0 -0
  25. {aicp_cli-0.3.0 → aicp_cli-0.4.0}/skills/safe-git-push/SKILL.md +0 -0
  26. {aicp_cli-0.3.0 → aicp_cli-0.4.0}/skills/safe-git-push/scripts/safe_push.py +0 -0
  27. {aicp_cli-0.3.0 → aicp_cli-0.4.0}/src/aicp/__init__.py +0 -0
  28. {aicp_cli-0.3.0 → aicp_cli-0.4.0}/src/aicp/_keyreader.py +0 -0
  29. {aicp_cli-0.3.0 → aicp_cli-0.4.0}/src/aicp/_utils.py +0 -0
  30. {aicp_cli-0.3.0 → aicp_cli-0.4.0}/src/aicp/budget.py +0 -0
  31. {aicp_cli-0.3.0 → aicp_cli-0.4.0}/src/aicp/config.py +0 -0
  32. {aicp_cli-0.3.0 → aicp_cli-0.4.0}/src/aicp/i18n.py +0 -0
  33. {aicp_cli-0.3.0 → aicp_cli-0.4.0}/src/aicp/notify.py +0 -0
  34. {aicp_cli-0.3.0 → aicp_cli-0.4.0}/src/aicp/present.py +0 -0
  35. {aicp_cli-0.3.0 → aicp_cli-0.4.0}/src/aicp/quota.py +0 -0
  36. {aicp_cli-0.3.0 → aicp_cli-0.4.0}/src/aicp/secrets.py +0 -0
  37. {aicp_cli-0.3.0 → aicp_cli-0.4.0}/src/aicp/timing.py +0 -0
  38. {aicp_cli-0.3.0 → aicp_cli-0.4.0}/tests/conftest.py +0 -0
  39. {aicp_cli-0.3.0 → aicp_cli-0.4.0}/tests/test_budget.py +0 -0
  40. {aicp_cli-0.3.0 → aicp_cli-0.4.0}/tests/test_cli.py +0 -0
  41. {aicp_cli-0.3.0 → aicp_cli-0.4.0}/tests/test_config.py +0 -0
  42. {aicp_cli-0.3.0 → aicp_cli-0.4.0}/tests/test_cross_platform.py +0 -0
  43. {aicp_cli-0.3.0 → aicp_cli-0.4.0}/tests/test_gitflow.py +0 -0
  44. {aicp_cli-0.3.0 → aicp_cli-0.4.0}/tests/test_i18n.py +0 -0
  45. {aicp_cli-0.3.0 → aicp_cli-0.4.0}/tests/test_menu_skills.py +0 -0
  46. {aicp_cli-0.3.0 → aicp_cli-0.4.0}/tests/test_notify.py +0 -0
  47. {aicp_cli-0.3.0 → aicp_cli-0.4.0}/tests/test_present.py +0 -0
  48. {aicp_cli-0.3.0 → aicp_cli-0.4.0}/tests/test_quota.py +0 -0
  49. {aicp_cli-0.3.0 → aicp_cli-0.4.0}/tests/test_runner.py +0 -0
  50. {aicp_cli-0.3.0 → aicp_cli-0.4.0}/tests/test_secrets.py +0 -0
  51. {aicp_cli-0.3.0 → aicp_cli-0.4.0}/tests/test_skills.py +0 -0
  52. {aicp_cli-0.3.0 → aicp_cli-0.4.0}/tests/test_timing.py +0 -0
  53. {aicp_cli-0.3.0 → aicp_cli-0.4.0}/tests/test_undo.py +0 -0
@@ -1,3 +1,20 @@
1
+ ## [0.4.0] - 2026-09-14
2
+
3
+ ### 🚜 Refactor
4
+
5
+ - **agents:** Centralize agent definitions
6
+ ## [0.3.1] - 2026-09-14
7
+
8
+ ### 🐛 Bug Fixes
9
+
10
+ - **menu:** Stop config panel resizing on row selection
11
+
12
+ ### 📚 Documentation
13
+
14
+ - **readme:** Document releasing and the wheel version check
15
+ - Remove CI section from README.md
16
+ - Add uv requirement note to README.md
17
+ - **changelog:** Release v0.3.1
1
18
  ## [0.3.0] - 2026-09-14
2
19
 
3
20
  ### 🐛 Bug Fixes
@@ -16,8 +33,13 @@
16
33
 
17
34
  - **release:** Publish wheels to PyPI via trusted publishing
18
35
  - Assert the built wheel reports the right version
36
+ - **release:** V0.3.0
19
37
  ## [0.2.1] - 2026-09-13
20
38
 
39
+ ### 📚 Documentation
40
+
41
+ - **changelog:** Release v0.2.1
42
+
21
43
  ### ⚙️ Miscellaneous Tasks
22
44
 
23
45
  - Refresh uv lockfile for v0.2.0
@@ -0,0 +1,7 @@
1
+ # CLAUDE.md
2
+
3
+ ## Cross-platform compatibility
4
+
5
+ Keep tools platform-agnostic. OS-specific behavior (clipboard, package installation, ANSI) belongs in a shared helper, funneled through one function, so individual tool modules stay clean.
6
+
7
+ Support macOS, Windows, and Linux for user-facing platform behavior.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: aicp-cli
3
- Version: 0.3.0
3
+ Version: 0.4.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
@@ -36,6 +36,7 @@ push that never reached the remote.
36
36
  ## Requirements
37
37
 
38
38
  - Python 3.10 or newer
39
+ - [`uv`](https://docs.astral.sh/uv/) — the install path below
39
40
  - `git`
40
41
  - At least one of the six AI CLIs above on `PATH`
41
42
 
@@ -48,7 +49,8 @@ uv tool install aicp-cli # latest release
48
49
  uv tool install aicp-cli==X.Y.Z # pin to a specific version
49
50
  ```
50
51
 
51
- The distribution is `aicp-cli`; the command it installs is `aicp`. (The plain
52
+ The distribution is [`aicp-cli`](https://pypi.org/project/aicp-cli/); the command
53
+ it installs is `aicp`. (The plain
52
54
  `aicp` name on PyPI belongs to an unrelated 2021 project — don't install it.)
53
55
  Replace `X.Y.Z` with the release version you want; re-running either command
54
56
  switches an existing install to that version.
@@ -178,30 +180,62 @@ that, and even then the original is moved aside to `<name>.bak` (numbered
178
180
  `.bak.1`, `.bak.2`, … so a second forced install never clobbers the first
179
181
  backup) before aicp writes its own copy.
180
182
 
181
- ### This repo's own CI
183
+ ### Releasing
182
184
 
183
- `.github/workflows/ci.yml` runs the suite on **macOS, Linux, and Windows** in
184
- parallel (`fail-fast: false`, so one platform failing still tells you about the
185
- other two), then builds the wheel, installs it into a throwaway venv, and checks
186
- that the console script runs and the vendored skills survived packaging.
185
+ `.github/workflows/release.yml` runs on a `v*` tag. It repeats the full
186
+ lint/test/build pass, installs the wheel into a throwaway venv, and refuses to
187
+ upload unless that wheel reports the version being tagged — so a tag that
188
+ disagrees with `pyproject.toml` fails before anything reaches PyPI. It then
189
+ publishes with Trusted Publishing (OIDC — there is no API token stored in this
190
+ repo) and attaches the wheel, sdist and `SHA256SUMS` to the GitHub release.
187
191
 
188
- A failed run on a `push` also sends one Telegram message. Failure-only is
189
- deliberate: a notification on every green push is one nobody reads.
192
+ Checksums are generated *after* publishing on purpose: `uv publish` uploads
193
+ everything in `dist/`, and `SHA256SUMS` is not a distribution.
190
194
 
191
- The credentials are **repo secrets — never committed**. The workflow reads them
192
- through `${{ secrets.* }}` and skips quietly when they're unset, so a fork or a
193
- fresh clone gets no second red X on top of the real failure. To enable it:
194
-
195
- ```sh
196
- gh secret set TELEGRAM_BOT_TOKEN -R <owner>/<repo> # paste the bot token
197
- gh secret set TELEGRAM_CHAT_ID -R <owner>/<repo> # paste the chat id
198
- ```
199
-
200
- Verify with `gh secret list -R <owner>/<repo>` — GitHub shows the names and
201
- timestamps only; secret values can never be read back, by you or by CI logs.
195
+ A tag runs the workflow file **as it existed at that tag**, so a release that
196
+ failed to publish can't be repaired by re-running the old tag — the fix isn't
197
+ in that tree. Bump the version and tag again.
202
198
 
203
199
  ## Configuration
204
200
 
201
+ Agent definitions live in [`src/aicp/agents.json`](src/aicp/agents.json),
202
+ bundled with the installed package. Both `aicp` and `aicp --config` use this
203
+ registry for executable lookup, invocation arguments, config directories,
204
+ and skill installation/status. To maintain an agent, edit its entry here.
205
+ Registry order supplies the default fallback order; `aicp_cli_order` still
206
+ controls the user's preferred order.
207
+
208
+ Use `~/` paths and bare executable names for entries shared across macOS,
209
+ Linux, and Windows. Python resolves `~` to the native home directory
210
+ (`USERPROFILE` on Windows); forward slashes work on all three systems.
211
+ Absolute paths are specific to the host OS; in JSON, a Windows path can use
212
+ `C:/Users/name/.codex` or escaped backslashes (`C:\\Users\\name\\.codex`).
213
+ Executable lookup uses `PATH` and, on Windows, `PATHEXT`; the runner handles
214
+ both native executables and `.cmd`/`.bat` launchers. CI runs the registry
215
+ integration tests and installed-wheel checks on all three operating systems.
216
+
217
+ | Agent field | Meaning |
218
+ | --- | --- |
219
+ | `executable` | Binary name resolved on `PATH`, or an absolute executable path. Separate from the stable agent ID used in fallback order and history. |
220
+ | `config_dir` | Absolute path or `~/`-relative directory; home is resolved when used. |
221
+ | `config_dir_env` | Optional environment variable overriding that directory (currently `GROK_HOME`). |
222
+ | `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`. |
224
+ | `memory_file` | Instruction filename used when adapting vendored skill text. Project directory references use the basename of `config_dir`. |
225
+ | `args` | Argument array containing exactly one standalone `{prompt}`, replaced with the complete prompt as one argument. No shell evaluation. |
226
+
227
+ The JSON envelope has `version: 1` and an `agents` object keyed by stable
228
+ agent IDs. Future supported settings can be added to each entry and the
229
+ loader; adding a JSON field alone does not implement new behavior.
230
+ Invocation defaults preserve agy's `--new-project` (use the current working
231
+ directory), vibe's `--trust` (avoid an interactive trust prompt), and the
232
+ existing MCP startup suppression flags.
233
+
234
+ This is packaged application data, not a per-user override file. Package
235
+ upgrades replace it; neither repository-local files nor
236
+ `~/.aicp/config.json` can override agent execution settings. Ordinary user
237
+ preferences continue to use the config file below.
238
+
205
239
  The config file lives at `~/.aicp/config.json` (override the path itself
206
240
  with `AICP_CONFIG`, which — being the thing that names the file — can only be
207
241
  set as a real environment variable). Precedence everywhere is
@@ -24,6 +24,7 @@ push that never reached the remote.
24
24
  ## Requirements
25
25
 
26
26
  - Python 3.10 or newer
27
+ - [`uv`](https://docs.astral.sh/uv/) — the install path below
27
28
  - `git`
28
29
  - At least one of the six AI CLIs above on `PATH`
29
30
 
@@ -36,7 +37,8 @@ uv tool install aicp-cli # latest release
36
37
  uv tool install aicp-cli==X.Y.Z # pin to a specific version
37
38
  ```
38
39
 
39
- The distribution is `aicp-cli`; the command it installs is `aicp`. (The plain
40
+ The distribution is [`aicp-cli`](https://pypi.org/project/aicp-cli/); the command
41
+ it installs is `aicp`. (The plain
40
42
  `aicp` name on PyPI belongs to an unrelated 2021 project — don't install it.)
41
43
  Replace `X.Y.Z` with the release version you want; re-running either command
42
44
  switches an existing install to that version.
@@ -166,30 +168,62 @@ that, and even then the original is moved aside to `<name>.bak` (numbered
166
168
  `.bak.1`, `.bak.2`, … so a second forced install never clobbers the first
167
169
  backup) before aicp writes its own copy.
168
170
 
169
- ### This repo's own CI
171
+ ### Releasing
170
172
 
171
- `.github/workflows/ci.yml` runs the suite on **macOS, Linux, and Windows** in
172
- parallel (`fail-fast: false`, so one platform failing still tells you about the
173
- other two), then builds the wheel, installs it into a throwaway venv, and checks
174
- that the console script runs and the vendored skills survived packaging.
173
+ `.github/workflows/release.yml` runs on a `v*` tag. It repeats the full
174
+ lint/test/build pass, installs the wheel into a throwaway venv, and refuses to
175
+ upload unless that wheel reports the version being tagged — so a tag that
176
+ disagrees with `pyproject.toml` fails before anything reaches PyPI. It then
177
+ publishes with Trusted Publishing (OIDC — there is no API token stored in this
178
+ repo) and attaches the wheel, sdist and `SHA256SUMS` to the GitHub release.
175
179
 
176
- A failed run on a `push` also sends one Telegram message. Failure-only is
177
- deliberate: a notification on every green push is one nobody reads.
180
+ Checksums are generated *after* publishing on purpose: `uv publish` uploads
181
+ everything in `dist/`, and `SHA256SUMS` is not a distribution.
178
182
 
179
- The credentials are **repo secrets — never committed**. The workflow reads them
180
- through `${{ secrets.* }}` and skips quietly when they're unset, so a fork or a
181
- fresh clone gets no second red X on top of the real failure. To enable it:
182
-
183
- ```sh
184
- gh secret set TELEGRAM_BOT_TOKEN -R <owner>/<repo> # paste the bot token
185
- gh secret set TELEGRAM_CHAT_ID -R <owner>/<repo> # paste the chat id
186
- ```
187
-
188
- Verify with `gh secret list -R <owner>/<repo>` — GitHub shows the names and
189
- timestamps only; secret values can never be read back, by you or by CI logs.
183
+ A tag runs the workflow file **as it existed at that tag**, so a release that
184
+ failed to publish can't be repaired by re-running the old tag — the fix isn't
185
+ in that tree. Bump the version and tag again.
190
186
 
191
187
  ## Configuration
192
188
 
189
+ Agent definitions live in [`src/aicp/agents.json`](src/aicp/agents.json),
190
+ bundled with the installed package. Both `aicp` and `aicp --config` use this
191
+ registry for executable lookup, invocation arguments, config directories,
192
+ and skill installation/status. To maintain an agent, edit its entry here.
193
+ Registry order supplies the default fallback order; `aicp_cli_order` still
194
+ controls the user's preferred order.
195
+
196
+ Use `~/` paths and bare executable names for entries shared across macOS,
197
+ Linux, and Windows. Python resolves `~` to the native home directory
198
+ (`USERPROFILE` on Windows); forward slashes work on all three systems.
199
+ Absolute paths are specific to the host OS; in JSON, a Windows path can use
200
+ `C:/Users/name/.codex` or escaped backslashes (`C:\\Users\\name\\.codex`).
201
+ Executable lookup uses `PATH` and, on Windows, `PATHEXT`; the runner handles
202
+ both native executables and `.cmd`/`.bat` launchers. CI runs the registry
203
+ integration tests and installed-wheel checks on all three operating systems.
204
+
205
+ | Agent field | Meaning |
206
+ | --- | --- |
207
+ | `executable` | Binary name resolved on `PATH`, or an absolute executable path. Separate from the stable agent ID used in fallback order and history. |
208
+ | `config_dir` | Absolute path or `~/`-relative directory; home is resolved when used. |
209
+ | `config_dir_env` | Optional environment variable overriding that directory (currently `GROK_HOME`). |
210
+ | `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`. |
212
+ | `memory_file` | Instruction filename used when adapting vendored skill text. Project directory references use the basename of `config_dir`. |
213
+ | `args` | Argument array containing exactly one standalone `{prompt}`, replaced with the complete prompt as one argument. No shell evaluation. |
214
+
215
+ The JSON envelope has `version: 1` and an `agents` object keyed by stable
216
+ agent IDs. Future supported settings can be added to each entry and the
217
+ loader; adding a JSON field alone does not implement new behavior.
218
+ Invocation defaults preserve agy's `--new-project` (use the current working
219
+ directory), vibe's `--trust` (avoid an interactive trust prompt), and the
220
+ existing MCP startup suppression flags.
221
+
222
+ This is packaged application data, not a per-user override file. Package
223
+ upgrades replace it; neither repository-local files nor
224
+ `~/.aicp/config.json` can override agent execution settings. Ordinary user
225
+ preferences continue to use the config file below.
226
+
193
227
  The config file lives at `~/.aicp/config.json` (override the path itself
194
228
  with `AICP_CONFIG`, which — being the thing that names the file — can only be
195
229
  set as a real environment variable). Precedence everywhere is
@@ -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.3.0"
5
+ version = "0.4.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,54 @@
1
+ {
2
+ "version": 1,
3
+ "agents": {
4
+ "copilot": {
5
+ "executable": "copilot",
6
+ "config_dir": "~/.copilot",
7
+ "memory_file": "AGENTS.md",
8
+ "skills_dir": "skills",
9
+ "skills": ["commit", "safe-git-push"],
10
+ "args": ["-p", "{prompt}", "--allow-all", "--disable-builtin-mcps", "--no-auto-update"]
11
+ },
12
+ "agy": {
13
+ "executable": "agy",
14
+ "config_dir": "~/.gemini",
15
+ "memory_file": "GEMINI.md",
16
+ "skills_dir": "skills",
17
+ "skills": ["commit", "safe-git-push"],
18
+ "args": ["-p", "{prompt}", "--dangerously-skip-permissions", "--new-project"]
19
+ },
20
+ "codex": {
21
+ "executable": "codex",
22
+ "config_dir": "~/.codex",
23
+ "memory_file": "AGENTS.md",
24
+ "skills_dir": "skills",
25
+ "skills": ["commit", "safe-git-push"],
26
+ "args": ["exec", "--dangerously-bypass-approvals-and-sandbox", "{prompt}"]
27
+ },
28
+ "claude": {
29
+ "executable": "claude",
30
+ "config_dir": "~/.claude",
31
+ "memory_file": "CLAUDE.md",
32
+ "skills_dir": "skills",
33
+ "skills": ["safe-git-push"],
34
+ "args": ["-p", "{prompt}", "--dangerously-skip-permissions", "--strict-mcp-config"]
35
+ },
36
+ "vibe": {
37
+ "executable": "vibe",
38
+ "config_dir": "~/.vibe",
39
+ "memory_file": "AGENTS.md",
40
+ "skills_dir": "skills",
41
+ "skills": ["commit", "safe-git-push"],
42
+ "args": ["-p", "{prompt}", "--auto-approve", "--trust"]
43
+ },
44
+ "grok": {
45
+ "executable": "grok",
46
+ "config_dir": "~/.grok",
47
+ "config_dir_env": "GROK_HOME",
48
+ "memory_file": "AGENTS.md",
49
+ "skills_dir": "skills",
50
+ "skills": ["commit", "safe-git-push"],
51
+ "args": ["-p", "{prompt}", "--always-approve", "--no-subagents"]
52
+ }
53
+ }
54
+ }
@@ -0,0 +1,83 @@
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
@@ -38,6 +38,7 @@ from typing import NoReturn
38
38
 
39
39
  from . import __version__, config, gitflow, i18n, menu, notify, present, runner, skills
40
40
  from ._utils import BLUE, BOLD, CYAN, DIM, GREEN, MAGENTA, RED, RESET, YELLOW, have
41
+ from .agents import executable
41
42
  from .contracts import ROSTER
42
43
  from .i18n import t
43
44
 
@@ -152,7 +153,7 @@ def _nudge(chain: Sequence[str]) -> None:
152
153
  target, no reads, no sidecar parsing).
153
154
  """
154
155
  for name in chain:
155
- if not have(name):
156
+ if not have(executable(name)):
156
157
  continue
157
158
  entry = _BY_NAME.get(name)
158
159
  if entry is None:
@@ -17,6 +17,8 @@ from collections.abc import Callable
17
17
  from dataclasses import dataclass
18
18
  from pathlib import Path
19
19
 
20
+ from .agents import AGENTS
21
+
20
22
  __all__ = [
21
23
  "CLI",
22
24
  "ROSTER",
@@ -35,7 +37,7 @@ class CLI:
35
37
  equivalents) both key off ``name``; T2 additionally needs ``config_dir``
36
38
  to detect whether a CLI is configured at all before trying to run it.
37
39
 
38
- ``name`` is the literal binary invoked on PATH (``shutil.which(name)``).
40
+ ``name`` is the stable agent ID; agents.json supplies its executable.
39
41
  ``config_dir`` is where that CLI keeps its own config/session state — and
40
42
  is deliberately a SEPARATE field, not derived from ``name``, because it
41
43
  does not always match: the ``agy`` binary (this project's wrapper name
@@ -53,13 +55,10 @@ class CLI:
53
55
  # saved-chain compatibility. T3 is the consumer that reorders/persists a
54
56
  # chain derived from this; T2 and T4 must never hardcode this tuple themselves
55
57
  # — import it.
56
- ROSTER: tuple[CLI, ...] = (
57
- CLI(name="copilot", config_dir=Path.home() / ".copilot"),
58
- CLI(name="agy", config_dir=Path.home() / ".gemini"), # the trap — see CLI's docstring
59
- CLI(name="codex", config_dir=Path.home() / ".codex"),
60
- CLI(name="claude", config_dir=Path.home() / ".claude"),
61
- CLI(name="vibe", config_dir=Path.home() / ".vibe"),
62
- CLI(name="grok", config_dir=Path.home() / ".grok"),
58
+ # Resolve defaults here; environment overrides stay live in config_root.
59
+ ROSTER: tuple[CLI, ...] = tuple(
60
+ CLI(name=name, config_dir=agent.config_root(Path.home()))
61
+ for name, agent in AGENTS.items()
63
62
  )
64
63
 
65
64
  # The resolved fallback chain — whatever order a run will actually try its
@@ -30,6 +30,7 @@ from dataclasses import dataclass
30
30
  from pathlib import Path
31
31
 
32
32
  from ._utils import BOLD, DIM, GREEN, RED, RESET, YELLOW, have, run_interruptible
33
+ from .agents import executable
33
34
  from .i18n import t
34
35
  from .secrets import report_lines, scan
35
36
 
@@ -188,7 +189,7 @@ def preflight(chain: Sequence[str]) -> tuple[bool, list[str]]:
188
189
  alone and succeeds with no AI CLI installed. A blanket pre-flight would
189
190
  turn that into a failure — a regression wearing a check's clothing.
190
191
  """
191
- if any(have(cli) for cli in chain):
192
+ if any(have(executable(cli)) for cli in chain):
192
193
  return True, []
193
194
  return False, [
194
195
  f"{RED}" + t("no_cli_found", "✗ no AI CLI found on PATH") + RESET,
@@ -653,8 +653,17 @@ def _panel(
653
653
  )
654
654
  title = f"{_t(state.lang, 'config_title', 'aicp config')} (v{__version__})"
655
655
  if selected is not None:
656
+ # Reserve the widest help line ANY row can show, not just the selected
657
+ # one's — otherwise the frame narrows and widens as the cursor moves
658
+ # across rows with shorter and longer help text (render_panel sizes
659
+ # its own width off the notes it is handed).
660
+ help_budget = frame_columns - 4
661
+ fitted_help = [_fit(_t(state.lang, *row.help), help_budget) for row in ROWS]
662
+ reserve = max(width(h) for h in fitted_help)
663
+ help_line = fitted_help[selected - 1]
664
+ help_line += " " * (reserve - width(help_line))
656
665
  texts = [
657
- _t(state.lang, *ROWS[selected - 1].help),
666
+ help_line,
658
667
  "",
659
668
  _t(state.lang, "config_keys_tui", "↑↓ select · ←→ change · ⏎ change/run · q/Ctrl-C quit · saves as you go"),
660
669
  ]
@@ -60,6 +60,7 @@ from aicp._utils import (
60
60
  have,
61
61
  run_interruptible,
62
62
  )
63
+ from aicp.agents import AGENTS
63
64
  from aicp.contracts import NotifyFn
64
65
  from aicp.i18n import t
65
66
  from aicp.present import Spinner
@@ -103,27 +104,6 @@ class StepResult:
103
104
  # budget.py's stated invariant true: a bad config value never bricks aicp.
104
105
  _MAX_DEADLINE = 365 * 24 * 3600
105
106
 
106
- # Per-CLI flags, ported flag-for-flag from _aicp_invoke. Each branch skips MCP
107
- # server startup where the CLI exposes a way to — /commit and /safe-git-push
108
- # are plain git prompts, no MCP tool use — which measurably shortens cold start;
109
- # codex and agy showed no consistent gain, so they keep their existing flags.
110
- _FLAGS: dict[str, tuple[str, ...]] = {
111
- # agy resumes its last session/workspace by default, ignoring $PWD entirely
112
- # unless --new-project pins it to the caller's cwd.
113
- "agy": ("--dangerously-skip-permissions", "--new-project"),
114
- "claude": ("--dangerously-skip-permissions", "--strict-mcp-config"),
115
- "copilot": ("--allow-all", "--disable-builtin-mcps", "--no-auto-update"),
116
- "grok": ("--always-approve", "--no-subagents"),
117
- # --trust is not the approval flag (--auto-approve is) and is not optional:
118
- # vibe asks once, interactively, before it will touch an untrusted
119
- # directory, and its own --help names --trust as the answer for
120
- # non-interactive automation. Without it the very first aicp run in a new
121
- # repo blocks on a prompt nobody can see behind the spinner until the budget
122
- # kills it — a hang presented as a timeout. Per-invocation, never written to
123
- # vibe's trusted_folders.toml, so aicp leaves no persistent trust behind.
124
- "vibe": ("--auto-approve", "--trust"),
125
- }
126
-
127
107
  # Literal signals confirmed by T1. Copilot and agy are intentionally absent:
128
108
  # neither had a supported quota signature, and broad guesses would turn an
129
109
  # ordinary failure into a run-scoped exclusion.
@@ -153,10 +133,10 @@ def invoke_argv(cli: str, prompt: str) -> list[str] | None:
153
133
  silently returned 0 for a name it did not know, which would be read here as
154
134
  "that CLI succeeded" without anything having run.
155
135
  """
156
- if cli == "codex": # the only one that takes the prompt positionally
157
- return ["codex", "exec", "--dangerously-bypass-approvals-and-sandbox", prompt]
158
- flags = _FLAGS.get(cli)
159
- return None if flags is None else [cli, "-p", prompt, *flags]
136
+ agent = AGENTS.get(cli)
137
+ if agent is None:
138
+ return None
139
+ return [agent.executable, *(prompt if arg == "{prompt}" else arg for arg in agent.args)]
160
140
 
161
141
 
162
142
  # ── Windows: launching an npm shim ───────────────────────────────────────────
@@ -187,7 +167,7 @@ _BATCH_SUFFIXES = frozenset({".cmd", ".bat"})
187
167
  # is expanded inside quotes too. There is no sound, portable escape for either
188
168
  # one on a cmd command line, so a launch carrying them is refused rather than
189
169
  # half-escaped. aicp never builds one — prompts are slash-command names and the
190
- # flags are fixed literals in _FLAGS — so this is a tripwire, not a limit
170
+ # flags are literals in the bundled agents.json — so this is a tripwire, not a limit
191
171
  # anyone meets.
192
172
  # ponytail: fail closed; write a real cmd quoter only if aicp ever has to pass
193
173
  # arbitrary free text as a prompt on Windows.
@@ -447,7 +427,7 @@ def run_step(
447
427
  with _capture_file() as (capture, capture_path):
448
428
  for cli in chain:
449
429
  argv = invoke_argv(cli, prompt)
450
- if argv is None or not have(cli):
430
+ if argv is None or not have(argv[0]):
451
431
  continue
452
432
 
453
433
  # An earlier run already found this one out of quota. Skipping is
@@ -8,9 +8,8 @@ vendored copies under ``skills/`` into each configured CLI.
8
8
 
9
9
  Three rules make it safe:
10
10
 
11
- * **Targets are keyed on** :attr:`aicp.contracts.CLI.config_dir`, never on the
12
- binary name — ``agy`` reads ``~/.gemini``, and ``~/.agy`` does not exist.
13
- :data:`PLATFORMS` is that table, keyed by the config dir's own name.
11
+ * **Targets come from agents.json**, never from the binary name — ``agy``
12
+ reads ``~/.gemini``, and ``~/.agy`` does not exist.
14
13
  * **Anything aicp did not write, byte for byte, is the user's**
15
14
  (:data:`FOREIGN`). It is never overwritten without an explicit
16
15
  ``force=True``; a ``--yes``-style non-interactive run installs only what's
@@ -24,11 +23,9 @@ Three rules make it safe:
24
23
  config dir is the skill itself. Pre-0.2 in-place ``.aicp-version`` sidecars
25
24
  are folded into that file and deleted on the next install.
26
25
 
27
- All public functions take ``home=`` and otherwise resolve :func:`Path.home`
28
- at call time (i.e. ``$HOME`` as it is *now*), so a test with a fake ``$HOME``
29
- can never reach the developer's real config dirs. The per-CLI directory name
30
- still comes from ``config_dir.name`` — from the contract, not from the binary
31
- name.
26
+ All public functions take ``home=`` to rebase home-relative registry paths
27
+ and otherwise resolve :func:`Path.home` at call time. Absolute registry paths
28
+ remain absolute; environment overrides apply only when home is omitted.
32
29
  """
33
30
 
34
31
  from __future__ import annotations
@@ -42,6 +39,7 @@ from dataclasses import dataclass
42
39
  from pathlib import Path
43
40
 
44
41
  from . import __version__
42
+ from .agents import AGENTS
45
43
  from .contracts import (
46
44
  CLI,
47
45
  ROSTER,
@@ -166,15 +164,10 @@ class Platform:
166
164
  skills: tuple[str, ...]
167
165
 
168
166
 
169
- _BOTH = ("commit", "safe-git-push")
170
-
171
167
  PLATFORMS: dict[str, Platform] = {
172
- ".claude": Platform(".claude", "CLAUDE.md", ("safe-git-push",)),
173
- ".codex": Platform(".codex", "AGENTS.md", _BOTH),
174
- ".copilot": Platform(".copilot", "AGENTS.md", _BOTH),
175
- ".gemini": Platform(".gemini", "GEMINI.md", _BOTH), # the agy binary
176
- ".vibe": Platform(".vibe", "AGENTS.md", _BOTH),
177
- ".grok": Platform(".grok", "AGENTS.md", _BOTH),
168
+ name: Platform(name, agent.memory_file, agent.skills)
169
+ for agent in AGENTS.values()
170
+ for name in (Path(agent.config_dir).name,)
178
171
  }
179
172
 
180
173
 
@@ -204,20 +197,21 @@ class InstallResult:
204
197
 
205
198
 
206
199
  def _platform(cli: CLI) -> Platform:
207
- return PLATFORMS[cli.config_dir.name]
200
+ agent = AGENTS[cli.name]
201
+ return Platform(Path(agent.config_dir).name, agent.memory_file, agent.skills)
208
202
 
209
203
 
210
204
  def config_root(cli: CLI, home: Path | None = None) -> Path:
211
205
  """This CLI's config dir, re-based on *home* or the live environment."""
212
- if home is not None:
213
- return Path(home) / cli.config_dir.name
214
- if cli.name == "grok" and (grok_home := os.environ.get("GROK_HOME")):
215
- return Path(grok_home)
216
- return Path.home() / cli.config_dir.name
206
+ return AGENTS[cli.name].config_root(home)
217
207
 
218
208
 
219
209
  def target_path(cli: CLI, skill: str, home: Path | None = None) -> Path:
220
- return config_root(cli, home) / SKILLS[skill].rel_target
210
+ return (
211
+ config_root(cli, home)
212
+ / AGENTS[cli.name].skills_dir
213
+ / SKILLS[skill].rel_target.relative_to("skills")
214
+ )
221
215
 
222
216
 
223
217
  # ── the state file ───────────────────────────────────────────────────────────
@@ -455,7 +449,7 @@ def _inspect(cli: CLI, skill: str, home: Path | None) -> tuple[str, str | None]:
455
449
  if not root.is_dir():
456
450
  return NOT_INSTALLED, None
457
451
  spec = SKILLS[skill]
458
- target = root / spec.rel_target
452
+ target = target_path(cli, skill, home)
459
453
  if not target.exists():
460
454
  return MISSING, None
461
455
 
@@ -500,7 +494,7 @@ def missing_skills(cli: CLI, home: Path | None = None) -> tuple[str, ...]:
500
494
  return tuple(
501
495
  name
502
496
  for name in _platform(cli).skills
503
- if not (root / SKILLS[name].rel_target).exists()
497
+ if not target_path(cli, name, home).exists()
504
498
  )
505
499
 
506
500
 
@@ -0,0 +1,112 @@
1
+ """Exercise registry edits through the real command and skill consumers."""
2
+
3
+ import json
4
+ import os
5
+ import shutil
6
+ import subprocess
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ import pytest
11
+
12
+ import aicp
13
+ from aicp.agents import load_agents
14
+
15
+
16
+ @pytest.mark.parametrize("location", ["home", "absolute", "environment"])
17
+ def test_registry_edit_reaches_commands_and_skills(tmp_path, location):
18
+ package = Path(aicp.__file__).parent
19
+ registry = package / "agents.json"
20
+ assert registry.is_file(), "agent definitions must ship with the package"
21
+ shutil.copytree(package, tmp_path / "aicp")
22
+ from aicp.skills import VENDOR_DIR
23
+
24
+ shutil.copytree(VENDOR_DIR, tmp_path / "aicp/_skills_data", dirs_exist_ok=True)
25
+ data = json.loads(registry.read_text())
26
+ root = Path.home() / "custom agent 測試" / "config"
27
+ data["agents"]["codex"].update(
28
+ executable=sys.executable,
29
+ config_dir="~/custom agent 測試/config" if location == "home" else str(root),
30
+ skills_dir="custom-skills",
31
+ args=["-c", "import sys; print(sys.argv[1])", "{prompt}"],
32
+ )
33
+ if location == "environment":
34
+ data["agents"]["codex"].update(
35
+ config_dir="~/unused", config_dir_env="AICP_TEST_AGENT_HOME"
36
+ )
37
+ (tmp_path / "aicp/agents.json").write_text(json.dumps(data))
38
+ result = subprocess.run(
39
+ [sys.executable, "-c", """
40
+ from pathlib import Path
41
+ import os
42
+ from aicp import cli, gitflow, runner, skills
43
+ from aicp.contracts import ROSTER
44
+ agent = next(c for c in ROSTER if c.name == 'codex')
45
+ root = Path(os.environ['AICP_TEST_AGENT_HOME'])
46
+ root.mkdir(parents=True)
47
+ assert skills.config_root(agent) == root
48
+ assert gitflow.preflight(['codex'])[0]
49
+ cli._nudge(['codex'])
50
+ assert skills.missing_skills(agent) == ('commit', 'safe-git-push')
51
+ skills.install([agent])
52
+ target = root / 'custom-skills/commit/SKILL.md'
53
+ assert target.is_file()
54
+ assert skills.detect(agent, 'commit') == skills.CURRENT
55
+ assert skills.missing_skills(agent) == ()
56
+ assert skills.full_status([agent])[0].target == target
57
+ prompt = 'literal $HOME; {prompt} with spaces'
58
+ assert runner.invoke_argv('codex', prompt)[-1] == prompt
59
+ assert runner.run_step(prompt, ['codex']).rc == 0
60
+ """],
61
+ cwd=tmp_path,
62
+ env={
63
+ **os.environ,
64
+ "PYTHONPATH": str(tmp_path),
65
+ "PATH": "",
66
+ "PYTHONUTF8": "1",
67
+ "AICP_TEST_AGENT_HOME": str(root),
68
+ },
69
+ capture_output=True,
70
+ text=True,
71
+ encoding="utf-8",
72
+ check=False,
73
+ )
74
+ assert result.returncode == 0, result.stdout + result.stderr
75
+ assert "missing the commit, safe-git-push skill" in result.stdout
76
+
77
+
78
+ @pytest.mark.parametrize(
79
+ ("field", "value"),
80
+ [
81
+ ("args", ["-p"]),
82
+ ("args", ["{prompt}", "{prompt}"]),
83
+ ("args", "-p {prompt}"),
84
+ ("args", ["{prompt}", 1]),
85
+ ("executable", ""),
86
+ ("config_dir", "relative/to/cwd"),
87
+ ("skills_dir", "../outside"),
88
+ ("skills_dir", "/outside"),
89
+ ("skills_dir", "."),
90
+ ("config_dir_env", ["INVALID"]),
91
+ ],
92
+ )
93
+ def test_rejects_invalid_registry_fields(tmp_path, field, value):
94
+ data = json.loads(Path(aicp.__file__).with_name("agents.json").read_text())
95
+ data["agents"]["codex"][field] = value
96
+ path = tmp_path / "agents.json"
97
+ path.write_text(json.dumps(data))
98
+ with pytest.raises(ValueError, match=field):
99
+ load_agents(path)
100
+
101
+
102
+ def test_config_root_preserves_absolute_paths_and_live_env(tmp_path, monkeypatch):
103
+ from dataclasses import replace
104
+
105
+ from aicp.agents import AGENTS
106
+
107
+ agent = replace(AGENTS["grok"], config_dir=str(tmp_path / "absolute"))
108
+ monkeypatch.setenv("GROK_HOME", str(tmp_path / "override"))
109
+ assert agent.config_root() == tmp_path / "override"
110
+ assert agent.config_root(tmp_path / "home") == tmp_path / "absolute"
111
+ monkeypatch.delenv("GROK_HOME")
112
+ assert agent.config_root() == tmp_path / "absolute"
@@ -146,6 +146,16 @@ def test_cli_order_motion_runs_the_other_way_too():
146
146
  assert _plain(frames[-1]) == " → ".join(after)
147
147
 
148
148
 
149
+ def test_selecting_a_different_row_does_not_resize_the_panel():
150
+ """Rows carry help text of very different lengths — the box must be sized
151
+ for the longest one up front, not reflow as the cursor passes each row."""
152
+ state = MenuState(Path("menu.aicprc"), True, True, "en", list(ROSTER_NAMES))
153
+
154
+ widths = {width(_panel(state, selected=i)[0]) for i in range(1, len(ROWS) + 1)}
155
+
156
+ assert len(widths) == 1, "the frame width changed when a different row was selected"
157
+
158
+
149
159
  @pytest.mark.parametrize("columns, lines", [(80, 24), (100, 24), (60, 24), (100, 14)])
150
160
  def test_the_panel_fits_the_terminal_it_draws_on(columns, lines, monkeypatch):
151
161
  """Every line inside the terminal, in both languages.
@@ -4,7 +4,7 @@ requires-python = ">=3.10"
4
4
 
5
5
  [[package]]
6
6
  name = "aicp-cli"
7
- version = "0.3.0"
7
+ version = "0.4.0"
8
8
  source = { editable = "." }
9
9
 
10
10
  [package.dev-dependencies]
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