@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.
Files changed (39) hide show
  1. package/README.md +6 -6
  2. package/bin/uscha.js +19 -7
  3. package/package.json +1 -1
  4. package/uscha-kit/.claude/skills/uscha-adr-refine/SKILL.md +2 -2
  5. package/uscha-kit/.claude/skills/uscha-devloop/SKILL.md +3 -1
  6. package/uscha-kit/.claude/skills/uscha-devloop/qa_ledger.py +729 -155
  7. package/uscha-kit/.claude/skills/uscha-mirador/SKILL.md +22 -7
  8. package/uscha-kit/.claude/skills/uscha-mirador/mirador-render.py +44 -5
  9. package/uscha-kit/.claude/skills/uscha-mirador/mirador-watch.ps1 +1 -1
  10. package/uscha-kit/.claude/skills/uscha-mirador/mirador-watch.sh +1 -1
  11. package/uscha-kit/.claude/skills/uscha-mirador/mirador.template.html +666 -586
  12. package/uscha-kit/.claude-plugin/plugin.json +1 -1
  13. package/uscha-kit/.codex-plugin/plugin.json +1 -1
  14. package/uscha-kit/CHANGELOG-1.41.0.md +18 -0
  15. package/uscha-kit/CHANGELOG-1.41.1.md +53 -0
  16. package/uscha-kit/CHANGELOG-1.41.2.md +34 -0
  17. package/uscha-kit/CHANGELOG-1.41.3.md +30 -0
  18. package/uscha-kit/CHANGELOG-1.42.0.md +41 -0
  19. package/uscha-kit/CHANGELOG-1.43.0.md +37 -0
  20. package/uscha-kit/INSTALL.md +120 -101
  21. package/uscha-kit/README.md +24 -13
  22. package/uscha-kit/VERSION +1 -1
  23. package/uscha-kit/WORKBENCH.md +19 -5
  24. package/uscha-kit/hooks/block-approved-writes.py +25 -0
  25. package/uscha-kit/install-uscha.py +534 -267
  26. package/uscha-kit/skills/uscha-adr-refine/SKILL.md +2 -2
  27. package/uscha-kit/skills/uscha-devloop/SKILL.md +3 -1
  28. package/uscha-kit/skills/uscha-devloop/qa_ledger.py +729 -155
  29. package/uscha-kit/skills/uscha-mirador/SKILL.md +22 -7
  30. package/uscha-kit/skills/uscha-mirador/mirador-render.py +44 -5
  31. package/uscha-kit/skills/uscha-mirador/mirador-watch.ps1 +1 -1
  32. package/uscha-kit/skills/uscha-mirador/mirador-watch.sh +1 -1
  33. package/uscha-kit/skills/uscha-mirador/mirador.template.html +666 -586
  34. package/uscha-kit/templates/CONSTITUTION.md +4 -4
  35. package/uscha-kit/templates/docs/adr/README.md +19 -19
  36. package/uscha-kit/tests/ledger-integrity-regressions.py +136 -0
  37. package/uscha-kit/tests/smoke-engine.sh +1312 -29
  38. package/uscha-kit/uscha.config.json +1 -1
  39. 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
- "uscha-discovery",
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
- def now_iso() -> str:
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) -> Path:
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: str) -> list[str]:
42
+ def selected_targets(value):
50
43
  return list(TARGETS) if value == "both" else [value]
51
44
 
52
45
 
