cli-router 0.3.0__tar.gz → 0.3.1__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 (71) hide show
  1. cli_router-0.3.1/.github/actionlint.yaml +3 -0
  2. cli_router-0.3.1/.github/dependabot.yml +14 -0
  3. cli_router-0.3.1/.github/workflows/agent-drift-watchdog.yml +255 -0
  4. {cli_router-0.3.0 → cli_router-0.3.1}/.github/workflows/publish.yml +10 -9
  5. cli_router-0.3.1/.github/workflows/test.yml +58 -0
  6. {cli_router-0.3.0 → cli_router-0.3.1}/CHANGELOG.md +10 -0
  7. {cli_router-0.3.0 → cli_router-0.3.1}/PKG-INFO +13 -2
  8. {cli_router-0.3.0 → cli_router-0.3.1}/README.md +4 -1
  9. {cli_router-0.3.0 → cli_router-0.3.1}/cli_router/__init__.py +1 -1
  10. {cli_router-0.3.0 → cli_router-0.3.1}/cli_router/cli.py +1 -1
  11. {cli_router-0.3.0 → cli_router-0.3.1}/cli_router/doctor.py +76 -13
  12. {cli_router-0.3.0 → cli_router-0.3.1}/cli_router/failures.py +0 -1
  13. {cli_router-0.3.0 → cli_router-0.3.1}/cli_router/logs.py +8 -6
  14. {cli_router-0.3.0 → cli_router-0.3.1}/cli_router/models.py +2 -2
  15. {cli_router-0.3.0 → cli_router-0.3.1}/cli_router/presets/hermes.yaml +1 -1
  16. {cli_router-0.3.0 → cli_router-0.3.1}/cli_router/runner.py +38 -14
  17. {cli_router-0.3.0 → cli_router-0.3.1}/cli_router/streamfmt.py +3 -3
  18. {cli_router-0.3.0 → cli_router-0.3.1}/cli_router/tui.py +18 -18
  19. {cli_router-0.3.0 → cli_router-0.3.1}/cli_router/workflows.py +29 -10
  20. cli_router-0.3.1/docs/agent-loops/self-hosted-runner.md +131 -0
  21. {cli_router-0.3.0 → cli_router-0.3.1}/pyproject.toml +36 -1
  22. cli_router-0.3.1/scripts/agent/apply_drift_fix.sh +28 -0
  23. cli_router-0.3.1/scripts/agent/build_prompt.py +52 -0
  24. cli_router-0.3.1/scripts/agent/detect_drift.py +78 -0
  25. cli_router-0.3.1/scripts/agent/preflight.py +95 -0
  26. cli_router-0.3.1/scripts/agent/validate_patch.py +60 -0
  27. cli_router-0.3.1/scripts/check_release.py +40 -0
  28. cli_router-0.3.1/tests/test_agent_scripts.py +96 -0
  29. {cli_router-0.3.0 → cli_router-0.3.1}/tests/test_doctor.py +62 -2
  30. {cli_router-0.3.0 → cli_router-0.3.1}/tests/test_models.py +8 -2
  31. cli_router-0.3.1/tests/test_release.py +19 -0
  32. {cli_router-0.3.0 → cli_router-0.3.1}/tests/test_runner.py +45 -0
  33. {cli_router-0.3.0 → cli_router-0.3.1}/tests/test_tui.py +7 -8
  34. cli_router-0.3.1/tests/test_version.py +7 -0
  35. {cli_router-0.3.0 → cli_router-0.3.1}/tests/test_workflows.py +54 -1
  36. cli_router-0.3.0/.github/workflows/test.yml +0 -24
  37. {cli_router-0.3.0 → cli_router-0.3.1}/.claude/skills/tui-design/SKILL.md +0 -0
  38. {cli_router-0.3.0 → cli_router-0.3.1}/.claude/skills/tui-design/references/cli-basics.md +0 -0
  39. {cli_router-0.3.0 → cli_router-0.3.1}/.claude/skills/tui-design/references/ecosystem-go.md +0 -0
  40. {cli_router-0.3.0 → cli_router-0.3.1}/.claude/skills/tui-design/references/ecosystem-python.md +0 -0
  41. {cli_router-0.3.0 → cli_router-0.3.1}/.claude/skills/tui-design/references/ecosystem-rust.md +0 -0
  42. {cli_router-0.3.0 → cli_router-0.3.1}/.claude/skills/tui-design/references/ecosystem-typescript.md +0 -0
  43. {cli_router-0.3.0 → cli_router-0.3.1}/.claude/skills/tui-design/references/exemplar-apps.md +0 -0
  44. {cli_router-0.3.0 → cli_router-0.3.1}/.claude/skills/tui-design/references/interaction-patterns.md +0 -0
  45. {cli_router-0.3.0 → cli_router-0.3.1}/.claude/skills/tui-design/references/visual-patterns.md +0 -0
  46. {cli_router-0.3.0 → cli_router-0.3.1}/.gitignore +0 -0
  47. {cli_router-0.3.0 → cli_router-0.3.1}/AGENTS.md +0 -0
  48. {cli_router-0.3.0 → cli_router-0.3.1}/LICENSE +0 -0
  49. {cli_router-0.3.0 → cli_router-0.3.1}/PRD.md +0 -0
  50. {cli_router-0.3.0 → cli_router-0.3.1}/cli_router/artifacts.py +0 -0
  51. {cli_router-0.3.0 → cli_router-0.3.1}/cli_router/config.py +0 -0
  52. {cli_router-0.3.0 → cli_router-0.3.1}/cli_router/extractors.py +0 -0
  53. {cli_router-0.3.0 → cli_router-0.3.1}/cli_router/modelcache.py +0 -0
  54. {cli_router-0.3.0 → cli_router-0.3.1}/cli_router/presets/__init__.py +0 -0
  55. {cli_router-0.3.0 → cli_router-0.3.1}/cli_router/presets/claude.yaml +0 -0
  56. {cli_router-0.3.0 → cli_router-0.3.1}/cli_router/presets/codex.yaml +0 -0
  57. {cli_router-0.3.0 → cli_router-0.3.1}/cli_router/presets/generic.yaml +0 -0
  58. {cli_router-0.3.0 → cli_router-0.3.1}/cli_router/presets/grok.yaml +0 -0
  59. {cli_router-0.3.0 → cli_router-0.3.1}/cli_router/runs.py +0 -0
  60. {cli_router-0.3.0 → cli_router-0.3.1}/cli_router/tools.py +0 -0
  61. {cli_router-0.3.0 → cli_router-0.3.1}/examples/claude_codex.yaml +0 -0
  62. {cli_router-0.3.0 → cli_router-0.3.1}/examples/cli-router.yaml +0 -0
  63. {cli_router-0.3.0 → cli_router-0.3.1}/examples/local_models.yaml +0 -0
  64. {cli_router-0.3.0 → cli_router-0.3.1}/tests/test_cli.py +0 -0
  65. {cli_router-0.3.0 → cli_router-0.3.1}/tests/test_config.py +0 -0
  66. {cli_router-0.3.0 → cli_router-0.3.1}/tests/test_extractors.py +0 -0
  67. {cli_router-0.3.0 → cli_router-0.3.1}/tests/test_failures.py +0 -0
  68. {cli_router-0.3.0 → cli_router-0.3.1}/tests/test_logs.py +0 -0
  69. {cli_router-0.3.0 → cli_router-0.3.1}/tests/test_modelcache.py +0 -0
  70. {cli_router-0.3.0 → cli_router-0.3.1}/tests/test_runs.py +0 -0
  71. {cli_router-0.3.0 → cli_router-0.3.1}/tests/test_streamfmt.py +0 -0
