@ronaldjdevfs/forge 1.0.2 → 1.1.0

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 (48) hide show
  1. package/README.md +130 -8
  2. package/package.json +2 -2
  3. package/skills/forge/SKILL.md +86 -15
  4. package/skills/forge/profiles/express-drizzle.md +107 -0
  5. package/skills/forge/profiles/fastify-mongodb.md +103 -0
  6. package/skills/forge/profiles/fastify-prisma.md +81 -0
  7. package/skills/forge/profiles/nestjs-mongodb.md +92 -0
  8. package/skills/forge/profiles/nestjs-postgres.md +98 -0
  9. package/skills/forge/reference/api-design.md +62 -0
  10. package/skills/forge/reference/assay.md +82 -0
  11. package/skills/forge/reference/cast.md +81 -7
  12. package/skills/forge/reference/data-patterns.md +86 -0
  13. package/skills/forge/reference/di-strategies.md +50 -0
  14. package/skills/forge/reference/errors.md +65 -0
  15. package/skills/forge/reference/events.md +95 -0
  16. package/skills/forge/reference/help.md +40 -0
  17. package/skills/forge/reference/hooks.md +62 -0
  18. package/skills/forge/reference/observability.md +66 -0
  19. package/skills/forge/reference/patterns.md +52 -0
  20. package/skills/forge/reference/principles.md +6 -0
  21. package/skills/forge/reference/reforge.md +69 -5
  22. package/skills/forge/reference/relocate.md +15 -2
  23. package/skills/forge/reference/security-patterns.md +87 -0
  24. package/skills/forge/reference/testing-patterns.md +69 -0
  25. package/skills/forge/scripts/assay.mjs +481 -0
  26. package/skills/forge/scripts/context.mjs +147 -43
  27. package/skills/forge/scripts/detect.mjs +371 -22
  28. package/skills/forge/scripts/forge-api.mjs +373 -0
  29. package/skills/forge/scripts/forge-config.mjs +268 -0
  30. package/skills/forge/scripts/forge-signals.mjs +131 -0
  31. package/skills/forge/scripts/forge-state.mjs +97 -0
  32. package/skills/forge/scripts/formatter.mjs +133 -0
  33. package/skills/forge/scripts/graph.mjs +5 -21
  34. package/skills/forge/scripts/hook.mjs +250 -0
  35. package/skills/forge/scripts/inspect.mjs +171 -22
  36. package/skills/forge/scripts/parse-imports.mjs +249 -0
  37. package/skills/forge/scripts/pin.mjs +151 -0
  38. package/skills/forge/scripts/posttool.mjs +224 -0
  39. package/skills/forge/scripts/profile.mjs +124 -20
  40. package/skills/forge/scripts/registry/rules.mjs +344 -0
  41. package/skills/forge/scripts/rename.mjs +669 -0
  42. package/skills/forge/scripts/rollback.mjs +213 -0
  43. package/skills/forge/scripts/update.mjs +114 -0
  44. package/skills/forge/templates/feature/domain-error.ts.md +9 -0
  45. package/skills/forge/templates/feature/domain-event.ts.md +9 -0
  46. package/skills/forge/templates/feature/event-handler.ts.md +10 -0
  47. package/skills/forge/templates/feature/use-case.ts.md +10 -2
  48. package/skills/forge/tests/core.test.mjs +403 -0
@@ -1,29 +1,16 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { join, basename } from "path";
3
+ import { join, basename, relative } from "path";
4
+ import { execFileSync } from "child_process";
4
5
  import { buildContext } from "./context.mjs";
5
6
  import { detectProfile, detectProfileExtended } from "./profile.mjs";
6
7
  import { buildDependencyGraph } from "./chain.mjs";
7
- import { allChecks } from "./detect.mjs";
8
+ import { allChecks, checkStructure, checkLayers, checkDecorators } from "./detect.mjs";
9
+ import { saveHistory, updateStateFromAudit } from "./forge-config.mjs";
8
10
 
