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.
- cli_router-0.3.1/.github/actionlint.yaml +3 -0
- cli_router-0.3.1/.github/dependabot.yml +14 -0
- cli_router-0.3.1/.github/workflows/agent-drift-watchdog.yml +255 -0
- {cli_router-0.3.0 → cli_router-0.3.1}/.github/workflows/publish.yml +10 -9
- cli_router-0.3.1/.github/workflows/test.yml +58 -0
- {cli_router-0.3.0 → cli_router-0.3.1}/CHANGELOG.md +10 -0
- {cli_router-0.3.0 → cli_router-0.3.1}/PKG-INFO +13 -2
- {cli_router-0.3.0 → cli_router-0.3.1}/README.md +4 -1
- {cli_router-0.3.0 → cli_router-0.3.1}/cli_router/__init__.py +1 -1
- {cli_router-0.3.0 → cli_router-0.3.1}/cli_router/cli.py +1 -1
- {cli_router-0.3.0 → cli_router-0.3.1}/cli_router/doctor.py +76 -13
- {cli_router-0.3.0 → cli_router-0.3.1}/cli_router/failures.py +0 -1
- {cli_router-0.3.0 → cli_router-0.3.1}/cli_router/logs.py +8 -6
- {cli_router-0.3.0 → cli_router-0.3.1}/cli_router/models.py +2 -2
- {cli_router-0.3.0 → cli_router-0.3.1}/cli_router/presets/hermes.yaml +1 -1
- {cli_router-0.3.0 → cli_router-0.3.1}/cli_router/runner.py +38 -14
- {cli_router-0.3.0 → cli_router-0.3.1}/cli_router/streamfmt.py +3 -3
- {cli_router-0.3.0 → cli_router-0.3.1}/cli_router/tui.py +18 -18
- {cli_router-0.3.0 → cli_router-0.3.1}/cli_router/workflows.py +29 -10
- cli_router-0.3.1/docs/agent-loops/self-hosted-runner.md +131 -0
- {cli_router-0.3.0 → cli_router-0.3.1}/pyproject.toml +36 -1
- cli_router-0.3.1/scripts/agent/apply_drift_fix.sh +28 -0
- cli_router-0.3.1/scripts/agent/build_prompt.py +52 -0
- cli_router-0.3.1/scripts/agent/detect_drift.py +78 -0
- cli_router-0.3.1/scripts/agent/preflight.py +95 -0
- cli_router-0.3.1/scripts/agent/validate_patch.py +60 -0
- cli_router-0.3.1/scripts/check_release.py +40 -0
- cli_router-0.3.1/tests/test_agent_scripts.py +96 -0
- {cli_router-0.3.0 → cli_router-0.3.1}/tests/test_doctor.py +62 -2
- {cli_router-0.3.0 → cli_router-0.3.1}/tests/test_models.py +8 -2
- cli_router-0.3.1/tests/test_release.py +19 -0
- {cli_router-0.3.0 → cli_router-0.3.1}/tests/test_runner.py +45 -0
- {cli_router-0.3.0 → cli_router-0.3.1}/tests/test_tui.py +7 -8
- cli_router-0.3.1/tests/test_version.py +7 -0
- {cli_router-0.3.0 → cli_router-0.3.1}/tests/test_workflows.py +54 -1
- cli_router-0.3.0/.github/workflows/test.yml +0 -24
- {cli_router-0.3.0 → cli_router-0.3.1}/.claude/skills/tui-design/SKILL.md +0 -0
- {cli_router-0.3.0 → cli_router-0.3.1}/.claude/skills/tui-design/references/cli-basics.md +0 -0
- {cli_router-0.3.0 → cli_router-0.3.1}/.claude/skills/tui-design/references/ecosystem-go.md +0 -0
- {cli_router-0.3.0 → cli_router-0.3.1}/.claude/skills/tui-design/references/ecosystem-python.md +0 -0
- {cli_router-0.3.0 → cli_router-0.3.1}/.claude/skills/tui-design/references/ecosystem-rust.md +0 -0
- {cli_router-0.3.0 → cli_router-0.3.1}/.claude/skills/tui-design/references/ecosystem-typescript.md +0 -0
- {cli_router-0.3.0 → cli_router-0.3.1}/.claude/skills/tui-design/references/exemplar-apps.md +0 -0
- {cli_router-0.3.0 → cli_router-0.3.1}/.claude/skills/tui-design/references/interaction-patterns.md +0 -0
- {cli_router-0.3.0 → cli_router-0.3.1}/.claude/skills/tui-design/references/visual-patterns.md +0 -0
- {cli_router-0.3.0 → cli_router-0.3.1}/.gitignore +0 -0
- {cli_router-0.3.0 → cli_router-0.3.1}/AGENTS.md +0 -0
- {cli_router-0.3.0 → cli_router-0.3.1}/LICENSE +0 -0
- {cli_router-0.3.0 → cli_router-0.3.1}/PRD.md +0 -0
- {cli_router-0.3.0 → cli_router-0.3.1}/cli_router/artifacts.py +0 -0
- {cli_router-0.3.0 → cli_router-0.3.1}/cli_router/config.py +0 -0
- {cli_router-0.3.0 → cli_router-0.3.1}/cli_router/extractors.py +0 -0
- {cli_router-0.3.0 → cli_router-0.3.1}/cli_router/modelcache.py +0 -0
- {cli_router-0.3.0 → cli_router-0.3.1}/cli_router/presets/__init__.py +0 -0
- {cli_router-0.3.0 → cli_router-0.3.1}/cli_router/presets/claude.yaml +0 -0
- {cli_router-0.3.0 → cli_router-0.3.1}/cli_router/presets/codex.yaml +0 -0
- {cli_router-0.3.0 → cli_router-0.3.1}/cli_router/presets/generic.yaml +0 -0
- {cli_router-0.3.0 → cli_router-0.3.1}/cli_router/presets/grok.yaml +0 -0
- {cli_router-0.3.0 → cli_router-0.3.1}/cli_router/runs.py +0 -0
- {cli_router-0.3.0 → cli_router-0.3.1}/cli_router/tools.py +0 -0
- {cli_router-0.3.0 → cli_router-0.3.1}/examples/claude_codex.yaml +0 -0
- {cli_router-0.3.0 → cli_router-0.3.1}/examples/cli-router.yaml +0 -0
- {cli_router-0.3.0 → cli_router-0.3.1}/examples/local_models.yaml +0 -0
- {cli_router-0.3.0 → cli_router-0.3.1}/tests/test_cli.py +0 -0
- {cli_router-0.3.0 → cli_router-0.3.1}/tests/test_config.py +0 -0
- {cli_router-0.3.0 → cli_router-0.3.1}/tests/test_extractors.py +0 -0
- {cli_router-0.3.0 → cli_router-0.3.1}/tests/test_failures.py +0 -0
- {cli_router-0.3.0 → cli_router-0.3.1}/tests/test_logs.py +0 -0
- {cli_router-0.3.0 → cli_router-0.3.1}/tests/test_modelcache.py +0 -0
- {cli_router-0.3.0 → cli_router-0.3.1}/tests/test_runs.py +0 -0
- {cli_router-0.3.0 → cli_router-0.3.1}/tests/test_streamfmt.py +0 -0
|
@@ -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@
|
|
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@
|
|
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@
|
|
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@
|
|
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.
|
|
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:
|
|
@@ -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
|
-
|
|
80
|
-
|
|
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 = [
|
|
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:
|
|
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
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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:
|
|
@@ -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
|
|
36
|
-
if not getattr(
|
|
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(
|
|
39
|
-
|
|
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(
|
|
42
|
-
|
|
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", "
|
|
80
|
+
return ["hermes", "--oneshot", "{prompt}"]
|
|
81
81
|
if provider == "grok":
|
|
82
|
-
return ["grok",
|
|
82
|
+
return ["grok", *_model_flag("-m", model), *_effort_args(provider, effort), "--single", "{prompt}"]
|
|
83
83
|
return [provider, "{prompt}"]
|
|
84
84
|
|
|
85
85
|
|