@caddis/cli 0.2.5 → 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.
Files changed (54) hide show
  1. package/bundles/antigravity-plugin/agents/claude-md-curator.md +1 -1
  2. package/bundles/antigravity-plugin/caddis_statusline.py +803 -0
  3. package/bundles/antigravity-plugin/claude-md/folder-agents.md.tmpl +21 -0
  4. package/bundles/antigravity-plugin/guard_agy.py +23 -4
  5. package/bundles/antigravity-plugin/plugin.json +1 -1
  6. package/bundles/antigravity-plugin/scripts/caddis_gate.py +524 -0
  7. package/bundles/antigravity-plugin/scripts/caddis_inventory.py +369 -0
  8. package/bundles/antigravity-plugin/scripts/caddis_migrate_dir.py +270 -0
  9. package/bundles/antigravity-plugin/scripts/caddis_spawn.py +300 -0
  10. package/bundles/antigravity-plugin/scripts/caddis_statusline.py +803 -0
  11. package/bundles/antigravity-plugin/scripts/caddis_tidy.py +366 -0
  12. package/bundles/antigravity-plugin/scripts/check_doc_coverage.py +924 -0
  13. package/bundles/antigravity-plugin/scripts/claude-deepseek.ps1 +4 -0
  14. package/bundles/antigravity-plugin/scripts/claude-deepseek.sh +3 -0
  15. package/bundles/antigravity-plugin/scripts/claude-glm.ps1 +4 -0
  16. package/bundles/antigravity-plugin/scripts/claude-glm.sh +3 -0
  17. package/bundles/antigravity-plugin/scripts/claude-oss.ps1 +64 -0
  18. package/bundles/antigravity-plugin/scripts/claude-oss.sh +60 -0
  19. package/bundles/antigravity-plugin/scripts/claudster_config.py +126 -0
  20. package/bundles/antigravity-plugin/scripts/claudster_doctor.py +356 -0
  21. package/bundles/antigravity-plugin/scripts/claudster_init.py +400 -0
  22. package/bundles/antigravity-plugin/scripts/claudster_migrate_rules.py +300 -0
  23. package/bundles/antigravity-plugin/scripts/dream_capture.py +249 -0
  24. package/bundles/antigravity-plugin/scripts/dream_memory.py +438 -0
  25. package/bundles/antigravity-plugin/scripts/enforcement_inventory.py +261 -0
  26. package/bundles/antigravity-plugin/scripts/hook_log.py +156 -0
  27. package/bundles/antigravity-plugin/scripts/oss_ask.py +98 -0
  28. package/bundles/antigravity-plugin/scripts/oss_model.py +166 -0
  29. package/bundles/antigravity-plugin/scripts/oss_review.py +718 -0
  30. package/bundles/antigravity-plugin/scripts/setup_project_ai.py +1429 -0
  31. package/bundles/antigravity-plugin/scripts/usage_review.py +1323 -0
  32. package/bundles/antigravity-plugin/session_end_agy.py +21 -2
  33. package/bundles/antigravity-plugin/skills/_registry.md +2 -0
  34. package/bundles/antigravity-plugin/skills/advisory-hub/reference/succession.md +22 -0
  35. package/bundles/antigravity-plugin/skills/fastapi-dev/SKILL.md +1 -1
  36. package/bundles/antigravity-plugin/skills/ship/SKILL.md +1 -1
  37. package/bundles/antigravity-plugin/skills/ship-merge/SKILL.md +1 -1
  38. package/bundles/antigravity-plugin/skills/ship-pr/SKILL.md +1 -1
  39. package/bundles/antigravity-plugin/skills/spawn-hub/SKILL.md +33 -0
  40. package/bundles/antigravity-plugin/skills/spawn-session/SKILL.md +213 -0
  41. package/bundles/antigravity-plugin/skills/statusline/SKILL.md +105 -0
  42. package/bundles/antigravity-plugin/skills/use-model/SKILL.md +15 -3
  43. package/bundles/antigravity-plugin/warm_start_agy.py +21 -2
  44. package/bundles/antigravity-plugin-extras/plugin.json +1 -1
  45. package/bundles/antigravity-plugin-extras/skills/architecture-design/SKILL.md +1 -1
  46. package/bundles/antigravity-plugin-extras/skills/caching-patterns/SKILL.md +1 -1
  47. package/bundles/antigravity-plugin-extras/skills/webapp-development/SKILL.md +1 -1
  48. package/bundles/manifest.json +3 -3
  49. package/dist/claude-deepseek.js +544 -0
  50. package/dist/claude-glm.js +544 -0
  51. package/dist/claude-oss.js +544 -0
  52. package/dist/cli.js +375 -46
  53. package/package.json +5 -2
  54. 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:]))