aicp-cli 0.3.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 (49) hide show
  1. aicp_cli-0.3.0/.github/workflows/ci.yml +141 -0
  2. aicp_cli-0.3.0/.github/workflows/release.yml +67 -0
  3. aicp_cli-0.3.0/.gitignore +12 -0
  4. aicp_cli-0.3.0/CHANGELOG.md +99 -0
  5. aicp_cli-0.3.0/LICENSE +20 -0
  6. aicp_cli-0.3.0/PKG-INFO +298 -0
  7. aicp_cli-0.3.0/README.md +286 -0
  8. aicp_cli-0.3.0/TODO.md +39 -0
  9. aicp_cli-0.3.0/config.example.json +14 -0
  10. aicp_cli-0.3.0/docs/images/aicp-run.png +0 -0
  11. aicp_cli-0.3.0/pyproject.toml +45 -0
  12. aicp_cli-0.3.0/skills/commit.md +90 -0
  13. aicp_cli-0.3.0/skills/safe-git-push/SKILL.md +55 -0
  14. aicp_cli-0.3.0/skills/safe-git-push/scripts/safe_push.py +248 -0
  15. aicp_cli-0.3.0/src/aicp/__init__.py +18 -0
  16. aicp_cli-0.3.0/src/aicp/_keyreader.py +210 -0
  17. aicp_cli-0.3.0/src/aicp/_utils.py +183 -0
  18. aicp_cli-0.3.0/src/aicp/budget.py +158 -0
  19. aicp_cli-0.3.0/src/aicp/cli.py +595 -0
  20. aicp_cli-0.3.0/src/aicp/config.py +434 -0
  21. aicp_cli-0.3.0/src/aicp/contracts.py +144 -0
  22. aicp_cli-0.3.0/src/aicp/gitflow.py +456 -0
  23. aicp_cli-0.3.0/src/aicp/i18n.py +228 -0
  24. aicp_cli-0.3.0/src/aicp/menu.py +1013 -0
  25. aicp_cli-0.3.0/src/aicp/notify.py +87 -0
  26. aicp_cli-0.3.0/src/aicp/present.py +303 -0
  27. aicp_cli-0.3.0/src/aicp/quota.py +115 -0
  28. aicp_cli-0.3.0/src/aicp/runner.py +560 -0
  29. aicp_cli-0.3.0/src/aicp/secrets.py +291 -0
  30. aicp_cli-0.3.0/src/aicp/skills.py +679 -0
  31. aicp_cli-0.3.0/src/aicp/timing.py +116 -0
  32. aicp_cli-0.3.0/tests/conftest.py +222 -0
  33. aicp_cli-0.3.0/tests/test_budget.py +269 -0
  34. aicp_cli-0.3.0/tests/test_cli.py +709 -0
  35. aicp_cli-0.3.0/tests/test_config.py +581 -0
  36. aicp_cli-0.3.0/tests/test_cross_platform.py +122 -0
  37. aicp_cli-0.3.0/tests/test_gitflow.py +320 -0
  38. aicp_cli-0.3.0/tests/test_i18n.py +253 -0
  39. aicp_cli-0.3.0/tests/test_menu.py +457 -0
  40. aicp_cli-0.3.0/tests/test_menu_skills.py +217 -0
  41. aicp_cli-0.3.0/tests/test_notify.py +165 -0
  42. aicp_cli-0.3.0/tests/test_present.py +129 -0
  43. aicp_cli-0.3.0/tests/test_quota.py +149 -0
  44. aicp_cli-0.3.0/tests/test_runner.py +952 -0
  45. aicp_cli-0.3.0/tests/test_secrets.py +295 -0
  46. aicp_cli-0.3.0/tests/test_skills.py +851 -0
  47. aicp_cli-0.3.0/tests/test_timing.py +159 -0
  48. aicp_cli-0.3.0/tests/test_undo.py +174 -0
  49. aicp_cli-0.3.0/uv.lock +215 -0
