@denial-web/clawguard 0.1.2 → 0.1.4

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 CHANGED
@@ -59,6 +59,15 @@ npx @denial-web/clawguard install ./path/to/skill --to ./.agents/skills --policy
59
59
 
60
60
  Install mode never executes scanned files or installs dependencies. It refuses warn/review/sandbox/block decisions before copying files.
61
61
 
62
+ For agent systems that search and install skills automatically, keep discovery native and gate only the install step:
63
+
64
+ ```bash
65
+ npx @denial-web/clawguard openclaw install ./candidate-skill --to ./.agents/skills --approval-out ./.clawguard/approvals.jsonl
66
+ npx @denial-web/clawguard hermes install ./candidate-skill --to ~/.hermes/skills --approval-out ./.clawguard/approvals.jsonl
67
+ ```
68
+
69
+ The approval JSONL payload is designed for a bot or daemon to forward to WhatsApp, Telegram, Slack, Discord, or another owner channel before any files are copied into a trusted skill folder.
70
+
62
71
  When testing the published package, run `npx` from outside this repository. From inside the ClawGuard source checkout, use the local commands instead:
63
72
 
64
73
  ```bash
@@ -13,6 +13,32 @@ This spec defines how ClawGuard should work with OpenClaw, ClawHub, GitHub, web
13
13
 
14
14
  ## OpenClaw Integration
15
15
 
16
+ ### Guarded Install With Owner Approval
17
+
18
+ Current wrapper pattern:
19
+
20
+ ```bash
21
+ clawguard openclaw install ./candidate-skill \
22
+ --to ./.agents/skills \
23
+ --approval-out ./.clawguard/approvals.jsonl
24
+ ```
25
+
26
+ This does not block OpenClaw or ClawHub discovery. Search and candidate selection can stay native. ClawGuard sits between the downloaded candidate and the trusted skill folder:
27
+
28
+ ```text
29
+ native search/discovery
30
+
31
+ candidate skill bundle
32
+
33
+ clawguard openclaw install
34
+
35
+ allow / approval request / block
36
+
37
+ trusted skill folder
38
+ ```
39
+
40
+ If `--approval-out` is set, non-allow decisions create a pending approval JSON payload instead of copying files. With `--approval-mode always`, even allow decisions pause for explicit owner approval. A messaging adapter can forward the `message` field to WhatsApp, Telegram, Slack, Discord, or another owner channel.
41
+
16
42
  ### Skill Folder Scan
17
43
 
18
44
  Command:
@@ -107,6 +133,20 @@ Current implementation:
107
133
  - Reports missing lockfile, missing origin metadata, version drift, source drift, invalid metadata, and unusual source URLs.
108
134
  - Adds a `clawhub` summary to JSON and HTML reports.
109
135
 
136
+ ## Hermes Agent Integration
137
+
138
+ Current wrapper pattern:
139
+
140
+ ```bash
141
+ clawguard hermes install ./candidate-skill \
142
+ --to ~/.hermes/skills \
143
+ --approval-out ./.clawguard/approvals.jsonl
144
+ ```
145
+
146
+ The first integration target is the same as OpenClaw: do not interfere with search or discovery. Scan the candidate before it is copied into a trusted Hermes skill directory, and emit an approval request when policy says the owner should decide.
147
+
148
+ ClawGuard is independent and not affiliated with Hermes Agent or Nous Research.
149
+
110
150
  ### Metadata Comparison
111
151
 
112
152
  ClawGuard should compare:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@denial-web/clawguard",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Explainable security scanner for OpenClaw-style skills and MCP tool configs.",
5
5
  "type": "module",
6
6
  "repository": {
@@ -89,7 +89,8 @@ async function main() {
89
89
  await pause(250);
90
90
  await dependencyRisk.click();
91
91
  await page.getByRole("heading", { name: "Dependency Risk" }).waitFor();
92
- await page.getByText("Block").waitFor();
92
+ await page.getByRole("heading", { name: "Block", exact: true }).waitFor();
93
+ await page.getByRole("heading", { name: "Install blocked" }).waitFor();
93
94
  await pause(700);
94
95
 
95
96
  await page.screenshot({
package/src/cli.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { promises as fs } from "node:fs";
4
+ import { randomUUID } from "node:crypto";
4
5
  import path from "node:path";
5
6
  import { loadConfig, mergeConfig, parseSize } from "./config.js";
6
7
  import { policyShouldFail } from "./policy.js";
@@ -25,7 +26,8 @@ if (args.length === 0 || args.includes("--help") || args.includes("-h")) {
25
26
  process.exit(0);
26
27
  }
27
28
 
28
- const command = args[0];
29
+ const commandContext = parseCommand(args);
30
+ const { command, framework, optionValues } = commandContext;
29
31
 
30
32
  if (!["scan", "scan-workspace", "gate", "install"].includes(command)) {
31
33
  console.error(`Unknown command: ${command}`);
@@ -34,9 +36,11 @@ if (!["scan", "scan-workspace", "gate", "install"].includes(command)) {
34
36
  }
35
37
 
36
38
  try {
37
- const cliOptions = parseOptions(args.slice(1));
39
+ const cliOptions = parseOptions(optionValues);
40
+ cliOptions.framework = framework;
38
41
  const loadedConfig = await loadConfig(cliOptions.target, cliOptions.configPath);
39
42
  const options = mergeConfig(loadedConfig.config, cliOptions);
43
+ options.framework = framework;
40
44
  const result = await scanTarget(options.target, {
41
45
  maxFileSizeBytes: options.maxFileSizeBytes,
42
46
  maxFindingsPerRulePerFile: options.maxFindingsPerRulePerFile,
@@ -54,6 +58,8 @@ try {
54
58
  await writeReportFile(options.htmlPath, createHtmlReport(result));
55
59
  }
56
60
 
61
+ let exitCode;
62
+
57
63
  if (command === "install") {
58
64
  const install = await handleInstall(result, options);
59
65
  if (options.json) {
@@ -61,6 +67,7 @@ try {
61
67
  } else {
62
68
  printInstallResult(result, install);
63
69
  }
70
+ exitCode = installExitCode(result.policy.decision, install);
64
71
  } else if (command === "gate") {
65
72
  if (options.json) {
66
73
  console.log(JSON.stringify(createGateResult(result), null, 2));
@@ -73,7 +80,7 @@ try {
73
80
  printHumanResult(result, options);
74
81
  }
75
82
 
76
- process.exit(["gate", "install"].includes(command) ? gateExitCode(result.policy.decision) : shouldFail(result, options) ? 2 : 0);
83
+ process.exit(exitCode ?? (command === "gate" ? gateExitCode(result.policy.decision) : shouldFail(result, options) ? 2 : 0));
77
84
  } catch (error) {
78
85
  console.error(`${commandLabel(command)} failed: ${error.message}`);
79
86
  process.exit(1);
@@ -86,6 +93,8 @@ Usage:
86
93
  clawguard scan <path> [--json] [--policy <preset>] [--fail-on <level>]
87
94
  clawguard gate <path> [--json] [--policy <preset>]
88
95
  clawguard install <path> --to <dir> [--policy <preset>] [--dry-run]
96
+ clawguard openclaw install <path> --to <dir> [--approval-out <path>]
97
+ clawguard hermes install <path> --to <dir> [--approval-out <path>]
89
98
  clawguard scan-workspace <path> [--json] [--policy <preset>]
90
99
  npm run scan -- <path>
91
100
 
@@ -107,6 +116,9 @@ Options:
107
116
  --to <dir> Install destination parent directory for install mode.
108
117
  --name <name> Install folder/file name. Defaults to the source basename.
109
118
  --dry-run Run install gate and show the destination without copying files.
119
+ --approval-out <path> Write a pending approval JSON request before copying.
120
+ Use .jsonl to append JSON lines for bot/daemon integrations.
121
+ --approval-mode <mode> Approval mode: non-allow, always. Default: non-allow.
110
122
 
111
123
  Gate exit codes:
112
124
  0 = allow
@@ -117,6 +129,8 @@ Examples:
117
129
  npx @denial-web/clawguard gate ./skills/my-skill
118
130
  npx @denial-web/clawguard gate ./skills/my-skill --policy governed
119
131
  npx @denial-web/clawguard install ./skills/my-skill --to ./.agents/skills --policy governed
132
+ npx @denial-web/clawguard openclaw install ./skills/my-skill --to ./.agents/skills --approval-out ./.clawguard/approvals.jsonl
133
+ npx @denial-web/clawguard hermes install ./skills/my-skill --to ~/.hermes/skills --approval-out ./.clawguard/approvals.jsonl
120
134
  npm run scan -- examples/risky-skill
121
135
  npm run scan -- examples/metadata-mismatch-skill --policy governed --fail-on-policy
122
136
  npm run scan -- examples/metadata-mismatch-skill --html clawguard.html
@@ -197,6 +211,42 @@ function printHumanResult(result, options) {
197
211
  }
198
212
  }
199
213
 
214
+ function parseCommand(values) {
215
+ const rawCommand = values[0];
216
+
217
+ if (["openclaw", "hermes"].includes(rawCommand)) {
218
+ const nestedCommand = values[1];
219
+
220
+ if (!nestedCommand) {
221
+ return {
222
+ command: "",
223
+ framework: rawCommand,
224
+ optionValues: []
225
+ };
226
+ }
227
+
228
+ if (!["gate", "install"].includes(nestedCommand)) {
229
+ return {
230
+ command: `${rawCommand} ${nestedCommand}`,
231
+ framework: rawCommand,
232
+ optionValues: values.slice(2)
233
+ };
234
+ }
235
+
236
+ return {
237
+ command: nestedCommand,
238
+ framework: rawCommand,
239
+ optionValues: values.slice(2)
240
+ };
241
+ }
242
+
243
+ return {
244
+ command: rawCommand,
245
+ framework: undefined,
246
+ optionValues: values.slice(1)
247
+ };
248
+ }
249
+
200
250
  function printGateResult(result, options) {
201
251
  const decision = result.policy.decision;
202
252
  console.log(`ClawGuard gate: ${result.target}`);
@@ -243,10 +293,18 @@ async function handleInstall(result, options) {
243
293
  const install = {
244
294
  destination,
245
295
  dryRun: options.dryRun,
296
+ framework: options.framework,
246
297
  installed: false,
247
- skipped: decision !== "allow"
298
+ skipped: decision !== "allow",
299
+ approvalRequest: null
248
300
  };
249
301
 
302
+ if (shouldCreateApprovalRequest(decision, options)) {
303
+ install.approvalRequest = await writeApprovalRequest(result, install, options);
304
+ install.skipped = true;
305
+ return install;
306
+ }
307
+
250
308
  if (decision !== "allow") {
251
309
  return install;
252
310
  }
@@ -277,10 +335,13 @@ async function handleInstall(result, options) {
277
335
  function printInstallResult(result, install) {
278
336
  const decision = result.policy.decision;
279
337
  console.log(`ClawGuard install: ${result.target}`);
338
+ if (install.framework) {
339
+ console.log(`Framework: ${displayFramework(install.framework)}`);
340
+ }
280
341
  console.log(`Decision: ${formatDecision(decision)}`);
281
342
  console.log(`Risk: ${result.level.toUpperCase()} (${result.score}/100)`);
282
343
  console.log(`Policy: ${result.policy.preset}`);
283
- console.log(`Exit code: ${gateExitCode(decision)}`);
344
+ console.log(`Exit code: ${installExitCode(decision, install)}`);
284
345
  console.log(`Destination: ${install.destination ?? "not selected"}`);
285
346
  console.log(`Installed: ${install.installed ? "yes" : "no"}`);
286
347
 
@@ -292,7 +353,11 @@ function printInstallResult(result, install) {
292
353
  console.log(`Required actions: ${result.policy.requiredActions.join(", ")}`);
293
354
  }
294
355
 
295
- if (decision === "allow" && install.installed) {
356
+ if (install.approvalRequest) {
357
+ console.log(`Approval request: ${install.approvalRequest.path}`);
358
+ console.log(`Approval id: ${install.approvalRequest.id}`);
359
+ console.log("\nInstall result: pending user approval before copying files.");
360
+ } else if (decision === "allow" && install.installed) {
296
361
  console.log("\nInstall result: copied after passing the selected policy.");
297
362
  } else if (decision === "allow" && install.dryRun) {
298
363
  console.log("\nInstall result: dry run passed; no files were copied.");
@@ -308,10 +373,13 @@ function printInstallResult(result, install) {
308
373
  function createInstallResult(result, install) {
309
374
  return {
310
375
  ...createGateResult(result),
376
+ exitCode: installExitCode(result.policy.decision, install),
377
+ framework: install.framework,
311
378
  destination: install.destination,
312
379
  installed: install.installed,
313
380
  dryRun: install.dryRun,
314
- skipped: install.skipped
381
+ skipped: install.skipped,
382
+ approvalRequest: install.approvalRequest
315
383
  };
316
384
  }
317
385
 
@@ -350,6 +418,106 @@ function resolveInstallDestination(sourcePath, options) {
350
418
  return path.resolve(options.installDir, installName);
351
419
  }
352
420
 
421
+ function shouldCreateApprovalRequest(decision, options) {
422
+ if (!options.approvalOut) {
423
+ return false;
424
+ }
425
+
426
+ if (options.approvalMode === "always") {
427
+ return true;
428
+ }
429
+
430
+ return decision !== "allow";
431
+ }
432
+
433
+ async function writeApprovalRequest(result, install, options) {
434
+ const request = createApprovalRequest(result, install, options);
435
+ const outputPath = path.resolve(options.approvalOut);
436
+ await fs.mkdir(path.dirname(outputPath), { recursive: true });
437
+
438
+ if (outputPath.endsWith(".jsonl")) {
439
+ await fs.appendFile(outputPath, `${JSON.stringify(request)}\n`);
440
+ } else {
441
+ await fs.writeFile(outputPath, `${JSON.stringify(request, null, 2)}\n`, { flag: "wx" });
442
+ }
443
+
444
+ return {
445
+ id: request.id,
446
+ path: outputPath,
447
+ status: request.status,
448
+ message: request.message
449
+ };
450
+ }
451
+
452
+ function createApprovalRequest(result, install, options) {
453
+ const id = randomUUID();
454
+ const decision = result.policy.decision;
455
+ const framework = options.framework ?? "generic";
456
+ const target = path.resolve(options.target);
457
+ const topFindings = result.findings.slice(0, 5).map((finding) => ({
458
+ ruleId: finding.ruleId,
459
+ severity: finding.severity,
460
+ title: finding.title,
461
+ file: finding.file,
462
+ line: finding.line,
463
+ recommendation: finding.recommendation
464
+ }));
465
+
466
+ return {
467
+ schemaVersion: "clawguard.approval.v1",
468
+ id,
469
+ status: "pending",
470
+ createdAt: new Date().toISOString(),
471
+ framework,
472
+ target,
473
+ destination: install.destination,
474
+ decision,
475
+ risk: {
476
+ level: result.level,
477
+ score: result.score
478
+ },
479
+ policy: {
480
+ preset: result.policy.preset,
481
+ reason: result.policy.reason,
482
+ requiredActions: result.policy.requiredActions
483
+ },
484
+ install: {
485
+ dryRun: install.dryRun,
486
+ installed: false,
487
+ skipped: true
488
+ },
489
+ summary: result.summary,
490
+ findings: topFindings,
491
+ message: createApprovalMessage({
492
+ framework,
493
+ target,
494
+ destination: install.destination,
495
+ decision,
496
+ risk: result.level,
497
+ score: result.score,
498
+ requiredActions: result.policy.requiredActions,
499
+ findings: topFindings
500
+ })
501
+ };
502
+ }
503
+
504
+ function createApprovalMessage(details) {
505
+ const findingLines = details.findings.length === 0
506
+ ? "No findings were reported."
507
+ : details.findings.map((finding) => `- ${finding.severity.toUpperCase()}: ${finding.title}`).join("\n");
508
+
509
+ return [
510
+ `ClawGuard approval needed for ${displayFramework(details.framework)} skill install.`,
511
+ `Decision: ${formatDecision(details.decision)}`,
512
+ `Risk: ${details.risk.toUpperCase()} (${details.score}/100)`,
513
+ `Source: ${details.target}`,
514
+ `Destination: ${details.destination ?? "not selected"}`,
515
+ `Required actions: ${details.requiredActions.length > 0 ? details.requiredActions.join(", ") : "none"}`,
516
+ "Top findings:",
517
+ findingLines
518
+ ].join("\n");
519
+ }
520
+
353
521
  async function assertInstallableSource(sourcePath) {
354
522
  const stats = await fs.lstat(sourcePath);
355
523
 
@@ -404,6 +572,18 @@ function commandLabel(commandName) {
404
572
  return "Scan";
405
573
  }
406
574
 
575
+ function displayFramework(value) {
576
+ if (value === "openclaw") {
577
+ return "OpenClaw";
578
+ }
579
+
580
+ if (value === "hermes") {
581
+ return "Hermes Agent";
582
+ }
583
+
584
+ return "agent";
585
+ }
586
+
407
587
  function formatDecision(decision) {
408
588
  return decision.replaceAll("_", " ").toUpperCase();
409
589
  }
@@ -420,6 +600,14 @@ function gateExitCode(decision) {
420
600
  return 1;
421
601
  }
422
602
 
603
+ function installExitCode(decision, install) {
604
+ if (install.approvalRequest) {
605
+ return 1;
606
+ }
607
+
608
+ return gateExitCode(decision);
609
+ }
610
+
423
611
  function parseOptions(values) {
424
612
  const options = {
425
613
  json: false,
@@ -434,6 +622,9 @@ function parseOptions(values) {
434
622
  installDir: undefined,
435
623
  installName: undefined,
436
624
  dryRun: false,
625
+ approvalOut: undefined,
626
+ approvalMode: "non-allow",
627
+ framework: undefined,
437
628
  target: "."
438
629
  };
439
630
  const paths = [];
@@ -527,6 +718,22 @@ function parseOptions(values) {
527
718
  continue;
528
719
  }
529
720
 
721
+ if (value === "--approval-out") {
722
+ options.approvalOut = requireNextValue(values, index, "--approval-out");
723
+ index += 1;
724
+ continue;
725
+ }
726
+
727
+ if (value === "--approval-mode") {
728
+ const mode = requireNextValue(values, index, "--approval-mode");
729
+ if (!["non-allow", "always"].includes(mode)) {
730
+ throw new Error("Invalid --approval-mode value. Use one of: non-allow, always");
731
+ }
732
+ options.approvalMode = mode;
733
+ index += 1;
734
+ continue;
735
+ }
736
+
530
737
  if (value.startsWith("--")) {
531
738
  throw new Error(`Unknown option: ${value}`);
532
739
  }
package/src/config.js CHANGED
@@ -64,7 +64,9 @@ export function mergeConfig(config, cliOptions = {}) {
64
64
  sarifPath: cliOptions.sarifPath,
65
65
  installDir: cliOptions.installDir,
66
66
  installName: cliOptions.installName,
67
- dryRun: Boolean(cliOptions.dryRun)
67
+ dryRun: Boolean(cliOptions.dryRun),
68
+ approvalOut: cliOptions.approvalOut,
69
+ approvalMode: cliOptions.approvalMode ?? "non-allow"
68
70
  };
69
71
  }
70
72
 
package/web/app.js CHANGED
@@ -37,6 +37,9 @@ const elements = {
37
37
  level: document.querySelector("#level"),
38
38
  decision: document.querySelector("#decision"),
39
39
  reason: document.querySelector("#reason"),
40
+ installVerdict: document.querySelector("#install-verdict"),
41
+ installMessage: document.querySelector("#install-message"),
42
+ installCommand: document.querySelector("#install-command"),
40
43
  actions: document.querySelector("#actions"),
41
44
  critical: document.querySelector("#critical-count"),
42
45
  high: document.querySelector("#high-count"),
@@ -235,9 +238,36 @@ function renderResult(result) {
235
238
  elements.downloadHtml.textContent = "Download HTML";
236
239
  elements.downloadHtml.disabled = false;
237
240
 
241
+ renderInstallGate(result);
238
242
  renderFindings(scan.findings ?? []);
239
243
  }
240
244
 
245
+ function renderInstallGate(result) {
246
+ const scan = result.scan;
247
+ const policy = scan.policy ?? {};
248
+ const decision = policy.decision ?? "allow";
249
+ const target = installTargetFor(result);
250
+ const installName = safeInstallName(result.displayTarget ?? "skill");
251
+ const command = `npx @denial-web/clawguard install ${target} --to ./.agents/skills --name ${installName} --policy ${policy.preset ?? elements.policy.value}`;
252
+
253
+ elements.installCommand.textContent = command;
254
+
255
+ if (decision === "allow") {
256
+ elements.installVerdict.textContent = "Install allowed";
257
+ elements.installMessage.textContent = "ClawGuard would copy this skill into the destination after the policy gate passes.";
258
+ return;
259
+ }
260
+
261
+ if (decision === "block") {
262
+ elements.installVerdict.textContent = "Install blocked";
263
+ elements.installMessage.textContent = "ClawGuard would stop before copying files. Review the findings before trusting this skill.";
264
+ return;
265
+ }
266
+
267
+ elements.installVerdict.textContent = "Install paused";
268
+ elements.installMessage.textContent = "ClawGuard would require review, sandboxing, or approval before copying files.";
269
+ }
270
+
241
271
  function renderFindings(findings) {
242
272
  if (findings.length === 0) {
243
273
  elements.findings.className = "findings empty-state";
@@ -342,6 +372,27 @@ function safeFilename(value) {
342
372
  .slice(0, 80) || "scan";
343
373
  }
344
374
 
375
+ function safeInstallName(value) {
376
+ return String(value || "skill")
377
+ .toLowerCase()
378
+ .replace(/[^a-z0-9_.-]/g, "-")
379
+ .replace(/-+/g, "-")
380
+ .replace(/^-|-$/g, "")
381
+ .slice(0, 60) || "skill";
382
+ }
383
+
384
+ function installTargetFor(result) {
385
+ if (result.source === "example" && result.example?.path) {
386
+ return result.example.path;
387
+ }
388
+
389
+ if (result.source === "folder") {
390
+ return "./uploaded-skill";
391
+ }
392
+
393
+ return "./pasted-skill";
394
+ }
395
+
345
396
  async function fetchJson(url, options = {}) {
346
397
  const response = await fetch(url, {
347
398
  headers: {
package/web/index.html CHANGED
@@ -4,7 +4,7 @@
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
6
  <title>ClawGuard Web Demo</title>
7
- <link rel="stylesheet" href="/styles.css">
7
+ <link rel="stylesheet" href="styles.css">
8
8
  </head>
9
9
  <body>
10
10
  <main class="shell">
@@ -75,6 +75,17 @@
75
75
  </div>
76
76
  </section>
77
77
 
78
+ <section class="install-panel" aria-label="Pre-install gate">
79
+ <div>
80
+ <p class="eyebrow">Pre-Install Gate</p>
81
+ <h2 id="install-verdict">Waiting for scan</h2>
82
+ <p id="install-message">Run a scan to see whether ClawGuard would install, pause, or block before files are trusted.</p>
83
+ </div>
84
+ <div class="command-box">
85
+ <code id="install-command">npx @denial-web/clawguard install ./skill --to ./.agents/skills --policy governed</code>
86
+ </div>
87
+ </section>
88
+
78
89
  <section class="metrics" aria-label="Finding summary">
79
90
  <div><span>Critical</span><strong id="critical-count">0</strong></div>
80
91
  <div><span>High</span><strong id="high-count">0</strong></div>
@@ -114,6 +125,6 @@
114
125
  </section>
115
126
  </section>
116
127
  </main>
117
- <script src="/app.js" type="module"></script>
128
+ <script src="app.js" type="module"></script>
118
129
  </body>
119
130
  </html>
package/web/styles.css CHANGED
@@ -264,7 +264,7 @@ input[type="file"] {
264
264
  align-items: stretch;
265
265
  }
266
266
 
267
- .score-ring, .decision, .metrics div, .metadata-grid div, .finding-card, .empty-state {
267
+ .score-ring, .decision, .install-panel, .metrics div, .metadata-grid div, .finding-card, .empty-state {
268
268
  border: 1px solid var(--line);
269
269
  border-radius: 8px;
270
270
  background: var(--panel);
@@ -302,6 +302,43 @@ input[type="file"] {
302
302
  text-transform: uppercase;
303
303
  }
304
304
 
305
+ .install-panel {
306
+ display: grid;
307
+ grid-template-columns: minmax(220px, 0.8fr) minmax(0, 1.2fr);
308
+ gap: 14px;
309
+ align-items: stretch;
310
+ padding: 16px;
311
+ }
312
+
313
+ .install-panel h2 {
314
+ margin-top: 5px;
315
+ font-size: 24px;
316
+ text-transform: uppercase;
317
+ }
318
+
319
+ #install-message {
320
+ margin-top: 6px;
321
+ color: var(--muted);
322
+ line-height: 1.4;
323
+ }
324
+
325
+ .command-box {
326
+ min-width: 0;
327
+ display: grid;
328
+ align-items: center;
329
+ border: 1px solid var(--line);
330
+ border-radius: 8px;
331
+ background: var(--field);
332
+ padding: 12px;
333
+ }
334
+
335
+ .command-box code {
336
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
337
+ font-size: 13px;
338
+ line-height: 1.45;
339
+ overflow-wrap: anywhere;
340
+ }
341
+
305
342
  .action-tags {
306
343
  display: flex;
307
344
  flex-wrap: wrap;
@@ -422,7 +459,7 @@ input[type="file"] {
422
459
  }
423
460
 
424
461
  @media (max-width: 900px) {
425
- .workbench, .score-panel {
462
+ .workbench, .score-panel, .install-panel {
426
463
  grid-template-columns: 1fr;
427
464
  }
428
465