@caddis/cli 0.2.6 → 0.3.0
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.
- package/bundles/antigravity-plugin/agents/claude-md-curator.md +1 -1
- package/bundles/antigravity-plugin/caddis_statusline.py +803 -0
- package/bundles/antigravity-plugin/claude-md/folder-agents.md.tmpl +21 -0
- package/bundles/antigravity-plugin/guard_agy.py +23 -4
- package/bundles/antigravity-plugin/plugin.json +1 -1
- package/bundles/antigravity-plugin/scripts/caddis_gate.py +524 -0
- package/bundles/antigravity-plugin/scripts/caddis_inventory.py +369 -0
- package/bundles/antigravity-plugin/scripts/caddis_migrate_dir.py +270 -0
- package/bundles/antigravity-plugin/scripts/caddis_spawn.py +300 -0
- package/bundles/antigravity-plugin/scripts/caddis_statusline.py +803 -0
- package/bundles/antigravity-plugin/scripts/caddis_tidy.py +366 -0
- package/bundles/antigravity-plugin/scripts/check_doc_coverage.py +924 -0
- package/bundles/antigravity-plugin/scripts/claude-deepseek.ps1 +4 -0
- package/bundles/antigravity-plugin/scripts/claude-deepseek.sh +3 -0
- package/bundles/antigravity-plugin/scripts/claude-glm.ps1 +4 -0
- package/bundles/antigravity-plugin/scripts/claude-glm.sh +3 -0
- package/bundles/antigravity-plugin/scripts/claude-oss.ps1 +64 -0
- package/bundles/antigravity-plugin/scripts/claude-oss.sh +60 -0
- package/bundles/antigravity-plugin/scripts/claudster_config.py +126 -0
- package/bundles/antigravity-plugin/scripts/claudster_doctor.py +356 -0
- package/bundles/antigravity-plugin/scripts/claudster_init.py +400 -0
- package/bundles/antigravity-plugin/scripts/claudster_migrate_rules.py +300 -0
- package/bundles/antigravity-plugin/scripts/dream_capture.py +249 -0
- package/bundles/antigravity-plugin/scripts/dream_memory.py +438 -0
- package/bundles/antigravity-plugin/scripts/enforcement_inventory.py +261 -0
- package/bundles/antigravity-plugin/scripts/hook_log.py +156 -0
- package/bundles/antigravity-plugin/scripts/oss_ask.py +98 -0
- package/bundles/antigravity-plugin/scripts/oss_model.py +166 -0
- package/bundles/antigravity-plugin/scripts/oss_review.py +718 -0
- package/bundles/antigravity-plugin/scripts/setup_project_ai.py +1429 -0
- package/bundles/antigravity-plugin/scripts/usage_review.py +1323 -0
- package/bundles/antigravity-plugin/session_end_agy.py +21 -2
- package/bundles/antigravity-plugin/skills/_registry.md +1 -0
- package/bundles/antigravity-plugin/skills/fastapi-dev/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/ship/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/ship-merge/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/ship-pr/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/statusline/SKILL.md +105 -0
- package/bundles/antigravity-plugin/skills/use-model/SKILL.md +15 -3
- package/bundles/antigravity-plugin/warm_start_agy.py +21 -2
- package/bundles/antigravity-plugin-extras/plugin.json +1 -1
- package/bundles/antigravity-plugin-extras/skills/architecture-design/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/caching-patterns/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/webapp-development/SKILL.md +1 -1
- package/bundles/manifest.json +3 -3
- package/dist/claude-deepseek.js +544 -0
- package/dist/claude-glm.js +544 -0
- package/dist/claude-oss.js +544 -0
- package/dist/cli.js +365 -44
- package/package.json +5 -2
- package/bundles/antigravity-plugin/statusline-command-agy.sh +0 -91
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Inventory caddis's load-bearing rules, and measure how many are actually enforced.
|
|
3
|
+
|
|
4
|
+
WHY THIS EXISTS
|
|
5
|
+
---------------
|
|
6
|
+
Three independent audits reached the same conclusion in different words: strip the prose from
|
|
7
|
+
this plugin and the machine content is very small. Nearly every "MUST" / "NEVER" / "STOP" is a
|
|
8
|
+
sentence in a markdown file that a model may or may not honour. Those degrade silently under
|
|
9
|
+
model change, context pressure, a cheaper lane, or a distracted session — and they read as
|
|
10
|
+
guarantees, which is worse than reading as advice.
|
|
11
|
+
|
|
12
|
+
The things that actually held under pressure were the ones with exit codes: pytest,
|
|
13
|
+
validate_pool, the hooks, oss_review's exit 2.
|
|
14
|
+
|
|
15
|
+
So this script measures the ratio of rules that a machine can fail on, to rules that only a
|
|
16
|
+
model can choose to honour. That number is the health metric for the plugin. It is expected to
|
|
17
|
+
start LOW; the point is that it is visible and moves in one direction.
|
|
18
|
+
|
|
19
|
+
THE ANTI-FLATTERY RULE
|
|
20
|
+
----------------------
|
|
21
|
+
The classification is DERIVED FROM THE LINT TESTS, never asserted by hand. A rule counts as
|
|
22
|
+
`machine` only when a test that targets its file asserts a literal string contained in that
|
|
23
|
+
rule's text — i.e. deleting the rule would turn a test red. Everything else is `advisory`.
|
|
24
|
+
|
|
25
|
+
This matters because the first version of this script DID use a hand-written registry, and it
|
|
26
|
+
was wrong in both directions: a global substring match credited an unrelated skill's "ALWAYS ask
|
|
27
|
+
via AskUserQuestion" to the headless-mode test, while genuinely-covered rules went uncounted
|
|
28
|
+
because their sentence lacked the registry keyword. A metric you can talk yourself into is not a
|
|
29
|
+
metric. Deriving it from the tests also means it moves on its own the moment someone adds or
|
|
30
|
+
removes a lint, which is the only way a health number stays honest.
|
|
31
|
+
|
|
32
|
+
Known limit, stated rather than hidden: matching is per-line, so a rule spanning two lines can be
|
|
33
|
+
missed, and a long asserted literal can match a neighbouring sentence. Treat the ratio as an
|
|
34
|
+
order-of-magnitude signal and a direction of travel, not a precise score.
|
|
35
|
+
|
|
36
|
+
Usage:
|
|
37
|
+
python scripts/enforcement_inventory.py # summary + ratio
|
|
38
|
+
python scripts/enforcement_inventory.py --list # every rule, classified
|
|
39
|
+
python scripts/enforcement_inventory.py --advisory # only the unenforced ones
|
|
40
|
+
"""
|
|
41
|
+
from __future__ import annotations
|
|
42
|
+
|
|
43
|
+
import argparse
|
|
44
|
+
import re
|
|
45
|
+
import sys
|
|
46
|
+
from dataclasses import dataclass
|
|
47
|
+
from pathlib import Path
|
|
48
|
+
|
|
49
|
+
if hasattr(sys.stdout, "reconfigure"):
|
|
50
|
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
51
|
+
|
|
52
|
+
REPO = Path(__file__).resolve().parents[1]
|
|
53
|
+
SCAN_ROOTS = [REPO / "claude-harness" / "commands", REPO / ".github" / "skills"]
|
|
54
|
+
|
|
55
|
+
# An imperative that makes a promise. Ordered longest-first so MUST NOT wins over MUST.
|
|
56
|
+
IMPERATIVE = re.compile(
|
|
57
|
+
r"\b(MUST NOT|MUST|NEVER|ALWAYS|REQUIRED|STOP|DO NOT|Do not)\b"
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
# ── Deriving enforcement FROM THE TESTS, not from a hand-written claim ──────
|
|
61
|
+
# An earlier version used a hand-maintained registry of rule->enforcer. It was wrong in both
|
|
62
|
+
# directions: a global substring match credited brand-design's "ALWAYS ask via AskUserQuestion"
|
|
63
|
+
# to the headless-mode test, which has nothing to do with it, while genuinely-covered rules went
|
|
64
|
+
# uncounted because their sentence lacked the registry's keyword.
|
|
65
|
+
#
|
|
66
|
+
# So the classifier now READS THE LINT TESTS. For each test module it works out which source
|
|
67
|
+
# files that module targets (from its module-level Path constants) and which literal strings it
|
|
68
|
+
# asserts on. A rule is `machine` when a test targeting ITS file asserts a literal contained in
|
|
69
|
+
# its text. Nothing is claimed that a test does not actually check, and the metric updates itself
|
|
70
|
+
# the moment someone adds or deletes a lint - which is the property a health metric must have.
|
|
71
|
+
LINT_TEST_DIRS = [REPO / "scripts" / "tests", REPO / "claude-harness" / "hooks" / "tests"]
|
|
72
|
+
|
|
73
|
+
# Literals shorter than this match too much to mean anything.
|
|
74
|
+
MIN_LITERAL = 10
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _test_modules() -> list[tuple[set[str], set[str]]]:
|
|
78
|
+
"""(source files this test targets, literals it asserts) for every lint test module."""
|
|
79
|
+
import ast
|
|
80
|
+
out = []
|
|
81
|
+
for d in LINT_TEST_DIRS:
|
|
82
|
+
if not d.exists():
|
|
83
|
+
continue
|
|
84
|
+
for f in sorted(d.glob("test_*.py")):
|
|
85
|
+
if f.name == "test_enforcement_inventory.py":
|
|
86
|
+
continue # measures the metric; must not feed it
|
|
87
|
+
try:
|
|
88
|
+
tree = ast.parse(f.read_text(encoding="utf-8", errors="ignore"))
|
|
89
|
+
except SyntaxError:
|
|
90
|
+
continue
|
|
91
|
+
# ONLY literals inside an `assert` count. Scanning every string constant swept in
|
|
92
|
+
# docstrings and fixture data, and this module's own test docstrings describe the very
|
|
93
|
+
# rules being measured - so prose about a rule was being counted as enforcement OF that
|
|
94
|
+
# rule. The metric was measuring how much we had WRITTEN about enforcement.
|
|
95
|
+
literals: set[str] = set()
|
|
96
|
+
for node in ast.walk(tree):
|
|
97
|
+
if not isinstance(node, ast.Assert):
|
|
98
|
+
continue
|
|
99
|
+
for sub in ast.walk(node):
|
|
100
|
+
if isinstance(sub, ast.Constant) and isinstance(sub.value, str):
|
|
101
|
+
v = sub.value.strip()
|
|
102
|
+
if len(v) >= MIN_LITERAL:
|
|
103
|
+
literals.add(v)
|
|
104
|
+
# TARGETS come from ALL string constants, not just asserts: a module names the file
|
|
105
|
+
# it lints in a module-level Path constant (`_IMPLEMENT = ... / "implement.md"`), which
|
|
106
|
+
# is not an assert. Deriving both from the assert set emptied every target and drove
|
|
107
|
+
# the ratio to a false 0.0% - caught by this module's own plausibility test.
|
|
108
|
+
targets = {
|
|
109
|
+
v.strip() for node in ast.walk(tree)
|
|
110
|
+
if isinstance(node, ast.Constant) and isinstance(node.value, str)
|
|
111
|
+
for v in [node.value] if v.strip().endswith(".md")
|
|
112
|
+
}
|
|
113
|
+
out.append((targets, literals))
|
|
114
|
+
return out
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
_MODULES = _test_modules()
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _enforcer_for(rel_path: str, text: str) -> str | None:
|
|
121
|
+
"""The lint that would fail if this rule were deleted, or None."""
|
|
122
|
+
fname = rel_path.rsplit("/", 1)[-1]
|
|
123
|
+
for targets, literals in _MODULES:
|
|
124
|
+
if not any(t == fname or t.endswith(fname) for t in targets):
|
|
125
|
+
continue
|
|
126
|
+
for lit in literals:
|
|
127
|
+
if lit.lower() in text.lower():
|
|
128
|
+
return "lint"
|
|
129
|
+
return None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# ── Gates: rules enforced by an executable, not merely by a content lint ────
|
|
133
|
+
# The first version counted only lint tests, so a rule backed by a REAL gate scored the same as
|
|
134
|
+
# one backed by nothing - the metric was blind to the strongest form of enforcement and would
|
|
135
|
+
# have shown no movement for the phase that introduced it. A rule counts as gate-enforced when
|
|
136
|
+
# its file invokes caddis_gate.py AND the rule concerns one of the gate's actual subcommands.
|
|
137
|
+
# The subcommand must exist in the script: a claim nobody implements is exactly what this file
|
|
138
|
+
# refuses to reward.
|
|
139
|
+
GATE_SCRIPT = REPO / "scripts" / "caddis_gate.py"
|
|
140
|
+
GATE_CONCERNS = {
|
|
141
|
+
"lane-check": ("lane", "spawn", "launch command", "--print", "-p"),
|
|
142
|
+
"verdict-gate": ("verdict", "advisory-hub", "blocked-pending-hub", "accept-degraded"),
|
|
143
|
+
"tracker-vs-git": ("tracker",),
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _live_gates() -> dict[str, tuple[str, ...]]:
|
|
148
|
+
"""Subcommands the gate script actually implements. Verified, never assumed."""
|
|
149
|
+
if not GATE_SCRIPT.is_file():
|
|
150
|
+
return {}
|
|
151
|
+
src = GATE_SCRIPT.read_text(encoding="utf-8", errors="ignore")
|
|
152
|
+
return {k: v for k, v in GATE_CONCERNS.items() if f'"{k}"' in src or f"'{k}'" in src}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
_GATES = _live_gates()
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _gate_for(file_text: str, text: str) -> str | None:
|
|
159
|
+
if "caddis_gate.py" not in file_text:
|
|
160
|
+
return None
|
|
161
|
+
low = text.lower()
|
|
162
|
+
for sub, needles in _GATES.items():
|
|
163
|
+
if any(n.lower() in low for n in needles):
|
|
164
|
+
return sub
|
|
165
|
+
return None
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@dataclass
|
|
169
|
+
class Rule:
|
|
170
|
+
path: str
|
|
171
|
+
line: int
|
|
172
|
+
marker: str
|
|
173
|
+
text: str
|
|
174
|
+
enforcer: str | None = None
|
|
175
|
+
|
|
176
|
+
@property
|
|
177
|
+
def machine(self) -> bool:
|
|
178
|
+
return self.enforcer is not None
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _sentence(line: str) -> str:
|
|
182
|
+
return " ".join(line.split())[:150]
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def collect(roots: list[Path] | None = None) -> list[Rule]:
|
|
186
|
+
rules: list[Rule] = []
|
|
187
|
+
for root in (roots or SCAN_ROOTS):
|
|
188
|
+
if not root.exists():
|
|
189
|
+
continue
|
|
190
|
+
for f in sorted(root.rglob("*.md")):
|
|
191
|
+
try:
|
|
192
|
+
lines = f.read_text(encoding="utf-8", errors="ignore").splitlines()
|
|
193
|
+
except Exception:
|
|
194
|
+
continue
|
|
195
|
+
# Fall back to the absolute path for roots outside the repo, so collect() can be
|
|
196
|
+
# pointed at a fixture directory. A metric script that cannot be tested on a fixture
|
|
197
|
+
# can only be verified by eyeballing its output, which is how metrics start lying.
|
|
198
|
+
whole = "\n".join(lines)
|
|
199
|
+
try:
|
|
200
|
+
rel = str(f.relative_to(REPO)).replace("\\", "/")
|
|
201
|
+
except ValueError:
|
|
202
|
+
rel = str(f).replace("\\", "/")
|
|
203
|
+
for i, line in enumerate(lines, 1):
|
|
204
|
+
m = IMPERATIVE.search(line)
|
|
205
|
+
if not m:
|
|
206
|
+
continue
|
|
207
|
+
text = _sentence(line)
|
|
208
|
+
enf = _enforcer_for(rel, text) or _gate_for(whole, text)
|
|
209
|
+
rules.append(Rule(rel, i, m.group(1), text, enf))
|
|
210
|
+
return rules
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def main(argv: list[str] | None = None) -> int:
|
|
214
|
+
ap = argparse.ArgumentParser(description="caddis enforcement inventory")
|
|
215
|
+
ap.add_argument("--list", action="store_true", help="print every rule, classified")
|
|
216
|
+
ap.add_argument("--advisory", action="store_true", help="print only the unenforced rules")
|
|
217
|
+
args = ap.parse_args(argv)
|
|
218
|
+
|
|
219
|
+
rules = collect()
|
|
220
|
+
machine = [r for r in rules if r.machine]
|
|
221
|
+
advisory = [r for r in rules if not r.machine]
|
|
222
|
+
total = len(rules)
|
|
223
|
+
ratio = (len(machine) / total) if total else 0.0
|
|
224
|
+
|
|
225
|
+
if args.list or args.advisory:
|
|
226
|
+
for r in rules:
|
|
227
|
+
if args.advisory and r.machine:
|
|
228
|
+
continue
|
|
229
|
+
tag = f"machine <- {r.enforcer}" if r.machine else "advisory"
|
|
230
|
+
print(f" [{tag}] {r.path}:{r.line} {r.marker}")
|
|
231
|
+
print(f" {r.text}")
|
|
232
|
+
print()
|
|
233
|
+
|
|
234
|
+
# Report the SPLIT, not just the headline. A single blended number is actively misleading:
|
|
235
|
+
# it is dominated by the ~235 skill rules, which are guidance ("prefer this", "consider that")
|
|
236
|
+
# and SHOULD be advisory - turning them into exit codes would be theatre. The number that
|
|
237
|
+
# matters is commands, because that is the execution path where a broken guarantee costs you.
|
|
238
|
+
cmd = [r for r in rules if "/commands/" in r.path]
|
|
239
|
+
skl = [r for r in rules if "/commands/" not in r.path]
|
|
240
|
+
def _pct(group):
|
|
241
|
+
return (sum(1 for r in group if r.machine) / len(group)) if group else 0.0
|
|
242
|
+
|
|
243
|
+
print("=" * 62)
|
|
244
|
+
print(f" load-bearing rules found : {total}")
|
|
245
|
+
print(f" machine-enforced : {len(machine)}")
|
|
246
|
+
print(f" advisory only : {len(advisory)}")
|
|
247
|
+
print("-" * 62)
|
|
248
|
+
print(f" COMMANDS (execution path): {sum(1 for r in cmd if r.machine)}/{len(cmd)} "
|
|
249
|
+
f"= {_pct(cmd):.1%} <- the figure that matters")
|
|
250
|
+
print(f" SKILLS (guidance) : {sum(1 for r in skl if r.machine)}/{len(skl)} "
|
|
251
|
+
f"= {_pct(skl):.1%} (advisory is correct here)")
|
|
252
|
+
print(f" blended : {ratio:.1%}")
|
|
253
|
+
print("=" * 62)
|
|
254
|
+
print(" A low number is not a bug report - it is the starting point. Advisory rules are")
|
|
255
|
+
print(" legitimate; the danger is an advisory rule that READS like a guarantee. Move the")
|
|
256
|
+
print(" load-bearing ones to exit codes, and label the rest honestly.")
|
|
257
|
+
return 0
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
if __name__ == "__main__":
|
|
261
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Throttled error ledger for caddis hooks — so a broken hook is not a silent one.
|
|
2
|
+
|
|
3
|
+
The problem
|
|
4
|
+
-----------
|
|
5
|
+
caddis hooks fail open, which is right: a SessionStart hook that raises would break
|
|
6
|
+
the session, and a PreToolUse hook that raises would break the tool call. The way
|
|
7
|
+
they fail open is ``except Exception: pass``, which is wrong: a hook that is broken
|
|
8
|
+
and a hook that had nothing to say produce the identical result — nothing.
|
|
9
|
+
|
|
10
|
+
There were 20 such swallows across 6 hook files. Each one guards a named feature.
|
|
11
|
+
When ``dream_memory``'s surface stops printing, nobody learns that it stopped; they
|
|
12
|
+
learn that they have not seen it lately, months later, if at all.
|
|
13
|
+
|
|
14
|
+
The fix keeps fail-open and adds a record. ``record()`` never raises, never prints,
|
|
15
|
+
and never blocks — if the ledger itself cannot be written, that is swallowed too.
|
|
16
|
+
|
|
17
|
+
Throttle
|
|
18
|
+
--------
|
|
19
|
+
One line per ``(hook, feature, error type)`` per day. A hook that fails on every
|
|
20
|
+
single tool call writes one line, not thousands. The ledger is a signal, not a trace.
|
|
21
|
+
|
|
22
|
+
Surfacing
|
|
23
|
+
---------
|
|
24
|
+
``summarise()`` returns a one-line summary of the last N days, or "" when clean.
|
|
25
|
+
``inject_relay.py`` prints it at SessionStart. That is the only place a user is
|
|
26
|
+
told; there is no second channel to ignore.
|
|
27
|
+
"""
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import json
|
|
31
|
+
import os
|
|
32
|
+
import time
|
|
33
|
+
|
|
34
|
+
_ARTIFACT_DIR = ".caddis"
|
|
35
|
+
_LEDGER_NAME = "hook-errors.jsonl"
|
|
36
|
+
_MAX_LINES = 400 # ring-buffer cap; the ledger must never grow without bound
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _ledger_path(root: str | None) -> str | None:
|
|
40
|
+
try:
|
|
41
|
+
base = root or os.getcwd()
|
|
42
|
+
d = os.path.join(base, _ARTIFACT_DIR)
|
|
43
|
+
if not os.path.isdir(d):
|
|
44
|
+
return None # not a caddis project — nothing to write into, and that is fine
|
|
45
|
+
return os.path.join(d, _LEDGER_NAME)
|
|
46
|
+
except Exception:
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def record(hook: str, feature: str, exc: BaseException, root: str | None = None) -> None:
|
|
51
|
+
"""Note that ``feature`` inside ``hook`` failed. Never raises, never prints.
|
|
52
|
+
|
|
53
|
+
``feature`` is the thing the user loses — "parked-stack surface", "relay read" —
|
|
54
|
+
not the function name. The ledger is read by a human deciding whether to care.
|
|
55
|
+
"""
|
|
56
|
+
try:
|
|
57
|
+
path = _ledger_path(root)
|
|
58
|
+
if path is None:
|
|
59
|
+
return
|
|
60
|
+
|
|
61
|
+
etype = type(exc).__name__
|
|
62
|
+
# Truncate: an exception message can carry a path, a command, or a chunk of
|
|
63
|
+
# file content. One line of context is enough to act on.
|
|
64
|
+
emsg = str(exc)[:200]
|
|
65
|
+
today = time.strftime("%Y-%m-%d")
|
|
66
|
+
key = f"{hook}|{feature}|{etype}"
|
|
67
|
+
|
|
68
|
+
lines: list[str] = []
|
|
69
|
+
if os.path.isfile(path):
|
|
70
|
+
try:
|
|
71
|
+
with open(path, "r", encoding="utf-8", errors="replace") as fh:
|
|
72
|
+
lines = fh.read().splitlines()
|
|
73
|
+
except Exception:
|
|
74
|
+
lines = []
|
|
75
|
+
|
|
76
|
+
for line in reversed(lines[-120:]): # today's entries are at the tail
|
|
77
|
+
try:
|
|
78
|
+
rec = json.loads(line)
|
|
79
|
+
except Exception:
|
|
80
|
+
continue
|
|
81
|
+
if rec.get("key") == key and str(rec.get("ts", ""))[:10] == today:
|
|
82
|
+
return # already recorded today — throttled
|
|
83
|
+
|
|
84
|
+
lines.append(json.dumps({
|
|
85
|
+
"ts": time.strftime("%Y-%m-%dT%H:%M:%S"),
|
|
86
|
+
"key": key,
|
|
87
|
+
"hook": hook,
|
|
88
|
+
"feature": feature,
|
|
89
|
+
"error": etype,
|
|
90
|
+
"message": emsg,
|
|
91
|
+
}, ensure_ascii=False))
|
|
92
|
+
|
|
93
|
+
with open(path, "w", encoding="utf-8", newline="\n") as fh:
|
|
94
|
+
fh.write("\n".join(lines[-_MAX_LINES:]) + "\n")
|
|
95
|
+
except Exception:
|
|
96
|
+
return # the ledger must never be the thing that breaks a hook
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def summarise(root: str | None = None, days: int = 7) -> str:
|
|
100
|
+
"""One line naming what has been failing, or "" when there is nothing to say."""
|
|
101
|
+
try:
|
|
102
|
+
path = _ledger_path(root)
|
|
103
|
+
if path is None or not os.path.isfile(path):
|
|
104
|
+
return ""
|
|
105
|
+
|
|
106
|
+
cutoff = time.strftime("%Y-%m-%d", time.localtime(time.time() - days * 86400))
|
|
107
|
+
counts: dict[str, int] = {}
|
|
108
|
+
with open(path, "r", encoding="utf-8", errors="replace") as fh:
|
|
109
|
+
for line in fh:
|
|
110
|
+
line = line.strip()
|
|
111
|
+
if not line:
|
|
112
|
+
continue
|
|
113
|
+
try:
|
|
114
|
+
rec = json.loads(line)
|
|
115
|
+
except Exception:
|
|
116
|
+
continue
|
|
117
|
+
if str(rec.get("ts", ""))[:10] < cutoff:
|
|
118
|
+
continue
|
|
119
|
+
label = f"{rec.get('hook', '?')}/{rec.get('feature', '?')}"
|
|
120
|
+
counts[label] = counts.get(label, 0) + 1
|
|
121
|
+
|
|
122
|
+
if not counts:
|
|
123
|
+
return ""
|
|
124
|
+
ranked = sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))
|
|
125
|
+
shown = ", ".join(f"{k} x{v}" for k, v in ranked[:4])
|
|
126
|
+
more = f" (+{len(ranked) - 4} more)" if len(ranked) > 4 else ""
|
|
127
|
+
return (f"[caddis] hook errors in the last {days} days: {shown}{more} — "
|
|
128
|
+
f"see {_ARTIFACT_DIR}/{_LEDGER_NAME}")
|
|
129
|
+
except Exception:
|
|
130
|
+
return ""
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def main() -> int:
|
|
134
|
+
"""``python hook_log.py [root]`` prints the ledger, newest last. For eyeballing."""
|
|
135
|
+
import sys
|
|
136
|
+
root = sys.argv[1] if len(sys.argv) > 1 else os.getcwd()
|
|
137
|
+
path = _ledger_path(root)
|
|
138
|
+
if path is None or not os.path.isfile(path):
|
|
139
|
+
print(f"no hook-error ledger at {root}/{_ARTIFACT_DIR}/{_LEDGER_NAME} — nothing has failed")
|
|
140
|
+
return 0
|
|
141
|
+
with open(path, "r", encoding="utf-8", errors="replace") as fh:
|
|
142
|
+
for line in fh:
|
|
143
|
+
try:
|
|
144
|
+
rec = json.loads(line)
|
|
145
|
+
except Exception:
|
|
146
|
+
continue
|
|
147
|
+
print(f"{rec.get('ts', '?'):<20} {rec.get('hook', '?')}/{rec.get('feature', '?'):<28} "
|
|
148
|
+
f"{rec.get('error', '?')}: {rec.get('message', '')}")
|
|
149
|
+
summary = summarise(root)
|
|
150
|
+
if summary:
|
|
151
|
+
print("\n" + summary)
|
|
152
|
+
return 0
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
if __name__ == "__main__":
|
|
156
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""One-shot question to an OSS provider (DeepSeek/GLM/OpenRouter) — plain chat, no tools.
|
|
3
|
+
|
|
4
|
+
Backs the /ask-glm and /ask-deepseek commands: a fast second-opinion answer from a
|
|
5
|
+
different model family, without spinning up a full Claude Code session. Speaks the
|
|
6
|
+
providers' OpenAI-compatible ``/chat/completions`` dialect (NOT the Anthropic endpoints
|
|
7
|
+
oss_model.py hands to Claude Code), and reuses oss_model's key resolution so one entry
|
|
8
|
+
in ~/.caddis/keys.env lights up every lane.
|
|
9
|
+
|
|
10
|
+
Usage:
|
|
11
|
+
python oss_ask.py <provider> <prompt...> [--model M] [--base-url U] [--system S]
|
|
12
|
+
|
|
13
|
+
Exit codes: 0 answer printed; 2 endpoint/parse error; 3 config error (message on stderr).
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import json
|
|
19
|
+
import sys
|
|
20
|
+
import urllib.error
|
|
21
|
+
import urllib.request
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
25
|
+
import oss_model # noqa: E402 (same-directory import: shared key resolution)
|
|
26
|
+
|
|
27
|
+
# OpenAI-compatible chat endpoints — intentionally different from oss_model.PROVIDERS,
|
|
28
|
+
# which holds the Anthropic-protocol endpoints for Claude Code.
|
|
29
|
+
CHAT_PROVIDERS: dict[str, dict[str, str]] = {
|
|
30
|
+
"deepseek": {"base_url": "https://api.deepseek.com", "model": "deepseek-v4-flash", "key_env": "DEEPSEEK_API_KEY"},
|
|
31
|
+
"glm": {"base_url": "https://api.z.ai/api/coding/paas/v4", "model": "glm-5.2", "key_env": "GLM_API_KEY"},
|
|
32
|
+
"openrouter": {"base_url": "https://openrouter.ai/api/v1", "model": "deepseek/deepseek-v4-flash", "key_env": "OPENROUTER_API_KEY"},
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
EXIT_OK = 0
|
|
36
|
+
EXIT_ERROR = 2
|
|
37
|
+
EXIT_CONFIG = 3
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def resolve_chat_config(provider: str, env: dict[str, str],
|
|
41
|
+
model: str | None = None, base_url: str | None = None) -> dict[str, str]:
|
|
42
|
+
"""{base_url, model, api_key} for the chat lane. Flag > preset; key via oss_model."""
|
|
43
|
+
preset = CHAT_PROVIDERS.get(provider.strip().lower(), {})
|
|
44
|
+
resolved_base = (base_url or preset.get("base_url") or "").rstrip("/")
|
|
45
|
+
resolved_model = model or preset.get("model") or ""
|
|
46
|
+
if not resolved_base or not resolved_model:
|
|
47
|
+
known = ", ".join(sorted(CHAT_PROVIDERS))
|
|
48
|
+
raise oss_model.ConfigError(
|
|
49
|
+
f"unknown provider {provider!r}: pass --base-url and --model, or use one of: {known}."
|
|
50
|
+
)
|
|
51
|
+
key_env = preset.get("key_env") or f"{provider.upper()}_API_KEY"
|
|
52
|
+
api_key = oss_model._resolve_key(provider, key_env, env)
|
|
53
|
+
return {"base_url": resolved_base, "model": resolved_model, "api_key": api_key}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def ask(cfg: dict[str, str], prompt: str, system: str | None = None, timeout: int = 120) -> str:
|
|
57
|
+
"""POST the prompt to {base_url}/chat/completions and return the reply text."""
|
|
58
|
+
messages = ([{"role": "system", "content": system}] if system else []) + [
|
|
59
|
+
{"role": "user", "content": prompt}
|
|
60
|
+
]
|
|
61
|
+
body = json.dumps({"model": cfg["model"], "messages": messages}).encode("utf-8")
|
|
62
|
+
req = urllib.request.Request(
|
|
63
|
+
f"{cfg['base_url']}/chat/completions",
|
|
64
|
+
data=body,
|
|
65
|
+
headers={
|
|
66
|
+
"Content-Type": "application/json",
|
|
67
|
+
"Authorization": f"Bearer {cfg['api_key']}",
|
|
68
|
+
},
|
|
69
|
+
)
|
|
70
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
71
|
+
payload = json.loads(resp.read().decode("utf-8"))
|
|
72
|
+
return payload["choices"][0]["message"]["content"]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def main(argv: list[str], env: dict[str, str] | None = None) -> int:
|
|
76
|
+
env = dict(oss_model.os.environ) if env is None else env
|
|
77
|
+
ap = argparse.ArgumentParser(description="One-shot question to an OSS provider (no tools).")
|
|
78
|
+
ap.add_argument("provider", help="deepseek | glm | openrouter (or custom with --base-url/--model)")
|
|
79
|
+
ap.add_argument("prompt", nargs="+", help="the question")
|
|
80
|
+
ap.add_argument("--model", default=None)
|
|
81
|
+
ap.add_argument("--base-url", default=None)
|
|
82
|
+
ap.add_argument("--system", default=None)
|
|
83
|
+
args = ap.parse_args(argv)
|
|
84
|
+
try:
|
|
85
|
+
cfg = resolve_chat_config(args.provider, env, model=args.model, base_url=args.base_url)
|
|
86
|
+
except oss_model.ConfigError as exc:
|
|
87
|
+
print(str(exc), file=sys.stderr)
|
|
88
|
+
return EXIT_CONFIG
|
|
89
|
+
try:
|
|
90
|
+
print(ask(cfg, " ".join(args.prompt), system=args.system))
|
|
91
|
+
except (urllib.error.URLError, KeyError, IndexError, json.JSONDecodeError, TimeoutError) as exc:
|
|
92
|
+
print(f"oss_ask: endpoint/parse failure: {exc}", file=sys.stderr)
|
|
93
|
+
return EXIT_ERROR
|
|
94
|
+
return EXIT_OK
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
if __name__ == "__main__":
|
|
98
|
+
raise SystemExit(main(sys.argv[1:]))
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Shared provider + key resolver for the model-switching launchers (Track A).
|
|
3
|
+
|
|
4
|
+
`claude-oss` / `claude-glm` point a Claude Code session at an OpenAI-compatible
|
|
5
|
+
endpoint (GLM, DeepSeek, OpenRouter, or any custom base_url) by setting the
|
|
6
|
+
``ANTHROPIC_*`` env vars. This module owns the ONE place that maps a provider name
|
|
7
|
+
to its endpoint + default model, and resolves the API key WITHOUT ever hardcoding a
|
|
8
|
+
path or a secret.
|
|
9
|
+
|
|
10
|
+
Key resolution precedence (highest wins):
|
|
11
|
+
1. the provider's explicit env var (GLM_API_KEY / DEEPSEEK_API_KEY / OPENROUTER_API_KEY),
|
|
12
|
+
or the generic OSS_API_KEY;
|
|
13
|
+
2. a keys file at CADDIS_KEYS_FILE (default ~/.caddis/keys.env), INI/KEY=VALUE style,
|
|
14
|
+
comments (#) and blank lines allowed;
|
|
15
|
+
3. ConfigError with an actionable message.
|
|
16
|
+
|
|
17
|
+
Endpoint/model precedence: OSS_BASE_URL / OSS_MODEL env override > the provider preset.
|
|
18
|
+
An unknown provider is fine as long as OSS_BASE_URL + OSS_MODEL are supplied.
|
|
19
|
+
|
|
20
|
+
The PROVIDERS table mirrors oss_review.py so a renamed model id / moved endpoint is a
|
|
21
|
+
one-line edit in each. (oss_review may import this later; not refactored here to keep
|
|
22
|
+
the diff small.)
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import os
|
|
27
|
+
import sys
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
# Provider presets — the SINGLE place a renamed model id / moved endpoint is edited.
|
|
31
|
+
# These are the providers' ANTHROPIC-protocol endpoints: this table feeds
|
|
32
|
+
# ANTHROPIC_BASE_URL for Claude Code, which speaks the Anthropic Messages API — NOT the
|
|
33
|
+
# OpenAI /chat/completions dialect that oss_review.py uses (its table differs on purpose).
|
|
34
|
+
# "openrouter" has no Anthropic-protocol endpoint: it only works through an
|
|
35
|
+
# Anthropic-compatible gateway (e.g. LiteLLM — see claude-harness/LOCAL-MODELS.md) whose
|
|
36
|
+
# URL you supply via OSS_BASE_URL.
|
|
37
|
+
PROVIDERS: dict[str, dict[str, str]] = {
|
|
38
|
+
"deepseek": {"base_url": "https://api.deepseek.com/anthropic", "model": "deepseek-v4-flash", "key_env": "DEEPSEEK_API_KEY"},
|
|
39
|
+
"glm": {"base_url": "https://api.z.ai/api/anthropic", "model": "glm-5.2", "key_env": "GLM_API_KEY"},
|
|
40
|
+
"openrouter": {"base_url": "", "model": "deepseek/deepseek-v4-flash", "key_env": "OPENROUTER_API_KEY"},
|
|
41
|
+
}
|
|
42
|
+
DEFAULT_PROVIDER = "deepseek"
|
|
43
|
+
DEFAULT_KEYS_FILE = "~/.caddis/keys.env"
|
|
44
|
+
|
|
45
|
+
# Env-var naming derives from the shared prefix constant, so a future rename is one edit there.
|
|
46
|
+
try:
|
|
47
|
+
from claudster_config import ENV_PREFIX
|
|
48
|
+
except Exception: # pragma: no cover — standalone copy
|
|
49
|
+
ENV_PREFIX = "CADDIS"
|
|
50
|
+
KEYS_FILE_ENV = f"{ENV_PREFIX}_KEYS_FILE"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class ConfigError(Exception):
|
|
54
|
+
"""Configuration can't be resolved — unknown provider without an override, or no key."""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _parse_keys_file(path: str) -> dict[str, str]:
|
|
58
|
+
"""Parse an INI/``KEY=VALUE`` keys file. Comments (#) and blank lines are ignored;
|
|
59
|
+
surrounding quotes are stripped. A missing file yields ``{}`` (not an error — the
|
|
60
|
+
key may still come from the environment)."""
|
|
61
|
+
p = Path(path).expanduser()
|
|
62
|
+
if not p.is_file():
|
|
63
|
+
return {}
|
|
64
|
+
out: dict[str, str] = {}
|
|
65
|
+
for raw in p.read_text(encoding="utf-8").splitlines():
|
|
66
|
+
line = raw.strip()
|
|
67
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
68
|
+
continue
|
|
69
|
+
key, val = line.split("=", 1)
|
|
70
|
+
out[key.strip()] = val.strip().strip('"').strip("'")
|
|
71
|
+
return out
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _resolve_key(provider: str, key_env: str, env: dict[str, str]) -> str:
|
|
75
|
+
"""Provider key via env, then the keys file, else ConfigError. Never logged/echoed."""
|
|
76
|
+
# 1. explicit env (provider-specific, then generic)
|
|
77
|
+
for name in (key_env, "OSS_API_KEY"):
|
|
78
|
+
val = (env.get(name) or "").strip()
|
|
79
|
+
if val:
|
|
80
|
+
return val
|
|
81
|
+
# 2. keys file (CADDIS_KEYS_FILE if set, else ~/.caddis/keys.env)
|
|
82
|
+
keys_path = env.get(KEYS_FILE_ENV) or DEFAULT_KEYS_FILE
|
|
83
|
+
file_keys = _parse_keys_file(keys_path)
|
|
84
|
+
for name in (key_env, "OSS_API_KEY"):
|
|
85
|
+
val = (file_keys.get(name) or "").strip()
|
|
86
|
+
if val:
|
|
87
|
+
return val
|
|
88
|
+
# 3. actionable error — name the var and the file, never a value.
|
|
89
|
+
#
|
|
90
|
+
# It also names the providers that DO have a key. When DeepSeek is unset but GLM is configured,
|
|
91
|
+
# the fix is `--provider glm`, not "go and find a key" — and the old message sent the reader
|
|
92
|
+
# hunting for a credential they already had. This fails at the moment of use, which is the
|
|
93
|
+
# least convenient moment to be told to go looking (`.caddis/parking-lot/003`).
|
|
94
|
+
others = [p for p in configured_providers(env) if p != provider]
|
|
95
|
+
have = (f"\nProviders that DO have a key here: {', '.join(others)}. "
|
|
96
|
+
f"To use one now: --provider {others[0]}." if others else "")
|
|
97
|
+
raise ConfigError(
|
|
98
|
+
f"no API key for provider {provider!r}. Set ${key_env} (or $OSS_API_KEY), or add\n"
|
|
99
|
+
f" {key_env}=<your-key>\n"
|
|
100
|
+
f"to your keys file ({keys_path}). Override the file path with ${KEYS_FILE_ENV}."
|
|
101
|
+
+ have
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def configured_providers(env: dict[str, str]) -> list[str]:
|
|
106
|
+
"""Every preset provider that currently has a usable key. Never returns or logs a key value.
|
|
107
|
+
|
|
108
|
+
Exists so a caller can find out BEFORE it needs one. `/caddis:cross-review` used to discover a
|
|
109
|
+
missing key mid-task, with a commit pending — and a command that fails once at an inconvenient
|
|
110
|
+
moment does not get retried, so the safety check it provides is simply lost and nobody records
|
|
111
|
+
that it was lost.
|
|
112
|
+
"""
|
|
113
|
+
keys_path = env.get(KEYS_FILE_ENV) or DEFAULT_KEYS_FILE
|
|
114
|
+
file_keys = _parse_keys_file(keys_path)
|
|
115
|
+
generic = (env.get("OSS_API_KEY") or file_keys.get("OSS_API_KEY") or "").strip()
|
|
116
|
+
out = []
|
|
117
|
+
for name, preset in sorted(PROVIDERS.items()):
|
|
118
|
+
key_env = preset.get("key_env") or f"{name.upper()}_API_KEY"
|
|
119
|
+
if generic or (env.get(key_env) or "").strip() or (file_keys.get(key_env) or "").strip():
|
|
120
|
+
out.append(name)
|
|
121
|
+
return out
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def resolve(provider: str | None, env: dict[str, str]) -> dict[str, str]:
|
|
125
|
+
"""Resolve ``{base_url, model, api_key}`` for ``provider`` using ``env``.
|
|
126
|
+
|
|
127
|
+
``provider`` defaults to $OSS_PROVIDER then DEEPSEEK. An unknown provider is allowed
|
|
128
|
+
only when OSS_BASE_URL + OSS_MODEL are supplied. Raises ConfigError otherwise, or
|
|
129
|
+
when no API key can be resolved.
|
|
130
|
+
"""
|
|
131
|
+
provider = (provider or env.get("OSS_PROVIDER") or DEFAULT_PROVIDER).strip().lower()
|
|
132
|
+
preset = PROVIDERS.get(provider, {})
|
|
133
|
+
base_url = (env.get("OSS_BASE_URL") or preset.get("base_url") or "").rstrip("/")
|
|
134
|
+
model = env.get("OSS_MODEL") or preset.get("model") or ""
|
|
135
|
+
if not base_url or not model:
|
|
136
|
+
known = ", ".join(sorted(PROVIDERS))
|
|
137
|
+
raise ConfigError(
|
|
138
|
+
f"unknown provider {provider!r}: set $OSS_BASE_URL and $OSS_MODEL, "
|
|
139
|
+
f"or use one of: {known}."
|
|
140
|
+
)
|
|
141
|
+
key_env = preset.get("key_env") or f"{provider.upper()}_API_KEY"
|
|
142
|
+
api_key = _resolve_key(provider, key_env, env)
|
|
143
|
+
return {"base_url": base_url, "model": model, "api_key": api_key}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def main(argv: list[str], env: dict[str, str] | None = None) -> int:
|
|
147
|
+
"""CLI bridge for the launchers: ``oss_model.py <provider>`` prints the resolved
|
|
148
|
+
base_url, model, and api_key on three lines (in that order) to stdout — the caller
|
|
149
|
+
(claude-oss.sh / .ps1) CAPTURES this output into variables and sets ANTHROPIC_*; it
|
|
150
|
+
is never displayed. A ConfigError prints its message to stderr and exits 3.
|
|
151
|
+
"""
|
|
152
|
+
env = os.environ if env is None else env
|
|
153
|
+
provider = argv[0] if argv else None
|
|
154
|
+
try:
|
|
155
|
+
cfg = resolve(provider, env)
|
|
156
|
+
except ConfigError as exc:
|
|
157
|
+
print(str(exc), file=sys.stderr)
|
|
158
|
+
return 3
|
|
159
|
+
print(cfg["base_url"])
|
|
160
|
+
print(cfg["model"])
|
|
161
|
+
print(cfg["api_key"])
|
|
162
|
+
return 0
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
if __name__ == "__main__":
|
|
166
|
+
raise SystemExit(main(sys.argv[1:]))
|