@@ -0,0 +1,141 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ pull_request:
6
+
7
+ permissions:
8
+ contents: read
9
+
10
+ concurrency:
11
+ group: ci-${{ github.workflow }}-${{ github.ref }}
12
+ cancel-in-progress: true
13
+
14
+ env:
15
+ PYTHONUTF8: "1"
16
+
17
+ jobs:
18
+ test:
19
+ name: Python 3.10 / ${{ matrix.os }}
20
+ timeout-minutes: 15
21
+ strategy:
22
+ fail-fast: false
23
+ matrix:
24
+ os: [macos-latest, ubuntu-latest, windows-latest]
25
+ runs-on: ${{ matrix.os }}
26
+ steps:
27
+ - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
28
+ with:
29
+ persist-credentials: false
30
+ - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7
31
+ with:
32
+ python-version: "3.10"
33
+ enable-cache: true
34
+ cache-dependency-glob: uv.lock
35
+ - name: Install locked dependencies
36
+ run: uv sync --locked --all-groups
37
+ - name: Lint
38
+ run: uv run ruff check .
39
+ - name: Test
40
+ run: uv run pytest
41
+ - name: Build distributions
42
+ run: uv build
43
+ - name: Validate wheel and console script
44
+ shell: python
45
+ run: |
46
+ import os
47
+ import pathlib
48
+ import re
49
+ import subprocess
50
+ import tempfile
51
+
52
+ wheel, = pathlib.Path("dist").glob("*.whl")
53
+ with tempfile.TemporaryDirectory() as directory:
54
+ environment = pathlib.Path(directory)
55
+ subprocess.run(["uv", "venv", environment], check=True)
56
+ scripts = environment / ("Scripts" if os.name == "nt" else "bin")
57
+ python = scripts / ("python.exe" if os.name == "nt" else "python")
58
+ subprocess.run(
59
+ ["uv", "pip", "install", "--python", python, "--no-index", wheel],
60
+ check=True,
61
+ )
62
+ suffix = ".exe" if os.name == "nt" else ""
63
+ subprocess.run([scripts / f"aicp{suffix}", "--help"], check=True)
64
+
65
+ # The distribution name (aicp-cli) and the import name (aicp)
66
+ # differ, so __init__'s importlib.metadata lookup can miss and
67
+ # fall back to "0+unknown" without failing anything. Compare the
68
+ # installed wheel's own report against pyproject.toml.
69
+ expected, = re.findall(
70
+ r'^version = "(.+)"$',
71
+ pathlib.Path("pyproject.toml").read_text(encoding="utf-8"),
72
+ re.MULTILINE,
73
+ )
74
+ reported = subprocess.run(
75
+ [scripts / f"aicp{suffix}", "--version"],
76
+ check=True,
77
+ capture_output=True,
78
+ text=True,
79
+ ).stdout.strip()
80
+ assert reported == f"aicp {expected}", f"{reported!r} != 'aicp {expected}'"
81
+ print("version OK:", reported)
82
+
83
+ # The vendored skills must survive packaging. Without the
84
+ # force-include key in pyproject.toml the wheel ships no skill
85
+ # data, _vendor_dir() falls back to a checkout path that does not
86
+ # exist once installed, and installing skills silently finds
87
+ # nothing. Assert against the INSTALLED package, never the checkout.
88
+ subprocess.run(
89
+ [
90
+ python,
91
+ "-c",
92
+ "from aicp import skills;"
93
+ "d = skills.VENDOR_DIR;"
94
+ "assert d.is_dir(), f'no vendored skills in wheel: {d}';"
95
+ "assert (d / 'commit.md').is_file(), 'commit.md missing';"
96
+ "assert (d / 'safe-git-push' / 'SKILL.md').is_file(),"
97
+ " 'safe-git-push missing';"
98
+ "print('vendored skills OK:', d)",
99
+ ],
100
+ check=True,
101
+ )
102
+
103
+ # Failure-only pager. A green run sends nothing on purpose: a notification
104
+ # that arrives on every push is one nobody reads. Credentials live in repo
105
+ # secrets, never in this file — see README "CI notifications" for the two
106
+ # `gh secret set` commands that configure them.
107
+ notify-telegram:
108
+ needs: test
109
+ if: ${{ failure() && github.event_name == 'push' }}
110
+ runs-on: ubuntu-latest
111
+ timeout-minutes: 5
112
+ steps:
113
+ - name: Notify Telegram
114
+ env:
115
+ TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
116
+ CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
117
+ run: |
118
+ # Unconfigured secrets are a valid state (a fork, or a clone whose
119
+ # owner never set them up). Skip quietly instead of failing the job
120
+ # and reporting a second, misleading red X on top of the real one.
121
+ if [ -z "$TOKEN" ] || [ -z "$CHAT_ID" ]; then
122
+ echo "::notice::TELEGRAM_BOT_TOKEN / TELEGRAM_CHAT_ID not set — skipping notification"
123
+ exit 0
124
+ fi
125
+ # parse_mode=HTML: a branch or commit subject containing < & > would
126
+ # otherwise produce unparseable markup and Telegram would 400.
127
+ esc() { printf '%s' "$1" | sed -e 's/&/\&amp;/g' -e 's/</\&lt;/g' -e 's/>/\&gt;/g'; }
128
+ ci_url="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID"
129
+ message="🚨 <b>CI failed</b>
130
+
131
+ <b>Repository:</b> <code>$(esc "$GITHUB_REPOSITORY")</code>
132
+ <b>Branch:</b> <code>$(esc "$GITHUB_REF_NAME")</code>
133
+ <b>Commit:</b> <code>$(esc "${GITHUB_SHA::7}")</code>
134
+
135
+ <a href=\"$ci_url\">Open failed CI run</a>"
136
+ curl --fail --silent --show-error --max-time 30 \
137
+ --data-urlencode "chat_id=$CHAT_ID" \
138
+ --data-urlencode "parse_mode=HTML" \
139
+ --data-urlencode "disable_web_page_preview=true" \
140
+ --data-urlencode "text=$message" \
141
+ "https://api.telegram.org/bot$TOKEN/sendMessage"
@@ -0,0 +1,67 @@
1
+ name: Release
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*"
7
+
8
+ permissions:
9
+ contents: write
10
+ id-token: write
11
+
12
+ concurrency:
13
+ group: release-${{ github.ref }}
14
+
15
+ env:
16
+ PYTHONUTF8: "1"
17
+
18
+ jobs:
19
+ release:
20
+ runs-on: ubuntu-latest
21
+ timeout-minutes: 15
22
+ steps:
23
+ - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
24
+ with:
25
+ persist-credentials: false
26
+ - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7
27
+ with:
28
+ python-version: "3.10"
29
+ enable-cache: true
30
+ cache-dependency-glob: uv.lock
31
+ - name: Install locked dependencies
32
+ run: uv sync --locked --all-groups
33
+ - name: Check, test, and build
34
+ run: |
35
+ uv run ruff check .
36
+ uv run pytest
37
+ uv build
38
+ - name: Validate release wheel
39
+ run: |
40
+ uv venv .release-venv
41
+ uv pip install --python .release-venv/bin/python --no-index dist/*.whl
42
+ .release-venv/bin/aicp --help
43
+ # The wheel must report the version being tagged. This catches both a
44
+ # tag/pyproject mismatch and the silent `0+unknown` that appears when
45
+ # __init__ asks importlib.metadata for the wrong distribution name.
46
+ reported="$(.release-venv/bin/aicp --version)"
47
+ expected="aicp ${GITHUB_REF_NAME#v}"
48
+ if [ "$reported" != "$expected" ]; then
49
+ echo "::error::wheel reports '$reported', expected '$expected'"
50
+ exit 1
51
+ fi
52
+ - name: Publish to PyPI
53
+ run: uv publish --trusted-publishing always
54
+
55
+ # After publish: uv publish uploads dist/*, and SHA256SUMS is not a distribution.
56
+ - name: Create checksums
57
+ run: sha256sum dist/* > dist/SHA256SUMS
58
+ - name: Create GitHub release
59
+ env:
60
+ GH_TOKEN: ${{ github.token }}
61
+ run: |
62
+ gh api "repos/${GITHUB_REPOSITORY}/git/refs/tags/$GITHUB_REF_NAME" >/dev/null
63
+ if gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1; then
64
+ gh release upload "$GITHUB_REF_NAME" dist/* --clobber
65
+ else
66
+ gh release create "$GITHUB_REF_NAME" dist/* --generate-notes --title "$GITHUB_REF_NAME" --verify-tag
67
+ fi
@@ -0,0 +1,12 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .venv/
7
+ .python-version
8
+ .DS_Store
9
+ *.swp
10
+ .aicp/
11
+ .aicprc
12
+ .omc/
@@ -0,0 +1,99 @@
1
+ ## [0.3.0] - 2026-09-14
2
+
3
+ ### 🐛 Bug Fixes
4
+
5
+ - **version:** Derive __version__ from package metadata
6
+
7
+ ### 💼 Other
8
+
9
+ - **pypi:** [**breaking**] Publish as aicp-cli
10
+
11
+ ### 📚 Documentation
12
+
13
+ - **readme:** Show install commands for pinning and upgrading
14
+
15
+ ### ⚙️ Miscellaneous Tasks
16
+
17
+ - **release:** Publish wheels to PyPI via trusted publishing
18
+ - Assert the built wheel reports the right version
19
+ ## [0.2.1] - 2026-09-13
20
+
21
+ ### ⚙️ Miscellaneous Tasks
22
+
23
+ - Refresh uv lockfile for v0.2.0
24
+ ## [0.2.0] - 2026-09-13
25
+
26
+ ### 🚀 Features
27
+
28
+ - **cli:** Wire the entry point and bridge config to its consumers
29
+ - **menu:** Add Skills and Doctor rows with inline status
30
+ - **i18n:** Fill the zh-TW catalogue and retire menu.py's _ZH bridge
31
+ - [**breaking**] Support grok and quota-aware fallback
32
+ - **menu:** Display full CLI chain in config panel
33
+ - **skills:** Track installed skills via hashed state file
34
+ - **config:** Migrate config store to ~/.aicp/config.json
35
+ - **menu:** Bold the cursor row in the config panel
36
+ - **runner:** Persist quota exclusion across runs
37
+
38
+ ### 🐛 Bug Fixes
39
+
40
+ - **aicp:** Double a trailing backslash when quoting a batch-shim argument
41
+ - **config:** [**breaking**] Deny AICP_CONFIG from .aicprc
42
+ - **cli:** Let AICP_LANG reach the translator, and refuse orphan sub-flags
43
+ - **menu:** Stop the config panel smearing during CLI-order changes
44
+ - **skills:** Reject unsafe record keys before hashing
45
+ - **config:** Normalize key casing between JSON and env
46
+ - **present:** Style config menu group headings
47
+ - **present:** Make config group headings legible
48
+
49
+ ### 📚 Documentation
50
+
51
+ - **todo:** Record the Windows timeout-orphan defect as a known issue
52
+ - Record grok and quota behavior
53
+ - Add demo screenshot to readme
54
+ - Note follow-up security review for skills state store
55
+ - Add aicp performance improvement todo item
56
+
57
+ ### 🧪 Testing
58
+
59
+ - Add intentional import failures for testing
60
+ - **config:** Cover case-folded and denylisted keys
61
+
62
+ ### ⚙️ Miscellaneous Tasks
63
+
64
+ - Page Telegram on a failed push
65
+ - Ignore local omc state
66
+ - **release:** Bump version to 0.2.0
67
+ ## [0.1.0] - 2026-09-12
68
+
69
+ ### 🚀 Features
70
+
71
+ - **aicp:** Scaffold Python port with frozen contracts and test harness
72
+ - **skills:** Add cross-platform skills installer
73
+ - **aicp:** Port the git flow, secret scanner, and notifier
74
+ - **aicp:** Port hardened .aicprc layer and the --config/--swap-ai menu
75
+ - **aicp:** Port the timing log with 5MB x 5-file rotation
76
+ - **aicp:** Port the per-CLI timeout budget with history widening
77
+ - **aicp:** Port the fallback-chain runner with signal-safe timeouts
78
+
79
+ ### 🐛 Bug Fixes
80
+
81
+ - **aicp:** Use CTRL_BREAK_EVENT and fix Windows stub argv logging
82
+ - **skills:** Overwrite a stale .bak on re-forced install
83
+ - **skills:** Never let a backup overwrite an earlier backup
84
+ - **aicp:** Harden the budget and notifier against values that crash a run
85
+ - **packaging:** Ship vendored skills in the wheel
86
+ - **aicp:** Stop the runner leaking children and writing through a symlink
87
+ - **aicp:** Bound the notifier so a hung tg-send.sh cannot block a run
88
+ - **aicp:** Launch npm .cmd shims on Windows instead of failing to start
89
+
90
+ ### 📚 Documentation
91
+
92
+ - **aicp:** Write README, changelog, and .aicprc.example for 0.1.0
93
+
94
+ ### 🧪 Testing
95
+
96
+ - **aicp:** Count Path.stat, and exercise the non-TTY guard on a real fd
97
+ - **aicp:** Bound the unreaped-child tests on elapsed time
98
+ - **aicp:** Delete git's read-only objects when tearing down a bare remote
99
+ - **aicp:** Skip the argv-passthrough test on the platform it does not describe
aicp_cli-0.3.0/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Wes Kao
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT, OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OF THE SOFTWARE.
@@ -0,0 +1,298 @@
1
+ Metadata-Version: 2.5
2
+ Name: aicp-cli
3
+ Version: 0.3.0
4
+ Summary: AI commit + push, with a git-verified result summary
5
+ Project-URL: Repository, https://github.com/weskao/aicp
6
+ Project-URL: Issues, https://github.com/weskao/aicp/issues
7
+ Author: Wes Kao
8
+ License: MIT
9
+ License-File: LICENSE
10
+ Requires-Python: >=3.10
11
+ Description-Content-Type: text/markdown
12
+
13
+ # aicp
14
+
15
+ AI commit + push, with a git-verified result summary.
16
+
17
+ `aicp` runs an AI coding CLI to write your commit message(s) and push, trying
18
+ a fallback chain of six CLIs — `copilot` → `agy` → `codex` → `claude` →
19
+ `vibe` → `grok` — until one exits 0; anything not installed is skipped. It
20
+ sends that CLI two literal prompts, `/commit` then `/safe-git-push`.
21
+
22
+ The part that matters: **aicp never trusts the CLI's own account of what
23
+ happened.** An AI CLI can print "pushed!" and exit 0 while `/safe-git-push`
24
+ quietly aborted inside it. So every number in the result summary — new
25
+ commits, ahead/behind, whether the branch is actually in sync — is read back
26
+ from `git` itself after the CLI is done, never taken from its output. A
27
+ failed `git fetch` is never read as "already in sync" either: a stale
28
+ remote-tracking ref resolves just fine and would otherwise report a clean
29
+ push that never reached the remote.
30
+
31
+ [Install](#install) · [Set up skills](#set-up-skills) · [Run it](#run-it) ·
32
+ [`--undo`](#--undo) · [Automation / CI](#automation--ci) ·
33
+ [Configuration](#configuration) · [Safety](#safety) ·
34
+ [Platform support](#platform-support)
35
+
36
+ ## Requirements
37
+
38
+ - Python 3.10 or newer
39
+ - `git`
40
+ - At least one of the six AI CLIs above on `PATH`
41
+
42
+ The Python package itself has no runtime dependencies.
43
+
44
+ ## Install
45
+
46
+ ```sh
47
+ uv tool install aicp-cli # latest release
48
+ uv tool install aicp-cli==X.Y.Z # pin to a specific version
49
+ ```
50
+
51
+ The distribution is `aicp-cli`; the command it installs is `aicp`. (The plain
52
+ `aicp` name on PyPI belongs to an unrelated 2021 project — don't install it.)
53
+ Replace `X.Y.Z` with the release version you want; re-running either command
54
+ switches an existing install to that version.
55
+
56
+ ```sh
57
+ uv tool upgrade aicp-cli
58
+ uv tool uninstall aicp-cli
59
+ ```
60
+
61
+ ## Set up skills
62
+
63
+ ```sh
64
+ aicp --config # -> Skills
65
+ ```
66
+
67
+ `/commit` and `/safe-git-push` only mean something if a skill by that exact
68
+ name exists in the CLI's own config directory — otherwise the CLI receives a
69
+ slash command it has never heard of and improvises. aicp ships byte-identical
70
+ copies of both skills and installs them for you, but it never overwrites a
71
+ skill you already have: a file with no aicp version marker next to it is
72
+ treated as yours and left alone. If you already have a better `/commit` for
73
+ this repo, it stays.
74
+
75
+ Targets follow each CLI's own config directory, not its binary name — `agy`
76
+ (this project's name for the Gemini CLI) reads `~/.gemini`, not `~/.agy`:
77
+
78
+ | CLI | Config dir | `/commit` installed? | `/safe-git-push` installed? |
79
+ | --- | --- | --- | --- |
80
+ | `claude` | `~/.claude` | No — keeps your existing `commands/commit.md` | Yes |
81
+ | `codex` | `~/.codex` | Yes | Yes |
82
+ | `copilot` | `~/.copilot` | Yes | Yes |
83
+ | `agy` (Gemini CLI) | `~/.gemini` | Yes | Yes |
84
+ | `vibe` | `~/.vibe` | Yes | Yes |
85
+ | `grok` | `$GROK_HOME` when set, otherwise `~/.grok` | Yes | Yes |
86
+
87
+ `claude` is the one exception: it already resolves `/commit` from its own
88
+ `commands/commit.md`, so aicp never installs a competing definition under
89
+ `skills/` there — installing one would just shadow the one Claude already
90
+ uses. Every other CLI gets both skills.
91
+
92
+ A CLI whose config directory doesn't exist at all is skipped, never created —
93
+ aicp only ever installs into a CLI you've actually set up.
94
+
95
+ ### Fallback results and quota limits
96
+
97
+ The opening run panel prints the resolved chain, and the commit panel and
98
+ final RESULT table name the CLI that handled each step (`—` when skipped).
99
+ When a CLI emits a supported, exact quota/rate-limit signal, aicp records the
100
+ outcome as `quota`, notifies through the usual notification path, and excludes
101
+ that CLI from the rest of that one commit/push flow. The exclusion also
102
+ persists: the CLI stays skipped for `AICP_QUOTA_COOLDOWN` seconds (default one
103
+ hour, state in `~/.aicp/quota.json`), because a token or rate-limit wall
104
+ normally stands for hours and every run inside that window would otherwise burn
105
+ a full budget per step on a CLI that cannot succeed. A skipped CLI prints how
106
+ long is left; `AICP_QUOTA_COOLDOWN=0` switches the cooldown off entirely, and
107
+ deleting the file clears it.
108
+
109
+ Only the `quota` outcome starts a cooldown. A timeout does not — a CLI that
110
+ hangs on its rate limit instead of exiting is indistinguishable from one merely
111
+ running long, and sidelining it for an hour on that guess costs more than the
112
+ retry does.
113
+
114
+ Exact detection is intentionally narrow. It is supported for Codex, Claude,
115
+ Vibe, and Grok only; Copilot and `agy` have no verified quota signature, so a
116
+ nonzero exit from either remains an ordinary failure and is still eligible for
117
+ the next step.
118
+
119
+ ## Run it
120
+
121
+ The whole command surface is two things to remember:
122
+
123
+ ```sh
124
+ aicp # commit, then push
125
+ aicp --config # settings, skills, health check — everything else lives here
126
+ ```
127
+
128
+ A plain `aicp` run: checks whether anything is pending (skipping the AI CLI
129
+ entirely on a clean, already-in-sync repo), scans for secrets, runs `/commit`
130
+ through the fallback chain, then `/safe-git-push` the same way, then prints
131
+ the git-verified result table described above. `-v`/`--verbose` streams each
132
+ CLI's raw output live instead of showing a spinner.
133
+
134
+ ![aicp run: commit, push, and the git-verified result table](docs/images/aicp-run.png)
135
+
136
+ `aicp --config` is one menu for everything that isn't "commit and push
137
+ right now": which steps run, message language, fallback CLI order, the
138
+ skills install/upgrade view, and a health check.
139
+
140
+ ### `--undo`
141
+
142
+ ```sh
143
+ aicp --undo
144
+ ```
145
+
146
+ Runs `git reset --soft HEAD^` on the last commit — the escape hatch for a bad
147
+ commit message or a wrong stage. Changes land back in the index, not lost,
148
+ not pushed. It never calls an AI CLI and refuses outright (HEAD untouched) in
149
+ four cases, because once a commit reaches the remote other clones or CI may
150
+ already be building on it:
151
+
152
+ - there's no branch to compare against (detached `HEAD`),
153
+ - there's no parent commit to reset onto,
154
+ - the last commit is already on the remote,
155
+ - the remote can't be verified at all (deleted remote, failed fetch, never
156
+ pushed) — "can't verify" is treated as the risky case, never as "safe."
157
+
158
+ ## Automation / CI
159
+
160
+ Everything below is a non-interactive escape hatch for scripting; day-to-day
161
+ use is the two commands above.
162
+
163
+ ```sh
164
+ aicp --doctor --json # skill-install status for every configured CLI, as JSON
165
+ aicp --install-skills --yes # install what's missing, upgrade what's outdated
166
+ aicp --install-skills --force # also replace files aicp doesn't recognize
167
+ ```
168
+
169
+ `--doctor --json` reports, per CLI and per skill: the target path and whether
170
+ it's missing, current, an outdated aicp copy, someone else's file, or the CLI
171
+ itself isn't configured — the same view `--config`'s Skills screen shows,
172
+ without a terminal.
173
+
174
+ `--install-skills --yes` installs anything `MISSING` and upgrades anything
175
+ older than the version aicp ships, exactly like the interactive Skills view —
176
+ and it **never** touches a file aicp doesn't recognize. Only `--force` does
177
+ that, and even then the original is moved aside to `<name>.bak` (numbered
178
+ `.bak.1`, `.bak.2`, … so a second forced install never clobbers the first
179
+ backup) before aicp writes its own copy.
180
+
181
+ ### This repo's own CI
182
+
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.
187
+
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.
190
+
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.
202
+
203
+ ## Configuration
204
+
205
+ The config file lives at `~/.aicp/config.json` (override the path itself
206
+ with `AICP_CONFIG`, which — being the thing that names the file — can only be
207
+ set as a real environment variable). Precedence everywhere is
208
+ **environment > `config.json` > hardcoded default**. The file is a JSON
209
+ object, parsed as data and never sourced or eval'd, written atomically and
210
+ owner-only (`0600`) by `--config`/`--swap-ai`; only keys matching
211
+ `AICP_[A-Z0-9_]*` case-insensitively with a **string** value built from
212
+ letters, digits and `` / . _ : @ + - `` survive — everything else (an unknown
213
+ key, a non-string value, a value outside that charset) is skipped
214
+ individually, so one bad key never costs the rest of the file. An invalid
215
+ *value* for a known key never aborts a run — it's reported on stderr and
216
+ falls back to the default.
217
+
218
+ `aicp` always **writes** keys `lower_case` (`aicp_do_commit`, not
219
+ `AICP_DO_COMMIT`) — every key in [`config.example.json`](config.example.json)
220
+ and any new knob added in the future follows the same convention. Reading is
221
+ case-insensitive, so an existing file with `AICP_`-cased keys still works.
222
+
223
+ A legacy `~/.aicprc` (the pre-JSON `KEY=value` format) is migrated into
224
+ `~/.aicp/config.json` automatically, once, the first time `aicp` runs — the
225
+ old file is left in place untouched, never deleted or rewritten.
226
+
227
+ See [`config.example.json`](config.example.json) for a ready-to-copy template.
228
+
229
+ | Variable | Default | What it does |
230
+ | --- | --- | --- |
231
+ | `AICP_DO_COMMIT` | `1` | Run the `/commit` step. `0` = only push what's already committed. |
232
+ | `AICP_DO_PUSH` | `1` | Run the `/safe-git-push` step. `0` = commit and stop. |
233
+ | `AICP_LANG` | `en` | Message language: `en` or `zh-TW`, everywhere including notifications. |
234
+ | `AICP_CLI_ORDER` | `copilot agy codex claude vibe grok` | Fallback order. A prefix is enough — any roster name left out is appended after it, in roster order. An unknown or repeated name is refused outright and the default order is used. |
235
+ | `AICP_TZ` | `Asia/Taipei` | IANA zone name used to render commit timestamps. Anything else falls back to the default. |
236
+ | `AICP_TZ_LABEL` | `UTC+8` | Cosmetic label shown beside those timestamps; not validated. |
237
+ | `AICP_STEP_TIMEOUT` | *(unset)* | Pins every CLI's per-step budget in seconds, skipping the formula and history below entirely. |
238
+ | `AICP_TIMEOUT_BASE` | `180` | Budget floor (seconds) — covers cold start plus a small prompt. |
239
+ | `AICP_TIMEOUT_PER_FILE` | `15` | Seconds added per changed or untracked file. |
240
+ | `AICP_TIMEOUT_PER_100L` | `5` | Seconds added per 100 changed lines in tracked files. |
241
+ | `AICP_TIMEOUT_MAX` | `1800` | Ceiling on the formula above. A CLI's own run history may still widen its budget past this — that's direct evidence it legitimately needs the time, not a guess. |
242
+ | `AICP_TIMEOUT_HISTORY_LINES` | `500` | How many recent timing-log rows are scanned when widening a budget from history. |
243
+ | `AICP_TIMEOUT_HISTORY_MULT` | `1.3` | Multiplier applied to a CLI's largest successful run when that exceeds the formula. |
244
+ | `AICP_QUOTA_COOLDOWN` | `3600` | Seconds a CLI that reported a quota/rate-limit signal stays skipped, across runs (state in `~/.aicp/quota.json`). `0` switches the feature off; anything not a plain integer in 1..604800 falls back to the default. |
245
+ | `AICP_SKIP_SECRET_SCAN` | *(unset)* | `1` bypasses the pre-commit secret scan for one run — the documented escape for a false positive. |
246
+ | `AICP_CONFIG` | `~/.aicp/config.json` | Which file this loader reads. Environment-variable only — a file can't rename itself. |
247
+ | `AICP_TG_SEND` | `~/.claude/scripts/tg-send.sh` | The Telegram send script run at the end of a notification. **Environment-variable only** — refused if set in `config.json`. |
248
+ | `AICP_TIMING_LOG` | `~/.aicp/timing.log` | Where per-CLI timing rows are appended (rotated at 5 MB, 5 kept). **Environment-variable only** — refused if set in `config.json`. |
249
+
250
+ `AICP_TG_SEND` and `AICP_TIMING_LOG` are refused from `config.json` on
251
+ purpose: both name a path that then gets *executed* (`AICP_TG_SEND`, run as a
252
+ script) or *written and rotated* (`AICP_TIMING_LOG`, `mkdir -p` / `>>` / a
253
+ rename). A config file is exactly the kind of thing that can arrive synced
254
+ from someone else's dotfiles repo, so anything that becomes a command or a
255
+ filesystem sink stays a real-environment-only decision — set it in your
256
+ shell, not the file.
257
+
258
+ ## Safety
259
+
260
+ Before any AI CLI runs, aicp scans everything the run *could* commit for
261
+ likely secrets: the added lines of the pending diff, every tracked file git
262
+ reports as binary, and every untracked file — since aicp never runs `git add`
263
+ itself, a brand-new file with a pasted secret is exactly the case that would
264
+ otherwise slip through unscanned. A hit **stops the run before any AI CLI is
265
+ called**, and only ever prints the file, line number and the pattern's name —
266
+ never the matched text itself, so a real secret can't reach your terminal,
267
+ a log, or a Telegram notification through this path.
268
+
269
+ Seven fixed patterns are checked (OpenAI-style `sk-…` keys, GitHub PATs and
270
+ App tokens, AWS access key IDs, bearer tokens, and PEM private-key blocks) —
271
+ deliberately prefix/format checks only, with **no entropy heuristic**: that
272
+ was tried and rejected upstream as the main source of false positives (this
273
+ project's own API-key-shaped test fixtures tripped it). A false positive is
274
+ bypassed once with `AICP_SKIP_SECRET_SCAN=1`.
275
+
276
+ A file the scanner can't read as text — genuinely binary, or a UTF-16 `.env`
277
+ full of NUL bytes — is never silently skipped: it's reported as "not
278
+ scanned, verify manually" so an unreadable file never reads as a clean one.
279
+
280
+ ## Platform support
281
+
282
+ Tested in CI on macOS, Linux, and Windows, full test suite on all three — no
283
+ platform is a reduced or best-effort target.
284
+
285
+ | Platform | Notes |
286
+ | --- | --- |
287
+ | macOS | No caveats. |
288
+ | Linux | No caveats. |
289
+ | Windows | Every AI CLI ships as an npm `.cmd`/`.bat` shim, which Windows can't launch directly (`CreateProcess` doesn't honor `PATHEXT` and can't run a batch file itself) — aicp resolves the real executable and, for a shim, launches it through `cmd.exe` itself rather than `shell=True`, so no user-controlled text ever builds a shell command line. Ctrl+C is forwarded as `CTRL_BREAK_EVENT` rather than delivered directly (Windows has no "foreground process group" concept), so a CLI that ignores that signal may not stop as cleanly as it would elsewhere. |
290
+
291
+ On POSIX, a per-CLI timeout signals the CLI process itself, not any
292
+ grandchildren it spawned. On Windows, terminating the intermediate `cmd.exe`
293
+ can orphan the CLI behind the shim; that high-priority defect remains open in
294
+ [TODO.md](TODO.md). Ctrl+C still targets the Windows process group.
295
+
296
+ ## License
297
+
298
+ MIT — see [`LICENSE`](LICENSE).