@@ -0,0 +1,3 @@
1
+ self-hosted-runner:
2
+ labels:
3
+ - mothership
@@ -0,0 +1,14 @@
1
+ version: 2
2
+ updates:
3
+ - package-ecosystem: pip
4
+ directory: /
5
+ schedule:
6
+ interval: weekly
7
+ groups:
8
+ python-development:
9
+ dependency-type: development
10
+
11
+ - package-ecosystem: github-actions
12
+ directory: /
13
+ schedule:
14
+ interval: weekly
@@ -0,0 +1,255 @@
1
+ name: Agent drift watchdog
2
+
3
+ # Scheduled loop: inspect live model catalogs on the self-hosted runner. A
4
+ # single-runner removal opens an advisory issue; only a maintainer-confirmed
5
+ # manual run may invoke the local patch agent and open a review-required PR.
6
+ #
7
+ # SECURITY: this runs on a self-hosted runner that holds your subscription
8
+ # credentials. It is triggered ONLY by the schedule or a maintainer clicking
9
+ # "Run workflow" — never by pull_request, and never by fork code. Do not add
10
+ # untrusted triggers to this file.
11
+
12
+ on:
13
+ schedule:
14
+ - cron: "0 14 * * 1" # Mondays 14:00 UTC
15
+ workflow_dispatch:
16
+ inputs:
17
+ dry_run:
18
+ description: "Detect and report drift only; do not patch or open a PR"
19
+ type: boolean
20
+ default: true
21
+ confirm_removals:
22
+ description: "Confirm this runner's missing models should be patched"
23
+ type: boolean
24
+ default: false
25
+
26
+ permissions: {}
27
+
28
+ concurrency:
29
+ group: agent-drift-watchdog
30
+ cancel-in-progress: false
31
+
32
+ jobs:
33
+ preflight:
34
+ runs-on: [self-hosted, mothership]
35
+ timeout-minutes: 5
36
+ permissions:
37
+ contents: read
38
+ issues: write
39
+ outputs:
40
+ proceed: ${{ steps.gate.outputs.proceed }}
41
+ steps:
42
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
43
+ with:
44
+ persist-credentials: false
45
+ - id: gate
46
+ name: Circuit breaker + kill switch
47
+ env:
48
+ GH_TOKEN: ${{ github.token }}
49
+ GITHUB_REPOSITORY: ${{ github.repository }}
50
+ DAILY_CAP: ${{ vars.AGENT_DAILY_CAP || '10' }}
51
+ run: python3 scripts/agent/preflight.py
52
+
53
+ drift:
54
+ needs: preflight
55
+ if: needs.preflight.outputs.proceed == 'true'
56
+ runs-on: [self-hosted, mothership]
57
+ timeout-minutes: 30
58
+ permissions:
59
+ contents: read
60
+ issues: write
61
+ outputs:
62
+ patch_ready: ${{ steps.package.outputs.patch_ready }}
63
+ steps:
64
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
65
+ with:
66
+ persist-credentials: false
67
+
68
+ - name: Install cli-router in an isolated environment
69
+ run: |
70
+ python3 -m venv "$RUNNER_TEMP/cli-router-venv"
71
+ "$RUNNER_TEMP/cli-router-venv/bin/python" -m pip install -e .
72
+ echo "$RUNNER_TEMP/cli-router-venv/bin" >> "$GITHUB_PATH"
73
+
74
+ - name: Detect drift
75
+ id: detect
76
+ env:
77
+ CONFIRM_REMOVALS: ${{ inputs.confirm_removals || 'false' }}
78
+ run: |
79
+ confirm_arg=""
80
+ if [ "$CONFIRM_REMOVALS" = "true" ]; then
81
+ confirm_arg="--confirm-removals"
82
+ fi
83
+ python3 scripts/agent/detect_drift.py --timeout 15 $confirm_arg | tee drift.json
84
+ drift=$(python3 -c "import json; print(str(json.load(open('drift.json'))['drift']).lower())")
85
+ candidate=$(python3 -c "import json; print(str(json.load(open('drift.json'))['candidate_drift']).lower())")
86
+ echo "drift=$drift" >> "$GITHUB_OUTPUT"
87
+ echo "candidate=$candidate" >> "$GITHUB_OUTPUT"
88
+
89
+ - name: No candidate drift
90
+ if: steps.detect.outputs.candidate != 'true'
91
+ run: echo "No retired shipped models. Nothing to do."
92
+
93
+ - name: Report candidate drift for maintainer confirmation
94
+ if: steps.detect.outputs.candidate == 'true' && steps.detect.outputs.drift != 'true'
95
+ env:
96
+ GH_TOKEN: ${{ github.token }}
97
+ run: |
98
+ gh label create agent-drift --color 1d76db --description "Model-list drift candidate" --force
99
+ existing=$(gh issue list --label agent-drift --state open --json number --jq '.[0].number')
100
+ body="$(cat drift.json)"
101
+ if [ -n "$existing" ]; then
102
+ gh issue comment "$existing" --body "Fresh single-runner observation; confirmation is still required:
103
+
104
+ \`\`\`json
105
+ $body
106
+ \`\`\`"
107
+ else
108
+ gh issue create --label agent-created --label agent-drift \
109
+ --title "Provider model-list drift requires confirmation" \
110
+ --body "One runner observed a shipped fallback model missing. This can vary by account or CLI version, so no patch was applied.
111
+
112
+ Re-run this workflow manually with \`confirm_removals=true\` after verification.
113
+
114
+ \`\`\`json
115
+ $body
116
+ \`\`\`"
117
+ fi
118
+
119
+ - name: Dry run — report only
120
+ if: steps.detect.outputs.candidate == 'true' && inputs.dry_run == true
121
+ run: |
122
+ echo "Drift found; dry_run=true, not patching:"
123
+ cat drift.json
124
+
125
+ # --- The following steps run only when there is real drift and it's not a dry run ---
126
+
127
+ - name: Build agent prompt
128
+ if: steps.detect.outputs.drift == 'true' && inputs.dry_run != true
129
+ run: python3 scripts/agent/build_prompt.py drift.json | tee patch_prompt.txt
130
+
131
+ - name: Create isolated patch branch
132
+ if: steps.detect.outputs.drift == 'true' && inputs.dry_run != true
133
+ run: |
134
+ git switch -c "agent/drift-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
135
+ git config --local credential.helper ""
136
+
137
+ - name: Reject persistent runner GitHub authentication
138
+ if: steps.detect.outputs.drift == 'true' && inputs.dry_run != true
139
+ run: |
140
+ if env -u GH_TOKEN -u GITHUB_TOKEN -u GH_CONFIG_DIR gh auth status >/dev/null 2>&1; then
141
+ echo "Persistent gh authentication is available to the runner user; refusing to launch the patch agent." >&2
142
+ exit 1
143
+ fi
144
+
145
+ - name: Apply fix with local agent
146
+ if: steps.detect.outputs.drift == 'true' && inputs.dry_run != true
147
+ env:
148
+ GH_CONFIG_DIR: ${{ runner.temp }}/cli-router-empty-gh
149
+ run: |
150
+ mkdir -p "$GH_CONFIG_DIR"
151
+ env -u GH_TOKEN -u GITHUB_TOKEN -u ACTIONS_RUNTIME_TOKEN \
152
+ -u ACTIONS_ID_TOKEN_REQUEST_TOKEN -u ACTIONS_ID_TOKEN_REQUEST_URL \
153
+ bash scripts/agent/apply_drift_fix.sh patch_prompt.txt
154
+
155
+ - name: Validate agent patch scope
156
+ if: steps.detect.outputs.drift == 'true' && inputs.dry_run != true
157
+ run: python3 scripts/agent/validate_patch.py
158
+
159
+ - id: package
160
+ name: Package the validated patch
161
+ if: steps.detect.outputs.drift == 'true' && inputs.dry_run != true
162
+ run: |
163
+ set -euo pipefail
164
+ git diff --binary HEAD -- CHANGELOG.md cli_router/models.py tests/test_models.py > "$RUNNER_TEMP/drift.patch"
165
+ test -s "$RUNNER_TEMP/drift.patch"
166
+ echo "patch_ready=true" >> "$GITHUB_OUTPUT"
167
+
168
+ - name: Upload the validated patch
169
+ if: steps.package.outputs.patch_ready == 'true'
170
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
171
+ with:
172
+ name: validated-drift-patch
173
+ path: ${{ runner.temp }}/drift.patch
174
+ if-no-files-found: error
175
+ retention-days: 1
176
+
177
+ verify_patch:
178
+ needs: drift
179
+ if: needs.drift.outputs.patch_ready == 'true'
180
+ runs-on: ubuntu-latest
181
+ timeout-minutes: 15
182
+ permissions:
183
+ contents: read
184
+ steps:
185
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
186
+ with:
187
+ persist-credentials: false
188
+
189
+ - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
190
+ with:
191
+ python-version: "3.12"
192
+
193
+ - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
194
+ with:
195
+ name: validated-drift-patch
196
+ path: ${{ runner.temp }}
197
+
198
+ - name: Apply and validate the isolated patch
199
+ run: |
200
+ git apply "$RUNNER_TEMP/drift.patch"
201
+ python3 scripts/agent/validate_patch.py
202
+
203
+ - name: Verify in an ephemeral read-only job
204
+ run: |
205
+ python -m pip install -e .
206
+ python -m pytest -q
207
+
208
+ publish:
209
+ needs: [drift, verify_patch]
210
+ if: needs.drift.outputs.patch_ready == 'true'
211
+ runs-on: ubuntu-latest
212
+ timeout-minutes: 10
213
+ permissions:
214
+ contents: write
215
+ pull-requests: write
216
+ issues: write
217
+ steps:
218
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
219
+ with:
220
+ persist-credentials: false
221
+
222
+ - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
223
+ with:
224
+ name: validated-drift-patch
225
+ path: ${{ runner.temp }}
226
+
227
+ - name: Apply the verified patch
228
+ run: |
229
+ git apply "$RUNNER_TEMP/drift.patch"
230
+ python3 scripts/agent/validate_patch.py
231
+
232
+ - name: Open review-required PR
233
+ env:
234
+ GH_TOKEN: ${{ github.token }}
235
+ run: |
236
+ set -euo pipefail
237
+ gh auth setup-git
238
+ git config user.name "cli-router-agent[bot]"
239
+ git config user.email "cli-router-agent@users.noreply.github.com"
240
+ branch="agent/drift-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
241
+ git switch -c "$branch"
242
+ git add -- cli_router/models.py tests/test_models.py CHANGELOG.md
243
+ git -c core.hooksPath=/dev/null commit -m "Fix provider model-list drift (automated)"
244
+ git push -u origin "$branch"
245
+
246
+ # Ensure labels exist (ignore errors if already created).
247
+ gh label create agent-created --color 5319e7 --description "Opened by an agent loop" 2>/dev/null || true
248
+ gh label create agent-drift --color 1d76db --description "Model-list drift fix" 2>/dev/null || true
249
+
250
+ url=$(gh pr create \
251
+ --title "Fix provider model-list drift" \
252
+ --body "Automated by the drift watchdog. \`cli-router doctor\` found a retired shipped model; this updates \`DEFAULT_MODELS\`, the test, and the changelog. The full test suite passed on the runner.
253
+ The self-hosted runner supplied only a validated three-file patch; publication ran in this isolated GitHub-hosted job." \
254
+ --label agent-created --label agent-drift)
255
+ echo "Opened $url"
@@ -3,7 +3,6 @@ name: Publish to PyPI
3
3
  on:
4
4
  release:
5
5
  types: [published]
6
- workflow_dispatch:
7
6
 
8
7
  permissions:
9
8
  contents: read
@@ -14,10 +13,10 @@ jobs:
14
13
  runs-on: ubuntu-latest
15
14
  strategy:
16
15
  matrix:
17
- python-version: ["3.10", "3.11", "3.12", "3.13"]
16
+ python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
18
17
  steps:
19
- - uses: actions/checkout@v5
20
- - uses: actions/setup-python@v6
18
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
19
+ - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
21
20
  with:
22
21
  python-version: ${{ matrix.python-version }}
23
22
  - name: Install test dependencies
@@ -26,14 +25,16 @@ jobs:
26
25
  run: python -m pip install -e .
27
26
  - name: Run tests
28
27
  run: python -m pytest tests -q
28
+ - name: Validate release identity
29
+ run: python scripts/check_release.py
29
30
 
30
31
  build:
31
32
  name: Build distributions
32
33
  runs-on: ubuntu-latest
33
34
  needs: test
34
35
  steps:
35
- - uses: actions/checkout@v5
36
- - uses: actions/setup-python@v6
36
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
37
+ - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
37
38
  with:
38
39
  python-version: "3.12"
39
40
  - name: Install build tools
@@ -43,7 +44,7 @@ jobs:
43
44
  - name: Check distributions
44
45
  run: python -m twine check dist/*
45
46
  - name: Upload distributions
46
- uses: actions/upload-artifact@v6
47
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
47
48
  with:
48
49
  name: dist
49
50
  path: dist/
@@ -60,9 +61,9 @@ jobs:
60
61
 
61
62
  steps:
62
63
  - name: Download distributions
63
- uses: actions/download-artifact@v7
64
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
64
65
  with:
65
66
  name: dist
66
67
  path: dist/
67
68
  - name: Publish package
68
- uses: pypa/gh-action-pypi-publish@release/v1
69
+ uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1
@@ -0,0 +1,58 @@
1
+ name: Tests
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ permissions:
9
+ contents: read
10
+
11
+ jobs:
12
+ test:
13
+ runs-on: ubuntu-latest
14
+ strategy:
15
+ matrix:
16
+ python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
17
+
18
+ steps:
19
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
20
+ - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
21
+ with:
22
+ python-version: ${{ matrix.python-version }}
23
+ - name: Install test dependencies
24
+ run: python -m pip install --upgrade pip pytest
25
+ - name: Install package
26
+ run: python -m pip install -e .
27
+ - name: Test
28
+ run: python -m pytest tests -q
29
+
30
+ quality:
31
+ name: Quality and package
32
+ runs-on: ubuntu-latest
33
+ steps:
34
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
35
+ - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
36
+ with:
37
+ python-version: "3.12"
38
+ - name: Install package and development tools
39
+ run: python -m pip install --upgrade pip -e ".[dev]"
40
+ - name: Lint
41
+ run: ruff check cli_router scripts tests
42
+ - name: Type check
43
+ run: mypy cli_router scripts
44
+ - name: Lint GitHub Actions workflows
45
+ run: |
46
+ curl --fail --silent --show-error --location \
47
+ --output /tmp/actionlint.tar.gz \
48
+ https://github.com/rhysd/actionlint/releases/download/v1.7.12/actionlint_1.7.12_linux_amd64.tar.gz
49
+ echo "8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8 /tmp/actionlint.tar.gz" \
50
+ | sha256sum --check --strict
51
+ tar -xzf /tmp/actionlint.tar.gz -C /tmp actionlint
52
+ /tmp/actionlint -color
53
+ - name: Test with coverage
54
+ run: coverage run -m pytest tests -q && coverage report
55
+ - name: Build package
56
+ run: python -m build
57
+ - name: Check distributions
58
+ run: python -m twine check dist/*
@@ -2,6 +2,16 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.3.1 - 2026-07-19
6
+
7
+ - Made workflow outcomes fail closed: `run` and `implement` now reject empty resolved stage lists, and `stop_on_failure: false` preserves the first failed stage as the overall result even when later stages succeed.
8
+ - Terminate the full subprocess process group on command timeout so provider helper processes do not survive an exit `124`.
9
+ - Restored home-config-only TUI persistence; project-local and explicit configuration files are no longer silently rewritten.
10
+ - Updated generated Hermes commands to the current non-interactive `hermes --oneshot` interface and corrected Grok `--single` argument ordering.
11
+ - Hardened Doctor repair backends to run tool-free/read-only from an isolated temporary directory without GitHub repository tokens; providers without a safe tool-free mode are excluded.
12
+ - Changed model drift removals to require explicit maintainer confirmation, isolated the patch agent from GitHub credentials, added a strict patch-file allowlist, and removed auto-merge from the watchdog.
13
+ - Added release identity validation, Python 3.14 CI, Ruff, mypy, actionlint, branch-aware coverage, package checks, script tests, and Dependabot configuration.
14
+
5
15
  ## 0.3.0 - 2026-07-13
6
16
 
7
17
  - Added a `doctor` feature (`cli-router doctor`, `--repair`) to catch agent-CLI drift over time. `doctor` reports each provider's discovery health (CLI present? live model list parses?). With `--repair`, when a provider's CLI is installed and responding but its model list no longer parses, Doctor uses a working agent as an LLM parser — it runs the sick provider's own list command itself, hands only the raw text to the agent, and expects back a JSON array of model ids (LLM-as-parser, never LLM-as-operator). Recovered lists are written to `~/.cli-router/model-cache.yaml` (`cli_router.modelcache.ModelCache`), which layers between live discovery and the static `DEFAULT_MODELS` fallback, so the fix survives across runs without editing source. Model discovery and the TUI model pickers now consult that cache.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cli-router
3
- Version: 0.3.0
3
+ Version: 0.3.1
4
4
  Summary: A simple Python CLI router for AI planning and coding agents.
5
5
  Project-URL: Homepage, https://github.com/coolrazor007/cli-router
6
6
  Project-URL: Repository, https://github.com/coolrazor007/cli-router
@@ -18,11 +18,19 @@ Classifier: Programming Language :: Python :: 3.10
18
18
  Classifier: Programming Language :: Python :: 3.11
19
19
  Classifier: Programming Language :: Python :: 3.12
20
20
  Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Programming Language :: Python :: 3.14
21
22
  Classifier: Topic :: Software Development
22
23
  Classifier: Topic :: Utilities
23
24
  Requires-Python: >=3.10
24
25
  Requires-Dist: pyyaml>=6.0
25
26
  Requires-Dist: rich>=13.0
27
+ Provides-Extra: dev
28
+ Requires-Dist: build>=1.2; extra == 'dev'
29
+ Requires-Dist: coverage[toml]>=7.6; extra == 'dev'
30
+ Requires-Dist: mypy>=1.11; extra == 'dev'
31
+ Requires-Dist: pytest>=8.3; extra == 'dev'
32
+ Requires-Dist: ruff>=0.9; extra == 'dev'
33
+ Requires-Dist: twine>=6.0; extra == 'dev'
26
34
  Description-Content-Type: text/markdown
27
35
 
28
36
  # CLI-Router
@@ -300,8 +308,11 @@ Set `defaults.log_dir` to store these files somewhere else, and `defaults.log_le
300
308
  ## Development
