aicp-cli 0.3.1__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.1 → aicp_cli-0.4.0}/CHANGELOG.md +6 -0
  2. aicp_cli-0.4.0/CLAUDE.md +7 -0
  3. {aicp_cli-0.3.1 → aicp_cli-0.4.0}/PKG-INFO +41 -2
  4. {aicp_cli-0.3.1 → aicp_cli-0.4.0}/README.md +40 -1
  5. {aicp_cli-0.3.1 → 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.1 → aicp_cli-0.4.0}/src/aicp/cli.py +2 -1
  9. {aicp_cli-0.3.1 → aicp_cli-0.4.0}/src/aicp/contracts.py +7 -8
  10. {aicp_cli-0.3.1 → aicp_cli-0.4.0}/src/aicp/gitflow.py +2 -1
  11. {aicp_cli-0.3.1 → aicp_cli-0.4.0}/src/aicp/runner.py +7 -27
  12. {aicp_cli-0.3.1 → aicp_cli-0.4.0}/src/aicp/skills.py +19 -25
  13. aicp_cli-0.4.0/tests/test_agents.py +112 -0
  14. {aicp_cli-0.3.1 → aicp_cli-0.4.0}/uv.lock +1 -1
  15. {aicp_cli-0.3.1 → aicp_cli-0.4.0}/.github/workflows/ci.yml +0 -0
  16. {aicp_cli-0.3.1 → aicp_cli-0.4.0}/.github/workflows/release.yml +0 -0
  17. {aicp_cli-0.3.1 → aicp_cli-0.4.0}/.gitignore +0 -0
  18. {aicp_cli-0.3.1 → aicp_cli-0.4.0}/LICENSE +0 -0
  19. {aicp_cli-0.3.1 → aicp_cli-0.4.0}/TODO.md +0 -0
  20. {aicp_cli-0.3.1 → aicp_cli-0.4.0}/config.example.json +0 -0
  21. {aicp_cli-0.3.1 → aicp_cli-0.4.0}/docs/images/aicp-run.png +0 -0
  22. {aicp_cli-0.3.1 → aicp_cli-0.4.0}/skills/commit.md +0 -0
  23. {aicp_cli-0.3.1 → aicp_cli-0.4.0}/skills/safe-git-push/SKILL.md +0 -0
  24. {aicp_cli-0.3.1 → aicp_cli-0.4.0}/skills/safe-git-push/scripts/safe_push.py +0 -0
  25. {aicp_cli-0.3.1 → aicp_cli-0.4.0}/src/aicp/__init__.py +0 -0
  26. {aicp_cli-0.3.1 → aicp_cli-0.4.0}/src/aicp/_keyreader.py +0 -0
  27. {aicp_cli-0.3.1 → aicp_cli-0.4.0}/src/aicp/_utils.py +0 -0
  28. {aicp_cli-0.3.1 → aicp_cli-0.4.0}/src/aicp/budget.py +0 -0
  29. {aicp_cli-0.3.1 → aicp_cli-0.4.0}/src/aicp/config.py +0 -0
  30. {aicp_cli-0.3.1 → aicp_cli-0.4.0}/src/aicp/i18n.py +0 -0
  31. {aicp_cli-0.3.1 → aicp_cli-0.4.0}/src/aicp/menu.py +0 -0
  32. {aicp_cli-0.3.1 → aicp_cli-0.4.0}/src/aicp/notify.py +0 -0
  33. {aicp_cli-0.3.1 → aicp_cli-0.4.0}/src/aicp/present.py +0 -0
  34. {aicp_cli-0.3.1 → aicp_cli-0.4.0}/src/aicp/quota.py +0 -0
  35. {aicp_cli-0.3.1 → aicp_cli-0.4.0}/src/aicp/secrets.py +0 -0
  36. {aicp_cli-0.3.1 → aicp_cli-0.4.0}/src/aicp/timing.py +0 -0
  37. {aicp_cli-0.3.1 → aicp_cli-0.4.0}/tests/conftest.py +0 -0
  38. {aicp_cli-0.3.1 → aicp_cli-0.4.0}/tests/test_budget.py +0 -0
  39. {aicp_cli-0.3.1 → aicp_cli-0.4.0}/tests/test_cli.py +0 -0
  40. {aicp_cli-0.3.1 → aicp_cli-0.4.0}/tests/test_config.py +0 -0
  41. {aicp_cli-0.3.1 → aicp_cli-0.4.0}/tests/test_cross_platform.py +0 -0
  42. {aicp_cli-0.3.1 → aicp_cli-0.4.0}/tests/test_gitflow.py +0 -0
  43. {aicp_cli-0.3.1 → aicp_cli-0.4.0}/tests/test_i18n.py +0 -0
  44. {aicp_cli-0.3.1 → aicp_cli-0.4.0}/tests/test_menu.py +0 -0
  45. {aicp_cli-0.3.1 → aicp_cli-0.4.0}/tests/test_menu_skills.py +0 -0
  46. {aicp_cli-0.3.1 → aicp_cli-0.4.0}/tests/test_notify.py +0 -0
  47. {aicp_cli-0.3.1 → aicp_cli-0.4.0}/tests/test_present.py +0 -0
  48. {aicp_cli-0.3.1 → aicp_cli-0.4.0}/tests/test_quota.py +0 -0
  49. {aicp_cli-0.3.1 → aicp_cli-0.4.0}/tests/test_runner.py +0 -0
  50. {aicp_cli-0.3.1 → aicp_cli-0.4.0}/tests/test_secrets.py +0 -0
  51. {aicp_cli-0.3.1 → aicp_cli-0.4.0}/tests/test_skills.py +0 -0
  52. {aicp_cli-0.3.1 → aicp_cli-0.4.0}/tests/test_timing.py +0 -0
  53. {aicp_cli-0.3.1 → aicp_cli-0.4.0}/tests/test_undo.py +0 -0
