@rafter-security/cli 0.6.6 → 0.7.1

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 (70) hide show
  1. package/README.md +29 -10
  2. package/dist/commands/agent/audit-skill.js +22 -20
  3. package/dist/commands/agent/audit.js +27 -0
  4. package/dist/commands/agent/components.js +800 -0
  5. package/dist/commands/agent/config.js +2 -1
  6. package/dist/commands/agent/disable.js +47 -0
  7. package/dist/commands/agent/enable.js +50 -0
  8. package/dist/commands/agent/exec.js +2 -0
  9. package/dist/commands/agent/index.js +6 -0
  10. package/dist/commands/agent/init.js +162 -163
  11. package/dist/commands/agent/install-hook.js +15 -14
  12. package/dist/commands/agent/list.js +72 -0
  13. package/dist/commands/agent/scan.js +4 -3
  14. package/dist/commands/agent/verify.js +1 -1
  15. package/dist/commands/backend/run.js +12 -3
  16. package/dist/commands/backend/scan-status.js +3 -2
  17. package/dist/commands/brief.js +22 -2
  18. package/dist/commands/ci/init.js +25 -21
  19. package/dist/commands/completion.js +4 -3
  20. package/dist/commands/docs/index.js +18 -0
  21. package/dist/commands/docs/list.js +37 -0
  22. package/dist/commands/docs/show.js +64 -0
  23. package/dist/commands/mcp/server.js +84 -0
  24. package/dist/commands/report.js +42 -41
  25. package/dist/commands/scan/index.js +7 -5
  26. package/dist/commands/skill/index.js +14 -0
  27. package/dist/commands/skill/install.js +89 -0
  28. package/dist/commands/skill/list.js +79 -0
  29. package/dist/commands/skill/registry.js +273 -0
  30. package/dist/commands/skill/remote.js +333 -0
  31. package/dist/commands/skill/review.js +975 -0
  32. package/dist/commands/skill/uninstall.js +65 -0
  33. package/dist/core/audit-logger.js +262 -21
  34. package/dist/core/config-manager.js +3 -0
  35. package/dist/core/docs-loader.js +148 -0
  36. package/dist/core/policy-loader.js +72 -1
  37. package/dist/core/risk-rules.js +16 -3
  38. package/dist/index.js +19 -9
  39. package/dist/scanners/gitleaks.js +6 -2
  40. package/package.json +1 -1
  41. package/resources/skills/rafter/SKILL.md +77 -97
  42. package/resources/skills/rafter/docs/backend.md +106 -0
  43. package/resources/skills/rafter/docs/cli-reference.md +199 -0
  44. package/resources/skills/rafter/docs/finding-triage.md +79 -0
  45. package/resources/skills/rafter/docs/guardrails.md +91 -0
  46. package/resources/skills/rafter/docs/shift-left.md +64 -0
  47. package/resources/skills/rafter-agent-security/SKILL.md +1 -1
  48. package/resources/skills/rafter-code-review/SKILL.md +91 -0
  49. package/resources/skills/rafter-code-review/docs/api.md +90 -0
  50. package/resources/skills/rafter-code-review/docs/asvs.md +120 -0
  51. package/resources/skills/rafter-code-review/docs/cwe-top25.md +78 -0
  52. package/resources/skills/rafter-code-review/docs/investigation-playbook.md +101 -0
  53. package/resources/skills/rafter-code-review/docs/llm.md +87 -0
  54. package/resources/skills/rafter-code-review/docs/web-app.md +84 -0
  55. package/resources/skills/rafter-secure-design/SKILL.md +103 -0
  56. package/resources/skills/rafter-secure-design/docs/api-design.md +97 -0
  57. package/resources/skills/rafter-secure-design/docs/auth.md +67 -0
  58. package/resources/skills/rafter-secure-design/docs/data-storage.md +90 -0
  59. package/resources/skills/rafter-secure-design/docs/dependencies.md +101 -0
  60. package/resources/skills/rafter-secure-design/docs/deployment.md +104 -0
  61. package/resources/skills/rafter-secure-design/docs/ingestion.md +98 -0
  62. package/resources/skills/rafter-secure-design/docs/standards-pointers.md +102 -0
  63. package/resources/skills/rafter-secure-design/docs/threat-modeling.md +128 -0
  64. package/resources/skills/rafter-skill-review/SKILL.md +106 -0
  65. package/resources/skills/rafter-skill-review/docs/authorship-provenance.md +82 -0
  66. package/resources/skills/rafter-skill-review/docs/changelog-review.md +99 -0
  67. package/resources/skills/rafter-skill-review/docs/data-practices.md +88 -0
  68. package/resources/skills/rafter-skill-review/docs/malware-indicators.md +79 -0
  69. package/resources/skills/rafter-skill-review/docs/prompt-injection.md +85 -0
  70. package/resources/skills/rafter-skill-review/docs/telemetry.md +78 -0
