SLOPSHOPPER

blast-radius

See what a risky command would change before it runs.

newpanebandguardprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · blast-radius
│ ┃ Blast Radius ✕ › fix the failing auth test and add an audit log call │ ┃ Blast Radius: rm -rf build && git push │ ┃ --force origin main ⏺ Read(src/auth.ts) │ ┃ Would delete 3 file(s) () under build. ⎿ Read 6 lines │ ┃ package.json ⏺ Update(src/auth.ts) │ ┃ README.md ⎿ Added 2 lines, removed 1 line │ ┃ src ⏺ Bash(bun test) │ ┃ [ Proceed ] [ Cancel ] ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Blast Radius
Blast Radius: rm -rf build && git push --force origin main Would delete 3 file(s) () under build. package.json README.md src [ Proceed ] [ Cancel ]
README

Strique Harness distribution builds

Source snapshot of the DeepSeek Harness and eight Strique plugins, with standalone browser-interface installers.

The current snapshot uses Harness 0.2.1-rc.2 and updates Branding, Codex CLI, MCP Manager, Memory, Notify, and Skills Manager for that runtime. Univer 0.3.7 and BetterSidebar 0.28.0 use the pulled upstream source snapshots from the local development workspace. Source revisions record the upstream commits included in this refresh.

Installer owner-state updates use atomic replacement so concurrent launch/status readers cannot observe partially written JSON.

This refresh passed the isolated source setup, all 14 installer lifecycle tests, and a macOS arm64 packaged browser smoke that rendered Strique branding and BetterSidebar without page errors. Platform-specific installer builds run in GitHub Actions.

Build a Windows installer

  1. Open Actions → Build Windows installer → Run workflow.
  2. Set a product version such as 0.1.0. Leave the update URL empty until hosting is configured.
  3. Download the strique-win32-x64 artifact from the successful run and extract its .exe.

The installer bundles Node, the harness, and the configured plugins. End users do not need GitHub accounts or a separate Node installation. These are unsigned development installers.

The Build Strique installers workflow builds all five supported OS/architecture targets. See standalone documentation for installation, signed updates, and limitations.

Refresh the source

This repository contains a source snapshot rather than submodules. On the development machine, run node setup/export-source.mjs NEW_OUTPUT_DIRECTORY and review the resulting snapshot before updating this repository. Changes on the development machine reach users only after a new snapshot is committed, a release is built, and its installer or signed update is distributed.

Credentials, sessions, dependency directories, and original Git histories are excluded. Upstream license files remain in their project folders.