@@ -1,3 +1,8 @@
1
+ ## [0.4.0] - 2026-09-14
2
+
3
+ ### 🚜 Refactor
4
+
5
+ - **agents:** Centralize agent definitions
1
6
  ## [0.3.1] - 2026-09-14
2
7
 
3
8
  ### 🐛 Bug Fixes
@@ -9,6 +14,7 @@
9
14
  - **readme:** Document releasing and the wheel version check
10
15
  - Remove CI section from README.md
11
16
  - Add uv requirement note to README.md
17
+ - **changelog:** Release v0.3.1
12
18
  ## [0.3.0] - 2026-09-14
13
19
 
14
20
  ### 🐛 Bug Fixes
@@ -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.1
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
@@ -49,7 +49,8 @@ uv tool install aicp-cli # latest release
49
49
  uv tool install aicp-cli==X.Y.Z # pin to a specific version
50
50
  ```
51
51
 
52
- 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
53
54
  `aicp` name on PyPI belongs to an unrelated 2021 project — don't install it.)
54
55
  Replace `X.Y.Z` with the release version you want; re-running either command
55
56
  switches an existing install to that version.
@@ -197,6 +198,44 @@ in that tree. Bump the version and tag again.
197
198
 
198
199
  ## Configuration
199
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
+
200
239
  The config file lives at `~/.aicp/config.json` (override the path itself
201
240
  with `AICP_CONFIG`, which — being the thing that names the file — can only be
202
241
  set as a real environment variable). Precedence everywhere is
@@ -37,7 +37,8 @@ uv tool install aicp-cli # latest release
37
37
  uv tool install aicp-cli==X.Y.Z # pin to a specific version
38
38
  ```
39
39
 
40
- 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
41
42
  `aicp` name on PyPI belongs to an unrelated 2021 project — don't install it.)
42
43
  Replace `X.Y.Z` with the release version you want; re-running either command
43
44
  switches an existing install to that version.
@@ -185,6 +186,44 @@ in that tree. Bump the version and tag again.
185
186
 
186
187
  ## Configuration
187
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
+
188
227
  The config file lives at `~/.aicp/config.json` (override the path itself
189
228
  with `AICP_CONFIG`, which — being the thing that names the file — can only be
190
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.1"
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,
@@ -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"
@@ -4,7 +4,7 @@ requires-python = ">=3.10"
4
4
 
5
5
  [[package]]
6
6
  name = "aicp-cli"
7
- version = "0.3.1"
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
File without changes
File without changes