skissue 0.1.15 → 0.1.17

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 (3) hide show
  1. package/README.md +9 -12
  2. package/dist/entry.js +643 -359
  3. package/package.json +1 -1
package/dist/entry.js CHANGED
@@ -4,13 +4,14 @@
4
4
  // src/entry.ts
5
5
  import { Command } from "commander";
6
6
  import { createRequire } from "node:module";
7
- import { resolve as resolve3 } from "node:path";
7
+ import { resolve as resolve4 } from "node:path";
8
8
 
9
9
  // src/commands/init.ts
10
- import * as p from "@clack/prompts";
11
- import chalk from "chalk";
12
- import { existsSync as existsSync3 } from "node:fs";
13
- import { mkdir as mkdir3 } from "node:fs/promises";
10
+ import * as p2 from "@clack/prompts";
11
+ import chalk2 from "chalk";
12
+ import { existsSync as existsSync4 } from "node:fs";
13
+ import { mkdir as mkdir4 } from "node:fs/promises";
14
+ import { relative, resolve as resolve3 } from "node:path";
14
15
 
15
16
  // src/config.ts
16
17
  import { existsSync } from "node:fs";
@@ -116,17 +117,10 @@ function defaultConfigTemplate() {
116
117
  return DEFAULT_CONFIG_YAML;
117
118
  }
118
119
 
119
- // src/git/registry-repo.ts
120
- import { createHash } from "node:crypto";
121
- import { existsSync as existsSync2, statSync } from "node:fs";
122
- import { mkdir as mkdir2, access, rm } from "node:fs/promises";
123
- import { homedir } from "node:os";
124
- import { join as join2, resolve } from "node:path";
125
-
126
120
  // src/git/exec.ts
127
121
  import { spawn } from "node:child_process";