Source 1 files
hooks/blast-radius.mjs 128 lines
1// Blast Radius, from "Getting started with Claude Code mods"
2// (https://claude.dev/blog/getting-started-with-claude-code-mods/, Anthropic, 2026-10-01).
3// The tool.call hook adds a waitArgv option for DSH's portable timer; the rest of the module
4// (classify, measure, the pane and band trees) is completed to the post's
5// description of the mod, which is marked where it starts.
6
7// Blast Radius: see what a risky command would change before it runs.
8
9// —— completed to the post's description (not in the published excerpt) ——
10
11const RISKS = [
12  { pattern: /\brm\s+(-[a-zA-Z]*r[a-zA-Z]*f|-[a-zA-Z]*f[a-zA-Z]*r)\b/, kind: "delete", what: "delete files recursively" },
13  { pattern: /\bgit\s+reset\s+--hard\b/, kind: "reset", what: "discard uncommitted changes" },
14  { pattern: /\bgit\s+clean\b/, kind: "clean", what: "delete untracked files" },
15  { pattern: /\bgit\s+push\b[^\n]*\s(--force|-f)\b/, kind: "force-push", what: "rewrite a remote branch" },
16  { pattern: /\b(migrate|manage\.py\s+migrate|prisma\s+migrate|rails\s+db:migrate)\b/, kind: "migrate", what: "change the database schema" },
17];
18
19// What the hook is holding: one command at a time, until a button decides.
20let held = null;
21
22export function register(on, { waitArgv = ["sleep", "0.25"] } = {}) {
23  on("tool.call", { tool: "Bash" }, async ($, e, next) => {
24    const risk = classify(String(e.command ?? ""));
25    if (risk === null) return next(e);                 // everything else runs as normal
26
27    const report = await measure($, risk, await $.session.cwd());  // git status, git clean -n, du, ...
28    held = { command: e.command, risk, report, decision: null };
29    const opened = await $.ui.open({ id: "blast-radius", title: "Blast Radius", focus: true });
30    if (!opened.isPlaced) held.where = "band";         // too narrow for a pane: draw above the prompt
31
32    while (held.decision === null && !next.signal.aborted) {
33      await $.process.run(waitArgv);                   // time inside $ calls doesn't count against the hook's time limit
34    }
35    if (held.decision === "proceed") return next(e);   // let it run
36    return { deny: `Blast Radius held this command: the user pressed Cancel. It would have: ${report.summary}.` };
37  });
38
39  // —— completed to the post's description (not in the published excerpt) ——
40
41  on("ui.render", { component: "Pane" }, ($, e, next) => {
42    if (e.requestId !== "blast-radius" || held === null || held.decision !== null) return next(e);
43    return report($, e);
44  });
45
46  on("ui.render", { component: "AbovePrompt" }, ($, e, next) => {
47    if (held === null || held.decision !== null || held.where !== "band") return next(e);
48    return report($, e);
49  });
50}
51
52function classify(command) {
53  const risk = RISKS.find((r) => r.pattern.test(command));
54  return risk ? { kind: risk.kind, what: risk.what, command } : null;
55}
56
57async function measure($, risk, cwd) {
58  const lines = [];
59  let summary = `${risk.what} in ${cwd}`;
60  if (risk.kind === "delete") {
61    const target = risk.command.match(/\brm\s+-\S+\s+(\S+)/)?.[1];
62    const du = await run($, ["du", "-sh", target ?? "."]);
63    const size = du.stdout.trim().split(/\s+/)[0] ?? "?";
64    const count = await run($, ["find", target ?? ".", "-type", "f"]);
65    const files = count.stdout.split("\n").filter(Boolean).length;
66    summary = `delete ${files} file(s) (${size}) under ${target ?? cwd}`;
67    lines.push(...count.stdout.split("\n").filter(Boolean).slice(0, 9));
68  } else if (risk.kind === "reset") {
69    const status = await run($, ["git", "status", "--porcelain"]);
70    const changed = status.stdout.split("\n").filter(Boolean);
71    summary = `discard uncommitted changes to ${changed.length} file(s)`;
72    lines.push(...changed.slice(0, 9));
73  } else if (risk.kind === "clean") {
74    const dry = await run($, ["git", "clean", "-n"]);
75    const removed = dry.stdout.split("\n").filter(Boolean);
76    summary = `delete ${removed.length} untracked path(s)`;
77    lines.push(...removed.slice(0, 9));
78  } else if (risk.kind === "force-push") {
79    const ahead = await run($, ["git", "log", "--oneline", "HEAD..origin/main"]);
80    const commits = ahead.stdout.split("\n").filter(Boolean);
81    summary = `rewrite the remote branch, dropping ${commits.length} commit(s) not on this branch`;
82    lines.push(...commits.slice(0, 9));
83  } else if (risk.kind === "migrate") {
84    const pending = await run($, ["sh", "-c", "python manage.py showmigrations --plan 2>/dev/null | grep '\\[ \\]' || true"]);
85    const migrations = pending.stdout.split("\n").filter(Boolean);
86    summary = `apply ${migrations.length} pending migration(s)`;
87    lines.push(...migrations.slice(0, 9));
88  }
89  return { summary, lines };
90}
91
92// A dry run that fails (no git, no python, no such path) still lets the user decide.
93async function run($, argv) {
94  try {
95    return await $.process.run(argv);
96  } catch {
97    return { exitCode: 1, stdout: "", stderr: "" };
98  }
99}
100
101function decide(decision) {
102  if (held !== null) held.decision = decision;
103}
104
105function report($, e) {
106  const { Box, Text, Button } = $.ui.resolve(e);
107  const detail = held.report.lines.map((line) => Text({ dimColor: true, children: `  ${line}` }));
108  return Box({
109    flexDirection: "column",
110    border: true,
111    borderColor: "yellow",
112    paddingX: 1,
113    children: [
114      Text({ color: "yellow", bold: true, children: `Blast Radius: ${held.command}` }),
115      Text({ children: `Would ${held.report.summary}.` }),
116      ...detail,
117      Box({
118        flexDirection: "row",
119        gap: 2,
120        children: [
121          Button({ label: "Proceed", hotkey: "1", onPress: () => decide("proceed") }),
122          Button({ label: "Cancel", hotkey: "2", onPress: () => decide("cancel") }),
123        ],
124      }),
125    ],
126  });
127}
128