301
309
 
302
310
  ```bash
303
- python -m pip install -e .
311
+ python -m pip install -e ".[dev]"
304
312
  python -m pytest
313
+ ruff check cli_router scripts tests
314
+ mypy cli_router scripts
315
+ coverage run -m pytest tests -q && coverage report
305
316
  ```
306
317
 
307
318
  Build and check a release locally:
@@ -273,8 +273,11 @@ Set `defaults.log_dir` to store these files somewhere else, and `defaults.log_le
273
273
  ## Development
274
274
 
275
275
  ```bash
276
- python -m pip install -e .
276
+ python -m pip install -e ".[dev]"
277
277
  python -m pytest
278
+ ruff check cli_router scripts tests
279
+ mypy cli_router scripts
280
+ coverage run -m pytest tests -q && coverage report
278
281
  ```
279
282
 
280
283
  Build and check a release locally:
@@ -1,3 +1,3 @@
1
1
  """CLI-Router package."""
2
2
 
3
- __version__ = "0.2.0"
3
+ __version__ = "0.3.1"
@@ -21,7 +21,7 @@ def main(argv: Sequence[str] | None = None) -> int:
21
21
  try:
22
22
  args = parser.parse_args(argv)
23
23
  except SystemExit as exc:
24
- return int(exc.code)
24
+ return exc.code if isinstance(exc.code, int) else 1
25
25
 
26
26
  try:
27
27
  config = load_config(args.config)
@@ -27,11 +27,14 @@ reuse it with a different task.
27
27
  from __future__ import annotations
28
28
 
29
29
  import json
30
+ import os
30
31
  import re
31
32
  import subprocess
33
+ import tempfile
32
34
  from collections.abc import Callable, Iterable
33
35
  from dataclasses import dataclass, field
34
36
 
37
+ from .modelcache import ModelCache
35
38
  from .models import (
36
39
  DEFAULT_MODELS,
37
40
  MODEL_LIST_COMMANDS,
@@ -42,13 +45,13 @@ from .models import (
42
45
  _provider_command,
43
46
  probe_models,
44
47
  )
45
- from .modelcache import ModelCache
46
48
 
47
49
  # Discovery during diagnosis is a deliberate setup/repair action, so it can wait
48
50
  # longer than the snappy interactive picker for a slow CLI to cold-start.
49
51
  DISCOVERY_TIMEOUT_SECONDS = 10.0
50
52
  # The doctor agent may need to read a long, messy CLI dump and think about it.
51
53
  DOCTOR_TIMEOUT_SECONDS = 120.0
54
+ SAFE_DOCTOR_PROVIDERS = frozenset({"codex", "claude", "grok"})
52
55
 
53
56
  WhichFn = Callable[[str], str | None]
54
57
  # (backend) -> recovered model ids; raises DoctorError when the backend fails.
@@ -76,8 +79,48 @@ class Backend:
76
79
  return f"{self.provider}:{self.model or 'default'}"
77
80
 
78
81
  def command(self, prompt: str) -> list[str]:
79
- argv = _provider_command(self.provider, self.model)
80
- return [prompt if part == "{prompt}" else part for part in argv]
82
+ if self.provider == "codex":
83
+ return [
84
+ "codex",
85
+ "exec",
86
+ "--sandbox",
87
+ "read-only",
88
+ "--ephemeral",
89
+ "--skip-git-repo-check",
90
+ "--ignore-rules",
91
+ *(["-m", self.model] if self.model else []),
92
+ prompt,
93
+ ]
94
+ if self.provider == "claude":
95
+ return [
96
+ "claude",
97
+ "-p",
98
+ *(["--model", self.model] if self.model else []),
99
+ "--tools",
100
+ "",
101
+ "--permission-mode",
102
+ "plan",
103
+ "--no-session-persistence",
104
+ "--safe-mode",
105
+ prompt,
106
+ ]
107
+ if self.provider == "grok":
108
+ return [
109
+ "grok",
110
+ *(["-m", self.model] if self.model else []),
111
+ "--tools",
112
+ "",
113
+ "--permission-mode",
114
+ "plan",
115
+ "--no-memory",
116
+ "--no-subagents",
117
+ "--disable-web-search",
118
+ "--max-turns",
119
+ "1",
120
+ "--single",
121
+ prompt,
122
+ ]
123
+ raise DoctorError(f"{self.provider} has no tool-free doctor mode")
81
124
 
82
125
 
83
126
  @dataclass(frozen=True)
@@ -202,12 +245,18 @@ def candidate_backends(
202
245
  which = which or _default_which
203
246
  backends: list[Backend] = []
204
247
  for provider in sorted(providers):
248
+ if provider not in SAFE_DOCTOR_PROVIDERS:
249
+ continue
205
250
  if which(_executable(provider)) is None:
206
251
  continue
207
252
  models = list(cache.get(provider)) if cache is not None else []
208
253
  models += list(DEFAULT_MODELS.get(provider, []))
209
254
  seen: set[str] = set()
210
- ordered = [m for m in models if not (m in seen or seen.add(m))]
255
+ ordered: list[str] = []
256
+ for model in models:
257
+ if model not in seen:
258
+ seen.add(model)
259
+ ordered.append(model)
211
260
  for model in ordered or [""]:
212
261
  backends.append(Backend(provider, model))
213
262
  return backends
@@ -243,7 +292,9 @@ def repair(
243
292
  selector = _BackendSelector(chain)
244
293
  reports: list[RepairResult] = []
245
294
  for h in drifting:
246
- task: BackendTask = lambda backend, health=h: _extract_models_for(backend, health, runner)
295
+ def task(backend: Backend, health: ProviderHealth = h) -> list[str]:
296
+ return _extract_models_for(backend, health, runner)
297
+
247
298
  try:
248
299
  backend, models = selector.run(task, cancelled)
249
300
  except (DoctorCancelled, KeyboardInterrupt):
@@ -345,15 +396,27 @@ def run_agent(
345
396
  so the selector can fail over to the next candidate.
346
397
  """
347
398
  argv = backend.command(prompt)
399
+ env = os.environ.copy()
400
+ for name in (
401
+ "GH_TOKEN",
402
+ "GITHUB_TOKEN",
403
+ "ACTIONS_RUNTIME_TOKEN",
404
+ "ACTIONS_ID_TOKEN_REQUEST_TOKEN",
405
+ "ACTIONS_ID_TOKEN_REQUEST_URL",
406
+ ):
407
+ env.pop(name, None)
348
408
  try:
349
- completed = runner(
350
- argv,
351
- capture_output=True,
352
- text=True,
353
- check=False,
354
- stdin=subprocess.DEVNULL,
355
- timeout=timeout,
356
- )
409
+ with tempfile.TemporaryDirectory(prefix="cli-router-doctor-") as working_dir:
410
+ completed = runner(
411
+ argv,
412
+ capture_output=True,
413
+ text=True,
414
+ check=False,
415
+ stdin=subprocess.DEVNULL,
416
+ timeout=timeout,
417
+ cwd=working_dir,
418
+ env=env,
419
+ )
357
420
  except (OSError, subprocess.TimeoutExpired) as exc:
358
421
  raise DoctorError(f"{backend.label} did not run: {exc}") from exc
359
422
  if completed.returncode != 0:
@@ -4,7 +4,6 @@ from __future__ import annotations
4
4
 
5
5
  from .runner import ToolRunResult
6
6
 
7
-
8
7
  USAGE_LIMIT_PATTERNS = (
9
8
  "usage limit",
10
9
  "session limit",
@@ -32,14 +32,16 @@ def configure_logging(config: RouterConfig) -> logging.Logger:
32
32
  logger.setLevel(_log_level(config))
33
33
  logger.propagate = False
34
34
 
35
- for handler in list(logger.handlers):
36
- if not getattr(handler, "_cli_router_log_handler", False):
35
+ for existing_handler in list(logger.handlers):
36
+ if not getattr(existing_handler, "_cli_router_log_handler", False):
37
37
  continue
38
- if getattr(handler, "_cli_router_log_path", None) == str(log_path) and not isinstance(handler, logging.NullHandler):
39
- handler.setLevel(logger.level)
38
+ if getattr(existing_handler, "_cli_router_log_path", None) == str(log_path) and not isinstance(
39
+ existing_handler, logging.NullHandler
40
+ ):
41
+ existing_handler.setLevel(logger.level)
40
42
  return logger
41
- logger.removeHandler(handler)
42
- handler.close()
43
+ logger.removeHandler(existing_handler)
44
+ existing_handler.close()
43
45
 
44
46
  try:
45
47
  log_dir.mkdir(parents=True, exist_ok=True)
@@ -77,9 +77,9 @@ def _provider_command(provider: str, model: str = "", effort: str = "") -> list[
77
77
  if provider == "claude":
78
78
  return ["claude", "-p", *_model_flag("--model", model), *_effort_args(provider, effort), "{prompt}"]
79
79
  if provider == "hermes":
80
- return ["hermes", "run", "{prompt}"]
80
+ return ["hermes", "--oneshot", "{prompt}"]
81
81
  if provider == "grok":
82
- return ["grok", "--single", *_model_flag("-m", model), *_effort_args(provider, effort), "{prompt}"]
82
+ return ["grok", *_model_flag("-m", model), *_effort_args(provider, effort), "--single", "{prompt}"]
83
83
  return [provider, "{prompt}"]
84
84
 
85
85