@@ -86,11 +86,11 @@ function generateHtmlReport(results, title) {
86
86
  ? "Low"
87
87
  : "None";
88
88
  const riskColor = {
89
- Critical: "#dc2626",
90
- High: "#ea580c",
91
- Medium: "#2563eb",
92
- Low: "#16a34a",
93
- None: "#16a34a",
89
+ Critical: "hsl(0 40% 55%)",
90
+ High: "hsl(25 35% 55%)",
91
+ Medium: "hsl(0 0% 64%)",
92
+ Low: "hsl(0 0% 50%)",
93
+ None: "hsl(0 0% 50%)",
94
94
  }[riskLevel];
95
95
  const topPatterns = Object.entries(patternCounts)
96
96
  .sort((a, b) => b[1] - a[1])
@@ -113,38 +113,39 @@ function generateHtmlReport(results, title) {
113
113
  <title>${escapeHtml(title)}</title>
114
114
  <style>
115
115
  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
116
- body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; line-height: 1.6; color: #1e293b; background: #f8fafc; }
116
+ body { font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; line-height: 1.6; color: hsl(0 0% 98%); background: hsl(0 0% 3.9%); }
117
117
  .container { max-width: 1100px; margin: 0 auto; padding: 2rem 1.5rem; }
118
- header { background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%); color: white; padding: 2rem 0; margin-bottom: 2rem; }
118
+ header { background: hsl(0 0% 7%); color: hsl(0 0% 98%); padding: 2rem 0; margin-bottom: 2rem; border-bottom: 1px solid hsl(0 0% 14.9%); }
119
119
  header .container { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 1rem; }
120
120
  header h1 { font-size: 1.5rem; font-weight: 700; }
121
- header .meta { font-size: 0.85rem; opacity: 0.8; text-align: right; }
122
- .card { background: white; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); padding: 1.5rem; margin-bottom: 1.5rem; }
123
- .card h2 { font-size: 1.1rem; font-weight: 600; margin-bottom: 1rem; color: #334155; }
121
+ header .meta { font-size: 0.85rem; opacity: 0.6; text-align: right; }
122
+ .card { background: hsl(0 0% 7%); border-radius: 8px; border: 1px solid hsl(0 0% 14.9%); padding: 1.5rem; margin-bottom: 1.5rem; }
123
+ .card h2 { font-size: 1.1rem; font-weight: 600; margin-bottom: 1rem; color: hsl(0 0% 98%); }
124
124
  .summary-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 1rem; }
