@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.
- package/README.md +29 -10
- package/dist/commands/agent/audit-skill.js +22 -20
- package/dist/commands/agent/audit.js +27 -0
- package/dist/commands/agent/components.js +800 -0
- package/dist/commands/agent/config.js +2 -1
- package/dist/commands/agent/disable.js +47 -0
- package/dist/commands/agent/enable.js +50 -0
- package/dist/commands/agent/exec.js +2 -0
- package/dist/commands/agent/index.js +6 -0
- package/dist/commands/agent/init.js +162 -163
- package/dist/commands/agent/install-hook.js +15 -14
- package/dist/commands/agent/list.js +72 -0
- package/dist/commands/agent/scan.js +4 -3
- package/dist/commands/agent/verify.js +1 -1
- package/dist/commands/backend/run.js +12 -3
- package/dist/commands/backend/scan-status.js +3 -2
- package/dist/commands/brief.js +22 -2
- package/dist/commands/ci/init.js +25 -21
- package/dist/commands/completion.js +4 -3
- package/dist/commands/docs/index.js +18 -0
- package/dist/commands/docs/list.js +37 -0
- package/dist/commands/docs/show.js +64 -0
- package/dist/commands/mcp/server.js +84 -0
- package/dist/commands/report.js +42 -41
- package/dist/commands/scan/index.js +7 -5
- package/dist/commands/skill/index.js +14 -0
- package/dist/commands/skill/install.js +89 -0
- package/dist/commands/skill/list.js +79 -0
- package/dist/commands/skill/registry.js +273 -0
- package/dist/commands/skill/remote.js +333 -0
- package/dist/commands/skill/review.js +975 -0
- package/dist/commands/skill/uninstall.js +65 -0
- package/dist/core/audit-logger.js +262 -21
- package/dist/core/config-manager.js +3 -0
- package/dist/core/docs-loader.js +148 -0
- package/dist/core/policy-loader.js +72 -1
- package/dist/core/risk-rules.js +16 -3
- package/dist/index.js +19 -9
- package/dist/scanners/gitleaks.js +6 -2
- package/package.json +1 -1
- package/resources/skills/rafter/SKILL.md +77 -97
- package/resources/skills/rafter/docs/backend.md +106 -0
- package/resources/skills/rafter/docs/cli-reference.md +199 -0
- package/resources/skills/rafter/docs/finding-triage.md +79 -0
- package/resources/skills/rafter/docs/guardrails.md +91 -0
- package/resources/skills/rafter/docs/shift-left.md +64 -0
- package/resources/skills/rafter-agent-security/SKILL.md +1 -1
- package/resources/skills/rafter-code-review/SKILL.md +91 -0
- package/resources/skills/rafter-code-review/docs/api.md +90 -0
- package/resources/skills/rafter-code-review/docs/asvs.md +120 -0
- package/resources/skills/rafter-code-review/docs/cwe-top25.md +78 -0
- package/resources/skills/rafter-code-review/docs/investigation-playbook.md +101 -0
- package/resources/skills/rafter-code-review/docs/llm.md +87 -0
- package/resources/skills/rafter-code-review/docs/web-app.md +84 -0
- package/resources/skills/rafter-secure-design/SKILL.md +103 -0
- package/resources/skills/rafter-secure-design/docs/api-design.md +97 -0
- package/resources/skills/rafter-secure-design/docs/auth.md +67 -0
- package/resources/skills/rafter-secure-design/docs/data-storage.md +90 -0
- package/resources/skills/rafter-secure-design/docs/dependencies.md +101 -0
- package/resources/skills/rafter-secure-design/docs/deployment.md +104 -0
- package/resources/skills/rafter-secure-design/docs/ingestion.md +98 -0
- package/resources/skills/rafter-secure-design/docs/standards-pointers.md +102 -0
- package/resources/skills/rafter-secure-design/docs/threat-modeling.md +128 -0
- package/resources/skills/rafter-skill-review/SKILL.md +106 -0
- package/resources/skills/rafter-skill-review/docs/authorship-provenance.md +82 -0
- package/resources/skills/rafter-skill-review/docs/changelog-review.md +99 -0
- package/resources/skills/rafter-skill-review/docs/data-practices.md +88 -0
- package/resources/skills/rafter-skill-review/docs/malware-indicators.md +79 -0
- package/resources/skills/rafter-skill-review/docs/prompt-injection.md +85 -0
- package/resources/skills/rafter-skill-review/docs/telemetry.md +78 -0
package/dist/commands/report.js
CHANGED
|
@@ -86,11 +86,11 @@ function generateHtmlReport(results, title) {
|
|
|
86
86
|
? "Low"
|
|
87
87
|
: "None";
|
|
88
88
|
const riskColor = {
|
|
89
|
-
Critical: "
|
|
90
|
-
High: "
|
|
91
|
-
Medium: "
|
|
92
|
-
Low: "
|
|
93
|
-
None: "
|
|
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: -
|
|
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:
|
|
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.
|
|
122
|
-
.card { background:
|
|
123
|
-
.card h2 { font-size: 1.1rem; font-weight: 600; margin-bottom: 1rem; color:
|
|
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:
|
|
126
|
-
.stat .value { font-size: 2rem; font-weight: 700; }
|
|
127
|
-
.stat .label { font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.05em; color:
|
|
128
|
-
.risk-badge { display: inline-block; padding: 0.25rem 0.75rem; border-radius: 4px;
|
|
129
|
-
.sev-critical { background:
|
|
130
|
-
.sev-high { background:
|
|
131
|
-
.sev-medium { background:
|
|
132
|
-
.sev-low { background:
|
|
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:
|
|
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:
|
|
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:
|
|
141
|
-
td { padding: 0.6rem 0.75rem; border-bottom: 1px solid
|
|
142
|
-
tr:hover td { background:
|
|
143
|
-
.sev-pill { display: inline-block; padding: 0.15rem 0.5rem; border-radius: 3px;
|
|
144
|
-
.file-path { font-
|
|
145
|
-
.redacted { font-
|
|
146
|
-
|
|
147
|
-
|
|
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:
|
|
156
|
-
.card {
|
|
157
|
-
header {
|
|
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"
|
|
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
|
|
194
|
-
<div class="stat"><div class="value" style="color
|
|
195
|
-
<div class="stat"><div class="value"
|
|
196
|
-
<div class="stat"><div class="value" style="color
|
|
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
|
|
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
|
|
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
|
|
5
|
-
* rafter scan remote: explicit alias for remote
|
|
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
|
|
30
|
+
// Root scan group — default action is remote scan
|
|
30
31
|
const scanGroup = new Command("scan")
|
|
31
|
-
.description("Scan for security issues. Default: remote
|
|
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
|
|
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
|
+
}
|