@skilly-hand/skilly-hand 0.21.0 → 0.22.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.
@@ -5,6 +5,7 @@ import { detectProject, inspectProjectFiles } from "../../detectors/src/index.js
5
5
 
6
6
  export const DEFAULT_AGENTS = ["standard", "codex", "claude", "cursor", "gemini", "copilot", "antigravity", "windsurf", "trae"];
7
7
  const MANAGED_MARKER = "<!-- Managed by skilly-hand.";
8
+ const NATIVE_SETUP_MARKER = "<!-- Managed by skilly-hand native setup.";
8
9
  const AGENT_INSTALL_PROFILES = {
9
10
  standard: {
10
11
  instructionFiles: [["AGENTS.md"]],
@@ -43,6 +44,61 @@ const AGENT_INSTALL_PROFILES = {
43
44
  }
44
45
  };
45
46
 
47
+ export const NATIVE_ADAPTER_REGISTRY = {
48
+ standard: {
49
+ supported: false,
50
+ reason: "No dedicated native instruction or hook surface is available for the standard profile."
51
+ },
52
+ codex: {
53
+ supported: true,
54
+ mode: "rule_file",
55
+ targetPath: [".codex", "rules", "skilly-hand.md"],
56
+ remediation: "Run `npx skilly-hand native setup --agent codex`."
57
+ },
58
+ claude: {
59
+ supported: true,
60
+ mode: "rule_file",
61
+ targetPath: [".claude", "rules", "skilly-hand.md"],
62
+ remediation: "Run `npx skilly-hand native setup --agent claude`."
63
+ },
64
+ cursor: {
65
+ supported: true,
66
+ mode: "rule_file",
67
+ targetPath: [".cursor", "rules", "skilly-hand.mdc"],
68
+ remediation: "Run `npx skilly-hand native setup --agent cursor`."
69
+ },
70
+ gemini: {
71
+ supported: true,
72
+ mode: "rule_file",
73
+ targetPath: [".gemini", "rules", "skilly-hand.md"],
74
+ remediation: "Run `npx skilly-hand native setup --agent gemini`."
75
+ },
76
+ copilot: {
77
+ supported: true,
78
+ mode: "rule_file",
79
+ targetPath: [".github", "instructions", "skilly-hand.md"],
80
+ remediation: "Run `npx skilly-hand native setup --agent copilot`."
81
+ },
82
+ antigravity: {
83
+ supported: true,
84
+ mode: "rule_file",
85
+ targetPath: [".agents", "rules", "skilly-hand-native.md"],
86
+ remediation: "Run `npx skilly-hand native setup --agent antigravity`."
87
+ },
88
+ windsurf: {
89
+ supported: true,
90
+ mode: "rule_file",
91
+ targetPath: [".windsurf", "rules", "skilly-hand.md"],
92
+ remediation: "Run `npx skilly-hand native setup --agent windsurf`."
93
+ },
94
+ trae: {
95
+ supported: true,
96
+ mode: "rule_file",
97
+ targetPath: [".trae", "rules", "skilly-hand.md"],
98
+ remediation: "Run `npx skilly-hand native setup --agent trae`."
99
+ }
100
+ };
101
+
46
102
  function uniq(values) {
47
103
  return [...new Set(values)];
48
104
  }
@@ -69,6 +125,63 @@ function nowIso() {
69
125
  return new Date().toISOString();
70
126
  }
71
127
 
128
+ function createLockData({ cwd, generatedAt, agents = [], skills = [], detections = [] }) {
129
+ return {
130
+ version: 1,
131
+ generatedAt,
132
+ cwd,
133
+ agents,
134
+ skills,
135
+ detections,
136
+ managedFiles: [],
137
+ managedSymlinks: [],
138
+ managedNativeFiles: [],
139
+ nativeProfiles: {},
140
+ backups: {}
141
+ };
142
+ }
143
+
144
+ function toRelativePath(cwd, targetPath) {
145
+ return path.relative(cwd, targetPath) || ".";
146
+ }
147
+
148
+ function resolveNativeAdapterTarget(cwd, adapter) {
149
+ return path.join(cwd, ...adapter.targetPath);
150
+ }
151
+
152
+ function buildNativeRuleContent(agent, adapter) {
153
+ const trigger = "Run token-optimizer before review-rangers when doing risk-heavy review passes.";
154
+ return [
155
+ `${NATIVE_SETUP_MARKER} Re-run \`npx skilly-hand native setup\` to regenerate. -->`,
156
+ `# skilly-hand Native Bootstrap (${agent})`,
157
+ "",
158
+ `This file is managed by skilly-hand to keep native ${adapter.mode.replace("_", " ")} behavior consistent.`,
159
+ "",
160
+ "## Always-On Defaults",
161
+ "- Apply AGENTS guidance from the repository root before task routing.",
162
+ "- Enforce optimizer gate order: `token-optimizer` then `output-optimizer`.",
163
+ "- Keep output concise by default (`step-brief`) unless user asks otherwise.",
164
+ "",
165
+ "## Token-Safe Review Stage",
166
+ `- ${trigger}`,
167
+ "- Keep review verdicts concise unless a blocker requires expanded rationale.",
168
+ "",
169
+ "## Sync",
170
+ "- Regenerate this file via `npx skilly-hand native setup` after workflow updates.",
171
+ ""
172
+ ].join("\n");
173
+ }
174
+
175
+ function retainNativeProfilesForAgents(previousLock, selectedAgents) {
176
+ const previousProfiles = previousLock?.nativeProfiles || {};
177
+ const retained = {};
178
+ for (const agent of selectedAgents) {
179
+ if (!previousProfiles[agent]) continue;
180
+ retained[agent] = previousProfiles[agent];
181
+ }
182
+ return retained;
183
+ }
184
+
72
185
  function normalizeAgentList(agents) {
73
186
  if (!agents || agents.length === 0) {
74
187
  return [...DEFAULT_AGENTS];
@@ -167,11 +280,11 @@ async function backupPathIfNeeded(targetPath, backupsDir, lockData) {
167
280
  return backupPath;
168
281
  }
169
282
 
170
- async function ensureManagedTextFile(targetPath, content, backupsDir, lockData) {
283
+ async function ensureManagedTextFile(targetPath, content, backupsDir, lockData, collectionKey = "managedFiles") {
171
284
  if (await exists(targetPath)) {
172
285
  const current = await readFile(targetPath, "utf8");
173
286
  if (current === content) {
174
- lockData.managedFiles.push(targetPath);
287
+ lockData[collectionKey].push(targetPath);
175
288
  return;
176
289
  }
177
290
 
@@ -184,7 +297,7 @@ async function ensureManagedTextFile(targetPath, content, backupsDir, lockData)
184
297
 
185
298
  await mkdir(path.dirname(targetPath), { recursive: true });
186
299
  await writeFile(targetPath, content, "utf8");
187
- lockData.managedFiles.push(targetPath);
300
+ lockData[collectionKey].push(targetPath);
188
301
  }
189
302
 
190
303
  async function ensureSymlink(targetPath, sourcePath, backupsDir, lockData) {
@@ -238,6 +351,7 @@ async function reconcileManagedTargets({
238
351
  previousLock,
239
352
  selectedInstructionTargets,
240
353
  selectedSkillTargets,
354
+ selectedNativeTargets = [],
241
355
  lockData
242
356
  }) {
243
357
  if (!previousLock) {
@@ -246,7 +360,8 @@ async function reconcileManagedTargets({
246
360
 
247
361
  const selectedInstructions = new Set(selectedInstructionTargets);
248
362
  const selectedSkills = new Set(selectedSkillTargets);
249
- const selectedTargets = new Set([...selectedInstructions, ...selectedSkills]);
363
+ const selectedNatives = new Set(selectedNativeTargets);
364
+ const selectedTargets = new Set([...selectedInstructions, ...selectedSkills, ...selectedNatives]);
250
365
  const previousBackups = previousLock.backups || {};
251
366
 
252
367
  for (const [targetPath, backupPath] of Object.entries(previousBackups)) {
@@ -295,6 +410,170 @@ async function reconcileManagedTargets({
295
410
  await rm(filePath, { force: true });
296
411
  }
297
412
  }
413
+
414
+ for (const filePath of previousLock.managedNativeFiles || []) {
415
+ if (selectedNatives.has(filePath)) {
416
+ continue;
417
+ }
418
+
419
+ const backupPath = previousBackups[filePath];
420
+ if (backupPath && await exists(backupPath)) {
421
+ await mkdir(path.dirname(filePath), { recursive: true });
422
+ await cp(backupPath, filePath, { recursive: true, force: true });
423
+ continue;
424
+ }
425
+
426
+ if (!(await exists(filePath))) {
427
+ continue;
428
+ }
429
+
430
+ const content = await readFile(filePath, "utf8");
431
+ if (content.includes(MANAGED_MARKER) || content.includes(NATIVE_SETUP_MARKER)) {
432
+ await rm(filePath, { force: true });
433
+ }
434
+ }
435
+ }
436
+
437
+ export async function evaluateNativeCoverage({ cwd, agents }) {
438
+ const selectedAgents = normalizeAgentList(agents);
439
+ const rows = [];
440
+
441
+ for (const agent of selectedAgents) {
442
+ const adapter = NATIVE_ADAPTER_REGISTRY[agent];
443
+ if (!adapter || adapter.supported === false) {
444
+ rows.push({
445
+ agent,
446
+ status: "not-supported",
447
+ mode: adapter?.mode || null,
448
+ target: null,
449
+ remediation: adapter?.reason || "No native setup adapter available for this agent."
450
+ });
451
+ continue;
452
+ }
453
+
454
+ const target = resolveNativeAdapterTarget(cwd, adapter);
455
+ const relativeTarget = toRelativePath(cwd, target);
456
+ if (!(await exists(target))) {
457
+ rows.push({
458
+ agent,
459
+ status: "missing",
460
+ mode: adapter.mode,
461
+ target: relativeTarget,
462
+ remediation: adapter.remediation
463
+ });
464
+ continue;
465
+ }
466
+
467
+ const content = await readFile(target, "utf8");
468
+ const hasMarker = content.includes(NATIVE_SETUP_MARKER);
469
+ rows.push({
470
+ agent,
471
+ status: hasMarker ? "configured" : "partial",
472
+ mode: adapter.mode,
473
+ target: relativeTarget,
474
+ remediation: hasMarker ? "No action needed." : adapter.remediation
475
+ });
476
+ }
477
+
478
+ return rows;
479
+ }
480
+
481
+ export async function setupNativeProject({
482
+ cwd,
483
+ agents,
484
+ dryRun = false
485
+ }) {
486
+ const selectedAgents = normalizeAgentList(agents);
487
+ const generatedAt = nowIso();
488
+ const installRoot = path.join(cwd, ".skilly-hand");
489
+ const backupsDir = path.join(installRoot, "backups");
490
+ const lockPath = path.join(installRoot, "manifest.lock.json");
491
+ const previousLock = await exists(lockPath) ? await readJson(lockPath) : null;
492
+ const selectedNativeTargets = selectedAgents
493
+ .map((agent) => NATIVE_ADAPTER_REGISTRY[agent])
494
+ .filter((adapter) => adapter && adapter.supported)
495
+ .map((adapter) => resolveNativeAdapterTarget(cwd, adapter));
496
+
497
+ const nativeStatusBefore = await evaluateNativeCoverage({ cwd, agents: selectedAgents });
498
+
499
+ const plan = {
500
+ cwd,
501
+ generatedAt,
502
+ agents: selectedAgents,
503
+ installRoot,
504
+ nativeStatus: nativeStatusBefore
505
+ };
506
+
507
+ if (dryRun) {
508
+ return {
509
+ plan,
510
+ applied: false
511
+ };
512
+ }
513
+
514
+ await mkdir(installRoot, { recursive: true });
515
+ await mkdir(backupsDir, { recursive: true });
516
+
517
+ const lockData = createLockData({
518
+ cwd,
519
+ generatedAt,
520
+ agents: previousLock?.agents || selectedAgents,
521
+ skills: previousLock?.skills || [],
522
+ detections: previousLock?.detections || []
523
+ });
524
+
525
+ await reconcileManagedTargets({
526
+ previousLock,
527
+ selectedInstructionTargets: previousLock?.managedFiles || [],
528
+ selectedSkillTargets: previousLock?.managedSymlinks || [],
529
+ selectedNativeTargets,
530
+ lockData
531
+ });
532
+
533
+ lockData.managedFiles = [...(previousLock?.managedFiles || [])];
534
+ lockData.managedSymlinks = [...(previousLock?.managedSymlinks || [])];
535
+
536
+ const retainedProfiles = retainNativeProfilesForAgents(previousLock, selectedAgents);
537
+ lockData.nativeProfiles = retainedProfiles;
538
+ lockData.managedNativeFiles = uniq(
539
+ Object.values(retainedProfiles)
540
+ .flatMap((profile) => profile?.managedFiles || [])
541
+ .filter(Boolean)
542
+ );
543
+
544
+ for (const agent of selectedAgents) {
545
+ const adapter = NATIVE_ADAPTER_REGISTRY[agent];
546
+ if (!adapter || adapter.supported === false) {
547
+ lockData.nativeProfiles[agent] = {
548
+ status: "not-supported",
549
+ mode: adapter?.mode || null,
550
+ managedFiles: [],
551
+ target: null
552
+ };
553
+ continue;
554
+ }
555
+
556
+ const targetPath = resolveNativeAdapterTarget(cwd, adapter);
557
+ const content = buildNativeRuleContent(agent, adapter);
558
+ await ensureManagedTextFile(targetPath, content, backupsDir, lockData, "managedNativeFiles");
559
+ lockData.nativeProfiles[agent] = {
560
+ status: "configured",
561
+ mode: adapter.mode,
562
+ managedFiles: [targetPath],
563
+ target: toRelativePath(cwd, targetPath)
564
+ };
565
+ }
566
+
567
+ lockData.managedNativeFiles = uniq(lockData.managedNativeFiles);
568
+
569
+ await writeJson(lockPath, lockData);
570
+
571
+ return {
572
+ plan,
573
+ applied: true,
574
+ lockPath,
575
+ nativeStatus: await evaluateNativeCoverage({ cwd, agents: selectedAgents })
576
+ };
298
577
  }
299
578
 
300
579
  export async function installProject({
@@ -327,17 +606,13 @@ export async function installProject({
327
606
  const backupsDir = path.join(installRoot, "backups");
328
607
  const lockPath = path.join(installRoot, "manifest.lock.json");
329
608
  const previousLock = await exists(lockPath) ? await readJson(lockPath) : null;
330
- const lockData = {
331
- version: 1,
332
- generatedAt: plan.generatedAt,
609
+ const lockData = createLockData({
333
610
  cwd,
611
+ generatedAt: plan.generatedAt,
334
612
  agents: selectedAgents,
335
613
  skills: skills.map((skill) => skill.id),
336
- detections,
337
- managedFiles: [],
338
- managedSymlinks: [],
339
- backups: {}
340
- };
614
+ detections
615
+ });
341
616
 
342
617
  await mkdir(installRoot, { recursive: true });
343
618
  await mkdir(targetCatalogDir, { recursive: true });
@@ -359,14 +634,24 @@ export async function installProject({
359
634
  const { instructionTargets, skillTargets } = buildInstallTargets(selectedAgents);
360
635
  const absoluteInstructionTargets = instructionTargets.map((pathParts) => path.join(cwd, ...pathParts));
361
636
  const absoluteSkillTargets = skillTargets.map((pathParts) => path.join(cwd, ...pathParts));
637
+ const retainedNativeProfiles = retainNativeProfilesForAgents(previousLock, selectedAgents);
638
+ const retainedNativeTargets = uniq(
639
+ Object.values(retainedNativeProfiles)
640
+ .flatMap((profile) => profile?.managedFiles || [])
641
+ .filter(Boolean)
642
+ );
362
643
 
363
644
  await reconcileManagedTargets({
364
645
  previousLock,
365
646
  selectedInstructionTargets: absoluteInstructionTargets,
366
647
  selectedSkillTargets: absoluteSkillTargets,
648
+ selectedNativeTargets: retainedNativeTargets,
367
649
  lockData
368
650
  });
369
651
 
652
+ lockData.nativeProfiles = retainedNativeProfiles;
653
+ lockData.managedNativeFiles = retainedNativeTargets;
654
+
370
655
  for (const targetPath of absoluteInstructionTargets) {
371
656
  await ensureManagedTextFile(targetPath, agentsMarkdown, backupsDir, lockData);
372
657
  }
@@ -407,6 +692,15 @@ export async function uninstallProject(cwd) {
407
692
  }
408
693
  }
409
694
 
695
+ for (const filePath of lockData.managedNativeFiles || []) {
696
+ if (await exists(filePath)) {
697
+ const content = await readFile(filePath, "utf8");
698
+ if (content.includes(NATIVE_SETUP_MARKER) || content.includes(MANAGED_MARKER)) {
699
+ await rm(filePath, { force: true });
700
+ }
701
+ }
702
+ }
703
+
410
704
  for (const [targetPath, backupPath] of Object.entries(lockData.backups || {})) {
411
705
  if (await exists(backupPath)) {
412
706
  await mkdir(path.dirname(targetPath), { recursive: true });
@@ -428,15 +722,19 @@ export async function runDoctor(cwd) {
428
722
  cwd,
429
723
  installed,
430
724
  catalogIssues,
431
- fileStatus
725
+ fileStatus,
726
+ nativeStatus: await evaluateNativeCoverage({ cwd, agents: installed ? undefined : DEFAULT_AGENTS })
432
727
  };
433
728
 
434
729
  if (installed) {
435
730
  const lock = await readJson(lockPath);
731
+ result.nativeStatus = await evaluateNativeCoverage({ cwd, agents: lock.agents });
436
732
  result.lock = {
437
733
  agents: lock.agents,
438
734
  skills: lock.skills,
439
- generatedAt: lock.generatedAt
735
+ generatedAt: lock.generatedAt,
736
+ managedNativeFiles: lock.managedNativeFiles || [],
737
+ nativeProfiles: lock.nativeProfiles || {}
440
738
  };
441
739
  }
442
740
 
@@ -86,15 +86,17 @@ export function createTerminalRenderer({
86
86
  stdout = process.stdout,
87
87
  stderr = process.stderr,
88
88
  env = process.env,
89
- platform = process.platform
89
+ platform = process.platform,
90
+ colorProfile = "mono-accent",
91
+ density = "balanced"
90
92
  } = {}) {
91
93
  const colorLevel = detectColorLevel({ env, stream: stdout });
92
94
  const colorEnabled = colorLevel > 0;
93
95
  const unicodeEnabled = detectUnicodeSupport({ env, stream: stdout, platform });
94
96
  const viewportWidth = detectViewportWidth({ stdout, env });
95
97
 
96
- const theme = createTheme(colorLevel);
97
- const layout = createLayout(theme, unicodeEnabled);
98
+ const theme = createTheme(colorLevel, { colorProfile });
99
+ const layout = createLayout(theme, unicodeEnabled, { density });
98
100
 
99
101
  // Backward-compat style object (callers in tests may use renderer.style.*)
100
102
  const style = {
@@ -123,9 +123,10 @@ const BOX_ASCII = {
123
123
  },
124
124
  };
125
125
 
126
- export function createLayout(theme, unicodeEnabled) {
126
+ export function createLayout(theme, unicodeEnabled, options = {}) {
127
127
  const box = unicodeEnabled ? BOX : BOX_ASCII;
128
128
  const brand = getBrand();
129
+ const density = options.density || "balanced";
129
130
 
130
131
  // ── Banner ────────────────────────────────────────────────────────────────
131
132
 
@@ -154,8 +155,10 @@ export function createLayout(theme, unicodeEnabled) {
154
155
  const d = box.d;
155
156
  const hLine = d.h.repeat(innerW + 2); // +2 for padding spaces inside border
156
157
 
157
- const rows = logo.map((logoLine, i) => {
158
- const logoColored = theme.primary(theme.bold(logoLine));
158
+ const rowCount = Math.max(logo.length, rightLines.length);
159
+ const rows = Array.from({ length: rowCount }, (_, i) => {
160
+ const logoLine = logo[i] ?? "";
161
+ const logoColored = logoLine ? theme.primary(theme.bold(logoLine)) : "";
159
162
  const rightLine = rightLines[i] || "";
160
163
  const logoLen = logoW;
161
164
  const logoPadded = padEnd(logoColored, logoLen + (visLen(logoColored) - stripAnsi(logoColored).length));
@@ -192,8 +195,8 @@ export function createLayout(theme, unicodeEnabled) {
192
195
  if (!unicodeEnabled) {
193
196
  return theme.bold(title);
194
197
  }
195
- const s = box.s;
196
- const decorated = theme.primary(s.tl) + theme.primary(s.h.repeat(2)) + " " + theme.bold(title) + " " + theme.primary(s.h.repeat(Math.max(2, 40 - title.length)));
198
+ const rightRuleLength = density === "compact" ? Math.max(2, 28 - title.length) : Math.max(4, 36 - title.length);
199
+ const decorated = `${theme.primary("")} ${theme.bold(title)} ${theme.muted("─".repeat(rightRuleLength))}`;
197
200
  return decorated;
198
201
  }
199
202
 
@@ -201,7 +204,7 @@ export function createLayout(theme, unicodeEnabled) {
201
204
 
202
205
  function borderedTable(columns, rows, opts = {}) {
203
206
  if (!columns || columns.length === 0) return "";
204
- const maxColW = opts.maxColWidth || 36;
207
+ const maxColW = opts.maxColWidth || (density === "compact" ? 32 : 40);
205
208
  const viewportWidth = Math.max(40, Number(opts.viewportWidth) || 80);
206
209
  const headers = columns.map((c) => String(c.header));
207
210
  const detailMarker = unicodeEnabled ? "↳ " : "-> ";
@@ -302,7 +305,8 @@ export function createLayout(theme, unicodeEnabled) {
302
305
  return cardRows.join("\n");
303
306
  }
304
307
 
305
- const canUseBordered = unicodeEnabled && (preferredSum + borderedOverhead <= viewportWidth);
308
+ const borderSlack = density === "compact" ? 0 : 2;
309
+ const canUseBordered = unicodeEnabled && (preferredSum + borderedOverhead <= (viewportWidth - borderSlack));
306
310
  if (canUseBordered) {
307
311
  const widths = preferredWidths;
308
312
  const topBorder = makeDivider(widths, s.tl, s.mt, s.tr, s.h);
@@ -69,7 +69,8 @@ const PALETTE_BG = {
69
69
 
70
70
  const identity = (value) => String(value);
71
71
 
72
- export function createTheme(level) {
72
+ export function createTheme(level, options = {}) {
73
+ const colorProfile = options.colorProfile || "mono-accent";
73
74
  if (level === 0) {
74
75
  const noop = identity;
75
76
  return {
@@ -98,7 +99,7 @@ export function createTheme(level) {
98
99
  return identity;
99
100
  }
100
101
 
101
- return {
102
+ const baseTheme = {
102
103
  level,
103
104
  primary: makeColor("primary"),
104
105
  accent: makeColor("accent"),
@@ -117,4 +118,17 @@ export function createTheme(level) {
117
118
  bgWarn: makeBg("warn"),
118
119
  bgError: makeBg("error"),
119
120
  };
121
+
122
+ if (colorProfile === "mono-accent") {
123
+ return {
124
+ ...baseTheme,
125
+ success: baseTheme.accent,
126
+ warn: identity,
127
+ error: identity,
128
+ info: baseTheme.accent,
129
+ magenta: baseTheme.accent
130
+ };
131
+ }
132
+
133
+ return baseTheme;
120
134
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skilly-hand/detectors",
3
- "version": "0.21.0",
3
+ "version": "0.22.0",
4
4
  "private": true,
5
5
  "type": "module"
6
6
  }