9
11
  const ROOT = process.cwd();
10
12
 
11
- const CYAN = "\x1b[36m";
12
- const GREEN = "\x1b[32m";
13
- const RED = "\x1b[31m";
14
- const YELLOW = "\x1b[33m";
15
- const BOLD = "\x1b[1m";
16
- const RESET = "\x1b[0m";
17
- const DIM = "\x1b[2m";
18
- const GRAY = "\x1b[90m";
19
-
20
- const SEVERITY_COLORS = {
21
- CRITICAL: RED,
22
- ERROR: RED,
23
- WARNING: YELLOW,
24
- INFO: CYAN,
25
- SUGGESTION: GRAY,
26
- };
13
+ import { CYAN, GREEN, RED, YELLOW, BOLD, RESET, DIM, GRAY, SEVERITY_COLORS, col, formatJson, formatCheck, scoreBar, formatReport } from "./formatter.mjs";
27
14
 
28
15
  const CAT_NAMES = {
29
16
  structure: "Estructura",
@@ -73,7 +60,7 @@ function buildReport(result) {
73
60
  return { total, max: maxTotal, categories: result.categories, violations, recommendations, severityCounts: countBySeverity(violations) };
74
61
  }
75
62
 
76
- function printReport(report, ctx, profile, graph, archGraph) {
63
+ function printReport(report, ctx, profile, graph, archGraph, profileExtended) {
77
64
  const barLen = 40;
78
65
  const pct = report.max > 0 ? Math.round((report.total / report.max) * 100) : 0;
79
66
 
@@ -162,6 +149,36 @@ function printReport(report, ctx, profile, graph, archGraph) {
162
149
  console.log();
163
150
  }
164
151
 
152
+ /* Contextual suggestions */
153
+ const suggestions = [];
154
+ const v = report.severityCounts;
155
+ if ((v.CRITICAL || 0) > 0) suggestions.push("forge quench — revisar reglas CRITICAL en detalle");
156
+ if ((v.ERROR || 0) > 0) suggestions.push("forge quench — corregir violaciones ERROR antes de avanzar");
157
+ if (graph.hasCycles) suggestions.push("forge reforge — eliminar ciclos del grafo de dependencias");
158
+ if (ctx.platform.exists && ctx.platform.components.length < 3) suggestions.push("forge forge — bootstrap de componentes platform faltantes");
159
+ if (!ctx.platform.exists) suggestions.push("forge forge — iniciar bootstrap de platform");
160
+ if (ctx.features.legacy.length > 0) suggestions.push(`forge relocate — migrar ${ctx.features.legacy.length} feature(s) legacy a estructura hexagonal`);
161
+ if (archGraph && archGraph.stats.dependencyHealth < 70) suggestions.push("forge temper — endurecer inyección de dependencias");
162
+ if (archGraph && archGraph.stats.riskScore > 50) suggestions.push("forge reforge — reducir riesgo arquitectónico");
163
+ if (pct >= 80) suggestions.push("forge inspect — mantener auditoría periódica");
164
+ if (pct < 50) suggestions.push("forge inspect — auditoría focalizada tras correcciones");
165
+
166
+ // Profile-derived suggestions
167
+ if (profileExtended) {
168
+ for (const dep of profileExtended.depIssues || []) {
169
+ suggestions.push(`profiler: ${dep}`);
170
+ }
171
+ for (const s of profileExtended.suggestions || []) {
172
+ suggestions.push(`profiler: ${s}`);
173
+ }
174
+ }
175
+
176
+ if (suggestions.length > 0) {
177
+ console.log(` ${BOLD}${CYAN}Siguientes pasos sugeridos${RESET}`);
178
+ suggestions.forEach((s, i) => console.log(` ${i + 1}. ${s}`));
179
+ console.log();
180
+ }
181
+
165
182
  console.log("═".repeat(58) + "\n");
166
183
  }
167
184
 
@@ -169,24 +186,156 @@ function printJson(report, ctx, profile, graph, archGraph) {
169
186
  console.log(JSON.stringify({ ...report, profile, context: ctx, dependencies: graph, architectureGraph: archGraph }, null, 2));
170
187
  }
171
188
 
189
+ /**
190
+ * Get files changed vs default branch (or working tree as fallback).
191
+ */
192
+ function getChangedFiles() {
193
+ // Try 1: changes vs default branch (committed but not merged)
194
+ let files = [];
195
+ try {
196
+ const defaultBranch = execFileSync("git", ["symbolic-ref", "refs/remotes/origin/HEAD"], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] })
197
+ .trim().replace("refs/remotes/origin/", "");
198
+ const raw = execFileSync("git", ["diff", "--name-only", "--diff-filter=ACMR", `${defaultBranch}...HEAD`], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] });
199
+ files = raw.trim().split("\n").filter(Boolean);
200
+ } catch {}
201
+
202
+ // Try 2: working tree changes (unstaged + uncommitted)
203
+ if (files.length === 0) {
204
+ try {
205
+ const raw = execFileSync("git", ["status", "--porcelain"], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] });
206
+ files = raw.split("\n").filter(Boolean).map(l => l.slice(3));
207
+ } catch {}
208
+ }
209
+
210
+ // Try 3: staged changes only
211
+ if (files.length === 0) {
212
+ try {
213
+ const raw = execFileSync("git", ["diff", "--cached", "--name-only", "--diff-filter=ACMR"], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] });
214
+ files = raw.trim().split("\n").filter(Boolean);
215
+ } catch {}
216
+ }
217
+
218
+ return files;
219
+ }
220
+
221
+ /**
222
+ * Determine which features are affected by changed files.
223
+ */
224
+ function getChangedFeatures(changedFiles, allFeatures) {
225
+ if (changedFiles.length === 0) return [];
226
+ const affected = new Set();
227
+ for (const file of changedFiles) {
228
+ const match = file.match(/src\/features\/([^/]+)\//);
229
+ if (match) affected.add(match[1]);
230
+ }
231
+ return allFeatures.filter(f => affected.has(f));
232
+ }
233
+
172
234
  async function main() {
173
235
  const args = process.argv.slice(2);
174
236
  const isJson = args.includes("--json");
237
+ const isDiff = args.includes("--diff");
175
238
  const filterSeverity = args.includes("--severity") ? args[args.indexOf("--severity") + 1] : null;
176
239
 
177
240
  const ctx = await buildContext();
178
- const profile = detectProfile(ctx);
241
+ const profileExtended = detectProfileExtended(ctx);
242
+ const profile = profileExtended.profile;
179
243
  const chainGraph = buildDependencyGraph();
180
244
  const archGraph = ctx.graph;
181
245
  const features = ctx.features.migrated;
182
- const result = allChecks(features, archGraph, ctx);
183
246
 
247
+ if (isDiff) {
248
+ const changedFiles = getChangedFiles();
249
+ const changedFeatures = getChangedFeatures(changedFiles, features);
250
+
251
+ if (!isJson) {
252
+ console.log(`\n${CYAN}═══ Diff-Aware Audit ═══${RESET}`);
253
+ console.log(`${DIM}Archivos cambiados: ${changedFiles.length}${RESET}`);
254
+ if (changedFiles.length > 0) {
255
+ for (const f of changedFiles.slice(0, 10)) {
256
+ console.log(` ${DIM}${f}${RESET}`);
257
+ }
258
+ if (changedFiles.length > 10) console.log(` ${DIM}... (+${changedFiles.length - 10})${RESET}`);
259
+ }
260
+ }
261
+
262
+ if (changedFeatures.length === 0) {
263
+ if (!isJson) {
264
+ console.log(`\n${YELLOW}⚠ No hay features afectados por los cambios.${RESET}`);
265
+ console.log(`${DIM}Los cambios están fuera de src/features/ o no hay features migrados.${RESET}\n`);
266
+ } else {
267
+ console.log(JSON.stringify({ diff: { changedFiles: changedFiles.length, changedFeatures: [], affectedFeatures: false } }));
268
+ }
269
+ process.exit(0);
270
+ }
271
+
272
+ if (!isJson) {
273
+ console.log(`${DIM}Features afectados: ${changedFeatures.join(", ")}${RESET}\n`);
274
+ }
275
+
276
+ // Run only feature-specific checks on changed features
277
+ const result = {
278
+ structure: checkStructure(changedFeatures),
279
+ layers: checkLayers(changedFeatures),
280
+ decorators: checkDecorators(changedFeatures),
281
+ graph: archGraph ? { score: 0, checks: [{ severity: "INFO", label: `Grafo: ${archGraph.stats.totalNodes} nodos, ${archGraph.stats.violations} violaciones (global)`, pass: true }] } : { score: 0, checks: [] },
282
+ };
283
+
284
+ // Provide a simpler report
285
+ let totalScore = result.structure.score + result.layers.score + (result.decorators?.score || 0);
286
+ const maxScore = 60; // structure 20 + layers 20 + decorators 20
287
+ const pct = Math.round((totalScore / maxScore) * 100);
288
+
289
+ if (!isJson) {
290
+ console.log(`${BOLD}Score en features afectados: ${pct >= 80 ? GREEN : pct >= 50 ? YELLOW : RED}${totalScore}/${maxScore} (${pct}%)${RESET}\n`);
291
+
292
+ for (const [key, cat] of Object.entries(result)) {
293
+ const name = key.charAt(0).toUpperCase() + key.slice(1);
294
+ console.log(` ${BOLD}${name} (${cat.score}/${cat.score === 0 && cat.checks.length > 0 ? "—" : maxScore})${RESET}`);
295
+ for (const check of cat.checks) {
296
+ const icon = check.pass ? `${GREEN}✔${RESET}` : `${RED}✘${RESET}`;
297
+ const sev = check.pass ? "" : ` ${SEVERITY_COLORS[check.severity]}[${check.severity}]${RESET}`;
298
+ const detail = check.detail ? ` ${GRAY}— ${check.detail}${RESET}` : "";
299
+ console.log(` ${icon}${sev} ${check.label}${detail}`);
300
+ if (!check.pass && check.fix) {
301
+ console.log(` ${DIM}→ Fix: ${check.fix}${RESET}`);
302
+ }
303
+ }
304
+ console.log();
305
+ }
306
+ } else {
307
+ console.log(JSON.stringify({
308
+ diff: { changedFiles: changedFiles.length, changedFeatures, totalScore, maxScore, pct },
309
+ categories: result,
310
+ }, null, 2));
311
+ }
312
+ updateStateFromAudit({ total: totalScore, grade: `${pct}%`, violations: [], health: "diff", context: { features: { total: features.length, migrated: features, legacy: [] } } });
313
+ saveHistory({ score: totalScore, grade: `${pct}%`, violationCount: 0, totalFeatures: features.length, migratedFeatures: features.length });
314
+ process.exit(0);
315
+ }
316
+
317
+ const result = allChecks(features, archGraph, ctx);
184
318
  const report = buildReport({ categories: result });
185
319
 
320
+ updateStateFromAudit({
321
+ total: report.total,
322
+ grade: report.grade,
323
+ violations: report.violations || [],
324
+ health: report.total >= 80 ? "healthy" : report.total >= 50 ? "fair" : "poor",
325
+ context: { features: { total: features.length, migrated: features, legacy: ctx.features?.legacy || [] } },
326
+ });
327
+ saveHistory({
328
+ score: report.total,
329
+ grade: report.grade,
330
+ violationCount: (report.violations || []).length,
331
+ totalFeatures: features.length,
332
+ migratedFeatures: features.length,
333
+ });
334
+
186
335
  if (isJson) {
187
336
  printJson(report, ctx, profile, chainGraph, archGraph);
188
337
  } else {
189
- printReport(report, ctx, profile, chainGraph, archGraph);
338
+ printReport(report, ctx, profile, chainGraph, archGraph, profileExtended);
190
339
  }
191
340
  }
192
341
 
@@ -0,0 +1,249 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Shared import parser for Forge.
5
+ * Uses @typescript-eslint/parser (AST) when available, falls back to regex.
6
+ * Handles imports, re-exports, dynamic imports, type-only imports, barrel exports.
7
+ */
8
+
9
+ import { existsSync } from "fs";
10
+ import { join, dirname } from "path";
11
+ import { createRequire } from "module";
12
+
13
+ const ROOT = process.cwd();
14
+
15
+ // ── AST Parser (try to load from project or skill node_modules) ──
16
+ let tsParser = null;
17
+
18
+ function tryLoadTSParser() {
19
+ if (tsParser !== null) return tsParser;
20
+
21
+ const candidates = [
22
+ join(ROOT, "node_modules", "@typescript-eslint", "parser"),
23
+ join(dirname(new URL(import.meta.url).pathname), "..", "node_modules", "@typescript-eslint", "parser"),
24
+ ];
25
+
26
+ for (const dir of candidates) {
27
+ if (existsSync(join(dir, "package.json"))) {
28
+ try {
29
+ const req = createRequire(dir);
30
+ const parser = req("@typescript-eslint/parser");
31
+ tsParser = parser;
32
+ return parser;
33
+ } catch {
34
+ // fall through
35
+ }
36
+ }
37
+ }
38
+
39
+ tsParser = false; // not available
40
+ return null;
41
+ }
42
+
43
+ function parseWithAST(content, filePath) {
44
+ const parser = tryLoadTSParser();
45
+ if (!parser) return null;
46
+
47
+ try {
48
+ const ast = parser.parse(content, {
49
+ jsx: filePath?.endsWith(".tsx") || filePath?.endsWith(".jsx") || undefined,
50
+ range: false,
51
+ loc: false,
52
+ tokens: false,
53
+ comment: false,
54
+ errorOnUnknownASTType: false,
55
+ });
56
+
57
+ const imports = [];
58
+
59
+ for (const node of ast.body) {
60
+ // import ... from '...'
61
+ if (node.type === "ImportDeclaration" && node.source?.value) {
62
+ imports.push(node.source.value);
63
+ }
64
+ // export { ... } from '...', export * from '...'
65
+ if (
66
+ (node.type === "ExportNamedDeclaration" || node.type === "ExportAllDeclaration") &&
67
+ node.source?.value
68
+ ) {
69
+ imports.push(node.source.value);
70
+ }
71
+ }
72
+
73
+ // Dynamic imports — walk the tree
74
+ function walk(node) {
75
+ if (!node || typeof node !== "object") return;
76
+ if (Array.isArray(node)) {
77
+ for (const child of node) walk(child);
78
+ return;
79
+ }
80
+
81
+ // ImportExpression (import('...'))
82
+ if (node.type === "ImportExpression" && node.source?.value) {
83
+ imports.push(node.source.value);
84
+ }
85
+
86
+ // Legacy CallExpression dynamic import
87
+ if (node.type === "CallExpression" && node.callee?.name === "import" && node.arguments?.[0]?.value) {
88
+ imports.push(node.arguments[0].value);
89
+ }
90
+
91
+ for (const key of Object.keys(node)) {
92
+ walk(node[key]);
93
+ }
94
+ }
95
+
96
+ walk(ast);
97
+
98
+ return [...new Set(imports)];
99
+ } catch {
100
+ return null;
101
+ }
102
+ }
103
+
104
+ function parseWithASTWithLines(content, filePath) {
105
+ const parser = tryLoadTSParser();
106
+ if (!parser) return null;
107
+
108
+ try {
109
+ const ast = parser.parse(content, {
110
+ jsx: filePath?.endsWith(".tsx") || filePath?.endsWith(".jsx") || undefined,
111
+ range: true,
112
+ loc: true,
113
+ tokens: false,
114
+ comment: false,
115
+ errorOnUnknownASTType: false,
116
+ });
117
+
118
+ const imports = [];
119
+
120
+ function addSource(node, source) {
121
+ if (source?.value && node.loc) {
122
+ imports.push({ file: filePath, source: source.value, line: node.loc.start.line });
123
+ }
124
+ }
125
+
126
+ for (const node of ast.body) {
127
+ if (node.type === "ImportDeclaration" && node.source?.value) {
128
+ imports.push({ file: filePath, source: node.source.value, line: node.loc.start.line });
129
+ }
130
+ if (node.type === "ExportNamedDeclaration" && node.source?.value) {
131
+ imports.push({ file: filePath, source: node.source.value, line: node.loc.start.line });
132
+ }
133
+ if (node.type === "ExportAllDeclaration" && node.source?.value) {
134
+ imports.push({ file: filePath, source: node.source.value, line: node.loc.start.line });
135
+ }
136
+ }
137
+
138
+ function walk(node) {
139
+ if (!node || typeof node !== "object") return;
140
+ if (Array.isArray(node)) {
141
+ for (const child of node) walk(child);
142
+ return;
143
+ }
144
+ if (node.type === "ImportExpression" && node.source?.value && node.loc) {
145
+ imports.push({ file: filePath, source: node.source.value, line: node.loc.start.line });
146
+ }
147
+ if (node.type === "CallExpression" && node.callee?.name === "import" && node.arguments?.[0]?.value && node.loc) {
148
+ imports.push({ file: filePath, source: node.arguments[0].value, line: node.loc.start.line });
149
+ }
150
+ for (const key of Object.keys(node)) {
151
+ walk(node[key]);
152
+ }
153
+ }
154
+ walk(ast);
155
+
156
+ return imports;
157
+ } catch {
158
+ return null;
159
+ }
160
+ }
161
+
162
+ // ── Regex fallback (same as before) ──
163
+
164
+ const PATTERNS = [
165
+ /import\s+(?:type\s+)?(?:\{[^}]*\}|[^;{]+?)\s+from\s+['"]([^'"]+)['"]/g,
166
+ /export\s+(?:type\s+)?(?:\{[^}]*\}|\*)\s+from\s+['"]([^'"]+)['"]/g,
167
+ /import\s*\(\s*['"]([^'"]+)['"]\s*\)/g,
168
+ /import\s+['"]([^'"]+)['"]/g,
169
+ ];
170
+
171
+ function parseWithRegex(content) {
172
+ const imports = [];
173
+ const seen = new Set();
174
+ for (const re of PATTERNS) {
175
+ re.lastIndex = 0;
176
+ let match;
177
+ while ((match = re.exec(content)) !== null) {
178
+ const path = match[1];
179
+ if (path && !seen.has(path)) {
180
+ seen.add(path);
181
+ imports.push(path);
182
+ }
183
+ }
184
+ }
185
+ return imports;
186
+ }
187
+
188
+ function parseWithRegexLines(content, filePath) {
189
+ const imports = [];
190
+ const lines = content.split("\n");
191
+ for (let i = 0; i < lines.length; i++) {
192
+ const line = lines[i];
193
+ for (const re of PATTERNS) {
194
+ re.lastIndex = 0;
195
+ const matches = [...line.matchAll(re)];
196
+ for (const m of matches) {
197
+ const path = m[1];
198
+ if (path) {
199
+ imports.push({ file: filePath, source: path, line: i + 1 });
200
+ }
201
+ }
202
+ }
203
+ }
204
+ return imports;
205
+ }
206
+
207
+ // ── Public API ──
208
+
209
+ /**
210
+ * Parse import/export paths from source code.
211
+ * Returns array of unique import path strings.
212
+ * Uses AST parser when available, falls back to regex.
213
+ */
214
+ export function parseImportPaths(content, filePath) {
215
+ const astResult = filePath ? parseWithAST(content, filePath) : null;
216
+ if (astResult) return astResult;
217
+ const regexResult = parseWithAST(content); // second try without filePath hint
218
+ if (regexResult) return regexResult;
219
+ return parseWithRegex(content);
220
+ }
221
+
222
+ /**
223
+ * Parse import/export statements with line numbers.
224
+ * Returns array of { file, source, line }.
225
+ * Uses AST parser when available, falls back to regex.
226
+ */
227
+ export function parseImportsWithLines(content, filePath) {
228
+ const astResult = parseWithASTWithLines(content, filePath);
229
+ if (astResult) return astResult;
230
+ return parseWithRegexLines(content, filePath);
231
+ }
232
+
233
+ /* ── CLI test ── */
234
+ if (process.argv[1] && process.argv[1].endsWith("parse-imports.mjs")) {
235
+ const fs = await import("fs");
236
+ const target = process.argv[2];
237
+ if (!target) {
238
+ console.error("Usage: node parse-imports.mjs <file>");
239
+ process.exit(1);
240
+ }
241
+ const content = fs.readFileSync(target, "utf-8");
242
+ const paths = parseImportPaths(content, target);
243
+ const lines = parseImportsWithLines(content, target);
244
+ console.log("── Import paths ──");
245
+ for (const p of paths) console.log(` ${p}`);
246
+ console.log(`\n── With line numbers ──`);
247
+ for (const l of lines) console.log(` ${l.line}: ${l.source}`);
248
+ console.log(`\nParser: ${tryLoadTSParser() ? "AST" : "regex (fallback)"}`);
249
+ }
@@ -0,0 +1,151 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Nail/unnail Forge sub-commands as standalone skill shortcuts.
4
+ *
5
+ * Usage:
6
+ * node pin.mjs nail <command>
7
+ * node pin.mjs unnail <command>
8
+ *
9
+ * `nail cast` creates a lightweight /cast skill that redirects to /forge cast.
10
+ * `unnail cast` removes that shortcut.
11
+ *
12
+ * Scans harness directories and creates/removes the pin in all of them.
13
+ */
14
+
15
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync } from 'node:fs';
16
+ import { join, resolve } from 'node:path';
17
+ import { fileURLToPath } from 'node:url';
18
+
19
+ const __dirname = fileURLToPath(new URL('.', import.meta.url));
20
+
21
+ const HARNESS_DIRS = ['.claude', '.cursor', '.gemini', '.codex', '.agents', '.opencode', '.kiro'];
22
+
23
+ const VALID_COMMANDS = ['forge', 'cast', 'inspect', 'quench', 'temper', 'chain', 'graph', 'relocate', 'reforge', 'smelt', 'inscribe'];
24
+
25
+ const PIN_MARKER = '<!-- forge-pinned-skill -->';
26
+
27
+ function findProjectRoot(startDir = process.cwd()) {
28
+ let dir = resolve(startDir);
29
+ while (dir !== '/') {
30
+ if (existsSync(join(dir, 'package.json')) || existsSync(join(dir, '.git'))) {
31
+ return dir;
32
+ }
33
+ const parent = resolve(dir, '..');
34
+ if (parent === dir) break;
35
+ dir = parent;
36
+ }
37
+ return resolve(startDir);
38
+ }
39
+
40
+ function findHarnessDirs(projectRoot) {
41
+ const dirs = [];
42
+ for (const harness of HARNESS_DIRS) {
43
+ const skillsDir = join(projectRoot, harness, 'skills');
44
+ const forgeDir = join(skillsDir, 'forge');
45
+ if (existsSync(forgeDir) || existsSync(join(skillsDir, 'i-forge'))) {
46
+ dirs.push(skillsDir);
47
+ }
48
+ }
49
+ return dirs;
50
+ }
51
+
52
+ function nail(command, projectRoot) {
53
+ const harnessDirs = findHarnessDirs(projectRoot);
54
+ if (harnessDirs.length === 0) {
55
+ console.log('No harness directories with Forge installed found.');
56
+ return false;
57
+ }
58
+
59
+ const content = `---
60
+ name: ${command}
61
+ description: "Shortcut for /forge ${command}."
62
+ argument-hint: ""
63
+ user-invocable: true
64
+ ---
65
+
66
+ ${PIN_MARKER}
67
+
68
+ This is a pinned shortcut for \`{{command_prefix}}forge ${command}\`.
69
+
70
+ Invoke {{command_prefix}}forge ${command}, passing along any arguments provided here, and follow its instructions.
71
+ `;
72
+
73
+ let created = 0;
74
+ for (const skillsDir of harnessDirs) {
75
+ const skillDir = join(skillsDir, command);
76
+ if (existsSync(skillDir)) {
77
+ const existingMd = join(skillDir, 'SKILL.md');
78
+ if (existsSync(existingMd)) {
79
+ const existing = readFileSync(existingMd, 'utf-8');
80
+ if (!existing.includes(PIN_MARKER)) {
81
+ console.log(` SKIP: ${skillDir} (non-pinned skill already exists)`);
82
+ continue;
83
+ }
84
+ }
85
+ }
86
+ mkdirSync(skillDir, { recursive: true });
87
+ writeFileSync(join(skillDir, 'SKILL.md'), content, 'utf-8');
88
+ console.log(` + ${skillDir}`);
89
+ created++;
90
+ }
91
+
92
+ if (created > 0) {
93
+ console.log(`\nPinned '${command}' as a standalone shortcut in ${created} location(s).`);
94
+ console.log(`You can now use /${command} directly.`);
95
+ }
96
+ return created > 0;
97
+ }
98
+
99
+ function unnail(command, projectRoot) {
100
+ const harnessDirs = findHarnessDirs(projectRoot);
101
+ let removed = 0;
102
+
103
+ for (const skillsDir of harnessDirs) {
104
+ const skillDir = join(skillsDir, command);
105
+ if (!existsSync(skillDir)) continue;
106
+ const skillMd = join(skillDir, 'SKILL.md');
107
+ if (!existsSync(skillMd)) continue;
108
+ const content = readFileSync(skillMd, 'utf-8');
109
+ if (!content.includes(PIN_MARKER)) {
110
+ console.log(` SKIP: ${skillDir} (not a pinned skill)`);
111
+ continue;
112
+ }
113
+ rmSync(skillDir, { recursive: true, force: true });
114
+ console.log(` - ${skillDir}`);
115
+ removed++;
116
+ }
117
+
118
+ if (removed > 0) {
119
+ console.log(`\nUnpinned '${command}' from ${removed} location(s).`);
120
+ } else {
121
+ console.log(`No pinned '${command}' shortcut found.`);
122
+ }
123
+ return removed > 0;
124
+ }
125
+
126
+ const [,, action, command] = process.argv;
127
+
128
+ if (!action || !command) {
129
+ console.log('Usage: node pin.mjs <nail|unnail> <command>');
130
+ console.log(`\nAvailable: ${VALID_COMMANDS.join(', ')}`);
131
+ process.exit(1);
132
+ }
133
+
134
+ if (action !== 'nail' && action !== 'unnail') {
135
+ console.error(`Unknown action: ${action}. Use 'nail' or 'unnail'.`);
136
+ process.exit(1);
137
+ }
138
+
139
+ if (!VALID_COMMANDS.includes(command)) {
140
+ console.error(`Unknown command: ${command}`);
141
+ console.error(`Available: ${VALID_COMMANDS.join(', ')}`);
142
+ process.exit(1);
143
+ }
144
+
145
+ const root = findProjectRoot();
146
+
147
+ if (action === 'nail') {
148
+ nail(command, root);
149
+ } else {
150
+ unnail(command, root);
151
+ }