@andresmassello/uscha 1.40.2 → 1.43.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/README.md +6 -6
- package/bin/uscha.js +19 -7
- package/package.json +1 -1
- package/uscha-kit/.claude/skills/uscha-adr-refine/SKILL.md +2 -2
- package/uscha-kit/.claude/skills/uscha-devloop/SKILL.md +3 -1
- package/uscha-kit/.claude/skills/uscha-devloop/qa_ledger.py +729 -155
- package/uscha-kit/.claude/skills/uscha-mirador/SKILL.md +22 -7
- package/uscha-kit/.claude/skills/uscha-mirador/mirador-render.py +44 -5
- package/uscha-kit/.claude/skills/uscha-mirador/mirador-watch.ps1 +1 -1
- package/uscha-kit/.claude/skills/uscha-mirador/mirador-watch.sh +1 -1
- package/uscha-kit/.claude/skills/uscha-mirador/mirador.template.html +666 -586
- package/uscha-kit/.claude-plugin/plugin.json +1 -1
- package/uscha-kit/.codex-plugin/plugin.json +1 -1
- package/uscha-kit/CHANGELOG-1.41.0.md +18 -0
- package/uscha-kit/CHANGELOG-1.41.1.md +53 -0
- package/uscha-kit/CHANGELOG-1.41.2.md +34 -0
- package/uscha-kit/CHANGELOG-1.41.3.md +30 -0
- package/uscha-kit/CHANGELOG-1.42.0.md +41 -0
- package/uscha-kit/CHANGELOG-1.43.0.md +37 -0
- package/uscha-kit/INSTALL.md +120 -101
- package/uscha-kit/README.md +24 -13
- package/uscha-kit/VERSION +1 -1
- package/uscha-kit/WORKBENCH.md +19 -5
- package/uscha-kit/hooks/block-approved-writes.py +25 -0
- package/uscha-kit/install-uscha.py +534 -267
- package/uscha-kit/skills/uscha-adr-refine/SKILL.md +2 -2
- package/uscha-kit/skills/uscha-devloop/SKILL.md +3 -1
- package/uscha-kit/skills/uscha-devloop/qa_ledger.py +729 -155
- package/uscha-kit/skills/uscha-mirador/SKILL.md +22 -7
- package/uscha-kit/skills/uscha-mirador/mirador-render.py +44 -5
- package/uscha-kit/skills/uscha-mirador/mirador-watch.ps1 +1 -1
- package/uscha-kit/skills/uscha-mirador/mirador-watch.sh +1 -1
- package/uscha-kit/skills/uscha-mirador/mirador.template.html +666 -586
- package/uscha-kit/templates/CONSTITUTION.md +4 -4
- package/uscha-kit/templates/docs/adr/README.md +19 -19
- package/uscha-kit/tests/ledger-integrity-regressions.py +136 -0
- package/uscha-kit/tests/smoke-engine.sh +1312 -29
- package/uscha-kit/uscha.config.json +1 -1
- package/uscha-kit/workbench-doctor.sh +47 -3
|
@@ -1,344 +1,611 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""Universal Uscha machine installer.
|
|
3
|
-
|
|
4
|
-
One small public interface, two adapters inside:
|
|
5
|
-
- Codex: personal local plugin at ~/plugins/uscha + ~/.agents/plugins/marketplace.json
|
|
6
|
-
- Claude: global skills/hook at ~/.claude/skills and ~/.claude/hooks
|
|
7
|
-
|
|
8
|
-
Stdlib only. Safe to test with --home and --dry-run.
|
|
9
|
-
"""
|
|
2
|
+
"""Universal Uscha machine installer (stdlib only, safe with --home/--dry-run)."""
|
|
10
3
|
from __future__ import annotations
|
|
11
4
|
|
|
12
5
|
import argparse
|
|
13
6
|
import json
|
|
14
7
|
import os
|
|
8
|
+
import shlex
|
|
15
9
|
import shutil
|
|
16
10
|
import subprocess
|
|
17
11
|
import sys
|
|
12
|
+
import tempfile
|
|
13
|
+
import time
|
|
14
|
+
import uuid
|
|
18
15
|
from datetime import datetime, timezone
|
|
19
16
|
from pathlib import Path
|
|
20
17
|
|
|
21
18
|
KIT_ROOT = Path(__file__).resolve().parent
|
|
22
19
|
PLUGIN_NAME = "uscha"
|
|
23
|
-
SKILLS = [
|
|
24
|
-
|
|
25
|
-
"uscha-adr-refine",
|
|
26
|
-
"uscha-reverse-discovery",
|
|
27
|
-
"uscha-characterize",
|
|
28
|
-
"uscha-devloop",
|
|
29
|
-
"uscha-sysdoc",
|
|
30
|
-
"uscha-rubric",
|
|
31
|
-
"uscha-mirador",
|
|
32
|
-
]
|
|
20
|
+
SKILLS = ["uscha-discovery", "uscha-adr-refine", "uscha-reverse-discovery", "uscha-characterize",
|
|
21
|
+
"uscha-devloop", "uscha-sysdoc", "uscha-rubric", "uscha-mirador"]
|
|
33
22
|
TARGETS = ("codex", "claude")
|
|
23
|
+
HOOK_NAME = "block-approved-writes.py"
|
|
24
|
+
|
|
34
25
|
|
|
26
|
+
class InstallError(Exception):
|
|
27
|
+
pass
|
|
35
28
|
|
|
36
|
-
def source_version() -> str:
|
|
37
|
-
raw = (KIT_ROOT / "VERSION").read_text(encoding="utf-8").strip()
|
|
38
|
-
return raw.split()[-1]
|
|
39
29
|
|
|
30
|
+
def source_version():
|
|
31
|
+
return (KIT_ROOT / "VERSION").read_text(encoding="utf-8").strip().split()[-1]
|
|
40
32
|
|
|
41
|
-
|
|
33
|
+
|
|
34
|
+
def now_iso():
|
|
42
35
|
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
|
43
36
|
|
|
44
37
|
|
|
45
|
-
def home_path(args)
|
|
38
|
+
def home_path(args):
|
|
46
39
|
return Path(args.home).expanduser().resolve() if args.home else Path.home().resolve()
|
|
47
40
|
|
|
48
41
|
|
|
49
|
-
def selected_targets(value
|
|
42
|
+
def selected_targets(value):
|
|
50
43
|
return list(TARGETS) if value == "both" else [value]
|
|
51
44
|
|
|
52
45
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
else:
|
|
91
|
-
shutil.rmtree(dst)
|
|
92
|
-
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
93
|
-
if mode == "copy":
|
|
94
|
-
shutil.copytree(src, dst, ignore=shutil.ignore_patterns("__pycache__", "*.pyc", "*.pyo"))
|
|
95
|
-
else:
|
|
96
|
-
link_dir(src, dst)
|
|
46
|
+
def load_json(path, label):
|
|
47
|
+
try:
|
|
48
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
49
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
50
|
+
raise InstallError("[install-uscha] invalid %s: %s" % (label, path)) from exc
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def atomic_json(path, data):
|
|
54
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
55
|
+
fd, temp = tempfile.mkstemp(prefix=".%s." % path.name, suffix=".tmp", dir=str(path.parent))
|
|
56
|
+
try:
|
|
57
|
+
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle:
|
|
58
|
+
json.dump(data, handle, indent=2, ensure_ascii=False)
|
|
59
|
+
handle.write("\n")
|
|
60
|
+
os.replace(temp, path)
|
|
61
|
+
finally:
|
|
62
|
+
if os.path.exists(temp):
|
|
63
|
+
os.unlink(temp)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def atomic_bytes(path, data):
|
|
67
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
68
|
+
fd, temp = tempfile.mkstemp(prefix=".%s." % path.name, suffix=".tmp", dir=str(path.parent))
|
|
69
|
+
try:
|
|
70
|
+
with os.fdopen(fd, "wb") as handle:
|
|
71
|
+
handle.write(data)
|
|
72
|
+
os.replace(temp, path)
|
|
73
|
+
finally:
|
|
74
|
+
if os.path.exists(temp):
|
|
75
|
+
os.unlink(temp)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def remove_path(path):
|
|
79
|
+
if path.is_symlink() or path.is_file():
|
|
80
|
+
path.unlink()
|
|
81
|
+
elif path.exists():
|
|
82
|
+
shutil.rmtree(path)
|
|
97
83
|
|
|
98
84
|
|
|
99
|
-
def link_dir(src
|
|
100
|
-
"""Create a directory link. On Windows prefer junctions for non-admin installs."""
|
|
85
|
+
def link_dir(src, dst):
|
|
101
86
|
if os.name == "nt":
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
raise SystemExit("[install-uscha] cannot create junction %s -> %s: %s" %
|
|
106
|
-
(dst, src, (res.stderr or res.stdout).strip()))
|
|
87
|
+
result = subprocess.run(["cmd", "/c", "mklink", "/J", str(dst), str(src)], text=True, capture_output=True)
|
|
88
|
+
if result.returncode:
|
|
89
|
+
raise InstallError("[install-uscha] cannot create junction %s -> %s" % (dst, src))
|
|
107
90
|
else:
|
|
108
91
|
os.symlink(src, dst, target_is_directory=True)
|
|
109
92
|
|
|
110
93
|
|
|
111
|
-
def
|
|
112
|
-
|
|
113
|
-
"
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
"
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
],
|
|
134
|
-
"
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
94
|
+
def copy_skill(src, dst, mode):
|
|
95
|
+
if mode == "copy":
|
|
96
|
+
shutil.copytree(src, dst, ignore=shutil.ignore_patterns("__pycache__", "*.pyc", "*.pyo"))
|
|
97
|
+
else:
|
|
98
|
+
link_dir(src, dst)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def source_skills():
|
|
102
|
+
root = KIT_ROOT / ".claude" / "skills"
|
|
103
|
+
missing = [skill for skill in SKILLS if not (root / skill / "SKILL.md").is_file()]
|
|
104
|
+
if missing:
|
|
105
|
+
raise InstallError("[install-uscha] source skills missing: %s" % ", ".join(missing))
|
|
106
|
+
if not (KIT_ROOT / "hooks" / HOOK_NAME).is_file():
|
|
107
|
+
raise InstallError("[install-uscha] source hook missing: %s" % HOOK_NAME)
|
|
108
|
+
return root
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def plugin_manifest():
|
|
112
|
+
return {"name": PLUGIN_NAME, "version": source_version(),
|
|
113
|
+
"description": "Uscha spec-driven development methodology for coding agents.",
|
|
114
|
+
"author": {"name": "Andres Massello", "url": "https://github.com/andresmassello"},
|
|
115
|
+
"homepage": "https://github.com/andresmassello/uscha", "repository": "https://github.com/andresmassello/uscha",
|
|
116
|
+
"license": "MIT", "keywords": ["spec-driven", "qa", "gates", "golden-testing", "readiness"],
|
|
117
|
+
"skills": "./skills/", "interface": {"displayName": "Uscha", "shortDescription": "Spec-driven development with fact gates and readiness.",
|
|
118
|
+
"longDescription": "Uscha installs discovery, ADR, characterization, devloop, rubric, sysdoc and Mirador skills plus qa_ledger.py.",
|
|
119
|
+
"developerName": "Andres Massello", "category": "Productivity", "capabilities": ["Write", "Interactive"],
|
|
120
|
+
"defaultPrompt": ["Run Uscha discovery for this feature.", "Use Uscha devloop to verify this change.", "Show the Uscha readiness for this repo."], "brandColor": "#7C3AED"}}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def marker(target, install_root, mode):
|
|
124
|
+
return {"name": PLUGIN_NAME, "target": target, "version": source_version(), "mode": mode,
|
|
125
|
+
"installed_at": now_iso(), "source": str(KIT_ROOT), "install_root": str(install_root)}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def marketplace_entry():
|
|
129
|
+
return {"name": PLUGIN_NAME, "source": {"source": "local", "path": "./plugins/uscha"},
|
|
130
|
+
"policy": {"installation": "AVAILABLE", "authentication": "ON_INSTALL"}, "category": "Productivity"}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def valid_marketplace_source(source):
|
|
134
|
+
if not isinstance(source, dict) or source.get("source") != "local":
|
|
135
|
+
return False
|
|
136
|
+
path = source.get("path")
|
|
137
|
+
if not isinstance(path, str) or not path or not path.startswith("./"):
|
|
138
|
+
return False
|
|
139
|
+
return not any(part in ("", ".", "..") for part in path[2:].split("/"))
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def valid_marketplace_plugin(plugin):
|
|
143
|
+
if not isinstance(plugin, dict) or not isinstance(plugin.get("name"), str) or not plugin["name"].strip():
|
|
144
|
+
return False
|
|
145
|
+
policy = plugin.get("policy")
|
|
146
|
+
if (not isinstance(policy, dict)
|
|
147
|
+
or policy.get("installation") not in {"AVAILABLE", "INSTALLED_BY_DEFAULT", "NOT_AVAILABLE"}
|
|
148
|
+
or policy.get("authentication") not in {"ON_INSTALL", "ON_USE"}):
|
|
149
|
+
return False
|
|
150
|
+
return (valid_marketplace_source(plugin.get("source"))
|
|
151
|
+
and isinstance(plugin.get("category"), str) and bool(plugin["category"].strip()))
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def valid_marketplace(data):
|
|
155
|
+
interface = data.get("interface") if isinstance(data, dict) else None
|
|
156
|
+
interface_ok = ("interface" not in data or isinstance(interface, dict)) if isinstance(data, dict) else False
|
|
157
|
+
if interface_ok and isinstance(interface, dict) and "displayName" in interface:
|
|
158
|
+
interface_ok = isinstance(interface["displayName"], str) and bool(interface["displayName"].strip())
|
|
159
|
+
return (isinstance(data, dict) and isinstance(data.get("name"), str) and bool(data["name"].strip())
|
|
160
|
+
and interface_ok and isinstance(data.get("plugins"), list)
|
|
161
|
+
and all(valid_marketplace_plugin(plugin) for plugin in data["plugins"]))
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def prepared_marketplace(path):
|
|
165
|
+
data = {"name": "personal", "interface": {"displayName": "Personal"}, "plugins": []}
|
|
166
|
+
if path.exists():
|
|
167
|
+
data = load_json(path, "marketplace.json")
|
|
168
|
+
if not valid_marketplace(data):
|
|
169
|
+
raise InstallError("[install-uscha] marketplace.json has no supported required shape: %s" % path)
|
|
170
|
+
entry = marketplace_entry()
|
|
171
|
+
plugins = list(data["plugins"])
|
|
172
|
+
index = next((i for i, plugin in enumerate(plugins) if plugin["name"] == PLUGIN_NAME), None)
|
|
173
|
+
if index is None:
|
|
174
|
+
plugins.append(entry)
|
|
175
|
+
else:
|
|
176
|
+
plugins[index] = entry
|
|
177
|
+
result = dict(data)
|
|
178
|
+
result["plugins"] = plugins
|
|
179
|
+
return result
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def hook_command(hook):
|
|
183
|
+
parts = [sys.executable, str(hook)]
|
|
184
|
+
return subprocess.list2cmdline(parts) if os.name == "nt" else " ".join(shlex.quote(part) for part in parts)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def hook_registered(settings, command):
|
|
188
|
+
hooks = settings.get("hooks", {}) if isinstance(settings, dict) else {}
|
|
189
|
+
groups = hooks.get("PreToolUse", []) if isinstance(hooks, dict) else []
|
|
190
|
+
return any(isinstance(group, dict) and group.get("matcher") == "*"
|
|
191
|
+
and any(isinstance(item, dict) and item.get("type") == "command" and item.get("command") == command
|
|
192
|
+
for item in group.get("hooks", []) if isinstance(group.get("hooks", []), list))
|
|
193
|
+
for group in groups if isinstance(groups, list))
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def prepared_settings(path, command):
|
|
197
|
+
data = {} if not path.exists() else load_json(path, "Claude settings.json")
|
|
198
|
+
if not isinstance(data, dict):
|
|
199
|
+
raise InstallError("[install-uscha] Claude settings.json must be an object: %s" % path)
|
|
200
|
+
hooks_data = data.get("hooks", {})
|
|
201
|
+
if not isinstance(hooks_data, dict):
|
|
202
|
+
raise InstallError("[install-uscha] Claude settings hooks must be an object: %s" % path)
|
|
203
|
+
groups_data = hooks_data.get("PreToolUse", [])
|
|
204
|
+
if not isinstance(groups_data, list):
|
|
205
|
+
raise InstallError("[install-uscha] Claude PreToolUse hooks must be an array: %s" % path)
|
|
206
|
+
for group in groups_data:
|
|
207
|
+
if not isinstance(group, dict):
|
|
208
|
+
raise InstallError("[install-uscha] Claude PreToolUse group must be an object: %s" % path)
|
|
209
|
+
items = group.get("hooks", [])
|
|
210
|
+
if not isinstance(items, list):
|
|
211
|
+
raise InstallError("[install-uscha] Claude PreToolUse group hooks must be an array: %s" % path)
|
|
212
|
+
if not all(isinstance(item, dict) for item in items):
|
|
213
|
+
raise InstallError("[install-uscha] Claude PreToolUse hook item must be an object: %s" % path)
|
|
214
|
+
result = dict(data)
|
|
215
|
+
hooks = dict(hooks_data)
|
|
216
|
+
groups = list(groups_data)
|
|
217
|
+
if not hook_registered(result, command):
|
|
218
|
+
groups.append({"matcher": "*", "hooks": [{"type": "command", "command": command}]})
|
|
219
|
+
hooks["PreToolUse"] = groups
|
|
220
|
+
result["hooks"] = hooks
|
|
221
|
+
return result
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def stage_plugin(plugin_root, mode):
|
|
225
|
+
source = source_skills()
|
|
226
|
+
stage = plugin_root.parent / (".%s.staging-%s" % (PLUGIN_NAME, uuid.uuid4().hex))
|
|
227
|
+
stage.mkdir(parents=True)
|
|
228
|
+
try:
|
|
229
|
+
(stage / ".codex-plugin").mkdir()
|
|
230
|
+
atomic_json(stage / ".codex-plugin" / "plugin.json", plugin_manifest())
|
|
231
|
+
(stage / "skills").mkdir()
|
|
232
|
+
for skill in SKILLS:
|
|
233
|
+
copy_skill(source / skill, stage / "skills" / skill, mode)
|
|
234
|
+
shutil.copy2(KIT_ROOT / "VERSION", stage / "VERSION")
|
|
235
|
+
shutil.copy2(KIT_ROOT / "uscha.config.json", stage / "uscha.config.json")
|
|
236
|
+
return stage
|
|
237
|
+
except Exception:
|
|
238
|
+
remove_path(stage)
|
|
239
|
+
raise
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def install_codex(home, mode, dry_run, operations):
|
|
152
243
|
plugin_root = home / "plugins" / PLUGIN_NAME
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
244
|
+
market = home / ".agents" / "plugins" / "marketplace.json"
|
|
245
|
+
market_data = prepared_marketplace(market) # preflight before any filesystem mutation, including dry-run
|
|
246
|
+
operations.append({"action": "stage-plugin", "path": str(plugin_root)})
|
|
247
|
+
operations.extend({"action": "copy-dir", "path": str(plugin_root / "skills" / skill)} for skill in SKILLS)
|
|
248
|
+
operations.extend([{"action": "atomic-write-json", "path": str(market)}, {"action": "write-marker-last", "path": str(plugin_root / "uscha-install.json")}])
|
|
249
|
+
if dry_run:
|
|
250
|
+
return plugin_root
|
|
251
|
+
stage = stage_plugin(plugin_root, mode)
|
|
252
|
+
backup = plugin_root.parent / (".%s.backup-%s" % (PLUGIN_NAME, uuid.uuid4().hex))
|
|
253
|
+
market_existed = market.exists()
|
|
254
|
+
market_before = market.read_bytes() if market_existed else None
|
|
255
|
+
missing_market_dirs = []
|
|
256
|
+
directory = market.parent
|
|
257
|
+
while directory != home and not directory.exists():
|
|
258
|
+
missing_market_dirs.append(directory)
|
|
259
|
+
directory = directory.parent
|
|
260
|
+
swapped = False
|
|
261
|
+
backed_up = False
|
|
262
|
+
market_mutated = False
|
|
263
|
+
try:
|
|
264
|
+
plugin_root.parent.mkdir(parents=True, exist_ok=True)
|
|
265
|
+
if plugin_root.exists() or plugin_root.is_symlink():
|
|
266
|
+
os.replace(plugin_root, backup)
|
|
267
|
+
backed_up = True
|
|
268
|
+
os.replace(stage, plugin_root)
|
|
269
|
+
swapped = True
|
|
270
|
+
atomic_json(market, market_data)
|
|
271
|
+
market_mutated = True
|
|
272
|
+
atomic_json(plugin_root / "uscha-install.json", marker("codex", plugin_root, mode))
|
|
273
|
+
# success: the pre-existing plugin (now in backup) is stale -- drop it HERE, not
|
|
274
|
+
# in `finally`, so a failure can never delete the backup before it is restored
|
|
275
|
+
# (kit 1.41.1 adversarial-review fix -- the Codex path used to gate the restore on
|
|
276
|
+
# `swapped` and unconditionally delete the backup, destroying the user's install).
|
|
277
|
+
if backed_up and (backup.exists() or backup.is_symlink()):
|
|
278
|
+
remove_path(backup)
|
|
279
|
+
backed_up = False
|
|
280
|
+
except Exception as exc:
|
|
281
|
+
rollback_errors = []
|
|
282
|
+
# restore gated on the BACKUP existing (not on `swapped`): if the swap failed
|
|
283
|
+
# AFTER the original was moved to backup, the original still lives in backup.
|
|
284
|
+
try:
|
|
285
|
+
if swapped and (plugin_root.exists() or plugin_root.is_symlink()):
|
|
286
|
+
remove_path(plugin_root)
|
|
287
|
+
if backed_up and (backup.exists() or backup.is_symlink()):
|
|
288
|
+
os.replace(backup, plugin_root)
|
|
289
|
+
backed_up = False
|
|
290
|
+
except Exception as rollback_exc:
|
|
291
|
+
rollback_errors.append("%s: %s" % (plugin_root, rollback_exc))
|
|
292
|
+
if market_mutated:
|
|
293
|
+
try:
|
|
294
|
+
if market_existed:
|
|
295
|
+
atomic_bytes(market, market_before)
|
|
296
|
+
elif market.exists() or market.is_symlink():
|
|
297
|
+
remove_path(market)
|
|
298
|
+
for created in missing_market_dirs:
|
|
299
|
+
try:
|
|
300
|
+
created.rmdir()
|
|
301
|
+
except OSError:
|
|
302
|
+
pass
|
|
303
|
+
except Exception as rollback_exc:
|
|
304
|
+
rollback_errors.append("%s: %s" % (market, rollback_exc))
|
|
305
|
+
if rollback_errors:
|
|
306
|
+
raise InstallError("[install-uscha] Codex rollback incomplete: %s" % "; ".join(rollback_errors)) from exc
|
|
307
|
+
raise
|
|
308
|
+
finally:
|
|
309
|
+
if stage.exists():
|
|
310
|
+
remove_path(stage)
|
|
311
|
+
# backup is intentionally NOT deleted here: on success it was dropped above; on
|
|
312
|
+
# failure it was restored; on a hard interrupt (KeyboardInterrupt bypasses the
|
|
313
|
+
# except) it is left in place so the user's original is never destroyed.
|
|
163
314
|
return plugin_root
|
|
164
315
|
|
|
165
316
|
|
|
166
|
-
def
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
317
|
+
def install_claude(home, mode, dry_run, operations):
|
|
318
|
+
source = source_skills()
|
|
319
|
+
root = home / ".claude"
|
|
320
|
+
hook = root / "hooks" / HOOK_NAME
|
|
321
|
+
settings = root / "settings.json"
|
|
322
|
+
install_marker = root / "uscha-install.json"
|
|
323
|
+
data = prepared_settings(settings, hook_command(hook)) # parse and merge before writes, including dry-run
|
|
324
|
+
marker_data = marker("claude", root, mode)
|
|
325
|
+
operations.extend({"action": "install-skill", "path": str(root / "skills" / skill)} for skill in SKILLS)
|
|
326
|
+
operations.extend([{"action": "copy-hook", "path": str(hook)}, {"action": "atomic-write-json", "path": str(settings)}, {"action": "write-marker-last", "path": str(install_marker)}])
|
|
327
|
+
if dry_run:
|
|
328
|
+
return root
|
|
329
|
+
|
|
330
|
+
home_existed = home.exists()
|
|
331
|
+
home.mkdir(parents=True, exist_ok=True)
|
|
332
|
+
transaction = home / (".uscha-claude-transaction-%s" % uuid.uuid4().hex)
|
|
333
|
+
staged = transaction / "staged"
|
|
334
|
+
backups = transaction / "backups"
|
|
335
|
+
cleanup_transaction = True
|
|
336
|
+
try:
|
|
337
|
+
(staged / "skills").mkdir(parents=True)
|
|
338
|
+
backups.mkdir()
|
|
339
|
+
for skill in SKILLS:
|
|
340
|
+
copy_skill(source / skill, staged / "skills" / skill, mode)
|
|
341
|
+
(staged / "hooks").mkdir()
|
|
342
|
+
shutil.copy2(KIT_ROOT / "hooks" / HOOK_NAME, staged / "hooks" / HOOK_NAME)
|
|
343
|
+
atomic_json(staged / "settings.json", data)
|
|
344
|
+
atomic_json(staged / "uscha-install.json", marker_data)
|
|
345
|
+
|
|
346
|
+
skills_root = root / "skills"
|
|
347
|
+
entries = [(skills_root / skill, staged / "skills" / skill, backups / "skills" / skill)
|
|
348
|
+
for skill in SKILLS]
|
|
349
|
+
entries.extend([
|
|
350
|
+
(hook, staged / "hooks" / HOOK_NAME, backups / "hooks" / HOOK_NAME),
|
|
351
|
+
(settings, staged / "settings.json", backups / "settings.json"),
|
|
352
|
+
(install_marker, staged / "uscha-install.json", backups / "uscha-install.json"),
|
|
353
|
+
])
|
|
354
|
+
preexisting = {target: target.exists() or target.is_symlink()
|
|
355
|
+
for target, _, _ in entries}
|
|
356
|
+
created_dirs = []
|
|
357
|
+
backed_up = set()
|
|
358
|
+
installed = set()
|
|
359
|
+
|
|
170
360
|
try:
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
361
|
+
for directory in (root, skills_root, hook.parent):
|
|
362
|
+
if not directory.exists():
|
|
363
|
+
directory.mkdir(parents=True)
|
|
364
|
+
created_dirs.append(directory)
|
|
365
|
+
|
|
366
|
+
for target, replacement, backup in entries:
|
|
367
|
+
if preexisting[target]:
|
|
368
|
+
backup.parent.mkdir(parents=True, exist_ok=True)
|
|
369
|
+
os.replace(target, backup)
|
|
370
|
+
backed_up.add(target)
|
|
371
|
+
os.replace(replacement, target)
|
|
372
|
+
installed.add(target)
|
|
373
|
+
except Exception as exc:
|
|
374
|
+
rollback_errors = []
|
|
375
|
+
for target, _, backup in reversed(entries):
|
|
376
|
+
try:
|
|
377
|
+
if target in installed and (target.exists() or target.is_symlink()):
|
|
378
|
+
remove_path(target)
|
|
379
|
+
if target in backed_up and (backup.exists() or backup.is_symlink()):
|
|
380
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
381
|
+
os.replace(backup, target)
|
|
382
|
+
except Exception as rollback_exc:
|
|
383
|
+
rollback_errors.append("%s: %s" % (target, rollback_exc))
|
|
384
|
+
for directory in reversed(created_dirs):
|
|
385
|
+
try:
|
|
386
|
+
directory.rmdir()
|
|
387
|
+
except OSError:
|
|
388
|
+
pass
|
|
389
|
+
if rollback_errors:
|
|
390
|
+
cleanup_transaction = False
|
|
391
|
+
raise InstallError(
|
|
392
|
+
"[install-uscha] Claude rollback incomplete; recovery retained at %s (%s)"
|
|
393
|
+
% (transaction, "; ".join(rollback_errors))
|
|
394
|
+
) from exc
|
|
395
|
+
raise
|
|
396
|
+
finally:
|
|
397
|
+
if cleanup_transaction and (transaction.exists() or transaction.is_symlink()):
|
|
398
|
+
remove_path(transaction)
|
|
399
|
+
if not home_existed:
|
|
400
|
+
try:
|
|
401
|
+
home.rmdir()
|
|
402
|
+
except OSError:
|
|
403
|
+
pass
|
|
404
|
+
return root
|
|
405
|
+
|
|
406
|
+
def marker_ok(path, target):
|
|
407
|
+
if not path.is_file():
|
|
408
|
+
return False, None
|
|
409
|
+
try:
|
|
410
|
+
data = load_json(path, "install marker")
|
|
411
|
+
except InstallError:
|
|
412
|
+
return False, None
|
|
413
|
+
return data.get("name") == PLUGIN_NAME and data.get("target") == target and data.get("version") == source_version(), data.get("version")
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def target_status(home, target):
|
|
201
417
|
if target == "codex":
|
|
202
418
|
root = home / "plugins" / PLUGIN_NAME
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
419
|
+
skills_root, marker_path = root / "skills", root / "uscha-install.json"
|
|
420
|
+
manifest_path, market_path = root / ".codex-plugin" / "plugin.json", home / ".agents" / "plugins" / "marketplace.json"
|
|
421
|
+
skills_present = [skill for skill in SKILLS if (skills_root / skill / "SKILL.md").is_file()]
|
|
422
|
+
try:
|
|
423
|
+
manifest = load_json(manifest_path, "plugin manifest")
|
|
424
|
+
manifest_ok = manifest.get("name") == PLUGIN_NAME and manifest.get("version") == source_version()
|
|
425
|
+
except InstallError:
|
|
426
|
+
manifest_ok = False
|
|
427
|
+
try:
|
|
428
|
+
market = prepared_marketplace(market_path)
|
|
429
|
+
marketplace_ok = market == load_json(market_path, "marketplace.json") and any(p == marketplace_entry() for p in market["plugins"])
|
|
430
|
+
except InstallError:
|
|
431
|
+
marketplace_ok = False
|
|
432
|
+
checks = {"skills_present": skills_present, "manifest": manifest_ok, "marketplace_registered": marketplace_ok}
|
|
207
433
|
else:
|
|
208
434
|
root = home / ".claude"
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
marker_path = root / "uscha-install.json"
|
|
213
|
-
installed = engine.exists() and manifest.exists()
|
|
214
|
-
installed_version = None
|
|
215
|
-
if marker_path.exists():
|
|
435
|
+
skills_root, marker_path = root / "skills", root / "uscha-install.json"
|
|
436
|
+
hook, settings_path = root / "hooks" / HOOK_NAME, root / "settings.json"
|
|
437
|
+
skills_present = [skill for skill in SKILLS if (skills_root / skill / "SKILL.md").is_file()]
|
|
216
438
|
try:
|
|
217
|
-
|
|
218
|
-
except
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
}
|
|
439
|
+
settings_ok = hook_registered(load_json(settings_path, "Claude settings.json"), hook_command(hook))
|
|
440
|
+
except InstallError:
|
|
441
|
+
settings_ok = False
|
|
442
|
+
checks = {"skills_present": skills_present, "hook_file": hook.is_file(), "hook_registered": settings_ok}
|
|
443
|
+
marker_valid, installed_version = marker_ok(marker_path, target)
|
|
444
|
+
checks["marker"] = marker_valid
|
|
445
|
+
checks["source_version_match"] = installed_version == source_version()
|
|
446
|
+
healthy = len(skills_present) == len(SKILLS) and all(value is True for key, value in checks.items() if key != "skills_present")
|
|
447
|
+
return {"healthy": healthy, "installed": healthy, "install_root": str(root), "installed_version": installed_version,
|
|
448
|
+
"source_version": source_version(), "version_match": installed_version == source_version(), "checks": checks,
|
|
449
|
+
"content_integrity": "not measured; checks verify presence, registration, marker, and version only"}
|
|
229
450
|
|
|
230
451
|
|
|
231
452
|
def cmd_version(args):
|
|
232
|
-
|
|
233
|
-
emit(out, args.json)
|
|
453
|
+
emit({"name": PLUGIN_NAME, "source_version": source_version(), "targets": list(TARGETS)}, args.json)
|
|
234
454
|
|
|
235
455
|
|
|
236
456
|
def cmd_install(args):
|
|
237
|
-
home = home_path(args)
|
|
238
|
-
plan = Plan(args.dry_run)
|
|
239
|
-
installed = {}
|
|
457
|
+
home, operations, installed = home_path(args), [], {}
|
|
240
458
|
for target in selected_targets(args.target):
|
|
241
|
-
if target == "codex"
|
|
242
|
-
|
|
243
|
-
else:
|
|
244
|
-
installed[target] = str(install_claude(plan, home, args.mode))
|
|
245
|
-
out = {"status": "planned" if args.dry_run else "installed", "dry_run": args.dry_run,
|
|
246
|
-
"source_version": source_version(), "home": str(home), "installed": installed,
|
|
247
|
-
"operations": plan.operations,
|
|
248
|
-
"next": next_steps(args.target)}
|
|
249
|
-
emit(out, args.json)
|
|
459
|
+
installed[target] = str(install_codex(home, args.mode, args.dry_run, operations) if target == "codex" else install_claude(home, args.mode, args.dry_run, operations))
|
|
460
|
+
emit({"status": "planned" if args.dry_run else "installed", "dry_run": args.dry_run, "source_version": source_version(), "home": str(home), "installed": installed, "operations": operations, "next": next_steps(args.target)}, args.json)
|
|
250
461
|
|
|
251
462
|
|
|
252
463
|
def cmd_doctor(args):
|
|
253
464
|
home = home_path(args)
|
|
254
|
-
targets = {
|
|
255
|
-
ok = all(
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
if not ok and not args.json:
|
|
260
|
-
sys.exit(1)
|
|
465
|
+
targets = {target: target_status(home, target) for target in selected_targets(args.target)}
|
|
466
|
+
ok = all(status["healthy"] for status in targets.values())
|
|
467
|
+
emit({"ok": ok, "source_version": source_version(), "home": str(home), "python": sys.version.split()[0], "targets": targets}, args.json)
|
|
468
|
+
if not ok:
|
|
469
|
+
raise SystemExit(1)
|
|
261
470
|
|
|
262
471
|
|
|
263
472
|
def cmd_init(args):
|
|
264
|
-
repo = Path(args.repo).expanduser().resolve()
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
for
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
473
|
+
repo, operations, conflicts = Path(args.repo).expanduser().resolve(), [], []
|
|
474
|
+
sources = [(KIT_ROOT / "uscha.config.json", repo / "uscha.config.json")] + [(KIT_ROOT / "templates" / name, repo / name) for name in ("CLAUDE.md", "CONSTITUTION.md", ".gitattributes")]
|
|
475
|
+
copies = []
|
|
476
|
+
for source, target in sources:
|
|
477
|
+
if not source.is_file():
|
|
478
|
+
raise InstallError("[install-uscha] init source missing: %s" % source)
|
|
479
|
+
if target.is_symlink():
|
|
480
|
+
raise InstallError("[install-uscha] init target must not be a symlink: %s" % target)
|
|
481
|
+
if target.exists() and not target.is_file():
|
|
482
|
+
raise InstallError("[install-uscha] init target must be a file: %s" % target)
|
|
483
|
+
if target.exists() and target.read_bytes() != source.read_bytes() and not args.force:
|
|
484
|
+
conflicts.append({"path": str(target), "source": str(source)})
|
|
485
|
+
operations.append({"action": "conflict", "path": str(target), "source": str(source)})
|
|
486
|
+
elif target.exists() and target.read_bytes() == source.read_bytes():
|
|
487
|
+
operations.append({"action": "unchanged", "path": str(target)})
|
|
488
|
+
else:
|
|
489
|
+
operations.append({"action": "copy-file", "path": str(target), "source": str(source), "note": "force" if target.exists() else None})
|
|
490
|
+
copies.append((source, target))
|
|
491
|
+
conflicted = bool(conflicts)
|
|
492
|
+
if not conflicted and not args.dry_run:
|
|
493
|
+
for source, target in copies:
|
|
494
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
495
|
+
shutil.copy2(source, target)
|
|
496
|
+
emit({"status": "conflicts" if conflicted else ("planned" if args.dry_run else "initialized"), "dry_run": args.dry_run, "repo": str(repo), "operations": operations, "conflicts": conflicts}, args.json)
|
|
497
|
+
if conflicted:
|
|
498
|
+
raise SystemExit(1)
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _open_best_effort(path):
|
|
502
|
+
"""Open the rendered file in the default browser; never fail (headless/CI)."""
|
|
503
|
+
try:
|
|
504
|
+
if sys.platform.startswith("win"):
|
|
505
|
+
os.startfile(path) # type: ignore[attr-defined] # Windows-only
|
|
506
|
+
elif sys.platform == "darwin":
|
|
507
|
+
subprocess.Popen(["open", path])
|
|
508
|
+
else:
|
|
509
|
+
subprocess.Popen(["xdg-open", path])
|
|
510
|
+
except Exception:
|
|
511
|
+
pass # the renderer already printed the absolute path
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def _mirador_render_path():
|
|
515
|
+
"""The mirador renderer inside this kit (either skill-tree layout)."""
|
|
516
|
+
for rel in (("skills", "uscha-mirador", "mirador-render.py"),
|
|
517
|
+
(".claude", "skills", "uscha-mirador", "mirador-render.py")):
|
|
518
|
+
candidate = KIT_ROOT.joinpath(*rel)
|
|
519
|
+
if candidate.is_file():
|
|
520
|
+
return candidate
|
|
521
|
+
return None
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def cmd_mirador(args):
|
|
525
|
+
"""`uscha mirador` — one command to render + open the project's dashboard.
|
|
526
|
+
No paths, no python: the renderer self-resolves its engine/template siblings, and the
|
|
527
|
+
ledger defaults to the QA-LEDGER.json convention in the current directory."""
|
|
528
|
+
render = _mirador_render_path()
|
|
529
|
+
if render is None:
|
|
530
|
+
print("[uscha mirador] mirador-render.py not found in the kit", file=sys.stderr)
|
|
531
|
+
raise SystemExit(1)
|
|
532
|
+
if not Path(args.ledger).is_file():
|
|
533
|
+
print("[uscha mirador] ledger '%s' not found here -- run the dev loop first, or pass --ledger"
|
|
534
|
+
% args.ledger, file=sys.stderr)
|
|
535
|
+
raise SystemExit(1)
|
|
536
|
+
base = [sys.executable, str(render), "--ledger", args.ledger, "--out", args.out]
|
|
537
|
+
if not args.watch:
|
|
538
|
+
# one-shot: the renderer writes the file and opens it (unless --no-open)
|
|
539
|
+
rc = subprocess.call(base + (["--no-open"] if args.no_open else []))
|
|
540
|
+
if rc:
|
|
541
|
+
raise SystemExit(rc)
|
|
542
|
+
return
|
|
543
|
+
# live view: the page carries its own meta-refresh and reloads itself in ONE tab, so we
|
|
544
|
+
# open once here and re-render quietly forever -- never re-open (no browser-tab spam).
|
|
545
|
+
rc = subprocess.call(base + ["--refresh", str(args.interval), "--no-open"])
|
|
546
|
+
if rc:
|
|
547
|
+
raise SystemExit(rc)
|
|
548
|
+
out_abs = os.path.abspath(args.out)
|
|
549
|
+
if not args.no_open:
|
|
550
|
+
_open_best_effort(out_abs)
|
|
551
|
+
print("[uscha mirador] live view every %ss at %s -- Ctrl-C to stop" % (args.interval, out_abs))
|
|
552
|
+
try:
|
|
553
|
+
while True:
|
|
554
|
+
time.sleep(args.interval)
|
|
555
|
+
subprocess.call(base + ["--refresh", str(args.interval), "--no-open"])
|
|
556
|
+
except KeyboardInterrupt:
|
|
557
|
+
print("\n[uscha mirador] stopped")
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def next_steps(target):
|
|
277
561
|
steps = []
|
|
278
|
-
if target in ("codex", "both"):
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
steps.append("Claude: restart Claude Code so global skills/hooks are reloaded.")
|
|
282
|
-
steps.append("Run: python install-uscha.py doctor --target %s" % target)
|
|
283
|
-
return steps
|
|
562
|
+
if target in ("codex", "both"): steps.append("Codex: restart or open a new thread, then install/use uscha from the Personal marketplace if needed.")
|
|
563
|
+
if target in ("claude", "both"): steps.append("Claude: restart Claude Code so global skills/hooks are reloaded.")
|
|
564
|
+
return steps + ["Run: python install-uscha.py doctor --target %s" % target]
|
|
284
565
|
|
|
285
566
|
|
|
286
|
-
def emit(data
|
|
567
|
+
def emit(data, as_json):
|
|
287
568
|
if as_json:
|
|
288
|
-
print(json.dumps(data, indent=2, ensure_ascii=False))
|
|
289
|
-
return
|
|
569
|
+
print(json.dumps(data, indent=2, ensure_ascii=False)); return
|
|
290
570
|
if "targets" in data and isinstance(data["targets"], dict):
|
|
291
571
|
print("Uscha %s" % data.get("source_version"))
|
|
292
|
-
for name,
|
|
293
|
-
mark = "OK" if st["installed"] and st["version_match"] else "WARN"
|
|
294
|
-
print(" %s: %s installed=%s version=%s" %
|
|
295
|
-
(mark, name, st["installed"], st.get("installed_version")))
|
|
572
|
+
for name, status in data["targets"].items(): print(" %s %s" % ("OK" if status["healthy"] else "WARN", name))
|
|
296
573
|
elif "operations" in data:
|
|
297
|
-
print("Uscha %s: %s (%s operations)" %
|
|
298
|
-
|
|
299
|
-
for op in data["operations"][:20]:
|
|
300
|
-
print(" - {action}: {path}".format(**op))
|
|
301
|
-
if len(data["operations"]) > 20:
|
|
302
|
-
print(" ... %d more" % (len(data["operations"]) - 20))
|
|
574
|
+
print("Uscha %s: %s (%s operations)" % (data.get("source_version", source_version()), data["status"], len(data["operations"])))
|
|
575
|
+
for operation in data["operations"][:20]: print(" - {action}: {path}".format(**operation))
|
|
303
576
|
else:
|
|
304
577
|
print("Uscha %s" % data.get("source_version", source_version()))
|
|
305
578
|
|
|
306
579
|
|
|
307
580
|
def build_parser():
|
|
308
|
-
|
|
309
|
-
sub =
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
pd.add_argument("--home", default=None)
|
|
326
|
-
pd.add_argument("--json", action="store_true")
|
|
327
|
-
pd.set_defaults(func=cmd_doctor)
|
|
328
|
-
|
|
329
|
-
pn = sub.add_parser("init", help="prepare a repo with Uscha config/templates")
|
|
330
|
-
pn.add_argument("--repo", default=".")
|
|
331
|
-
pn.add_argument("--dry-run", action="store_true")
|
|
332
|
-
pn.add_argument("--json", action="store_true")
|
|
333
|
-
pn.set_defaults(func=cmd_init)
|
|
334
|
-
return p
|
|
581
|
+
parser = argparse.ArgumentParser(description="Install/update Uscha for Codex and Claude machines")
|
|
582
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
583
|
+
version = sub.add_parser("version", help="show source version and supported targets"); version.add_argument("--json", action="store_true"); version.set_defaults(func=cmd_version)
|
|
584
|
+
install = sub.add_parser("install", help="install Uscha globally for a machine")
|
|
585
|
+
install.add_argument("--target", choices=["codex", "claude", "both"], default="both"); install.add_argument("--mode", choices=["copy", "link"], default="copy"); install.add_argument("--home"); install.add_argument("--dry-run", action="store_true"); install.add_argument("--json", action="store_true"); install.set_defaults(func=cmd_install)
|
|
586
|
+
doctor = sub.add_parser("doctor", help="check installed Uscha presence, registrations, and version drift")
|
|
587
|
+
doctor.add_argument("--target", choices=["codex", "claude", "both"], default="both"); doctor.add_argument("--home"); doctor.add_argument("--json", action="store_true"); doctor.set_defaults(func=cmd_doctor)
|
|
588
|
+
init = sub.add_parser("init", help="prepare a repo with Uscha config/templates")
|
|
589
|
+
init.add_argument("--repo", default="."); init.add_argument("--force", action="store_true", help="replace differing init files deliberately"); init.add_argument("--dry-run", action="store_true"); init.add_argument("--json", action="store_true"); init.set_defaults(func=cmd_init)
|
|
590
|
+
mirador = sub.add_parser("mirador", help="render + open the project's mirador dashboard from QA-LEDGER.json")
|
|
591
|
+
mirador.add_argument("--ledger", default="QA-LEDGER.json", help="ledger to read (default: the QA-LEDGER.json convention)")
|
|
592
|
+
mirador.add_argument("--out", default="mirador.html")
|
|
593
|
+
mirador.add_argument("--watch", action="store_true", help="live second-screen view: re-render every --interval seconds")
|
|
594
|
+
mirador.add_argument("--interval", type=int, default=30)
|
|
595
|
+
mirador.add_argument("--no-open", action="store_true", help="write the file but do not open a browser")
|
|
596
|
+
mirador.set_defaults(func=cmd_mirador)
|
|
597
|
+
return parser
|
|
335
598
|
|
|
336
599
|
|
|
337
600
|
def main(argv=None):
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
601
|
+
args = build_parser().parse_args(argv)
|
|
602
|
+
try:
|
|
603
|
+
args.func(args)
|
|
604
|
+
except InstallError as exc:
|
|
605
|
+
print(str(exc), file=sys.stderr)
|
|
606
|
+
return 1
|
|
607
|
+
return 0
|
|
341
608
|
|
|
342
609
|
|
|
343
610
|
if __name__ == "__main__":
|
|
344
|
-
main()
|
|
611
|
+
sys.exit(main())
|