125
- .stat { text-align: center; padding: 1rem; border-radius: 6px; background: #f1f5f9; }
126
- .stat .value { font-size: 2rem; font-weight: 700; }
127
- .stat .label { font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.05em; color: #64748b; margin-top: 0.25rem; }
128
- .risk-badge { display: inline-block; padding: 0.25rem 0.75rem; border-radius: 4px; color: white; font-weight: 600; font-size: 0.85rem; }
129
- .sev-critical { background: #dc2626; }
130
- .sev-high { background: #ea580c; }
131
- .sev-medium { background: #2563eb; }
132
- .sev-low { background: #16a34a; }
125
+ .stat { text-align: center; padding: 1rem; border-radius: 6px; background: hsl(0 0% 10%); border: 1px solid hsl(0 0% 14.9%); }
126
+ .stat .value { font-size: 2rem; font-weight: 700; color: hsl(0 0% 98%); }
127
+ .stat .label { font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.05em; color: hsl(0 0% 50%); margin-top: 0.25rem; }
128
+ .risk-badge { display: inline-block; padding: 0.25rem 0.75rem; border-radius: 4px; font-weight: 600; font-size: 0.85rem; }
129
+ .sev-critical { background: hsl(0 30% 20%); color: hsl(0 40% 75%); border: 1px solid hsl(0 30% 30%); }
130
+ .sev-high { background: hsl(25 25% 18%); color: hsl(25 35% 70%); border: 1px solid hsl(25 25% 28%); }
131
+ .sev-medium { background: hsl(0 0% 18%); color: hsl(0 0% 70%); border: 1px solid hsl(0 0% 25%); }
132
+ .sev-low { background: hsl(0 0% 14%); color: hsl(0 0% 55%); border: 1px solid hsl(0 0% 22%); }
133
133
  .bar-chart { margin-top: 0.5rem; }
134
134
  .bar-row { display: flex; align-items: center; margin-bottom: 0.4rem; }
135
- .bar-label { width: 180px; font-size: 0.85rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
136
- .bar-track { flex: 1; height: 20px; background: #e2e8f0; border-radius: 3px; overflow: hidden; }
137
- .bar-fill { height: 100%; border-radius: 3px; min-width: 2px; }
138
- .bar-count { width: 40px; text-align: right; font-size: 0.85rem; font-weight: 600; color: #475569; margin-left: 0.5rem; }
135
+ .bar-label { width: 180px; font-size: 0.85rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: hsl(0 0% 70%); }
136
+ .bar-track { flex: 1; height: 20px; background: hsl(0 0% 14.9%); border-radius: 3px; overflow: hidden; }
137
+ .bar-fill { height: 100%; border-radius: 3px; min-width: 2px; background: hsl(0 0% 98%); opacity: 0.6; }
138
+ .bar-count { width: 40px; text-align: right; font-size: 0.85rem; font-weight: 600; color: hsl(0 0% 64%); margin-left: 0.5rem; }
139
139
  table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
140
- th { text-align: left; padding: 0.6rem 0.75rem; background: #f1f5f9; border-bottom: 2px solid #e2e8f0; font-weight: 600; color: #475569; white-space: nowrap; }
141
- td { padding: 0.6rem 0.75rem; border-bottom: 1px solid #e2e8f0; vertical-align: top; }
142
- tr:hover td { background: #f8fafc; }
143
- .sev-pill { display: inline-block; padding: 0.15rem 0.5rem; border-radius: 3px; color: white; font-weight: 600; font-size: 0.75rem; text-transform: uppercase; }
144
- .file-path { font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; font-size: 0.8rem; word-break: break-all; }
145
- .redacted { font-family: monospace; font-size: 0.8rem; color: #94a3b8; }
146
- footer { text-align: center; padding: 2rem 0; font-size: 0.8rem; color: #94a3b8; }
147
- .no-findings { text-align: center; padding: 3rem; color: #16a34a; }
140
+ th { text-align: left; padding: 0.6rem 0.75rem; background: hsl(0 0% 10%); border-bottom: 2px solid hsl(0 0% 14.9%); font-weight: 600; color: hsl(0 0% 64%); white-space: nowrap; text-transform: uppercase; letter-spacing: 0.05em; font-size: 0.75rem; }
141
+ td { padding: 0.6rem 0.75rem; border-bottom: 1px solid hsl(0 0% 14.9%); vertical-align: top; }
142
+ tr:hover td { background: hsl(0 0% 10%); }
143
+ .sev-pill { display: inline-block; padding: 0.15rem 0.5rem; border-radius: 3px; font-weight: 600; font-size: 0.75rem; text-transform: uppercase; }
144
+ .file-path { font-size: 0.8rem; word-break: break-all; }
145
+ .redacted { font-size: 0.8rem; color: hsl(0 0% 40%); }
146
+ .description { color: hsl(0 0% 50%); }
147
+ footer { text-align: center; padding: 2rem 0; font-size: 0.8rem; color: hsl(0 0% 35%); border-top: 1px solid hsl(0 0% 14.9%); }
148
+ .no-findings { text-align: center; padding: 3rem; color: hsl(0 0% 64%); }
148
149
  .no-findings .icon { font-size: 3rem; margin-bottom: 0.5rem; }
149
150
  @media (max-width: 768px) {
150
151
  .summary-grid { grid-template-columns: repeat(2, 1fr); }
@@ -152,9 +153,9 @@ function generateHtmlReport(results, title) {
152
153
  table { display: block; overflow-x: auto; }
153
154
  }
154
155
  @media print {
155
- body { background: white; }
156
- .card { box-shadow: none; border: 1px solid #e2e8f0; break-inside: avoid; }
157
- header { background: #0f172a !important; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
156
+ body { background: hsl(0 0% 3.9%); color: hsl(0 0% 98%); }
157
+ .card { break-inside: avoid; }
158
+ header { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
158
159
  }
159
160
  </style>
160
161
  </head>
@@ -173,7 +174,7 @@ function generateHtmlReport(results, title) {
173
174
  <h2>Executive Summary</h2>
174
175
  <div class="summary-grid">
175
176
  <div class="stat">
176
- <div class="value" style="color:${riskColor}">${totalFindings}</div>
177
+ <div class="value">${totalFindings}</div>
177
178
  <div class="label">Total Findings</div>
178
179
  </div>
179
180
  <div class="stat">
@@ -181,7 +182,7 @@ function generateHtmlReport(results, title) {
181
182
  <div class="label">Files Affected</div>
182
183
  </div>
183
184
  <div class="stat">
184
- <div class="value"><span class="risk-badge" style="background:${riskColor}">${riskLevel}</span></div>
185
+ <div class="value"><span class="risk-badge" style="background:${riskColor};color:hsl(0 0% 98%)">${riskLevel}</span></div>
185
186
  <div class="label">Overall Risk</div>
186
187
  </div>
187
188
  </div>
@@ -190,10 +191,10 @@ function generateHtmlReport(results, title) {
190
191
  <div class="card">
191
192
  <h2>Severity Breakdown</h2>
192
193
  <div class="summary-grid">
193
- <div class="stat"><div class="value" style="color:#dc2626">${severityCounts.critical}</div><div class="label">Critical</div></div>
194
- <div class="stat"><div class="value" style="color:#ea580c">${severityCounts.high}</div><div class="label">High</div></div>
195
- <div class="stat"><div class="value" style="color:#2563eb">${severityCounts.medium}</div><div class="label">Medium</div></div>
196
- <div class="stat"><div class="value" style="color:#16a34a">${severityCounts.low}</div><div class="label">Low</div></div>
194
+ <div class="stat"><div class="value" style="color:hsl(0 40% 70%)">${severityCounts.critical}</div><div class="label">Critical</div></div>
195
+ <div class="stat"><div class="value" style="color:hsl(25 30% 65%)">${severityCounts.high}</div><div class="label">High</div></div>
196
+ <div class="stat"><div class="value">${severityCounts.medium}</div><div class="label">Medium</div></div>
197
+ <div class="stat"><div class="value" style="color:hsl(0 0% 50%)">${severityCounts.low}</div><div class="label">Low</div></div>
197
198
  </div>
198
199
  </div>
199
200
 
@@ -204,7 +205,7 @@ ${topPatterns.map(([name, count]) => {
204
205
  const pct = Math.round((count / totalFindings) * 100);
205
206
  return ` <div class="bar-row">
206
207
  <div class="bar-label" title="${escapeHtml(name)}">${escapeHtml(name)}</div>
207
- <div class="bar-track"><div class="bar-fill sev-medium" style="width:${pct}%"></div></div>
208
+ <div class="bar-track"><div class="bar-fill" style="width:${pct}%"></div></div>
208
209
  <div class="bar-count">${count}</div>
209
210
  </div>`;
210
211
  }).join("\n")}
@@ -226,7 +227,7 @@ ${totalFindings > 0 ? ` <div class="card">
226
227
  <tbody>
227
228
  ${findingsRows.map((f) => ` <tr>
228
229
  <td><span class="sev-pill sev-${f.severity}">${f.severity}</span></td>
229
- <td>${f.pattern}${f.description ? `<br><small style="color:#94a3b8">${f.description}</small>` : ""}</td>
230
+ <td>${f.pattern}${f.description ? `<br><small class="description">${f.description}</small>` : ""}</td>
230
231
  <td class="file-path">${f.file}</td>
231
232
  <td>${f.line}</td>
232
233
  <td class="redacted">${f.redacted}</td>
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * rafter scan — top-level scan command group.
3
3
  *
4
- * Default (no subcommand): remote backend scan (same as `rafter run`)
5
- * rafter scan remote: explicit alias for remote backend scan
4
+ * Default (no subcommand): remote scan (same as `rafter run`)
5
+ * rafter scan remote: explicit alias for remote scan
6
6
  * rafter scan local [path]: local secret scanner (was `rafter agent scan`)
7
7
  */
8
8
  import { Command } from "commander";
@@ -21,25 +21,27 @@ export function createScanGroupCommand() {
21
21
  .option("-k, --api-key <key>", "API key or RAFTER_API_KEY env var")
22
22
  .option("-f, --format <format>", "json | md", "md")
23
23
  .option("-m, --mode <mode>", "scan mode: fast | plus", "fast")
24
+ .option("--github-token <token>", "GitHub PAT for private repos (or RAFTER_GITHUB_TOKEN env var)")
24
25
  .option("--skip-interactive", "do not wait for scan to complete")
25
26
  .option("--quiet", "suppress status messages")
26
27
  .action(async (opts) => {
27
28
  await runRemoteScan(opts);
28
29
  });
29
- // Root scan group — default action is remote backend scan
30
+ // Root scan group — default action is remote scan
30
31
  const scanGroup = new Command("scan")
31
- .description("Scan for security issues. Default: remote backend scan. Use 'scan local' for local secret scanning.")
32
+ .description("Scan for security issues. Default: remote scan. Use 'scan local' for local secret scanning.")
32
33
  .enablePositionalOptions()
33
34
  .option("-r, --repo <repo>", "org/repo (default: current)")
34
35
  .option("-b, --branch <branch>", "branch (default: current else main)")
35
36
  .option("-k, --api-key <key>", "API key or RAFTER_API_KEY env var")
36
37
  .option("-f, --format <format>", "json | md", "md")
37
38
  .option("-m, --mode <mode>", "scan mode: fast | plus", "fast")
39
+ .option("--github-token <token>", "GitHub PAT for private repos (or RAFTER_GITHUB_TOKEN env var)")
38
40
  .option("--skip-interactive", "do not wait for scan to complete")
39
41
  .option("--quiet", "suppress status messages");
40
42
  scanGroup.addCommand(localCmd);
41
43
  scanGroup.addCommand(remoteCmd);
42
- // When invoked with no subcommand, run remote backend scan
44
+ // When invoked with no subcommand, run remote scan
43
45
  scanGroup.action(async (opts) => {
44
46
  await runRemoteScan(opts);
45
47
  });
@@ -0,0 +1,14 @@
1
+ import { Command } from "commander";
2
+ import { createListCommand } from "./list.js";
3
+ import { createInstallCommand } from "./install.js";
4
+ import { createUninstallCommand } from "./uninstall.js";
5
+ import { createReviewCommand } from "./review.js";
6
+ export function createSkillCommand() {
7
+ const skill = new Command("skill")
8
+ .description("Manage rafter-authored skills (list / install / uninstall / review)");
9
+ skill.addCommand(createListCommand());
10
+ skill.addCommand(createInstallCommand());
11
+ skill.addCommand(createUninstallCommand());
12
+ skill.addCommand(createReviewCommand());
13
+ return skill;
14
+ }
@@ -0,0 +1,89 @@
1
+ import { Command } from "commander";
2
+ import fs from "fs";
3
+ import { resolveSkill, listBundledSkills, skillDestPath, skillDetectDir, resolveExplicitDest, writeSkillTo, recordSkillState, SKILL_PLATFORMS, } from "./registry.js";
4
+ import { fmt } from "../../utils/formatter.js";
5
+ /**
6
+ * `rafter skill install <name>` — install a rafter-authored skill to one or
7
+ * more platforms (or an explicit --to path).
8
+ *
9
+ * Exit codes:
10
+ * 0 — installed successfully (or already installed — copy is idempotent)
11
+ * 1 — unknown skill, unknown platform, or install failure
12
+ * 2 — no detected platform found and --force was not passed
13
+ */
14
+ export function createInstallCommand() {
15
+ return new Command("install")
16
+ .description("Install a rafter-authored skill to detected platform(s) or an explicit path")
17
+ .argument("<name>", "Skill name (e.g. rafter, rafter-secure-design)")
18
+ .option("--platform <platform...>", `Target platform(s). One or more of: ${SKILL_PLATFORMS.join(", ")}. Default: all detected.`)
19
+ .option("--to <path>", "Explicit destination. If it ends in .md/.mdc, used as-is; otherwise treated as a skills-base directory.")
20
+ .option("--force", "Install even if no target platform is detected")
21
+ .action((name, opts) => {
22
+ const skill = resolveSkill(name);
23
+ if (!skill) {
24
+ console.error(fmt.error(`Unknown skill: ${name}`));
25
+ console.error(fmt.info(`Available: ${listBundledSkills().map((s) => s.name).join(", ") || "(none)"}`));
26
+ process.exit(1);
27
+ }
28
+ // --to overrides platform-based resolution.
29
+ if (opts.to) {
30
+ const destPath = resolveExplicitDest(opts.to, skill.name);
31
+ try {
32
+ writeSkillTo(skill, destPath);
33
+ console.log(fmt.success(`Installed ${skill.name} v${skill.version} → ${destPath}`));
34
+ process.exit(0);
35
+ }
36
+ catch (e) {
37
+ console.error(fmt.error(`Failed to install ${skill.name} to ${destPath}: ${e}`));
38
+ process.exit(1);
39
+ }
40
+ }
41
+ // Resolve target platforms: either explicit --platform list, or "all detected".
42
+ let targets;
43
+ if (Array.isArray(opts.platform) && opts.platform.length > 0) {
44
+ targets = [];
45
+ for (const raw of opts.platform) {
46
+ const p = raw.trim();
47
+ if (!SKILL_PLATFORMS.includes(p)) {
48
+ console.error(fmt.error(`Unknown platform: ${raw}. Known: ${SKILL_PLATFORMS.join(", ")}`));
49
+ process.exit(1);
50
+ }
51
+ targets.push(p);
52
+ }
53
+ }
54
+ else {
55
+ targets = SKILL_PLATFORMS.filter((p) => fs.existsSync(skillDetectDir(p)));
56
+ if (targets.length === 0) {
57
+ if (!opts.force) {
58
+ console.error(fmt.warning(`No supported platform detected. Re-run with --platform <name> or --force to install to all known platforms.`));
59
+ process.exit(2);
60
+ }
61
+ targets = [...SKILL_PLATFORMS];
62
+ }
63
+ }
64
+ let exitCode = 0;
65
+ for (const platform of targets) {
66
+ const detected = fs.existsSync(skillDetectDir(platform));
67
+ if (!detected && !opts.force && !(Array.isArray(opts.platform) && opts.platform.length > 0)) {
68
+ // Shouldn't hit this — we pre-filtered to detected — but defensive.
69
+ continue;
70
+ }
71
+ if (!detected && !opts.force && Array.isArray(opts.platform) && opts.platform.length > 0) {
72
+ console.error(fmt.warning(`${platform}: not detected (${skillDetectDir(platform)}). Re-run with --force to install anyway.`));
73
+ exitCode = exitCode || 2;
74
+ continue;
75
+ }
76
+ const destPath = skillDestPath(platform, skill.name);
77
+ try {
78
+ writeSkillTo(skill, destPath);
79
+ recordSkillState(platform, skill.name, true, skill.version);
80
+ console.log(fmt.success(`Installed ${skill.name} v${skill.version} → ${destPath} (${platform})`));
81
+ }
82
+ catch (e) {
83
+ console.error(fmt.error(`Failed to install ${skill.name} for ${platform}: ${e}`));
84
+ exitCode = 1;
85
+ }
86
+ }
87
+ process.exit(exitCode);
88
+ });
89
+ }
@@ -0,0 +1,79 @@
1
+ import { Command } from "commander";
2
+ import { listBundledSkills, snapshotSkills, SKILL_PLATFORMS, } from "./registry.js";
3
+ import { fmt } from "../../utils/formatter.js";
4
+ /**
5
+ * `rafter skill list` — show rafter-authored skills available in this CLI and
6
+ * whether each is installed for each supported platform (claude-code, codex,
7
+ * openclaw, cursor).
8
+ *
9
+ * Exit code: 0 on success.
10
+ */
11
+ export function createListCommand() {
12
+ return new Command("list")
13
+ .description("List rafter-authored skills and their install state per platform")
14
+ .option("--json", "Output machine-readable JSON")
15
+ .option("--installed", "Only show (skill, platform) pairs where the skill is installed")
16
+ .option("--platform <platform>", "Limit to one platform")
17
+ .action((opts) => {
18
+ const bundled = listBundledSkills();
19
+ let rows = snapshotSkills();
20
+ const platformFilter = opts.platform;
21
+ if (platformFilter) {
22
+ if (!SKILL_PLATFORMS.includes(platformFilter)) {
23
+ console.error(fmt.error(`Unknown platform: ${platformFilter}. Known: ${SKILL_PLATFORMS.join(", ")}`));
24
+ process.exit(1);
25
+ }
26
+ rows = rows.filter((r) => r.platform === platformFilter);
27
+ }
28
+ if (opts.installed)
29
+ rows = rows.filter((r) => r.installed);
30
+ if (opts.json) {
31
+ const payload = {
32
+ skills: bundled.map((s) => ({
33
+ name: s.name,
34
+ version: s.version,
35
+ description: s.description,
36
+ })),
37
+ installations: rows.map((r) => ({
38
+ name: r.name,
39
+ platform: r.platform,
40
+ path: r.path,
41
+ detected: r.detected,
42
+ installed: r.installed,
43
+ version: r.version,
44
+ })),
45
+ };
46
+ console.log(JSON.stringify(payload, null, 2));
47
+ return;
48
+ }
49
+ console.log(fmt.header("Rafter-authored skills"));
50
+ for (const s of bundled) {
51
+ console.log(` ${s.name.padEnd(24)} v${s.version}`);
52
+ }
53
+ console.log();
54
+ console.log(fmt.header("Installations by platform"));
55
+ const byPlatform = new Map();
56
+ for (const r of rows) {
57
+ const arr = byPlatform.get(r.platform) ?? [];
58
+ arr.push(r);
59
+ byPlatform.set(r.platform, arr);
60
+ }
61
+ for (const [platform, list] of byPlatform) {
62
+ const detected = list[0]?.detected ?? false;
63
+ const suffix = detected ? "" : " (not detected)";
64
+ console.log(`\n${platform}${suffix}`);
65
+ for (const r of list) {
66
+ const label = r.name.padEnd(24);
67
+ if (r.installed) {
68
+ const ver = r.version ? ` v${r.version}` : "";
69
+ console.log(` ${label} ● installed${ver} (${r.path})`);
70
+ }
71
+ else {
72
+ console.log(` ${label} ○ not installed`);
73
+ }
74
+ }
75
+ }
76
+ console.log();
77
+ console.log(fmt.info("Use `rafter skill install <name>` / `rafter skill uninstall <name>` to toggle skills."));
78
+ });
79
+ }
@@ -0,0 +1,273 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import os from "os";
4
+ import { fileURLToPath } from "url";
5
+ import { ConfigManager } from "../../core/config-manager.js";
6
+ const __filename = fileURLToPath(import.meta.url);
7
+ const __dirname = path.dirname(__filename);
8
+ /**
9
+ * Rafter-authored skills that ship inside this package. Lifecycle commands
10
+ * (`rafter skill list/install/uninstall`) only operate on names in this list —
11
+ * the intent is to manage first-party skills, not arbitrary third-party files.
12
+ */
13
+ export const KNOWN_SKILL_NAMES = [
14
+ "rafter",
15
+ "rafter-agent-security",
16
+ "rafter-secure-design",
17
+ "rafter-code-review",
18
+ "rafter-skill-review",
19
+ ];
20
+ export const SKILL_PLATFORMS = [
21
+ "claude-code",
22
+ "codex",
23
+ "openclaw",
24
+ "cursor",
25
+ ];
26
+ function skillsResourcesRoot() {
27
+ return path.join(__dirname, "..", "..", "..", "resources", "skills");
28
+ }
29
+ function parseFrontmatter(content) {
30
+ const match = content.match(/^---\n([\s\S]*?)\n---/);
31
+ if (!match)
32
+ return {};
33
+ const out = {};
34
+ for (const line of match[1].split("\n")) {
35
+ const m = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
36
+ if (!m)
37
+ continue;
38
+ let val = m[2].trim();
39
+ if (val.startsWith('"') && val.endsWith('"'))
40
+ val = val.slice(1, -1);
41
+ else if (val.startsWith("'") && val.endsWith("'"))
42
+ val = val.slice(1, -1);
43
+ out[m[1]] = val;
44
+ }
45
+ return out;
46
+ }
47
+ /** Read frontmatter from a SKILL.md file on disk. Returns {} on any failure. */
48
+ export function readSkillFrontmatter(filePath) {
49
+ try {
50
+ const content = fs.readFileSync(filePath, "utf-8");
51
+ return parseFrontmatter(content);
52
+ }
53
+ catch {
54
+ return {};
55
+ }
56
+ }
57
+ /** Enumerate bundled rafter-authored skills present in this installation. */
58
+ export function listBundledSkills() {
59
+ const root = skillsResourcesRoot();
60
+ const skills = [];
61
+ for (const name of KNOWN_SKILL_NAMES) {
62
+ const sourcePath = path.join(root, name, "SKILL.md");
63
+ if (!fs.existsSync(sourcePath))
64
+ continue;
65
+ const fm = readSkillFrontmatter(sourcePath);
66
+ skills.push({
67
+ name,
68
+ version: fm.version ?? "unknown",
69
+ description: fm.description ?? "",
70
+ sourcePath,
71
+ });
72
+ }
73
+ return skills;
74
+ }
75
+ export function resolveSkill(name) {
76
+ const normalized = name.trim();
77
+ return listBundledSkills().find((s) => s.name === normalized);
78
+ }
79
+ export function skillDetectDir(platform) {
80
+ const home = os.homedir();
81
+ switch (platform) {
82
+ case "claude-code":
83
+ return path.join(home, ".claude");
84
+ case "codex":
85
+ return path.join(home, ".codex");
86
+ case "openclaw":
87
+ return path.join(home, ".openclaw");
88
+ case "cursor":
89
+ return path.join(home, ".cursor");
90
+ }
91
+ }
92
+ /**
93
+ * Base directory where a platform stores INSTALLED skill files. Used by
94
+ * `rafter skill review --installed` to walk every skill on this machine.
95
+ *
96
+ * Shape per platform (see `skillDestPath` for where we *write* skills):
97
+ * claude-code → ~/.claude/skills/<name>/SKILL.md
98
+ * codex → ~/.agents/skills/<name>/SKILL.md
99
+ * openclaw → ~/.openclaw/skills/<name>.md
100
+ * cursor → ~/.cursor/rules/<name>.mdc
101
+ */
102
+ export function skillBaseDir(platform) {
103
+ const home = os.homedir();
104
+ switch (platform) {
105
+ case "claude-code":
106
+ return path.join(home, ".claude", "skills");
107
+ case "codex":
108
+ return path.join(home, ".agents", "skills");
109
+ case "openclaw":
110
+ return path.join(home, ".openclaw", "skills");
111
+ case "cursor":
112
+ return path.join(home, ".cursor", "rules");
113
+ }
114
+ }
115
+ /**
116
+ * Walk every known platform's skill base directory and return one entry per
117
+ * installed skill file. Platform layout determines whether skills are per-dir
118
+ * (claude-code, codex) or flat files (openclaw, cursor). Missing base dirs are
119
+ * silently skipped. Unreadable entries are silently skipped (permission denied
120
+ * on a single subdir never aborts the whole walk).
121
+ */
122
+ export function discoverInstalledSkills(platform) {
123
+ const targets = platform ? [platform] : SKILL_PLATFORMS;
124
+ const out = [];
125
+ for (const p of targets) {
126
+ const base = skillBaseDir(p);
127
+ let entries;
128
+ try {
129
+ entries = fs.readdirSync(base, { withFileTypes: true });
130
+ }
131
+ catch {
132
+ continue; // missing or unreadable → nothing to audit here
133
+ }
134
+ for (const entry of entries) {
135
+ const full = path.join(base, entry.name);
136
+ if (p === "claude-code" || p === "codex") {
137
+ if (!entry.isDirectory())
138
+ continue;
139
+ const skillFile = path.join(full, "SKILL.md");
140
+ try {
141
+ if (!fs.statSync(skillFile).isFile())
142
+ continue;
143
+ }
144
+ catch {
145
+ continue;
146
+ }
147
+ out.push({ platform: p, name: entry.name, path: skillFile });
148
+ }
149
+ else if (p === "openclaw") {
150
+ if (!entry.isFile() || !entry.name.toLowerCase().endsWith(".md"))
151
+ continue;
152
+ out.push({
153
+ platform: p,
154
+ name: entry.name.replace(/\.md$/i, ""),
155
+ path: full,
156
+ });
157
+ }
158
+ else if (p === "cursor") {
159
+ if (!entry.isFile() || !entry.name.toLowerCase().endsWith(".mdc"))
160
+ continue;
161
+ out.push({
162
+ platform: p,
163
+ name: entry.name.replace(/\.mdc$/i, ""),
164
+ path: full,
165
+ });
166
+ }
167
+ }
168
+ }
169
+ // Deterministic ordering — tests golden-file against this.
170
+ out.sort((a, b) => {
171
+ if (a.platform !== b.platform)
172
+ return a.platform.localeCompare(b.platform);
173
+ return a.name.localeCompare(b.name);
174
+ });
175
+ return out;
176
+ }
177
+ /** Destination file path for a skill on a given platform. */
178
+ export function skillDestPath(platform, skillName) {
179
+ const home = os.homedir();
180
+ switch (platform) {
181
+ case "claude-code":
182
+ return path.join(home, ".claude", "skills", skillName, "SKILL.md");
183
+ case "codex":
184
+ return path.join(home, ".agents", "skills", skillName, "SKILL.md");
185
+ case "openclaw":
186
+ return path.join(home, ".openclaw", "skills", `${skillName}.md`);
187
+ case "cursor":
188
+ return path.join(home, ".cursor", "rules", `${skillName}.mdc`);
189
+ }
190
+ }
191
+ /** Resolve a --to argument to a concrete file path for a skill.
192
+ *
193
+ * Rules:
194
+ * - If `dest` ends in `.md` / `.mdc`, it's taken as the literal file path.
195
+ * - Otherwise `dest` is treated as a skills *base* directory, and the skill
196
+ * is written to `<dest>/<skill>/SKILL.md` (matches claude-code / codex layout).
197
+ */
198
+ export function resolveExplicitDest(dest, skillName) {
199
+ const lower = dest.toLowerCase();
200
+ if (lower.endsWith(".md") || lower.endsWith(".mdc"))
201
+ return dest;
202
+ return path.join(dest, skillName, "SKILL.md");
203
+ }
204
+ function ensureParent(filePath) {
205
+ const dir = path.dirname(filePath);
206
+ if (!fs.existsSync(dir))
207
+ fs.mkdirSync(dir, { recursive: true });
208
+ }
209
+ /** Write a skill's SKILL.md to `destPath`. Creates parent directories as needed. */
210
+ export function writeSkillTo(skill, destPath) {
211
+ ensureParent(destPath);
212
+ fs.copyFileSync(skill.sourcePath, destPath);
213
+ }
214
+ /** Delete a skill file at `destPath`; prune the immediate parent dir if empty. */
215
+ export function deleteSkillAt(destPath) {
216
+ if (!fs.existsSync(destPath))
217
+ return false;
218
+ fs.rmSync(destPath, { force: true });
219
+ const parent = path.dirname(destPath);
220
+ try {
221
+ if (fs.existsSync(parent) && fs.readdirSync(parent).length === 0) {
222
+ fs.rmdirSync(parent);
223
+ }
224
+ }
225
+ catch {
226
+ // non-empty or races — leave it
227
+ }
228
+ return true;
229
+ }
230
+ /** Snapshot of every (platform, skill) pair's install state on disk. */
231
+ export function snapshotSkills() {
232
+ const bundled = listBundledSkills();
233
+ const rows = [];
234
+ for (const skill of bundled) {
235
+ for (const platform of SKILL_PLATFORMS) {
236
+ const destPath = skillDestPath(platform, skill.name);
237
+ const detected = fs.existsSync(skillDetectDir(platform));
238
+ const installed = fs.existsSync(destPath);
239
+ let version = null;
240
+ if (installed) {
241
+ const fm = readSkillFrontmatter(destPath);
242
+ version = fm.version ?? null;
243
+ }
244
+ rows.push({
245
+ name: skill.name,
246
+ platform,
247
+ detected,
248
+ installed,
249
+ path: destPath,
250
+ version,
251
+ });
252
+ }
253
+ }
254
+ return rows;
255
+ }
256
+ /**
257
+ * Record a skill's install/uninstall state in ~/.rafter/config.json under
258
+ * `skills.<platform>.<name>`. Writes the whole `skills` map in one shot to
259
+ * avoid splitting the skill name (which can contain hyphens but not dots) —
260
+ * unlike component IDs, there's no dot-key hazard here, but we keep one
261
+ * serialization path for consistency.
262
+ */
263
+ export function recordSkillState(platform, name, enabled, version) {
264
+ const cm = new ConfigManager();
265
+ const existing = (cm.get("skillInstallations") ?? {});
266
+ existing[platform] ?? (existing[platform] = {});
267
+ existing[platform][name] = {
268
+ enabled,
269
+ version: version ?? undefined,
270
+ updatedAt: new Date().toISOString(),
271
+ };
272
+ cm.set("skillInstallations", existing);
273
+ }