@andresmassello/uscha 1.55.0 → 1.56.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 +33 -3
- package/package.json +5 -1
- package/uscha-kit/.claude-plugin/plugin.json +1 -1
- package/uscha-kit/.codex-plugin/plugin.json +1 -1
- package/uscha-kit/README.md +3 -3
- package/uscha-kit/VERSION +1 -1
- package/uscha-kit/hooks/block-approved-writes.py +119 -13
- package/uscha-kit/install-uscha.py +152 -1
- package/uscha-kit/uscha.config.json +1 -1
package/README.md
CHANGED
|
@@ -40,8 +40,8 @@ Requires **Python 3.8+** on the machine (the engine is Python stdlib — no pip
|
|
|
40
40
|
runtime dependencies). The npm package is a thin router; the canonical installer is
|
|
41
41
|
`uscha-kit/install-uscha.py`.
|
|
42
42
|
|
|
43
|
-
**Kit v1.
|
|
44
|
-
[changelog](https://github.com/andresmassello/uscha/blob/main/uscha-kit/CHANGELOG-1.
|
|
43
|
+
**Kit v1.56.0** <!-- uscha:version --> · [uscha.dev](https://uscha.dev) ·
|
|
44
|
+
[changelog](https://github.com/andresmassello/uscha/blob/main/uscha-kit/CHANGELOG-1.56.0.md)
|
|
45
45
|
(the per-release changelogs live in the repo, not in the npm tarball)
|
|
46
46
|
|
|
47
47
|
---
|
|
@@ -80,6 +80,32 @@ and see which file, which test, and when.
|
|
|
80
80
|
evidence from **11 language stacks** — maven, gradle, ant, python, node, go, rust, dotnet,
|
|
81
81
|
cpp, swift, flutter — and computes a readiness score with hard caps and visible provenance.
|
|
82
82
|
|
|
83
|
+
## Compatibility matrix
|
|
84
|
+
|
|
85
|
+
Generated from `TARGETS`/`SKILL_ROOTS` in the installer, so it cannot drift from the code.
|
|
86
|
+
|
|
87
|
+
| target | agent | installs to | INV-GOLDEN-01 | exercised against a real agent |
|
|
88
|
+
|---|---|---|---|---|
|
|
89
|
+
| `codex` | Codex | `~/plugins/uscha` | advisory | **yes** |
|
|
90
|
+
| `claude` | Claude Code | `~/.claude/skills` | **enforced** (PreToolUse hook, best-effort) | **yes** |
|
|
91
|
+
| `pi` | pi (Earendil) | `~/.agents/skills` | advisory | no — placement + read-back only |
|
|
92
|
+
| `cursor` | Cursor | `~/.cursor/skills` | advisory | no — placement + read-back only |
|
|
93
|
+
| `copilot` | VS Code / Copilot | `~/.copilot/skills` | advisory | no — placement + read-back only |
|
|
94
|
+
| `gemini` | Gemini CLI | `~/.gemini/skills` | advisory | no — placement + read-back only |
|
|
95
|
+
| `cline` | Cline | `~/.cline/skills` | advisory | no — placement + read-back only |
|
|
96
|
+
|
|
97
|
+
**"Exercised" is the column that matters.** For every target but Claude Code and Codex, what
|
|
98
|
+
is measured is that the nine skills land where that agent documents reading them and that
|
|
99
|
+
`doctor` reads them back — *that they load is a documented expectation, not a measurement.*
|
|
100
|
+
INV-GOLDEN-01 is mechanically attempted only where a blocking pre-tool hook exists; everywhere
|
|
101
|
+
else `doctor` reports `advisory` rather than implying a guard it cannot see.
|
|
102
|
+
|
|
103
|
+
| OS | how it is verified | status |
|
|
104
|
+
|---|---|---|
|
|
105
|
+
| Linux | CI matrix (py3.8 + py3.13) + local WSL | **measured** |
|
|
106
|
+
| Windows | CI matrix (py3.8 + py3.13) + native local | **measured** |
|
|
107
|
+
| macOS | CI matrix (py3.8 + py3.13), real runners | **measured** |
|
|
108
|
+
|
|
83
109
|
## The loop, in short
|
|
84
110
|
|
|
85
111
|
1. **Model first.** `/uscha-discovery` (new) or `/uscha-adr-refine` (known feature) writes
|
|
@@ -135,7 +161,11 @@ audits/ # adversarial audit outputs
|
|
|
135
161
|
|
|
136
162
|
The rules are in [`CLAUDE.md`](CLAUDE.md). The short version: no doc may claim what the
|
|
137
163
|
engine does not do; the ES and EN twins travel together; every engine change carries a
|
|
138
|
-
smoke test; and the agent
|
|
164
|
+
smoke test; and a PreToolUse hook stops the agent from writing a `.approved` golden.
|
|
165
|
+
**Scoped honestly**: that hook is a *best-effort* guard and it is registered on the Claude
|
|
166
|
+
target only — it inspects a tool call as TEXT, so an indirect write (a script that assembles
|
|
167
|
+
the filename, a symlink) gets through, and every other target reports `golden_guard:
|
|
168
|
+
advisory`. The MEASURED control is `golden-diff`, which compares bytes.
|
|
139
169
|
|
|
140
170
|
## History
|
|
141
171
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@andresmassello/uscha",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.56.0",
|
|
4
4
|
"description": "Spec-driven development for LLM coding agents: 9 skills + a stdlib evidence engine. Facts block, guesses advise; the human approves.",
|
|
5
|
+
"author": {
|
|
6
|
+
"name": "Andres Massello",
|
|
7
|
+
"url": "https://github.com/andresmassello"
|
|
8
|
+
},
|
|
5
9
|
"bin": {
|
|
6
10
|
"uscha": "bin/uscha.js",
|
|
7
11
|
"uscha-kit": "bin/uscha.js"
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
|
|
3
3
|
"name": "uscha",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.56.0",
|
|
5
5
|
"displayName": "Uscha",
|
|
6
6
|
"description": "Spec-driven development for LLM coding agents: 9 skills (discovery, adr-refine, reverse-discovery, characterize, devloop, sysdoc, rubric, mirador, status) + a stdlib measurement engine (qa_ledger.py, 29 subcommands + universal installer + npm/npx router). Facts block, guesses advise; the human approves.",
|
|
7
7
|
"author": {
|
package/uscha-kit/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# uscha-kit
|
|
2
2
|
|
|
3
|
-
**Kit version:** v1.
|
|
3
|
+
**Kit version:** v1.56.0 <!-- uscha:version --> · **[uscha.dev](https://uscha.dev)**
|
|
4
4
|
|
|
5
5
|
Spec-driven orchestrator + multi-repo QA for Claude Code, with a deterministic ledger.
|
|
6
6
|
**Nine skills** (`uscha-discovery`, `uscha-adr-refine`, `uscha-devloop`, `uscha-sysdoc`, `uscha-reverse-discovery`,
|
|
@@ -18,7 +18,7 @@ uscha-kit/
|
|
|
18
18
|
?? install-uscha.py # canonical installer used by npm/npx
|
|
19
19
|
├─ uscha.config.json # config: repos, thresholds, commands
|
|
20
20
|
├─ hooks/
|
|
21
|
-
│ └─ block-approved-writes.py # PreToolUse:
|
|
21
|
+
│ └─ block-approved-writes.py # PreToolUse: best-effort block on writing a golden (Claude only)
|
|
22
22
|
├─ templates/
|
|
23
23
|
│ ├─ CLAUDE.md # permanent repo protocol
|
|
24
24
|
│ ├─ CONSTITUTION.md # inviolable invariants (fill in the domain)
|
|
@@ -60,7 +60,7 @@ BEFORE touching anything.
|
|
|
60
60
|
/uscha-reverse-discovery # FACTS map of the old system (endpoints, contracts, dependencies)
|
|
61
61
|
↓
|
|
62
62
|
/uscha-characterize # runs the ORIGINAL code with a real corpus → .received → STOPS:
|
|
63
|
-
↓ # a HUMAN approves the
|
|
63
|
+
↓ # a HUMAN approves the golden (a best-effort hook discourages the agent; golden-diff measures)
|
|
64
64
|
/uscha-devloop # migrates; golden-diff byte-compares against the .approved on each pass
|
|
65
65
|
```
|
|
66
66
|
|
package/uscha-kit/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
uscha-kit 1.
|
|
1
|
+
uscha-kit 1.56.0
|
|
@@ -1,24 +1,130 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
2
|
+
"""PreToolUse hook for INV-GOLDEN-01 -- a BEST-EFFORT guard, not a sandbox.
|
|
3
|
+
|
|
4
|
+
What it does
|
|
5
|
+
------------
|
|
6
|
+
Stops the obvious ways an agent writes, renames or deletes an approved golden
|
|
7
|
+
fixture, so the human stays the only author of field truth. Reads are allowed on
|
|
8
|
+
purpose: golden-diff has to read the file it compares against.
|
|
9
|
+
|
|
10
|
+
What it CANNOT do -- read this before trusting it
|
|
11
|
+
-------------------------------------------------
|
|
12
|
+
It inspects the tool call as TEXT. It cannot know a command's real filesystem
|
|
13
|
+
effects, so any indirect write gets through: a Python or Node one-liner that
|
|
14
|
+
assembles the filename from pieces, a script whose contents it never sees, a
|
|
15
|
+
symlink pointing at the golden, a process spawned by another process. Treat this
|
|
16
|
+
as a guardrail against accident and casual shortcut -- NOT as a security boundary
|
|
17
|
+
against an adversarial agent. The measured control is `golden-diff`, which
|
|
18
|
+
compares bytes; this hook only lowers the odds of ever needing it.
|
|
19
|
+
|
|
20
|
+
It also only exists where a blocking pre-tool hook exists. `install-uscha.py`
|
|
21
|
+
registers it for the Claude target only; every Agent-Skills target
|
|
22
|
+
(codex/pi/cursor/copilot/gemini/cline) reports `golden_guard: advisory`, which is
|
|
23
|
+
the doctor telling the truth rather than implying a guard it cannot see.
|
|
24
|
+
|
|
25
|
+
Posture
|
|
26
|
+
-------
|
|
27
|
+
FAIL-CLOSED. An unparseable payload blocks instead of allowing: a guard that
|
|
28
|
+
opens when it is confused is not a guard. Matching is case-insensitive, because
|
|
29
|
+
Windows and macOS filesystems are.
|
|
30
|
+
"""
|
|
3
31
|
import json
|
|
32
|
+
import re
|
|
4
33
|
import sys
|
|
5
34
|
|
|
35
|
+
GOLDEN = ".approved"
|
|
36
|
+
|
|
37
|
+
BLOCK_MSG = (
|
|
38
|
+
"BLOCKED by INV-GOLDEN-01: the agent may not create, edit, rename or delete an "
|
|
39
|
+
"approved golden. The approved file is field truth -- a HUMAN signs it. Emit a "
|
|
40
|
+
".received instead and stop for human approval."
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
# Tools that cannot write. Reading a golden is legitimate (golden-diff does it).
|
|
44
|
+
READ_ONLY_TOOLS = {"read", "grep", "glob", "notebookread", "websearch", "webfetch",
|
|
45
|
+
"ls", "listdirectory"}
|
|
46
|
+
|
|
47
|
+
# Shell words that read without modifying. Anything NOT here is treated as a write.
|
|
48
|
+
READ_ONLY_SHELL = {
|
|
49
|
+
"cat", "less", "more", "head", "tail", "grep", "egrep", "fgrep", "rg", "ag",
|
|
50
|
+
"diff", "cmp", "wc", "md5sum", "sha1sum", "sha256sum", "shasum", "file", "stat",
|
|
51
|
+
"od", "xxd", "hexdump", "strings", "awk", "cut", "sort", "uniq", "tr", "column",
|
|
52
|
+
"echo", "printf", "test", "true", "false", "ls", "find", "basename", "dirname",
|
|
53
|
+
"git", "python", "python3", "py", "node", "bash", "sh", "env", "which", "type",
|
|
54
|
+
}
|
|
55
|
+
# ... except these, which are how a "reader" still writes.
|
|
56
|
+
WRITE_FLAGS = {"-i", "--in-place", "-o", "--output", "-w", "--write"}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _touches_golden(text):
|
|
60
|
+
return GOLDEN in str(text).lower()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _bash_writes(command):
|
|
64
|
+
"""True when a shell command plausibly WRITES a golden. Default-deny: anything
|
|
65
|
+
this cannot prove is read-only counts as a write."""
|
|
66
|
+
low = command.lower()
|
|
67
|
+
# any redirection at all in a command naming a golden -- `cat a.approved > b.approved`
|
|
68
|
+
# starts with a reader and still writes one.
|
|
69
|
+
if ">" in low:
|
|
70
|
+
return True
|
|
71
|
+
if any(w in WRITE_FLAGS for w in low.split()):
|
|
72
|
+
return True
|
|
73
|
+
# EVERY segment of the pipeline must start with a known reader. Splitting on the
|
|
74
|
+
# separators FIRST matters: an earlier version blanked them before looking, so it only
|
|
75
|
+
# ever saw the first verb and `echo x | tee y.approved` read as a plain `echo`.
|
|
76
|
+
segments = re.split(r"\|\||&&|[|;&]", low)
|
|
77
|
+
verbs = []
|
|
78
|
+
for seg in segments:
|
|
79
|
+
words = seg.split()
|
|
80
|
+
if words:
|
|
81
|
+
verbs.append(words[0].split("/")[-1].split("\\")[-1])
|
|
82
|
+
if not verbs:
|
|
83
|
+
return True
|
|
84
|
+
return not all(v in READ_ONLY_SHELL for v in verbs)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def decide(payload):
|
|
88
|
+
"""Return True to BLOCK."""
|
|
89
|
+
tool = str(payload.get("tool_name", "")).strip().lower()
|
|
90
|
+
tool_input = payload.get("tool_input")
|
|
91
|
+
if not isinstance(tool_input, dict):
|
|
92
|
+
tool_input = {}
|
|
93
|
+
if tool == "bash" or tool == "shell":
|
|
94
|
+
command = tool_input.get("command", "")
|
|
95
|
+
return _touches_golden(command) and _bash_writes(str(command))
|
|
96
|
+
if tool in READ_ONLY_TOOLS:
|
|
97
|
+
return False
|
|
98
|
+
# Any other tool: if a golden appears ANYWHERE in its arguments, block. Unknown
|
|
99
|
+
# write-capable tools must not slip through just because they are unknown.
|
|
100
|
+
def walk(v):
|
|
101
|
+
if isinstance(v, str):
|
|
102
|
+
return _touches_golden(v)
|
|
103
|
+
if isinstance(v, dict):
|
|
104
|
+
return any(walk(x) for x in v.values())
|
|
105
|
+
if isinstance(v, (list, tuple)):
|
|
106
|
+
return any(walk(x) for x in v)
|
|
107
|
+
return False
|
|
108
|
+
return walk(tool_input)
|
|
109
|
+
|
|
6
110
|
|
|
7
111
|
def main():
|
|
8
112
|
try:
|
|
9
113
|
payload = json.load(sys.stdin)
|
|
10
|
-
except
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
return
|
|
20
|
-
|
|
21
|
-
|
|
114
|
+
except Exception:
|
|
115
|
+
# FAIL-CLOSED: previously this returned 0 (allow), so a malformed payload
|
|
116
|
+
# silently disabled the guard.
|
|
117
|
+
print("BLOCKED by INV-GOLDEN-01: the hook could not read its input, so it "
|
|
118
|
+
"cannot prove this call is safe (fail-closed).", file=sys.stderr)
|
|
119
|
+
return 2
|
|
120
|
+
if not isinstance(payload, dict):
|
|
121
|
+
print("BLOCKED by INV-GOLDEN-01: unexpected hook payload shape (fail-closed).",
|
|
122
|
+
file=sys.stderr)
|
|
123
|
+
return 2
|
|
124
|
+
if decide(payload):
|
|
125
|
+
print(BLOCK_MSG, file=sys.stderr)
|
|
126
|
+
return 2
|
|
127
|
+
return 0
|
|
22
128
|
|
|
23
129
|
|
|
24
130
|
if __name__ == "__main__":
|
|
@@ -807,6 +807,141 @@ def cmd_mirador(args):
|
|
|
807
807
|
print("\n[uscha mirador] stopped")
|
|
808
808
|
|
|
809
809
|
|
|
810
|
+
def settings_without_hook(path):
|
|
811
|
+
"""Return (new_settings, removed_count): the user's settings with OUR PreToolUse entries
|
|
812
|
+
dropped and nothing else touched. A foreign hook -- including one in the same group -- is
|
|
813
|
+
preserved; an emptied group disappears rather than being left as an empty shell."""
|
|
814
|
+
if not path.exists():
|
|
815
|
+
return None, 0
|
|
816
|
+
data = load_json(path, "Claude settings.json")
|
|
817
|
+
if not isinstance(data, dict):
|
|
818
|
+
raise InstallError("[install-uscha] Claude settings.json must be an object: %s" % path)
|
|
819
|
+
hooks_data = data.get("hooks")
|
|
820
|
+
if not isinstance(hooks_data, dict):
|
|
821
|
+
return None, 0
|
|
822
|
+
groups = hooks_data.get("PreToolUse")
|
|
823
|
+
if not isinstance(groups, list):
|
|
824
|
+
return None, 0
|
|
825
|
+
removed = 0
|
|
826
|
+
new_groups = []
|
|
827
|
+
for group in groups:
|
|
828
|
+
if not isinstance(group, dict):
|
|
829
|
+
new_groups.append(group); continue
|
|
830
|
+
items = group.get("hooks")
|
|
831
|
+
if not isinstance(items, list):
|
|
832
|
+
new_groups.append(group); continue
|
|
833
|
+
keep = []
|
|
834
|
+
for item in items:
|
|
835
|
+
c = item.get("command") if isinstance(item, dict) else None
|
|
836
|
+
if isinstance(c, str) and HOOK_NAME in c:
|
|
837
|
+
removed += 1
|
|
838
|
+
else:
|
|
839
|
+
keep.append(item)
|
|
840
|
+
if keep:
|
|
841
|
+
g = dict(group); g["hooks"] = keep; new_groups.append(g)
|
|
842
|
+
elif not items:
|
|
843
|
+
new_groups.append(group) # was already empty; not ours to prune
|
|
844
|
+
if not removed:
|
|
845
|
+
return None, 0
|
|
846
|
+
result = dict(data)
|
|
847
|
+
hooks = dict(hooks_data)
|
|
848
|
+
if new_groups:
|
|
849
|
+
hooks["PreToolUse"] = new_groups
|
|
850
|
+
else:
|
|
851
|
+
hooks.pop("PreToolUse", None)
|
|
852
|
+
if hooks:
|
|
853
|
+
result["hooks"] = hooks
|
|
854
|
+
else:
|
|
855
|
+
result.pop("hooks", None)
|
|
856
|
+
return result, removed
|
|
857
|
+
|
|
858
|
+
|
|
859
|
+
def uninstall_target(target, home, dry_run, operations, force):
|
|
860
|
+
"""Remove one target. Refuses on ambiguity instead of guessing: without OUR marker there is
|
|
861
|
+
no proof the files at that root are ours, and deleting a stranger's skills would be a far
|
|
862
|
+
worse bug than leaving ours behind. --force overrides, and says what it assumed."""
|
|
863
|
+
removed, kept = [], []
|
|
864
|
+
if target == "codex":
|
|
865
|
+
root = home / "plugins" / PLUGIN_NAME
|
|
866
|
+
marker_path = root / "uscha-install.json"
|
|
867
|
+
market_path = home / ".agents" / "plugins" / "marketplace.json"
|
|
868
|
+
elif target in SKILL_ROOTS:
|
|
869
|
+
root = home.joinpath(*SKILL_ROOTS[target])
|
|
870
|
+
marker_path = root / "uscha-install.json"
|
|
871
|
+
market_path = None
|
|
872
|
+
else:
|
|
873
|
+
root = home / ".claude"
|
|
874
|
+
marker_path = root / "uscha-install.json"
|
|
875
|
+
market_path = None
|
|
876
|
+
|
|
877
|
+
valid, _ = marker_ok(marker_path, target)
|
|
878
|
+
if not valid and not force:
|
|
879
|
+
raise InstallError(
|
|
880
|
+
"[install-uscha] %s: no uscha install marker at %s -- refusing to delete files this "
|
|
881
|
+
"kit cannot prove it wrote. Re-run with --force if you are sure." % (target, marker_path))
|
|
882
|
+
|
|
883
|
+
def drop(p, why):
|
|
884
|
+
operations.append({"action": "remove", "path": str(p), "reason": why})
|
|
885
|
+
if p.exists() or p.is_symlink():
|
|
886
|
+
if not dry_run:
|
|
887
|
+
remove_path(p)
|
|
888
|
+
removed.append(str(p))
|
|
889
|
+
|
|
890
|
+
if target == "codex":
|
|
891
|
+
drop(root, "codex plugin tree (ours: marker verified)")
|
|
892
|
+
if market_path and market_path.is_file():
|
|
893
|
+
try:
|
|
894
|
+
data = load_json(market_path, "marketplace.json")
|
|
895
|
+
plugins = [p for p in data.get("plugins", []) if p != marketplace_entry()]
|
|
896
|
+
if len(plugins) != len(data.get("plugins", [])):
|
|
897
|
+
data = dict(data); data["plugins"] = plugins
|
|
898
|
+
operations.append({"action": "edit", "path": str(market_path),
|
|
899
|
+
"reason": "drop the uscha marketplace entry, keep the rest"})
|
|
900
|
+
if not dry_run:
|
|
901
|
+
atomic_json(market_path, data)
|
|
902
|
+
removed.append(str(market_path) + " (entry)")
|
|
903
|
+
else:
|
|
904
|
+
kept.append(str(market_path) + " (no uscha entry)")
|
|
905
|
+
except InstallError:
|
|
906
|
+
kept.append(str(market_path) + " (unreadable, left untouched)")
|
|
907
|
+
elif target in SKILL_ROOTS:
|
|
908
|
+
for skill in SKILLS:
|
|
909
|
+
drop(root / skill, "uscha skill")
|
|
910
|
+
drop(marker_path, "install marker")
|
|
911
|
+
else:
|
|
912
|
+
skills_root = root / "skills"
|
|
913
|
+
for skill in SKILLS:
|
|
914
|
+
drop(skills_root / skill, "uscha skill")
|
|
915
|
+
drop(root / "hooks" / HOOK_NAME, "INV-GOLDEN-01 hook")
|
|
916
|
+
drop(marker_path, "install marker")
|
|
917
|
+
settings_path = root / "settings.json"
|
|
918
|
+
try:
|
|
919
|
+
new_settings, n = settings_without_hook(settings_path)
|
|
920
|
+
except InstallError:
|
|
921
|
+
new_settings, n = None, 0
|
|
922
|
+
kept.append(str(settings_path) + " (unreadable, left untouched)")
|
|
923
|
+
if n:
|
|
924
|
+
operations.append({"action": "edit", "path": str(settings_path),
|
|
925
|
+
"reason": "drop %d uscha PreToolUse entry(ies), keep foreign hooks" % n})
|
|
926
|
+
if not dry_run:
|
|
927
|
+
atomic_json(settings_path, new_settings)
|
|
928
|
+
removed.append(str(settings_path) + " (%d hook entry)" % n)
|
|
929
|
+
else:
|
|
930
|
+
kept.append(str(settings_path) + " (no uscha hook entry)")
|
|
931
|
+
return {"removed": removed, "kept": kept}
|
|
932
|
+
|
|
933
|
+
|
|
934
|
+
def cmd_uninstall(args):
|
|
935
|
+
home, operations, result = home_path(args), [], {}
|
|
936
|
+
for target in selected_targets(args.target):
|
|
937
|
+
result[target] = uninstall_target(target, home, args.dry_run, operations, args.force)
|
|
938
|
+
emit({"status": "planned" if args.dry_run else "uninstalled", "dry_run": args.dry_run,
|
|
939
|
+
"home": str(home), "targets": result, "operations": operations,
|
|
940
|
+
"next": ["Run: python install-uscha.py doctor --target %s" % args.target,
|
|
941
|
+
"Your own files were left alone: only paths this kit wrote are removed."]},
|
|
942
|
+
args.json)
|
|
943
|
+
|
|
944
|
+
|
|
810
945
|
def next_steps(target):
|
|
811
946
|
picked = selected_targets(target) # resolves both/all so each installed target speaks
|
|
812
947
|
steps = []
|
|
@@ -826,7 +961,16 @@ def next_steps(target):
|
|
|
826
961
|
def emit(data, as_json):
|
|
827
962
|
if as_json:
|
|
828
963
|
print(json.dumps(data, indent=2, ensure_ascii=False)); return
|
|
829
|
-
|
|
964
|
+
# uninstall also reports per-target, but with removed/kept instead of health -- match on
|
|
965
|
+
# the SHAPE, not just the key name, or a payload that merely has "targets" crashes here.
|
|
966
|
+
if data.get("status") in ("uninstalled", "planned") and isinstance(data.get("targets"), dict):
|
|
967
|
+
print("Uscha uninstall%s" % (" (dry-run: nothing was touched)" if data.get("dry_run") else ""))
|
|
968
|
+
for name, res in data["targets"].items():
|
|
969
|
+
print(" %-8s removed %d, left alone %d" % (name, len(res.get("removed", [])), len(res.get("kept", []))))
|
|
970
|
+
for k in res.get("kept", []) if data["targets"] else []:
|
|
971
|
+
print(" kept: %s" % k)
|
|
972
|
+
elif ("targets" in data and isinstance(data["targets"], dict)
|
|
973
|
+
and all(isinstance(v, dict) and "healthy" in v for v in data["targets"].values())):
|
|
830
974
|
print("Uscha %s" % data.get("source_version"))
|
|
831
975
|
for name, status in data["targets"].items(): print(" %s %s" % ("OK" if status["healthy"] else "WARN", name))
|
|
832
976
|
elif "operations" in data:
|
|
@@ -846,6 +990,13 @@ def build_parser():
|
|
|
846
990
|
doctor.add_argument("--target", choices=list(TARGETS) + ["both", "all"], default="both"); doctor.add_argument("--home"); doctor.add_argument("--json", action="store_true"); doctor.set_defaults(func=cmd_doctor)
|
|
847
991
|
init = sub.add_parser("init", help="prepare a repo with Uscha config/templates")
|
|
848
992
|
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)
|
|
993
|
+
uninstall = sub.add_parser("uninstall", help="remove what this kit installed, and nothing else")
|
|
994
|
+
uninstall.add_argument("--target", choices=list(TARGETS) + ["both", "all"], default="both")
|
|
995
|
+
uninstall.add_argument("--home"); uninstall.add_argument("--dry-run", action="store_true")
|
|
996
|
+
uninstall.add_argument("--json", action="store_true")
|
|
997
|
+
uninstall.add_argument("--force", action="store_true",
|
|
998
|
+
help="remove even without an install marker (you assert the files are ours)")
|
|
999
|
+
uninstall.set_defaults(func=cmd_uninstall)
|
|
849
1000
|
mirador = sub.add_parser("mirador", help="render + open the project's mirador dashboard from QA-LEDGER.json")
|
|
850
1001
|
mirador.add_argument("--ledger", default="QA-LEDGER.json", help="ledger to read (default: the QA-LEDGER.json convention)")
|
|
851
1002
|
mirador.add_argument("--out", default="mirador.html")
|