See what a risky command would change before it runs.

English | 中文
Ahel Desktop is a desktop chat app for ahel.ai. Sign in with your ahel.ai account and the Ahel MCP connects on its own, so your apps, their cards and their approvals work right in the chat.
Models come from your ahel.ai account, your own API key, or a coding CLI already installed on your computer.
Get the latest macOS (Apple silicon) or Windows build from the latest release.
Builds are unsigned beta builds for now. macOS: open the app once; when macOS refuses it, go to System Settings > Privacy & Security, press Open Anyway and enter your Mac password (right-click > Open no longer works on macOS 15). Windows: SmartScreen > More info > Run anyway.
Install Node.js and pnpm, then run:
pnpm install
pnpm run build
pnpm run package:desktop:mac:arm64:dev
The unsigned app lands under apps/desktop/.desktop-build/. See the development guide and AGENTS.md for the rest.
The desktop client in this repository is open source. The ahel connector (MCP), the chat gateway, the catalog, the knowledge data and billing are the hosted ahel.ai service and are not in this repository.
See CONTRIBUTING.md. Report security issues as described in SECURITY.md.
Code written by Ahel Technologies OÜ is licensed under the Apache License 2.0. Code inherited from DeepSeek Harness by DeepSeek stays under the MIT License; NOTICE keeps its copyright notice and explains how to tell the two apart. Third-party dependencies and their licenses are listed in THIRD_PARTY_NOTICES.md. Bundled fonts are licensed under the SIL Open Font License.
The ahel name, the tile and the wordmark are trademarks of Ahel Technologies OÜ and are not covered by either license; forks must rename and replace them, as TRADEMARK.md describes. DeepSeek Harness is a trademark of DeepSeek; this project is not affiliated with or endorsed by DeepSeek.
hooks/blast-radius.mjs 128 lines1// 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 post publishes the tool.call hook below unchanged; 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) {
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(["sleep", "0.25"]); // 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