128
122
  function execGit(args, options = {}) {
129
- return new Promise((resolve4) => {
123
+ return new Promise((resolve5) => {
130
124
  const env = { ...process.env, ...options.env };
131
125
  if (env.GIT_TERMINAL_PROMPT === void 0) {
132
126
  env.GIT_TERMINAL_PROMPT = "0";
@@ -151,7 +145,7 @@ function execGit(args, options = {}) {
151
145
  child.on("close", (code, signal) => {
152
146
  if (timeout !== void 0) clearTimeout(timeout);
153
147
  if (signal === "SIGTERM") {
154
- resolve4({
148
+ resolve5({
155
149
  code: 124,
156
150
  stdout,
157
151
  stderr: `${stderr}
@@ -159,16 +153,21 @@ skissue: git timed out after ${timeoutMs}ms`.trim()
159
153
  });
160
154
  return;
161
155
  }
162
- resolve4({ code: code ?? 1, stdout, stderr });
156
+ resolve5({ code: code ?? 1, stdout, stderr });
163
157
  });
164
158
  child.on("error", (err) => {
165
159
  if (timeout !== void 0) clearTimeout(timeout);
166
- resolve4({ code: 1, stdout, stderr: String(err) });
160
+ resolve5({ code: 1, stdout, stderr: String(err) });
167
161
  });
168
162
  });
169
163
  }
170
164
 
171
165
  // src/git/registry-repo.ts
166
+ import { createHash } from "node:crypto";
167
+ import { existsSync as existsSync2, statSync } from "node:fs";
168
+ import { mkdir as mkdir2, access, rm } from "node:fs/promises";
169
+ import { homedir } from "node:os";
170
+ import { join as join2, resolve } from "node:path";
172
171
  function githubTokenFromEnv() {
173
172
  const raw = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN;
174
173
  const t = typeof raw === "string" ? raw.trim() : "";
@@ -287,7 +286,7 @@ async function ensureRegistryCheckout(cwd, config) {
287
286
  }
288
287
  } else {
289
288
  const fetch = await execGit(
290
- ["fetch", "origin", `refs/heads/${branch}:refs/remotes/origin/${branch}`, "--depth", "1"],
289
+ ["fetch", "origin", `+refs/heads/${branch}:refs/remotes/origin/${branch}`, "--depth", "1"],
291
290
  { cwd: dir, timeoutMs: 3e5 }
292
291
  );
293
292
  if (fetch.code !== 0) {
@@ -340,8 +339,214 @@ async function diffPath(repoPath, fromCommit, toCommit, pathInRepo) {
340
339
  function isPathStale(diffStdout) {
341
340
  return diffStdout.trim().length > 0;
342
341
  }
342
+ async function isSkillPathStaleAtHead(repoPath, head, entry) {
343
+ const d = await diffPath(repoPath, entry.registryCommit, head, entry.skillPath);
344
+ return isPathStale(d);
345
+ }
346
+
347
+ // src/commands/init-registry.ts
348
+ import * as p from "@clack/prompts";
349
+ import chalk from "chalk";
350
+ import { existsSync as existsSync3, statSync as statSync2 } from "node:fs";
351
+ import { mkdir as mkdir3, writeFile as writeFile2 } from "node:fs/promises";
352
+ import { join as join3, resolve as resolve2 } from "node:path";
353
+ function validateSkillId(raw) {
354
+ const id = raw.trim();
355
+ if (!id) return "Skill id is required";
356
+ if (id.includes("/") || id.includes("\\")) return "Use a single segment (no path separators)";
357
+ if (id === "." || id === ".." || id.includes("..")) return "Invalid skill id";
358
+ return void 0;
359
+ }
360
+ function registryLayoutExists(root) {
361
+ const jsonPath = join3(root, "registry.json");
362
+ const regDir = join3(root, "registry");
363
+ if (existsSync3(jsonPath)) return true;
364
+ if (!existsSync3(regDir)) return false;
365
+ try {
366
+ return statSync2(regDir).isDirectory();
367
+ } catch {
368
+ return false;
369
+ }
370
+ }
371
+ function skillMarkdown(skillId) {
372
+ const title = skillId.split(/[-_]/).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
373
+ return `---
374
+ name: ${skillId}
375
+ description: Sample skill scaffolded by skissue init-registry. Replace this description.
376
+ ---
377
+
378
+ # ${title}
379
+
380
+ A minimal starter skill for your registry.
381
+
382
+ ## When to use
383
+
384
+ Use when the user works with this sample capability (customize this section).
385
+
386
+ ## Instructions
387
+
388
+ 1. Edit this file to describe what the agent should do.
389
+ 2. Optionally add \`hard/\` with executable checks.
390
+ `;
391
+ }
392
+ async function commitScaffoldedRegistry(root, skillId) {
393
+ const gitDir = join3(root, ".git");
394
+ if (!existsSync3(gitDir)) {
395
+ return { committed: false };
396
+ }
397
+ const regSkillPath = `registry/${skillId}`;
398
+ const addJson = await execGit(["add", "registry.json"], { cwd: root });
399
+ if (addJson.code !== 0) {
400
+ throw new Error(
401
+ `git add registry.json failed: ${addJson.stderr || addJson.stdout || `exit ${addJson.code}`}`
402
+ );
403
+ }
404
+ const addSkill = await execGit(["add", regSkillPath], { cwd: root });
405
+ if (addSkill.code !== 0) {
406
+ throw new Error(
407
+ `git add ${regSkillPath} failed: ${addSkill.stderr || addSkill.stdout || `exit ${addSkill.code}`}`
408
+ );
409
+ }
410
+ const diff = await execGit(["diff", "--cached", "--quiet"], { cwd: root });
411
+ const hasStaged = diff.code === 1;
412
+ if (hasStaged) {
413
+ const commit = await execGit(["commit", "-m", "chore: scaffold skill registry"], { cwd: root });
414
+ if (commit.code !== 0) {
415
+ throw new Error(
416
+ `git commit failed. Configure git user.name and user.email in this repo or globally. ${(commit.stderr || commit.stdout || "").trim()}`
417
+ );
418
+ }
419
+ return { committed: true };
420
+ }
421
+ const head = await execGit(["rev-parse", "HEAD"], { cwd: root });
422
+ if (head.code !== 0) {
423
+ throw new Error(
424
+ "Local registry must have at least one git commit so installs can resolve HEAD. Stage and commit the scaffolded files, or run git commit."
425
+ );
426
+ }
427
+ return { committed: false };
428
+ }
429
+ async function promptMinimalRegistryScaffold(root) {
430
+ const exists = registryLayoutExists(root);
431
+ if (exists) {
432
+ const ok = await p.confirm({
433
+ message: `${chalk.yellow("registry.json and/or registry/ already exist here.")} Replace with a minimal single-skill layout?`,
434
+ initialValue: false
435
+ });
436
+ if (p.isCancel(ok) || !ok) {
437
+ return null;
438
+ }
439
+ }
440
+ const idRaw = await p.text({
441
+ message: "Sample skill id (folder name under registry/)",
442
+ initialValue: "sample-skill",
443
+ validate: (v) => validateSkillId(String(v ?? ""))
444
+ });
445
+ if (p.isCancel(idRaw)) {
446
+ return null;
447
+ }
448
+ const skillId = String(idRaw).trim();
449
+ const gitDir = join3(root, ".git");
450
+ const hadGit = existsSync3(gitDir);
451
+ let runGitInit = false;
452
+ if (!hadGit) {
453
+ const initGit = await p.confirm({
454
+ message: "No git repository here yet. Run `git init`? (recommended \u2014 skissue local registry mode needs a git repo)",
455
+ initialValue: true
456
+ });
457
+ if (p.isCancel(initGit)) {
458
+ return null;
459
+ }
460
+ runGitInit = Boolean(initGit);
461
+ }
462
+ return { skillId, runGitInit, hadGit };
463
+ }
464
+ async function scaffoldMinimalRegistry(params) {
465
+ const { root, skillId, runGitInit } = params;
466
+ const regPath = `registry/${skillId}`;
467
+ const manifest = { skills: { [skillId]: regPath } };
468
+ await mkdir3(join3(root, regPath), { recursive: true });
469
+ await writeFile2(join3(root, "registry.json"), `${JSON.stringify(manifest, null, 2)}
470
+ `, "utf8");
471
+ await writeFile2(join3(root, regPath, "SKILL.md"), skillMarkdown(skillId), "utf8");
472
+ let gitInitRan = false;
473
+ if (runGitInit) {
474
+ const r = await execGit(["init"], { cwd: root });
475
+ if (r.code === 0) {
476
+ gitInitRan = true;
477
+ } else {
478
+ throw new Error(`git init failed: ${r.stderr || r.stdout || `exit ${r.code}`}`);
479
+ }
480
+ }
481
+ const { committed } = await commitScaffoldedRegistry(root, skillId);
482
+ return { root, skillId, gitInitRan, committed };
483
+ }
484
+ async function runInitRegistry(cwd) {
485
+ p.intro(chalk.bold("skissue init-registry"));
486
+ const where = await p.select({
487
+ message: "Where should the skill registry live?",
488
+ options: [
489
+ { value: "here", label: "Current directory \u2014 create registry files here" },
490
+ { value: "other", label: "Another directory \u2014 I will enter the path" }
491
+ ],
492
+ initialValue: "here"
493
+ });
494
+ if (p.isCancel(where)) {
495
+ p.cancel("Aborted.");
496
+ process.exit(0);
497
+ }
498
+ let root;
499
+ if (where === "here") {
500
+ root = resolve2(cwd);
501
+ } else {
502
+ const raw = await p.text({
503
+ message: "Path to registry root (absolute or relative to current directory)",
504
+ placeholder: "../my-skill-registry",
505
+ validate: (v) => v?.trim() ? void 0 : "Required"
506
+ });
507
+ if (p.isCancel(raw)) {
508
+ p.cancel("Aborted.");
509
+ process.exit(0);
510
+ }
511
+ root = resolve2(cwd, String(raw).trim());
512
+ }
513
+ const prompted = await promptMinimalRegistryScaffold(root);
514
+ if (!prompted) {
515
+ p.cancel("Aborted. No files were changed.");
516
+ process.exit(0);
517
+ }
518
+ const { skillId, runGitInit, hadGit } = prompted;
519
+ try {
520
+ const result = await scaffoldMinimalRegistry({ root, skillId, runGitInit });
521
+ if (!hadGit && !result.gitInitRan) {
522
+ p.note(
523
+ "This folder is not a git repository. Run `git init` before using it as a local skissue registry.",
524
+ chalk.yellow("Heads up")
525
+ );
526
+ }
527
+ p.outro(
528
+ chalk.green(
529
+ `Wrote ${chalk.cyan("registry.json")} and ${chalk.cyan(`registry/${skillId}/SKILL.md`)} under ${chalk.cyan(result.root)}`
530
+ )
531
+ );
532
+ } catch (e) {
533
+ p.cancel(e instanceof Error ? e.message : String(e));
534
+ process.exitCode = 1;
535
+ }
536
+ }
343
537
 
344
538
  // src/commands/init.ts
539
+ async function gitBranchShowCurrent(root) {
540
+ const r = await execGit(["branch", "--show-current"], { cwd: root });
541
+ if (r.code !== 0) return void 0;
542
+ const b = r.stdout.trim();
543
+ return b.length > 0 ? b : void 0;
544
+ }
545
+ function localRegistryPathForConfig(cwd, resolvedRoot) {
546
+ const rel = relative(cwd, resolvedRoot);
547
+ if (!rel || rel === "") return ".";
548
+ return rel;
549
+ }
345
550
  function summarizeConfig(cfg) {
346
551
  const r = cfg.registry;
347
552
  if (r.path?.trim()) {
@@ -357,27 +562,27 @@ Branch: ${r.branch}
357
562
  skillsRoot: ${cfg.skillsRoot}`;
358
563
  }
359
564
  async function runInit(cwd) {
360
- p.intro(chalk.bold("skissue init"));
565
+ p2.intro(chalk2.bold("skissue init"));
361
566
  const existingPath = configPath(cwd);
362
567
  let hasExistingConfig = false;
363
- if (existsSync3(existingPath)) {
568
+ if (existsSync4(existingPath)) {
364
569
  hasExistingConfig = true;
365
570
  try {
366
571
  const existing = await loadConfig(cwd);
367
- p.note(summarizeConfig(existing), "Current config");
572
+ p2.note(summarizeConfig(existing), "Current config");
368
573
  } catch (err) {
369
- p.note(err instanceof Error ? err.message : String(err), "Existing config (could not parse)");
574
+ p2.note(err instanceof Error ? err.message : String(err), "Existing config (could not parse)");
370
575
  }
371
- const overwrite = await p.confirm({
576
+ const overwrite = await p2.confirm({
372
577
  message: "Overwrite existing .skill-issue/config.yaml?",
373
578
  initialValue: false
374
579
  });
375
- if (p.isCancel(overwrite) || !overwrite) {
376
- p.cancel("Aborted. Existing config unchanged.");
580
+ if (p2.isCancel(overwrite) || !overwrite) {
581
+ p2.cancel("Aborted. Existing config unchanged.");
377
582
  process.exit(0);
378
583
  }
379
584
  }
380
- const source = await p.select({
585
+ const source = await p2.select({
381
586
  message: "Where does the skill registry live?",
382
587
  options: [
383
588
  {
@@ -391,64 +596,151 @@ async function runInit(cwd) {
391
596
  ],
392
597
  initialValue: "local"
393
598
  });
394
- if (p.isCancel(source)) {
395
- p.cancel("Aborted.");
599
+ if (p2.isCancel(source)) {
600
+ p2.cancel("Aborted.");
396
601
  process.exit(0);
397
602
  }
398
603
  let cfg;
604
+ let bootstrappedSkillId;
399
605
  if (source === "local") {
400
- const regPath = await p.text({
401
- message: "Path to registry repo root (relative to this project or absolute). Must contain registry/ and be a git repository.",
402
- placeholder: ".",
403
- initialValue: ".",
404
- validate: (v) => v?.trim() ? void 0 : "Required"
606
+ const localMode = await p2.select({
607
+ message: "Local registry setup",
608
+ options: [
609
+ {
610
+ value: "existing",
611
+ label: "Use an existing registry folder (registry.json + registry/)"
612
+ },
613
+ {
614
+ value: "bootstrap",
615
+ label: "Bootstrap a minimal sample registry"
616
+ }
617
+ ],
618
+ initialValue: "existing"
405
619
  });
406
- if (p.isCancel(regPath)) {
407
- p.cancel("Aborted.");
620
+ if (p2.isCancel(localMode)) {
621
+ p2.cancel("Aborted.");
408
622
  process.exit(0);
409
623
  }
410
- const branch = await p.text({
411
- message: "Branch label (stored in config; installs use git HEAD from that directory)",
412
- initialValue: "main",
413
- validate: (v) => v?.trim() ? void 0 : "Required"
414
- });
415
- if (p.isCancel(branch)) {
416
- p.cancel("Aborted.");
417
- process.exit(0);
624
+ if (localMode === "existing") {
625
+ const regPath = await p2.text({
626
+ message: "Path to registry repo root (relative to this project or absolute). Must contain registry/ and be a git repository.",
627
+ placeholder: ".",
628
+ initialValue: ".",
629
+ validate: (v) => v?.trim() ? void 0 : "Required"
630
+ });
631
+ if (p2.isCancel(regPath)) {
632
+ p2.cancel("Aborted.");
633
+ process.exit(0);
634
+ }
635
+ const resolvedExisting = resolve3(cwd, String(regPath).trim());
636
+ const branchDefault = await gitBranchShowCurrent(resolvedExisting) ?? "main";
637
+ const branch = await p2.text({
638
+ message: "Branch label (stored in config; installs use git HEAD from that directory)",
639
+ initialValue: branchDefault,
640
+ validate: (v) => v?.trim() ? void 0 : "Required"
641
+ });
642
+ if (p2.isCancel(branch)) {
643
+ p2.cancel("Aborted.");
644
+ process.exit(0);
645
+ }
646
+ cfg = ConfigSchema.parse({
647
+ registry: {
648
+ path: String(regPath).trim(),
649
+ branch: String(branch).trim()
650
+ },
651
+ skillsRoot: ".agents/skills"
652
+ });
653
+ } else {
654
+ const pathRaw = await p2.text({
655
+ message: "Path for the new registry root (relative to this project or absolute)",
656
+ placeholder: "./skill-registry",
657
+ initialValue: "./skill-registry",
658
+ validate: (v) => v?.trim() ? void 0 : "Required"
659
+ });
660
+ if (p2.isCancel(pathRaw)) {
661
+ p2.cancel("Aborted.");
662
+ process.exit(0);
663
+ }
664
+ const resolvedRoot = resolve3(cwd, String(pathRaw).trim());
665
+ const atConsumerRoot = resolvedRoot === resolve3(cwd);
666
+ if (atConsumerRoot && !registryLayoutExists(resolvedRoot)) {
667
+ const okRoot = await p2.confirm({
668
+ message: "This adds registry.json and registry/ at your project root. Continue?",
669
+ initialValue: false
670
+ });
671
+ if (p2.isCancel(okRoot) || !okRoot) {
672
+ p2.cancel("Aborted.");
673
+ process.exit(0);
674
+ }
675
+ }
676
+ const prompted = await promptMinimalRegistryScaffold(resolvedRoot);
677
+ if (!prompted) {
678
+ p2.cancel("Aborted. No files were changed.");
679
+ process.exit(0);
680
+ }
681
+ let result;
682
+ try {
683
+ result = await scaffoldMinimalRegistry({
684
+ root: resolvedRoot,
685
+ skillId: prompted.skillId,
686
+ runGitInit: prompted.runGitInit
687
+ });
688
+ } catch (e) {
689
+ p2.cancel(e instanceof Error ? e.message : String(e));
690
+ process.exitCode = 1;
691
+ return;
692
+ }
693
+ bootstrappedSkillId = result.skillId;
694
+ if (!prompted.hadGit && !result.gitInitRan) {
695
+ p2.note(
696
+ "This folder is not a git repository. Run `git init` before using it as a local skissue registry.",
697
+ chalk2.yellow("Heads up")
698
+ );
699
+ }
700
+ const branchDefault = await gitBranchShowCurrent(resolvedRoot) ?? "main";
701
+ const branch = await p2.text({
702
+ message: "Branch label (stored in config; installs use git HEAD from that directory)",
703
+ initialValue: branchDefault,
704
+ validate: (v) => v?.trim() ? void 0 : "Required"
705
+ });
706
+ if (p2.isCancel(branch)) {
707
+ p2.cancel("Aborted.");
708
+ process.exit(0);
709
+ }
710
+ cfg = ConfigSchema.parse({
711
+ registry: {
712
+ path: localRegistryPathForConfig(cwd, resolvedRoot),
713
+ branch: String(branch).trim()
714
+ },
715
+ skillsRoot: ".agents/skills"
716
+ });
418
717
  }
419
- cfg = ConfigSchema.parse({
420
- registry: {
421
- path: String(regPath).trim(),
422
- branch: String(branch).trim()
423
- },
424
- skillsRoot: ".agents/skills"
425
- });
426
718
  } else {
427
- const owner = await p.text({
719
+ const owner = await p2.text({
428
720
  message: "GitHub registry owner (org or user)",
429
721
  placeholder: "acme",
430
722
  validate: (v) => v?.trim() ? void 0 : "Required"
431
723
  });
432
- if (p.isCancel(owner)) {
433
- p.cancel("Aborted.");
724
+ if (p2.isCancel(owner)) {
725
+ p2.cancel("Aborted.");
434
726
  process.exit(0);
435
727
  }
436
- const repo = await p.text({
728
+ const repo = await p2.text({
437
729
  message: "Registry repository name",
438
730
  placeholder: "skill-registry",
439
731
  validate: (v) => v?.trim() ? void 0 : "Required"
440
732
  });
441
- if (p.isCancel(repo)) {
442
- p.cancel("Aborted.");
733
+ if (p2.isCancel(repo)) {
734
+ p2.cancel("Aborted.");
443
735
  process.exit(0);
444
736
  }
445
- const branch = await p.text({
737
+ const branch = await p2.text({
446
738
  message: "Branch to track",
447
739
  initialValue: "main",
448
740
  validate: (v) => v?.trim() ? void 0 : "Required"
449
741
  });
450
- if (p.isCancel(branch)) {
451
- p.cancel("Aborted.");
742
+ if (p2.isCancel(branch)) {
743
+ p2.cancel("Aborted.");
452
744
  process.exit(0);
453
745
  }
454
746
  cfg = ConfigSchema.parse({
@@ -460,42 +752,42 @@ async function runInit(cwd) {
460
752
  skillsRoot: ".agents/skills"
461
753
  });
462
754
  }
463
- const skillsRoot = await p.text({
755
+ const skillsRoot = await p2.text({
464
756
  message: "Install skills under (relative to project root)",
465
757
  initialValue: cfg.skillsRoot,
466
758
  validate: (v) => v?.trim() ? void 0 : "Required"
467
759
  });
468
- if (p.isCancel(skillsRoot)) {
469
- p.cancel("Aborted.");
760
+ if (p2.isCancel(skillsRoot)) {
761
+ p2.cancel("Aborted.");
470
762
  process.exit(0);
471
763
  }
472
764
  cfg = ConfigSchema.parse({ ...cfg, skillsRoot: String(skillsRoot).trim() });
473
- const confirm4 = await p.confirm({
474
- message: hasExistingConfig ? `Overwrite ${chalk.cyan(".skill-issue/config.yaml")} and use ${chalk.cyan(cfg.skillsRoot)} for installs?` : `Create ${chalk.cyan(".skill-issue/config.yaml")} and use ${chalk.cyan(cfg.skillsRoot)} for installs?`,
765
+ const confirm4 = await p2.confirm({
766
+ message: hasExistingConfig ? `Overwrite ${chalk2.cyan(".skill-issue/config.yaml")} and use ${chalk2.cyan(cfg.skillsRoot)} for installs?` : `Create ${chalk2.cyan(".skill-issue/config.yaml")} and use ${chalk2.cyan(cfg.skillsRoot)} for installs?`,
475
767
  initialValue: true
476
768
  });
477
- if (p.isCancel(confirm4) || !confirm4) {
478
- p.cancel("Aborted.");
769
+ if (p2.isCancel(confirm4) || !confirm4) {
770
+ p2.cancel("Aborted.");
479
771
  process.exit(0);
480
772
  }
481
773
  const dir = skillIssueDir(cwd);
482
- await mkdir3(dir, { recursive: true });
774
+ await mkdir4(dir, { recursive: true });
483
775
  await writeConfig(cwd, cfg);
484
- p.note(defaultConfigTemplate().trim(), "Template reference");
485
- p.outro(
486
- chalk.green(
487
- "Wrote .skill-issue/config.yaml. Run skissue install <id>, or skissue for the manage menu."
488
- )
489
- );
776
+ p2.note(defaultConfigTemplate().trim(), "Template reference");
777
+ const outroLines = bootstrappedSkillId ? [
778
+ "Wrote .skill-issue/config.yaml.",
779
+ `Try ${chalk2.cyan(`skissue install ${bootstrappedSkillId}`)}, or run skissue for the manage menu.`
780
+ ] : ["Wrote .skill-issue/config.yaml. Run skissue install <id>, or skissue for the manage menu."];
781
+ p2.outro(chalk2.green(outroLines.join(" ")));
490
782
  }
491
783
 
492
784
  // src/commands/install.ts
493
- import chalk2 from "chalk";
785
+ import chalk3 from "chalk";
494
786
  import ora from "ora";
495
- import { join as join5 } from "node:path";
787
+ import { join as join6 } from "node:path";
496
788
 
497
789
  // src/lockfile.ts
498
- import { readFile as readFile2, writeFile as writeFile2, mkdir as mkdir4 } from "node:fs/promises";
790
+ import { readFile as readFile2, writeFile as writeFile3, mkdir as mkdir5 } from "node:fs/promises";
499
791
  import { dirname as dirname2 } from "node:path";
500
792
  import { z as z2 } from "zod";
501
793
  var LockSkillEntrySchema = z2.object({
@@ -518,9 +810,9 @@ async function readLock(cwd) {
518
810
  }
519
811
  async function writeLock(cwd, lock) {
520
812
  const path = lockPath(cwd);
521
- await mkdir4(dirname2(path), { recursive: true });
813
+ await mkdir5(dirname2(path), { recursive: true });
522
814
  const parsed = LockSchema.parse(lock);
523
- await writeFile2(path, JSON.stringify(parsed, null, 2) + "\n", "utf8");
815
+ await writeFile3(path, JSON.stringify(parsed, null, 2) + "\n", "utf8");
524
816
  }
525
817
  async function readLockOrEmpty(cwd) {
526
818
  try {
@@ -543,29 +835,29 @@ function removeSkillLock(lock, skillId) {
543
835
 
544
836
  // src/registry/resolve.ts
545
837
  import { readFile as readFile3 } from "node:fs/promises";
546
- import { join as join3 } from "node:path";
838
+ import { join as join4 } from "node:path";
547
839
  import { z as z3 } from "zod";
548
840
  var RegistryJsonSchema = z3.object({
549
841
  skills: z3.record(z3.string(), z3.string()).optional()
550
842
  }).passthrough();
551
843
  async function resolveSkillPath(registryRepoRoot, skillId) {
552
- const registryFile = join3(registryRepoRoot, "registry.json");
844
+ const registryFile = join4(registryRepoRoot, "registry.json");
553
845
  let raw;
554
846
  try {
555
847
  raw = await readFile3(registryFile, "utf8");
556
848
  } catch {
557
- return { skillPath: join3("registry", skillId).replace(/\\/g, "/"), source: "convention" };
849
+ return { skillPath: join4("registry", skillId).replace(/\\/g, "/"), source: "convention" };
558
850
  }
559
851
  const parsed = JSON.parse(raw);
560
852
  const reg = RegistryJsonSchema.safeParse(parsed);
561
853
  if (!reg.success || !reg.data.skills) {
562
- return { skillPath: join3("registry", skillId).replace(/\\/g, "/"), source: "convention" };
854
+ return { skillPath: join4("registry", skillId).replace(/\\/g, "/"), source: "convention" };
563
855
  }
564
856
  const mapped = reg.data.skills[skillId];
565
857
  if (mapped !== void 0 && mapped.length > 0) {
566
858
  return { skillPath: normalizeRelPath(mapped), source: "registry.json" };
567
859
  }
568
- return { skillPath: join3("registry", skillId).replace(/\\/g, "/"), source: "convention" };
860
+ return { skillPath: join4("registry", skillId).replace(/\\/g, "/"), source: "convention" };
569
861
  }
570
862
  function normalizeRelPath(p4) {
571
863
  return p4.replace(/\\/g, "/").replace(/^\.\/+/, "").replace(/\/+$/, "");
@@ -574,9 +866,9 @@ function normalizeRelPath(p4) {
574
866
  // src/io.ts
575
867
  import { access as access2, cp, rm as rm2 } from "node:fs/promises";
576
868
  import { constants } from "node:fs";
577
- import { join as join4 } from "node:path";
869
+ import { join as join5 } from "node:path";
578
870
  async function assertSkillMdPresent(skillSourceDir) {
579
- const p4 = join4(skillSourceDir, "SKILL.md");
871
+ const p4 = join5(skillSourceDir, "SKILL.md");
580
872
  try {
581
873
  await access2(p4, constants.R_OK);
582
874
  } catch {
@@ -591,7 +883,7 @@ async function copySkillTree(fromDir, toDir) {
591
883
  // src/commands/install.ts
592
884
  async function installSkillFromCheckout(cwd, config, repoPath, head, skillId) {
593
885
  const { skillPath } = await resolveSkillPath(repoPath, skillId);
594
- const src = join5(repoPath, skillPath);
886
+ const src = join6(repoPath, skillPath);
595
887
  await assertSkillMdPresent(src);
596
888
  const dest = skillInstallPath(cwd, config.skillsRoot, skillId);
597
889
  await copySkillTree(src, dest);
@@ -611,9 +903,9 @@ async function runInstall(cwd, skillId) {
611
903
  try {
612
904
  const { path: repoPath, head } = await ensureRegistryCheckout(cwd, config);
613
905
  const dest = await installSkillFromCheckout(cwd, config, repoPath, head, skillId);
614
- spin.succeed(chalk2.green(`Installed ${skillId} \u2192 ${dest}`));
906
+ spin.succeed(chalk3.green(`Installed ${skillId} \u2192 ${dest}`));
615
907
  } catch (err) {
616
- spin.fail(chalk2.red(err instanceof Error ? err.message : String(err)));
908
+ spin.fail(chalk3.red(err instanceof Error ? err.message : String(err)));
617
909
  throw err;
618
910
  }
619
911
  }
@@ -629,9 +921,9 @@ async function runInstallMany(cwd, skillIds, options) {
629
921
  const c = await ensureRegistryCheckout(cwd, config);
630
922
  repoPath = c.path;
631
923
  head = c.head;
632
- prep.succeed(chalk2.green("Registry ready."));
924
+ prep.succeed(chalk3.green("Registry ready."));
633
925
  } catch (err) {
634
- prep.fail(chalk2.red(err instanceof Error ? err.message : String(err)));
926
+ prep.fail(chalk3.red(err instanceof Error ? err.message : String(err)));
635
927
  throw err;
636
928
  }
637
929
  }
@@ -639,16 +931,16 @@ async function runInstallMany(cwd, skillIds, options) {
639
931
  const spin = ora(`Installing ${skillId}\u2026`).start();
640
932
  try {
641
933
  const dest = await installSkillFromCheckout(cwd, config, repoPath, head, skillId);
642
- spin.succeed(chalk2.green(`Installed ${skillId} \u2192 ${dest}`));
934
+ spin.succeed(chalk3.green(`Installed ${skillId} \u2192 ${dest}`));
643
935
  } catch (err) {
644
- spin.fail(chalk2.red(err instanceof Error ? err.message : String(err)));
936
+ spin.fail(chalk3.red(err instanceof Error ? err.message : String(err)));
645
937
  throw err;
646
938
  }
647
939
  }
648
940
  }
649
941
 
650
942
  // src/commands/uninstall.ts
651
- import chalk3 from "chalk";
943
+ import chalk4 from "chalk";
652
944
  import ora2 from "ora";
653
945
  import { rm as rm3 } from "node:fs/promises";
654
946
  async function runUninstall(cwd, skillId) {
@@ -660,27 +952,27 @@ async function runUninstall(cwd, skillId) {
660
952
  const lock = await readLockOrEmpty(cwd);
661
953
  if (!lock.skills[skillId]) {
662
954
  spin.stopAndPersist({
663
- symbol: chalk3.yellow("\u26A0"),
664
- text: chalk3.yellow(`No lock entry for ${skillId}; removed directory if present.`)
955
+ symbol: chalk4.yellow("\u26A0"),
956
+ text: chalk4.yellow(`No lock entry for ${skillId}; removed directory if present.`)
665
957
  });
666
958
  } else {
667
959
  await writeLock(cwd, removeSkillLock(lock, skillId));
668
- spin.succeed(chalk3.green(`Uninstalled ${skillId}`));
960
+ spin.succeed(chalk4.green(`Uninstalled ${skillId}`));
669
961
  }
670
962
  } catch (err) {
671
- spin.fail(chalk3.red(err instanceof Error ? err.message : String(err)));
963
+ spin.fail(chalk4.red(err instanceof Error ? err.message : String(err)));
672
964
  throw err;
673
965
  }
674
966
  }
675
967
 
676
968
  // src/commands/list.ts
677
- import chalk4 from "chalk";
969
+ import chalk5 from "chalk";
678
970
  async function runList(cwd) {
679
971
  const config = await loadConfig(cwd);
680
972
  const lock = await readLockOrEmpty(cwd);
681
973
  const ids = Object.keys(lock.skills).sort();
682
974
  if (ids.length === 0) {
683
- console.log(chalk4.dim("No skills installed (lock empty)."));
975
+ console.log(chalk5.dim("No skills installed (lock empty)."));
684
976
  return;
685
977
  }
686
978
  for (const id of ids) {
@@ -688,24 +980,24 @@ async function runList(cwd) {
688
980
  const dest = skillInstallPath(cwd, config.skillsRoot, id);
689
981
  console.log(
690
982
  [
691
- chalk4.bold(id),
692
- chalk4.dim("\u2192"),
983
+ chalk5.bold(id),
984
+ chalk5.dim("\u2192"),
693
985
  dest,
694
- chalk4.dim(`commit=${e.registryCommit.slice(0, 7)} path=${e.skillPath}`)
986
+ chalk5.dim(`commit=${e.registryCommit.slice(0, 7)} path=${e.skillPath}`)
695
987
  ].join(" ")
696
988
  );
697
989
  }
698
990
  }
699
991
 
700
992
  // src/commands/outdated.ts
701
- import chalk5 from "chalk";
993
+ import chalk6 from "chalk";
702
994
  import ora3 from "ora";
703
995
  async function runOutdated(cwd) {
704
996
  const config = await loadConfig(cwd);
705
997
  const lock = await readLockOrEmpty(cwd);
706
998
  const ids = Object.keys(lock.skills).sort();
707
999
  if (ids.length === 0) {
708
- console.log(chalk5.dim("Nothing installed."));
1000
+ console.log(chalk6.dim("Nothing installed."));
709
1001
  return;
710
1002
  }
711
1003
  const spin = ora3("Fetching registry and comparing paths").start();
@@ -714,32 +1006,29 @@ async function runOutdated(cwd) {
714
1006
  spin.stop();
715
1007
  for (const id of ids) {
716
1008
  const e = lock.skills[id];
717
- const d = await diffPath(repoPath, e.registryCommit, head, e.skillPath);
718
- const stale = isPathStale(d);
1009
+ const stale = await isSkillPathStaleAtHead(repoPath, head, e);
719
1010
  if (stale) {
720
1011
  console.log(
721
- chalk5.yellow(`${id}`) + chalk5.dim(
722
- ` outdated (path changed ${e.registryCommit.slice(0, 7)} \u2192 ${head.slice(0, 7)})`
723
- )
1012
+ chalk6.bold.yellowBright("OUTDATED") + " " + chalk6.yellow(id) + chalk6.dim(` \u2014 path changed ${e.registryCommit.slice(0, 7)} \u2192 ${head.slice(0, 7)}`)
724
1013
  );
725
1014
  } else {
726
- console.log(chalk5.green(`${id}`) + chalk5.dim(` up to date @ ${head.slice(0, 7)}`));
1015
+ console.log(chalk6.green(`${id}`) + chalk6.dim(` up to date @ ${head.slice(0, 7)}`));
727
1016
  }
728
1017
  }
729
1018
  } catch (err) {
730
- spin.fail(chalk5.red(err instanceof Error ? err.message : String(err)));
1019
+ spin.fail(chalk6.red(err instanceof Error ? err.message : String(err)));
731
1020
  process.exitCode = 1;
732
1021
  }
733
1022
  }
734
1023
 
735
1024
  // src/commands/update.ts
736
- import chalk6 from "chalk";
1025
+ import chalk7 from "chalk";
737
1026
  async function runUpdate(cwd, skillId) {
738
1027
  await loadConfig(cwd);
739
1028
  const lock = await readLockOrEmpty(cwd);
740
1029
  if (skillId) {
741
1030
  if (!lock.skills[skillId]) {
742
- console.error(chalk6.red(`Unknown skill in lock: ${skillId}`));
1031
+ console.error(chalk7.red(`Unknown skill in lock: ${skillId}`));
743
1032
  process.exitCode = 1;
744
1033
  return;
745
1034
  }
@@ -752,7 +1041,7 @@ async function runUpdate(cwd, skillId) {
752
1041
  }
753
1042
  const ids = Object.keys(lock.skills).sort();
754
1043
  if (ids.length === 0) {
755
- console.log(chalk6.dim("Nothing to update (lock empty)."));
1044
+ console.log(chalk7.dim("Nothing to update (lock empty)."));
756
1045
  return;
757
1046
  }
758
1047
  try {
@@ -763,11 +1052,12 @@ async function runUpdate(cwd, skillId) {
763
1052
  }
764
1053
 
765
1054
  // src/commands/manage.ts
766
- import * as p2 from "@clack/prompts";
767
- import chalk8 from "chalk";
1055
+ import * as p3 from "@clack/prompts";
1056
+ import chalk9 from "chalk";
1057
+ import readline from "node:readline";
768
1058
 
769
1059
  // src/commands/banner.ts
770
- import chalk7 from "chalk";
1060
+ import chalk8 from "chalk";
771
1061
  var LOGO = [
772
1062
  " \u2597\u2584\u2584\u2596\u2597\u2596 \u2597\u2596\u2597\u2584\u2584\u2584\u2596\u2597\u2596 \u2597\u2596 ",
773
1063
  " \u2590\u258C \u2590\u258C\u2597\u259E\u2598 \u2588 \u2590\u258C \u2590\u258C ",
@@ -801,7 +1091,7 @@ function gradientLine(line, row, totalRows) {
801
1091
  const hi = Math.min(lo + 1, GRADIENT_STOPS.length - 1);
802
1092
  const t = stopIdx - lo;
803
1093
  const [r, g, b] = lerpColor(GRADIENT_STOPS[lo], GRADIENT_STOPS[hi], t);
804
- return chalk7.rgb(r, g, b)(line);
1094
+ return chalk8.rgb(r, g, b)(line);
805
1095
  }
806
1096
  function printSkillIssueBanner(version) {
807
1097
  console.log("");
@@ -809,7 +1099,7 @@ function printSkillIssueBanner(version) {
809
1099
  console.log(gradientLine(LOGO[i], i, LOGO.length));
810
1100
  }
811
1101
  if (version) {
812
- console.log(chalk7.dim(` v${version}`));
1102
+ console.log(chalk8.dim(` v${version}`));
813
1103
  }
814
1104
  console.log("");
815
1105
  }
@@ -817,14 +1107,14 @@ function printSkillIssueBanner(version) {
817
1107
  // src/registry/catalog.ts
818
1108
  import { access as access3, readFile as readFile4, readdir } from "node:fs/promises";
819
1109
  import { constants as constants2 } from "node:fs";
820
- import { join as join6 } from "node:path";
1110
+ import { join as join7 } from "node:path";
821
1111
  import { z as z4 } from "zod";
822
1112
  var RegistryJsonSchema2 = z4.object({
823
1113
  skills: z4.record(z4.string(), z4.string()).optional()
824
1114
  }).passthrough();
825
1115
  async function listRegistrySkillIds(registryRepoRoot) {
826
1116
  const ids = /* @__PURE__ */ new Set();
827
- const registryFile = join6(registryRepoRoot, "registry.json");
1117
+ const registryFile = join7(registryRepoRoot, "registry.json");
828
1118
  try {
829
1119
  const raw = await readFile4(registryFile, "utf8");
830
1120
  const parsed = JSON.parse(raw);
@@ -836,16 +1126,16 @@ async function listRegistrySkillIds(registryRepoRoot) {
836
1126
  }
837
1127
  } catch {
838
1128
  }
839
- const registryDir = join6(registryRepoRoot, "registry");
1129
+ const registryDir = join7(registryRepoRoot, "registry");
840
1130
  try {
841
1131
  const entries = await readdir(registryDir, { withFileTypes: true });
842
1132
  for (const e of entries) {
843
1133
  if (!e.isDirectory()) continue;
844
1134
  const id = e.name;
845
1135
  if (!id.trim()) continue;
846
- const skillRoot = join6(registryDir, id);
1136
+ const skillRoot = join7(registryDir, id);
847
1137
  try {
848
- await access3(join6(skillRoot, "SKILL.md"), constants2.R_OK);
1138
+ await access3(join7(skillRoot, "SKILL.md"), constants2.R_OK);
849
1139
  ids.add(id);
850
1140
  } catch {
851
1141
  }
@@ -856,133 +1146,274 @@ async function listRegistrySkillIds(registryRepoRoot) {
856
1146
  }
857
1147
 
858
1148
  // src/commands/manage.ts
1149
+ var MAIN_MENU_DOUBLE_ESCAPE_MS = 1200;
1150
+ async function promptMainMenuSelect(availableCount, installedCount) {
1151
+ const onCtrlC = (_s, key) => {
1152
+ if (key?.ctrl && key?.name === "c") {
1153
+ p3.cancel("Aborted.");
1154
+ process.exit(0);
1155
+ }
1156
+ };
1157
+ const { stdin } = process;
1158
+ if (stdin.isTTY) {
1159
+ readline.emitKeypressEvents(stdin);
1160
+ stdin.prependListener("keypress", onCtrlC);
1161
+ }
1162
+ try {
1163
+ return await p3.select({
1164
+ message: chalk9.bold("Next step"),
1165
+ initialValue: "install",
1166
+ options: [
1167
+ {
1168
+ value: "install",
1169
+ label: "Install",
1170
+ hint: chalk9.dim(`${availableCount} available`)
1171
+ },
1172
+ {
1173
+ value: "uninstall",
1174
+ label: "Uninstall",
1175
+ hint: chalk9.dim(`${installedCount} installed`)
1176
+ },
1177
+ {
1178
+ value: "update",
1179
+ label: "Update",
1180
+ hint: chalk9.dim("refresh from registry checkout")
1181
+ },
1182
+ { value: "done", label: "Exit", hint: chalk9.dim("back to shell") }
1183
+ ]
1184
+ });
1185
+ } finally {
1186
+ if (stdin.isTTY) {
1187
+ stdin.removeListener("keypress", onCtrlC);
1188
+ }
1189
+ }
1190
+ }
859
1191
  function printRegistryStats(catalogLen, installed, canInstall) {
860
- const sep = chalk8.dim(" \xB7 ");
1192
+ const sep = chalk9.dim(" \xB7 ");
861
1193
  const line = [
862
- chalk8.dim("Registry"),
863
- chalk8.cyan(catalogLen),
864
- chalk8.dim("skills"),
1194
+ chalk9.dim("Registry"),
1195
+ chalk9.cyan(catalogLen),
1196
+ chalk9.dim("skills"),
865
1197
  sep,
866
- chalk8.dim("installed"),
867
- chalk8.cyan(installed),
1198
+ chalk9.dim("installed"),
1199
+ chalk9.cyan(installed),
868
1200
  sep,
869
- chalk8.dim("not installed"),
870
- chalk8.cyan(canInstall)
1201
+ chalk9.dim("not installed"),
1202
+ chalk9.cyan(canInstall)
871
1203
  ].join(" ");
872
- p2.log.info(line);
1204
+ p3.log.info(line);
873
1205
  }
874
1206
  async function runManage(cwd) {
875
1207
  printSkillIssueBanner();
876
- p2.intro(chalk8.dim("manage"));
1208
+ p3.intro(chalk9.dim("manage"));
877
1209
  const config = await loadConfig(cwd);
878
- const spin = p2.spinner();
1210
+ const spin = p3.spinner();
879
1211
  spin.start("Preparing registry\u2026");
880
1212
  let checkout;
881
1213
  try {
882
1214
  checkout = await ensureRegistryCheckout(cwd, config);
883
- spin.stop(chalk8.green("Registry ready."));
1215
+ spin.stop(chalk9.green("Registry ready."));
884
1216
  } catch (err) {
885
1217
  const msg = err instanceof Error ? err.message : String(err);
886
- spin.stop(chalk8.red(msg));
1218
+ spin.stop(chalk9.red(msg));
887
1219
  throw err;
888
1220
  }
889
1221
  const catalog = await listRegistrySkillIds(checkout.path);
890
1222
  let lock = await readLockOrEmpty(cwd);
1223
+ let lastMainMenuEscapeAt = 0;
891
1224
  while (true) {
892
1225
  const installedIds = Object.keys(lock.skills).sort();
893
1226
  const available = catalog.filter((id) => !lock.skills[id]);
894
1227
  printRegistryStats(catalog.length, installedIds.length, available.length);
895
- const action = await p2.select({
896
- message: chalk8.bold("Next step"),
897
- initialValue: "install",
898
- options: [
899
- {
900
- value: "install",
901
- label: "Install",
902
- hint: chalk8.dim(`${available.length} available`)
903
- },
904
- {
905
- value: "uninstall",
906
- label: "Uninstall",
907
- hint: chalk8.dim(`${installedIds.length} installed`)
908
- },
909
- { value: "done", label: "Exit", hint: chalk8.dim("back to shell") }
910
- ]
911
- });
912
- if (p2.isCancel(action)) {
913
- p2.cancel("Aborted.");
914
- process.exit(0);
1228
+ const action = await promptMainMenuSelect(available.length, installedIds.length);
1229
+ if (p3.isCancel(action)) {
1230
+ const now = Date.now();
1231
+ if (now - lastMainMenuEscapeAt < MAIN_MENU_DOUBLE_ESCAPE_MS) {
1232
+ p3.cancel("Aborted.");
1233
+ process.exit(0);
1234
+ }
1235
+ lastMainMenuEscapeAt = now;
1236
+ p3.log.message(
1237
+ chalk9.dim(
1238
+ `Press Esc twice within ${MAIN_MENU_DOUBLE_ESCAPE_MS / 1e3}s to exit, or choose a step.`
1239
+ )
1240
+ );
1241
+ continue;
915
1242
  }
1243
+ lastMainMenuEscapeAt = 0;
916
1244
  if (action === "done") {
917
- p2.outro(chalk8.green("Finished."));
1245
+ p3.outro(chalk9.green("Finished."));
918
1246
  return;
919
1247
  }
1248
+ if (action === "update") {
1249
+ if (installedIds.length === 0) {
1250
+ p3.log.warn(chalk9.dim("Nothing installed yet."));
1251
+ continue;
1252
+ }
1253
+ const checkSpin = p3.spinner();
1254
+ checkSpin.start("Checking installed skills against registry\u2026");
1255
+ let staleById;
1256
+ try {
1257
+ staleById = {};
1258
+ for (const id of installedIds) {
1259
+ staleById[id] = await isSkillPathStaleAtHead(
1260
+ checkout.path,
1261
+ checkout.head,
1262
+ lock.skills[id]
1263
+ );
1264
+ }
1265
+ checkSpin.stop(chalk9.green("Ready."));
1266
+ } catch (err) {
1267
+ const msg = err instanceof Error ? err.message : String(err);
1268
+ checkSpin.stop(chalk9.red(msg));
1269
+ p3.log.error(chalk9.red(`Could not compare skills: ${msg}`));
1270
+ continue;
1271
+ }
1272
+ const staleIds = installedIds.filter((id) => staleById[id]);
1273
+ const updateMode = await p3.select({
1274
+ message: chalk9.bold("Update skills"),
1275
+ initialValue: "pick",
1276
+ options: [
1277
+ {
1278
+ value: "pick",
1279
+ label: "Choose which to update",
1280
+ hint: staleIds.length > 0 ? chalk9.bold.yellowBright(
1281
+ `${staleIds.length} OUTDATED \u2014 differs from registry head`
1282
+ ) : chalk9.dim("all match registry")
1283
+ },
1284
+ {
1285
+ value: "all",
1286
+ label: "Update all",
1287
+ hint: chalk9.dim(`${installedIds.length} installed`)
1288
+ }
1289
+ ]
1290
+ });
1291
+ if (p3.isCancel(updateMode)) {
1292
+ p3.log.message(chalk9.dim("Back to the menu."));
1293
+ continue;
1294
+ }
1295
+ if (updateMode === "all") {
1296
+ const ok2 = await p3.confirm({
1297
+ message: `Update all ${chalk9.cyan(String(installedIds.length))} installed skills?`,
1298
+ initialValue: true
1299
+ });
1300
+ if (p3.isCancel(ok2)) {
1301
+ p3.log.message(chalk9.dim("Back to the menu."));
1302
+ continue;
1303
+ }
1304
+ if (!ok2) continue;
1305
+ try {
1306
+ await runInstallMany(cwd, installedIds, { checkout });
1307
+ } catch {
1308
+ p3.log.error(chalk9.red("Update failed."));
1309
+ }
1310
+ lock = await readLockOrEmpty(cwd);
1311
+ continue;
1312
+ }
1313
+ p3.log.message(chalk9.dim("Space toggle \xB7 Enter confirm \xB7 Esc back"));
1314
+ const selected = await p3.multiselect({
1315
+ message: "Skills to update",
1316
+ options: installedIds.map((id) => ({
1317
+ value: id,
1318
+ label: id,
1319
+ hint: staleById[id] ? chalk9.bold.yellowBright("OUTDATED") + chalk9.dim(" \xB7 update to sync") : chalk9.dim("up to date")
1320
+ })),
1321
+ initialValues: staleIds,
1322
+ required: false
1323
+ });
1324
+ if (p3.isCancel(selected)) {
1325
+ p3.log.message(chalk9.dim("Back to the menu."));
1326
+ continue;
1327
+ }
1328
+ if (selected.length === 0) {
1329
+ p3.log.warn(chalk9.dim("No selection \u2014 back to the menu."));
1330
+ continue;
1331
+ }
1332
+ const ok = await p3.confirm({
1333
+ message: `Update ${chalk9.cyan(selected.join(", "))}?`,
1334
+ initialValue: true
1335
+ });
1336
+ if (p3.isCancel(ok)) {
1337
+ p3.log.message(chalk9.dim("Back to the menu."));
1338
+ continue;
1339
+ }
1340
+ if (!ok) continue;
1341
+ try {
1342
+ await runInstallMany(cwd, selected, { checkout });
1343
+ } catch {
1344
+ p3.log.error(chalk9.red("Update failed."));
1345
+ }
1346
+ lock = await readLockOrEmpty(cwd);
1347
+ continue;
1348
+ }
920
1349
  if (action === "install") {
921
1350
  if (available.length === 0) {
922
- p2.log.warn(
923
- chalk8.dim("Nothing left to install \u2014 everything in the registry is already installed.")
1351
+ p3.log.warn(
1352
+ chalk9.dim("Nothing left to install \u2014 everything in the registry is already installed.")
924
1353
  );
925
1354
  continue;
926
1355
  }
927
- p2.log.message(chalk8.dim("Space toggle \xB7 Enter confirm \xB7 Esc cancel"));
928
- const selected2 = await p2.multiselect({
1356
+ p3.log.message(chalk9.dim("Space toggle \xB7 Enter confirm \xB7 Esc back"));
1357
+ const selected = await p3.multiselect({
929
1358
  message: "Skills to install",
930
1359
  options: available.map((id) => ({ value: id, label: id })),
931
1360
  required: false
932
1361
  });
933
- if (p2.isCancel(selected2)) {
934
- p2.cancel("Aborted.");
935
- process.exit(0);
1362
+ if (p3.isCancel(selected)) {
1363
+ p3.log.message(chalk9.dim("Back to the menu."));
1364
+ continue;
936
1365
  }
937
- if (selected2.length === 0) {
938
- p2.log.warn(chalk8.dim("No selection \u2014 back to the menu."));
1366
+ if (selected.length === 0) {
1367
+ p3.log.warn(chalk9.dim("No selection \u2014 back to the menu."));
939
1368
  continue;
940
1369
  }
941
- const ok2 = await p2.confirm({
942
- message: `Install ${chalk8.cyan(selected2.join(", "))}?`,
1370
+ const ok = await p3.confirm({
1371
+ message: `Install ${chalk9.cyan(selected.join(", "))}?`,
943
1372
  initialValue: true
944
1373
  });
945
- if (p2.isCancel(ok2)) {
946
- p2.cancel("Aborted.");
947
- process.exit(0);
1374
+ if (p3.isCancel(ok)) {
1375
+ p3.log.message(chalk9.dim("Back to the menu."));
1376
+ continue;
948
1377
  }
949
- if (!ok2) continue;
950
- await runInstallMany(cwd, selected2, { checkout });
1378
+ if (!ok) continue;
1379
+ await runInstallMany(cwd, selected, { checkout });
951
1380
  lock = await readLockOrEmpty(cwd);
952
1381
  continue;
953
1382
  }
954
- if (installedIds.length === 0) {
955
- p2.log.warn(chalk8.dim("Nothing installed yet."));
956
- continue;
957
- }
958
- p2.log.message(chalk8.dim("Space toggle \xB7 Enter confirm \xB7 Esc cancel"));
959
- const selected = await p2.multiselect({
960
- message: "Skills to remove",
961
- options: installedIds.map((id) => ({ value: id, label: id })),
962
- required: false
963
- });
964
- if (p2.isCancel(selected)) {
965
- p2.cancel("Aborted.");
966
- process.exit(0);
967
- }
968
- if (selected.length === 0) {
969
- p2.log.warn(chalk8.dim("No selection \u2014 back to the menu."));
970
- continue;
971
- }
972
- const ok = await p2.confirm({
973
- message: `Remove ${chalk8.cyan(selected.join(", "))}?`,
974
- initialValue: false
975
- });
976
- if (p2.isCancel(ok)) {
977
- p2.cancel("Aborted.");
978
- process.exit(0);
979
- }
980
- if (!ok) continue;
981
- const toRemove = [...selected].sort((a, b) => a.localeCompare(b));
982
- for (const id of toRemove) {
983
- await runUninstall(cwd, id);
1383
+ if (action === "uninstall") {
1384
+ if (installedIds.length === 0) {
1385
+ p3.log.warn(chalk9.dim("Nothing installed yet."));
1386
+ continue;
1387
+ }
1388
+ p3.log.message(chalk9.dim("Space toggle \xB7 Enter confirm \xB7 Esc back"));
1389
+ const selected = await p3.multiselect({
1390
+ message: "Skills to remove",
1391
+ options: installedIds.map((id) => ({ value: id, label: id })),
1392
+ required: false
1393
+ });
1394
+ if (p3.isCancel(selected)) {
1395
+ p3.log.message(chalk9.dim("Back to the menu."));
1396
+ continue;
1397
+ }
1398
+ if (selected.length === 0) {
1399
+ p3.log.warn(chalk9.dim("No selection \u2014 back to the menu."));
1400
+ continue;
1401
+ }
1402
+ const ok = await p3.confirm({
1403
+ message: `Remove ${chalk9.cyan(selected.join(", "))}?`,
1404
+ initialValue: false
1405
+ });
1406
+ if (p3.isCancel(ok)) {
1407
+ p3.log.message(chalk9.dim("Back to the menu."));
1408
+ continue;
1409
+ }
1410
+ if (!ok) continue;
1411
+ const toRemove = [...selected].sort((a, b) => a.localeCompare(b));
1412
+ for (const id of toRemove) {
1413
+ await runUninstall(cwd, id);
1414
+ }
1415
+ lock = await readLockOrEmpty(cwd);
984
1416
  }
985
- lock = await readLockOrEmpty(cwd);
986
1417
  }
987
1418
  }
988
1419
 
@@ -997,7 +1428,7 @@ async function runDefault(cwd) {
997
1428
  }
998
1429
 
999
1430
  // src/commands/doctor.ts
1000
- import chalk9 from "chalk";
1431
+ import chalk10 from "chalk";
1001
1432
  function nodeOk() {
1002
1433
  const major = Number(process.versions.node.split(".")[0]);
1003
1434
  if (Number.isFinite(major) && major >= 24) {
@@ -1020,172 +1451,25 @@ function authHint(config) {
1020
1451
  }
1021
1452
  async function runDoctor(cwd) {
1022
1453
  const n = nodeOk();
1023
- console.log(n.ok ? chalk9.green(`\u2713 ${n.detail}`) : chalk9.red(`\u2717 ${n.detail}`));
1454
+ console.log(n.ok ? chalk10.green(`\u2713 ${n.detail}`) : chalk10.red(`\u2717 ${n.detail}`));
1024
1455
  let config;
1025
1456
  try {
1026
1457
  config = await loadConfig(cwd);
1027
- console.log(chalk9.green("\u2713 .skill-issue/config.yaml valid"));
1458
+ console.log(chalk10.green("\u2713 .skill-issue/config.yaml valid"));
1028
1459
  } catch (e) {
1029
- console.log(chalk9.red(`\u2717 config: ${e instanceof Error ? e.message : String(e)}`));
1460
+ console.log(chalk10.red(`\u2717 config: ${e instanceof Error ? e.message : String(e)}`));
1030
1461
  process.exitCode = 1;
1031
1462
  return;
1032
1463
  }
1033
1464
  if (!isLocalRegistry(config)) {
1034
- console.log(chalk9.dim(`\u2022 ${authHint(config)}`));
1465
+ console.log(chalk10.dim(`\u2022 ${authHint(config)}`));
1035
1466
  }
1036
1467
  try {
1037
1468
  const { path: repoPath, head } = await ensureRegistryCheckout(cwd, config);
1038
1469
  const label = isLocalRegistry(config) ? "Local registry" : "Registry cache";
1039
- console.log(chalk9.green(`\u2713 ${label} synced (${repoPath}) @ ${head.slice(0, 7)}`));
1040
- } catch (e) {
1041
- console.log(chalk9.red(`\u2717 Registry: ${e instanceof Error ? e.message : String(e)}`));
1042
- process.exitCode = 1;
1043
- }
1044
- }
1045
-
1046
- // src/commands/init-registry.ts
1047
- import * as p3 from "@clack/prompts";
1048
- import chalk10 from "chalk";
1049
- import { existsSync as existsSync4, statSync as statSync2 } from "node:fs";
1050
- import { mkdir as mkdir5, writeFile as writeFile3 } from "node:fs/promises";
1051
- import { join as join7, resolve as resolve2 } from "node:path";
1052
- function validateSkillId(raw) {
1053
- const id = raw.trim();
1054
- if (!id) return "Skill id is required";
1055
- if (id.includes("/") || id.includes("\\")) return "Use a single segment (no path separators)";
1056
- if (id === "." || id === ".." || id.includes("..")) return "Invalid skill id";
1057
- return void 0;
1058
- }
1059
- function registryLayoutExists(root) {
1060
- const jsonPath = join7(root, "registry.json");
1061
- const regDir = join7(root, "registry");
1062
- if (existsSync4(jsonPath)) return true;
1063
- if (!existsSync4(regDir)) return false;
1064
- try {
1065
- return statSync2(regDir).isDirectory();
1066
- } catch {
1067
- return false;
1068
- }
1069
- }
1070
- function skillMarkdown(skillId) {
1071
- const title = skillId.split(/[-_]/).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
1072
- return `---
1073
- name: ${skillId}
1074
- description: Sample skill scaffolded by skissue init-registry. Replace this description.
1075
- ---
1076
-
1077
- # ${title}
1078
-
1079
- A minimal starter skill for your registry.
1080
-
1081
- ## When to use
1082
-
1083
- Use when the user works with this sample capability (customize this section).
1084
-
1085
- ## Instructions
1086
-
1087
- 1. Edit this file to describe what the agent should do.
1088
- 2. Optionally add \`hard/\` with executable checks.
1089
- `;
1090
- }
1091
- async function scaffoldMinimalRegistry(params) {
1092
- const { root, skillId, runGitInit } = params;
1093
- const regPath = `registry/${skillId}`;
1094
- const manifest = { skills: { [skillId]: regPath } };
1095
- await mkdir5(join7(root, regPath), { recursive: true });
1096
- await writeFile3(join7(root, "registry.json"), `${JSON.stringify(manifest, null, 2)}
1097
- `, "utf8");
1098
- await writeFile3(join7(root, regPath, "SKILL.md"), skillMarkdown(skillId), "utf8");
1099
- let gitInitRan = false;
1100
- if (runGitInit) {
1101
- const r = await execGit(["init"], { cwd: root });
1102
- if (r.code === 0) {
1103
- gitInitRan = true;
1104
- } else {
1105
- throw new Error(`git init failed: ${r.stderr || r.stdout || `exit ${r.code}`}`);
1106
- }
1107
- }
1108
- return { root, skillId, gitInitRan };
1109
- }
1110
- async function runInitRegistry(cwd) {
1111
- p3.intro(chalk10.bold("skissue init-registry"));
1112
- const where = await p3.select({
1113
- message: "Where should the skill registry live?",
1114
- options: [
1115
- { value: "here", label: "Current directory \u2014 create registry files here" },
1116
- { value: "other", label: "Another directory \u2014 I will enter the path" }
1117
- ],
1118
- initialValue: "here"
1119
- });
1120
- if (p3.isCancel(where)) {
1121
- p3.cancel("Aborted.");
1122
- process.exit(0);
1123
- }
1124
- let root;
1125
- if (where === "here") {
1126
- root = resolve2(cwd);
1127
- } else {
1128
- const raw = await p3.text({
1129
- message: "Path to registry root (absolute or relative to current directory)",
1130
- placeholder: "../my-skill-registry",
1131
- validate: (v) => v?.trim() ? void 0 : "Required"
1132
- });
1133
- if (p3.isCancel(raw)) {
1134
- p3.cancel("Aborted.");
1135
- process.exit(0);
1136
- }
1137
- root = resolve2(cwd, String(raw).trim());
1138
- }
1139
- const exists = registryLayoutExists(root);
1140
- if (exists) {
1141
- const ok = await p3.confirm({
1142
- message: `${chalk10.yellow("registry.json and/or registry/ already exist here.")} Replace with a minimal single-skill layout?`,
1143
- initialValue: false
1144
- });
1145
- if (p3.isCancel(ok) || !ok) {
1146
- p3.cancel("Aborted. No files were changed.");
1147
- process.exit(0);
1148
- }
1149
- }
1150
- const idRaw = await p3.text({
1151
- message: "Sample skill id (folder name under registry/)",
1152
- initialValue: "sample-skill",
1153
- validate: (v) => validateSkillId(String(v ?? ""))
1154
- });
1155
- if (p3.isCancel(idRaw)) {
1156
- p3.cancel("Aborted.");
1157
- process.exit(0);
1158
- }
1159
- const skillId = String(idRaw).trim();
1160
- const gitDir = join7(root, ".git");
1161
- const hadGit = existsSync4(gitDir);
1162
- let runGitInit = false;
1163
- if (!hadGit) {
1164
- const initGit = await p3.confirm({
1165
- message: "No git repository here yet. Run `git init`? (recommended \u2014 skissue local registry mode needs a git repo)",
1166
- initialValue: true
1167
- });
1168
- if (p3.isCancel(initGit)) {
1169
- p3.cancel("Aborted.");
1170
- process.exit(0);
1171
- }
1172
- runGitInit = Boolean(initGit);
1173
- }
1174
- try {
1175
- const result = await scaffoldMinimalRegistry({ root, skillId, runGitInit });
1176
- if (!hadGit && !result.gitInitRan) {
1177
- p3.note(
1178
- "This folder is not a git repository. Run `git init` before using it as a local skissue registry.",
1179
- chalk10.yellow("Heads up")
1180
- );
1181
- }
1182
- p3.outro(
1183
- chalk10.green(
1184
- `Wrote ${chalk10.cyan("registry.json")} and ${chalk10.cyan(`registry/${skillId}/SKILL.md`)} under ${chalk10.cyan(result.root)}`
1185
- )
1186
- );
1470
+ console.log(chalk10.green(`\u2713 ${label} synced (${repoPath}) @ ${head.slice(0, 7)}`));
1187
1471
  } catch (e) {
1188
- p3.cancel(e instanceof Error ? e.message : String(e));
1472
+ console.log(chalk10.red(`\u2717 Registry: ${e instanceof Error ? e.message : String(e)}`));
1189
1473
  process.exitCode = 1;
1190
1474
  }
1191
1475
  }
@@ -1262,7 +1546,7 @@ program.command("update").description("Re-fetch and overwrite installed skill(s)
1262
1546
  }
1263
1547
  });
1264
1548
  program.command("doctor").description("Check Node, config, and sync registry checkout").option("-C, --cwd <path>", "Project root", process.cwd()).action(async (opts) => {
1265
- const cwd = resolve3(opts.cwd ?? process.cwd());
1549
+ const cwd = resolve4(opts.cwd ?? process.cwd());
1266
1550
  try {
1267
1551
  await runDoctor(cwd);
1268
1552
  } catch {