53
- class Plan:
54
- def __init__(self, dry_run: bool):
55
- self.dry_run = dry_run
56
- self.operations: list[dict] = []
57
-
58
- def add(self, action: str, path: Path, source: Path | None = None, note: str | None = None):
59
- row = {"action": action, "path": str(path)}
60
- if source is not None:
61
- row["source"] = str(source)
62
- if note:
63
- row["note"] = note
64
- self.operations.append(row)
65
-
66
- def ensure_dir(self, path: Path):
67
- self.add("mkdir", path)
68
- if not self.dry_run:
69
- path.mkdir(parents=True, exist_ok=True)
70
-
71
- def write_json(self, path: Path, data: dict):
72
- self.add("write-json", path)
73
- if not self.dry_run:
74
- path.parent.mkdir(parents=True, exist_ok=True)
75
- path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
76
-
77
- def copy_file(self, src: Path, dst: Path):
78
- self.add("copy-file", dst, src)
79
- if not self.dry_run:
80
- dst.parent.mkdir(parents=True, exist_ok=True)
81
- shutil.copy2(src, dst)
82
-
83
- def copy_dir(self, src: Path, dst: Path, mode: str):
84
- self.add("copy-dir" if mode == "copy" else "link-dir", dst, src)
85
- if self.dry_run:
86
- return
87
- if dst.exists() or dst.is_symlink():
88
- if dst.is_symlink() or dst.is_file():
89
- dst.unlink()
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: Path, dst: Path):
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
- cmd = ["cmd", "/c", "mklink", "/J", str(dst), str(src)]
103
- res = subprocess.run(cmd, text=True, capture_output=True)
104
- if res.returncode != 0:
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 plugin_manifest() -> dict:
112
- return {
113
- "name": PLUGIN_NAME,
114
- "version": source_version(),
115
- "description": "Uscha spec-driven development methodology for coding agents.",
116
- "author": {"name": "Andres Massello", "url": "https://github.com/andresmassello"},
117
- "homepage": "https://github.com/andresmassello/uscha",
118
- "repository": "https://github.com/andresmassello/uscha",
119
- "license": "MIT",
120
- "keywords": ["spec-driven", "qa", "gates", "golden-testing", "readiness"],
121
- "skills": "./skills/",
122
- "interface": {
123
- "displayName": "Uscha",
124
- "shortDescription": "Spec-driven development with fact gates and readiness.",
125
- "longDescription": "Uscha installs discovery, ADR, characterization, devloop, rubric, sysdoc and Mirador skills plus the qa_ledger.py evidence engine.",
126
- "developerName": "Andres Massello",
127
- "category": "Productivity",
128
- "capabilities": ["Write", "Interactive"],
129
- "defaultPrompt": [
130
- "Run Uscha discovery for this feature.",
131
- "Use Uscha devloop to verify this change.",
132
- "Show the Uscha readiness for this repo."
133
- ],
134
- "brandColor": "#7C3AED"
135
- }
136
- }
137
-
138
-
139
- def marker(target: str, install_root: Path, mode: str) -> dict:
140
- return {
141
- "name": PLUGIN_NAME,
142
- "target": target,
143
- "version": source_version(),
144
- "mode": mode,
145
- "installed_at": now_iso(),
146
- "source": str(KIT_ROOT),
147
- "install_root": str(install_root),
148
- }
149
-
150
-
151
- def install_codex(plan: Plan, home: Path, mode: str):
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
- plan.ensure_dir(plugin_root / ".codex-plugin")
154
- plan.write_json(plugin_root / ".codex-plugin" / "plugin.json", plugin_manifest())
155
- skills_dst = plugin_root / "skills"
156
- plan.ensure_dir(skills_dst)
157
- for skill in SKILLS:
158
- plan.copy_dir(KIT_ROOT / ".claude" / "skills" / skill, skills_dst / skill, mode)
159
- plan.copy_file(KIT_ROOT / "VERSION", plugin_root / "VERSION")
160
- plan.copy_file(KIT_ROOT / "uscha.config.json", plugin_root / "uscha.config.json")
161
- plan.write_json(plugin_root / "uscha-install.json", marker("codex", plugin_root, mode))
162
- write_marketplace(plan, home, plugin_root)
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 write_marketplace(plan: Plan, home: Path, plugin_root: Path):
167
- market = home / ".agents" / "plugins" / "marketplace.json"
168
- data = {"name": "personal", "interface": {"displayName": "Personal"}, "plugins": []}
169
- if market.exists() and not plan.dry_run:
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
- data = json.loads(market.read_text(encoding="utf-8"))
172
- except json.JSONDecodeError:
173
- raise SystemExit("[install-uscha] marketplace.json is invalid: %s" % market)
174
- plugins = [p for p in data.get("plugins", []) if p.get("name") != PLUGIN_NAME]
175
- plugins.append({
176
- "name": PLUGIN_NAME,
177
- "source": {"source": "local", "path": "./plugins/uscha"},
178
- "policy": {"installation": "AVAILABLE", "authentication": "ON_INSTALL"},
179
- "category": "Productivity",
180
- })
181
- data["plugins"] = plugins
182
- data.setdefault("name", "personal")
183
- data.setdefault("interface", {"displayName": "Personal"})
184
- plan.write_json(market, data)
185
-
186
-
187
- def install_claude(plan: Plan, home: Path, mode: str):
188
- skills_root = home / ".claude" / "skills"
189
- plan.ensure_dir(skills_root)
190
- for skill in SKILLS:
191
- plan.copy_dir(KIT_ROOT / ".claude" / "skills" / skill, skills_root / skill, mode)
192
- hooks_root = home / ".claude" / "hooks"
193
- plan.ensure_dir(hooks_root)
194
- plan.copy_file(KIT_ROOT / "hooks" / "block-approved-writes.ps1",
195
- hooks_root / "block-approved-writes.ps1")
196
- plan.write_json(home / ".claude" / "uscha-install.json", marker("claude", home / ".claude", mode))
197
- return home / ".claude"
198
-
199
-
200
- def target_status(home: Path, target: str) -> dict:
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
- manifest = root / ".codex-plugin" / "plugin.json"
204
- engine = root / "skills" / "uscha-devloop" / "qa_ledger.py"
205
- market = home / ".agents" / "plugins" / "marketplace.json"
206
- marker_path = root / "uscha-install.json"
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
- manifest = root / "uscha-install.json"
210
- engine = root / "skills" / "uscha-devloop" / "qa_ledger.py"
211
- market = root / "hooks" / "block-approved-writes.ps1"
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
- installed_version = json.loads(marker_path.read_text(encoding="utf-8")).get("version")
218
- except json.JSONDecodeError:
219
- installed_version = None
220
- return {
221
- "installed": installed,
222
- "install_root": str(root),
223
- "engine": str(engine),
224
- "marketplace_or_hook": str(market),
225
- "installed_version": installed_version,
226
- "source_version": source_version(),
227
- "version_match": installed_version == source_version(),
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
- out = {"name": PLUGIN_NAME, "source_version": source_version(), "targets": list(TARGETS)}
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
- installed[target] = str(install_codex(plan, home, args.mode))
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 = {t: target_status(home, t) for t in selected_targets(args.target)}
255
- ok = all(v["installed"] and v["version_match"] for v in targets.values())
256
- out = {"ok": ok, "source_version": source_version(), "home": str(home),
257
- "python": sys.version.split()[0], "targets": targets}
258
- emit(out, args.json)
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
- plan = Plan(args.dry_run)
266
- plan.copy_file(KIT_ROOT / "uscha.config.json", repo / "uscha.config.json")
267
- for name in ("CLAUDE.md", "CONSTITUTION.md", ".gitattributes"):
268
- src = KIT_ROOT / "templates" / name
269
- if src.exists():
270
- plan.copy_file(src, repo / name)
271
- out = {"status": "planned" if args.dry_run else "initialized", "dry_run": args.dry_run,
272
- "repo": str(repo), "operations": plan.operations}
273
- emit(out, args.json)
274
-
275
-
276
- def next_steps(target: str) -> list[str]:
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
- steps.append("Codex: restart or open a new thread, then install/use uscha from the Personal marketplace if needed.")
280
- if target in ("claude", "both"):
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: dict, as_json: bool):
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, st in data["targets"].items():
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
- (data.get("source_version", source_version()), data["status"], len(data["operations"])))
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
- p = argparse.ArgumentParser(description="Install/update Uscha for Codex and Claude machines")
309
- sub = p.add_subparsers(dest="cmd", required=True)
310
-
311
- pv = sub.add_parser("version", help="show source version and supported targets")
312
- pv.add_argument("--json", action="store_true")
313
- pv.set_defaults(func=cmd_version)
314
-
315
- pi = sub.add_parser("install", help="install Uscha globally for a machine")
316
- pi.add_argument("--target", choices=["codex", "claude", "both"], default="both")
317
- pi.add_argument("--mode", choices=["copy", "link"], default="copy")
318
- pi.add_argument("--home", default=None, help="override home dir; useful for tests")
319
- pi.add_argument("--dry-run", action="store_true")
320
- pi.add_argument("--json", action="store_true")
321
- pi.set_defaults(func=cmd_install)
322
-
323
- pd = sub.add_parser("doctor", help="check installed Uscha and version drift")
324
- pd.add_argument("--target", choices=["codex", "claude", "both"], default="both")
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
- parser = build_parser()
339
- args = parser.parse_args(argv)
340
- args.func(args)
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())