@silverassist/agents-toolkit 2.2.0 → 2.4.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.
package/bin/cli.js CHANGED
@@ -7,7 +7,9 @@
7
7
 
8
8
  import fs from 'fs';
9
9
  import path from 'path';
10
+ import crypto from 'node:crypto';
10
11
  import { fileURLToPath } from 'url';
12
+ import { VERSION } from '../src/index.js';
11
13
 
12
14
  const __filename = fileURLToPath(import.meta.url);
13
15
  const __dirname = path.dirname(__filename);
@@ -59,10 +61,11 @@ const FILE_CATEGORIES = {
59
61
  universal: [
60
62
  'analyze-ticket', 'work-ticket', 'analyze-github-issue', 'work-github-issue',
61
63
  'create-plan', 'create-pr', 'prepare-pr', 'finalize-pr',
64
+ 'create-github-pr', 'finalize-github-pr',
62
65
  'review-code', 'fix-issues', 'add-tests', 'prepare-release',
63
66
  ],
64
67
  jira: ['analyze-ticket', 'work-ticket', 'create-pr', 'finalize-pr'],
65
- github: ['analyze-github-issue', 'work-github-issue'],
68
+ github: ['analyze-github-issue', 'work-github-issue', 'create-github-pr', 'finalize-github-pr'],
66
69
  },
67
70
  partials: {
68
71
  jira: ['jira-integration'],
@@ -179,6 +182,242 @@ function getClaudeTargetDir(global = false) {
179
182
  return path.join(process.cwd(), '.claude');
180
183
  }
181
184
 
185
+ /**
186
+ * Get the canonical skills directory following the `npx skills` standard.
187
+ * Skills live here once (single source of truth) and each agent's skills
188
+ * directory symlinks to it.
189
+ * @param {boolean} global - Use user-level ~/.agents/skills/
190
+ * @returns {string} Path to .agents/skills directory
191
+ */
192
+ function getAgentsSkillsDir(global = false) {
193
+ const base = global ? getHomeDir() : process.cwd();
194
+ return path.join(base, '.agents', 'skills');
195
+ }
196
+
197
+ /** Path to the project-level lockfile. */
198
+ const LOCKFILE_NAME = 'agents-toolkit-lock.json';
199
+
200
+ /**
201
+ * Compute a hex-encoded SHA-256 hash of a skill's SKILL.md content.
202
+ * Matches the algorithm used by `npx skills` (Vercel).
203
+ * @param {string} skillDir - Absolute path to the skill directory
204
+ * @returns {string|null} Hex hash string, or null if SKILL.md is missing
205
+ */
206
+ function computeSkillHash(skillDir) {
207
+ const skillMdPath = path.join(skillDir, 'SKILL.md');
208
+ if (!fs.existsSync(skillMdPath)) return null;
209
+ const content = fs.readFileSync(skillMdPath, 'utf-8');
210
+ return crypto.createHash('sha256').update(content).digest('hex');
211
+ }
212
+
213
+ /**
214
+ * Read and parse the project lockfile.
215
+ * @param {string} [cwd] - Directory to look in (defaults to process.cwd())
216
+ * @returns {Object|null} Parsed lockfile, or null if absent or invalid
217
+ */
218
+ function readLockfile(cwd = process.cwd()) {
219
+ const lockPath = path.join(cwd, LOCKFILE_NAME);
220
+ if (!fs.existsSync(lockPath)) return null;
221
+ try {
222
+ return JSON.parse(fs.readFileSync(lockPath, 'utf-8'));
223
+ } catch {
224
+ return null;
225
+ }
226
+ }
227
+
228
+ /**
229
+ * Write the project lockfile, merging new skills with any existing entries.
230
+ * This preserves skills installed by a previous `install --target` run so that
231
+ * running `install` followed by `install --claude` does not lose the first
232
+ * target's entries in the lockfile.
233
+ * @param {Object} params
234
+ * @param {Record<string, {computedHash: string|null, agents: string[]}>} params.skills - Newly installed skills map
235
+ * @param {{ stack: string, tracker: string }} params.config - Active install config
236
+ * @param {string} params.packageVersion - Current package version
237
+ * @param {string} [params.cwd] - Directory to write to (defaults to process.cwd())
238
+ */
239
+ function writeLockfile({ skills, config, packageVersion, cwd = process.cwd() }) {
240
+ const lockPath = path.join(cwd, LOCKFILE_NAME);
241
+
242
+ // Merge with any existing lockfile so successive multi-target installs
243
+ // (e.g. `install` then `install --claude`) accumulate entries.
244
+ const existing = readLockfile(cwd);
245
+ const mergedSkills = Object.assign({}, existing?.skills ?? {});
246
+
247
+ for (const [name, meta] of Object.entries(skills)) {
248
+ const prev = mergedSkills[name];
249
+ // Merge agents arrays: union of previous and new agent dirs.
250
+ const prevAgents = prev?.agents ?? [];
251
+ const allAgents = Array.from(new Set([...prevAgents, ...meta.agents]));
252
+ mergedSkills[name] = {
253
+ source: '@silverassist/agents-toolkit',
254
+ packageVersion,
255
+ computedHash: meta.computedHash,
256
+ agents: allAgents,
257
+ };
258
+ }
259
+
260
+ const lockfile = {
261
+ version: 1,
262
+ packageVersion,
263
+ config,
264
+ skills: mergedSkills,
265
+ };
266
+ fs.writeFileSync(lockPath, JSON.stringify(lockfile, null, 2) + '\n', 'utf-8');
267
+ success(`Wrote ${LOCKFILE_NAME}`);
268
+ }
269
+
270
+ /**
271
+ * Append skills-managed entries to .gitignore if not already present.
272
+ * Never runs during dry-run or global installs.
273
+ * @param {string} cwd - Project root directory
274
+ */
275
+ function appendSkillsToGitignore(cwd) {
276
+ const gitignorePath = path.join(cwd, '.gitignore');
277
+ const block = [
278
+ '',
279
+ '# agents-toolkit managed — regenerate with: npx @silverassist/agents-toolkit restore',
280
+ '.agents/skills/',
281
+ '.github/skills/',
282
+ '.claude/skills/',
283
+ ].join('\n');
284
+
285
+ let existing = '';
286
+ if (fs.existsSync(gitignorePath)) {
287
+ existing = fs.readFileSync(gitignorePath, 'utf-8');
288
+ }
289
+
290
+ // Only append if none of the three managed paths are already present.
291
+ if (
292
+ existing.includes('.agents/skills/') ||
293
+ existing.includes('.github/skills/') ||
294
+ existing.includes('.claude/skills/')
295
+ ) {
296
+ return;
297
+ }
298
+
299
+ fs.writeFileSync(gitignorePath, existing + block + '\n', 'utf-8');
300
+ info('Updated .gitignore with agents-toolkit managed paths');
301
+ }
302
+
303
+ /**
304
+ * Link a single skill from the canonical store into an agent's skills
305
+ * directory, following the `npx skills` standard (symlink with copy fallback).
306
+ * @param {string} canonicalSkillDir - Absolute path to the canonical skill folder
307
+ * @param {string} agentSkillLinkPath - Absolute path where the agent expects the skill
308
+ * @param {Object} options - Link options
309
+ * @param {boolean} [options.dryRun] - Only report what would happen
310
+ * @param {boolean} [options.force] - Replace an existing non-matching entry
311
+ * @param {boolean} [options.copy] - Force a copy instead of a symlink
312
+ * @returns {{ written: number, planned: number }} Change counters
313
+ */
314
+ function linkSkill(canonicalSkillDir, agentSkillLinkPath, options = {}) {
315
+ const { dryRun = false, force = false, copy = false } = options;
316
+ const totals = { written: 0, planned: 0 };
317
+
318
+ const relTarget = path.relative(path.dirname(agentSkillLinkPath), canonicalSkillDir);
319
+ const rel = (p) => path.relative(process.cwd(), p);
320
+
321
+ // Inspect any existing entry at the link path.
322
+ let existing = null;
323
+ try {
324
+ existing = fs.lstatSync(agentSkillLinkPath);
325
+ } catch {
326
+ existing = null;
327
+ }
328
+
329
+ if (existing) {
330
+ // Already a symlink pointing at our canonical store — nothing to do.
331
+ if (existing.isSymbolicLink()) {
332
+ const current = fs.readlinkSync(agentSkillLinkPath);
333
+ const resolved = path.resolve(path.dirname(agentSkillLinkPath), current);
334
+ if (resolved === path.resolve(canonicalSkillDir) && !copy) {
335
+ return totals;
336
+ }
337
+ }
338
+ if (!force) {
339
+ warn(`Skipping existing skill: ${rel(agentSkillLinkPath)}`);
340
+ return totals;
341
+ }
342
+ if (!dryRun) {
343
+ fs.rmSync(agentSkillLinkPath, { recursive: true, force: true });
344
+ }
345
+ }
346
+
347
+ totals.planned++;
348
+
349
+ if (dryRun) {
350
+ info(copy ? `Would copy skill: ${rel(agentSkillLinkPath)}` : `Would link: ${rel(agentSkillLinkPath)} -> ${relTarget}`);
351
+ return totals;
352
+ }
353
+
354
+ fs.mkdirSync(path.dirname(agentSkillLinkPath), { recursive: true });
355
+
356
+ const doCopy = () => {
357
+ copyDir(canonicalSkillDir, agentSkillLinkPath, { force: true });
358
+ };
359
+
360
+ if (copy) {
361
+ doCopy();
362
+ } else {
363
+ try {
364
+ fs.symlinkSync(relTarget, agentSkillLinkPath, 'dir');
365
+ } catch {
366
+ // Symlinks unsupported (e.g. Windows without developer mode) — copy instead.
367
+ doCopy();
368
+ }
369
+ }
370
+
371
+ totals.written++;
372
+ return totals;
373
+ }
374
+
375
+ /**
376
+ * Install skills following the `npx skills` standard: copy each skill once
377
+ * into the canonical .agents/skills store, then symlink the agent's skills
378
+ * directory entries to that store.
379
+ * @param {Object} params - Install parameters
380
+ * @param {boolean} params.isGlobal - Install at user level
381
+ * @param {string} params.agentSkillsDir - Agent skills dir (.claude/skills or .github/skills)
382
+ * @param {boolean} [params.force] - Overwrite existing files/links
383
+ * @param {boolean} [params.dryRun] - Only report planned changes
384
+ * @param {boolean} [params.copy] - Copy instead of symlink
385
+ * @param {(name: string) => boolean} [params.dirFilter] - Skill folder filter
386
+ * @returns {{ written: number, planned: number, installedSkills: Record<string, { canonicalDir: string }> }} Aggregated change counters and installed skill metadata
387
+ */
388
+ function installSkillsStandard({ isGlobal, agentSkillsDir, force = false, dryRun = false, copy = false, dirFilter = null }) {
389
+ const totals = { written: 0, planned: 0, installedSkills: {} };
390
+ const skillsSrc = path.join(TEMPLATES_DIR, 'shared', 'skills');
391
+ const canonicalDir = getAgentsSkillsDir(isGlobal);
392
+
393
+ if (!fs.existsSync(skillsSrc)) {
394
+ return totals;
395
+ }
396
+
397
+ // 1. Populate the canonical store once (filtered by stack).
398
+ const canonicalResult = copyDir(skillsSrc, canonicalDir, { force, dryRun, dirFilter });
399
+ totals.written += canonicalResult.written;
400
+ totals.planned += canonicalResult.planned;
401
+
402
+ // 2. Symlink each included skill folder into the agent's skills directory.
403
+ const entries = fs.readdirSync(skillsSrc, { withFileTypes: true });
404
+ for (const entry of entries) {
405
+ if (!entry.isDirectory()) continue;
406
+ if (dirFilter && !dirFilter(entry.name)) continue;
407
+
408
+ const canonicalSkillDir = path.join(canonicalDir, entry.name);
409
+ const agentSkillLinkPath = path.join(agentSkillsDir, entry.name);
410
+ const linkResult = linkSkill(canonicalSkillDir, agentSkillLinkPath, { dryRun, force, copy });
411
+ totals.written += linkResult.written;
412
+ totals.planned += linkResult.planned;
413
+
414
+ // Track for lockfile even during dry-run (so callers know what would be installed).
415
+ totals.installedSkills[entry.name] = { canonicalDir: canonicalSkillDir };
416
+ }
417
+
418
+ return totals;
419
+ }
420
+
182
421
  /**
183
422
  * Strip GitHub Copilot frontmatter from a prompt file
184
423
  * Removes the ---\nagent: ...\ndescription: ...\n--- block
@@ -286,14 +525,16 @@ function getInstallScope(options = {}) {
286
525
  partialsOnly = false,
287
526
  skillsOnly = false,
288
527
  instructionsOnly = false,
528
+ hooksOnly = false,
289
529
  } = options;
290
530
 
291
- const hasSpecificFlag = promptsOnly || partialsOnly || skillsOnly || instructionsOnly;
531
+ const hasSpecificFlag = promptsOnly || partialsOnly || skillsOnly || instructionsOnly || hooksOnly;
292
532
 
293
533
  return {
294
534
  shouldInstallPrompts: !hasSpecificFlag || promptsOnly || partialsOnly,
295
535
  shouldInstallInstructions: !hasSpecificFlag || instructionsOnly,
296
536
  shouldInstallSkills: !hasSpecificFlag || skillsOnly,
537
+ shouldInstallHooks: !hasSpecificFlag || hooksOnly,
297
538
  };
298
539
  }
299
540
 
@@ -301,6 +542,71 @@ function getChangeCount(result, dryRun) {
301
542
  return dryRun ? result.planned : result.written;
302
543
  }
303
544
 
545
+ /**
546
+ * Finalize installed hook configs so Copilot can resolve the script command.
547
+ * Copilot runs a hook `command` from the workspace root by default, not from
548
+ * the hooks directory, so the relative `scripts/<name>.sh` command needs a
549
+ * `cwd` pointing at the actual hooks dir. Also ensures the required
550
+ * `version: 1` field is present.
551
+ * @param {string} hooksDest - Absolute path to the installed hooks directory
552
+ * @param {boolean} isGlobal - Whether this is a global (~/.copilot) install
553
+ */
554
+ function finalizeHookConfigs(hooksDest, isGlobal) {
555
+ // Global installs have no workspace anchor → use the absolute hooks path.
556
+ // Project installs use a path relative to the workspace root so the config
557
+ // stays portable/committable across machines and teammates.
558
+ const cwd = isGlobal
559
+ ? hooksDest
560
+ : path.relative(process.cwd(), hooksDest).split(path.sep).join('/');
561
+
562
+ const jsonFiles = fs.readdirSync(hooksDest).filter((f) => f.endsWith('.json'));
563
+ for (const file of jsonFiles) {
564
+ const filePath = path.join(hooksDest, file);
565
+ const config = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
566
+ config.version = 1;
567
+ for (const events of Object.values(config.hooks || {})) {
568
+ for (const entry of events) {
569
+ entry.cwd = cwd;
570
+ }
571
+ }
572
+ fs.writeFileSync(filePath, JSON.stringify(config, null, 2) + '\n');
573
+ }
574
+ }
575
+
576
+ function installHooks({ targetDir, force = false, dryRun = false, global: isGlobal = false }) {
577
+ const hooksSrc = path.join(TEMPLATES_DIR, 'shared', 'hooks');
578
+ const hooksDest = path.join(targetDir, 'hooks');
579
+
580
+ if (!fs.existsSync(hooksSrc)) {
581
+ warn('No hooks templates found — skipping');
582
+ return { written: 0, skipped: 0, planned: 0 };
583
+ }
584
+
585
+ // Copy hook JSON configs and scripts
586
+ const result = copyDir(hooksSrc, hooksDest, { force, dryRun });
587
+
588
+ // Make scripts executable and finalize configs (non-dry-run only)
589
+ if (!dryRun) {
590
+ const scriptsDir = path.join(hooksDest, 'scripts');
591
+ if (fs.existsSync(scriptsDir)) {
592
+ const scripts = fs.readdirSync(scriptsDir).filter(f => f.endsWith('.sh'));
593
+ for (const script of scripts) {
594
+ const scriptPath = path.join(scriptsDir, script);
595
+ fs.chmodSync(scriptPath, 0o755);
596
+ }
597
+ }
598
+ if (fs.existsSync(hooksDest)) {
599
+ finalizeHookConfigs(hooksDest, isGlobal);
600
+ }
601
+ }
602
+
603
+ if (!dryRun && result.written > 0) {
604
+ success(`Installed ${result.written} hook files`);
605
+ }
606
+
607
+ return result;
608
+ }
609
+
304
610
  function ensureConfigFile({ dryRun = false, global = false } = {}) {
305
611
  const configDir = global ? getHomeDir() : process.cwd();
306
612
  const configPath = path.join(configDir, '.agents-toolkit.json');
@@ -439,6 +745,7 @@ function installGitBasedTarget(options = {}, target = 'copilot') {
439
745
  force = false,
440
746
  append = false,
441
747
  dryRun = false,
748
+ copy = false,
442
749
  global: isGlobal = false,
443
750
  filters = { stack: 'all', tracker: 'all' },
444
751
  } = options;
@@ -446,6 +753,8 @@ function installGitBasedTarget(options = {}, target = 'copilot') {
446
753
  const targetDir = getTargetDir(isGlobal);
447
754
  const scope = getInstallScope(options);
448
755
  let totalChanges = 0;
756
+ /** @type {Record<string, { canonicalDir: string, agents: string[] }>} */
757
+ const installedSkillsMap = {};
449
758
 
450
759
  const makeFilter = (category) => (name) => {
451
760
  const basename = name.replace(/\.(prompt\.md|instructions\.md|md)$/, '');
@@ -486,15 +795,35 @@ function installGitBasedTarget(options = {}, target = 'copilot') {
486
795
  }
487
796
 
488
797
  if (scope.shouldInstallSkills) {
489
- info('Installing skills...');
490
- const result = copyDir(path.join(TEMPLATES_DIR, 'shared', 'skills'), path.join(targetDir, 'skills'), { force, dryRun, dirFilter: makeFilter('skills') });
798
+ info('Installing skills (npx skills standard)...');
799
+ const result = installSkillsStandard({
800
+ isGlobal,
801
+ agentSkillsDir: path.join(targetDir, 'skills'),
802
+ force,
803
+ dryRun,
804
+ copy,
805
+ dirFilter: makeFilter('skills'),
806
+ });
491
807
  totalChanges += getChangeCount(result, dryRun);
808
+ // Merge installed skills for lockfile (agent dir = .github/skills or ~/.copilot/skills).
809
+ for (const [name, meta] of Object.entries(result.installedSkills)) {
810
+ if (!installedSkillsMap[name]) {
811
+ installedSkillsMap[name] = { canonicalDir: meta.canonicalDir, agents: [] };
812
+ }
813
+ installedSkillsMap[name].agents.push(path.relative(process.cwd(), path.join(targetDir, 'skills')));
814
+ }
492
815
 
493
816
  if (!dryRun && result.written > 0) {
494
- success(`Installed ${result.written} skill files`);
817
+ success(`Installed ${result.written} skill files/links`);
495
818
  }
496
819
  }
497
820
 
821
+ if (scope.shouldInstallHooks) {
822
+ info('Installing hooks...');
823
+ const hooksResult = installHooks({ targetDir, force, dryRun, global: isGlobal });
824
+ totalChanges += getChangeCount(hooksResult, dryRun);
825
+ }
826
+
498
827
  const configResult = ensureConfigFile({ dryRun, global: isGlobal });
499
828
  totalChanges += getChangeCount(configResult, dryRun);
500
829
 
@@ -511,6 +840,19 @@ function installGitBasedTarget(options = {}, target = 'copilot') {
511
840
  totalChanges += getChangeCount(agentsResult, dryRun);
512
841
  }
513
842
 
843
+ // Write lockfile and update .gitignore for project (non-global, non-dry-run) installs.
844
+ if (!isGlobal && !dryRun && scope.shouldInstallSkills && Object.keys(installedSkillsMap).length > 0) {
845
+ const skillsForLock = {};
846
+ for (const [name, meta] of Object.entries(installedSkillsMap)) {
847
+ skillsForLock[name] = {
848
+ computedHash: computeSkillHash(meta.canonicalDir),
849
+ agents: meta.agents,
850
+ };
851
+ }
852
+ writeLockfile({ skills: skillsForLock, config: filters, packageVersion: VERSION });
853
+ appendSkillsToGitignore(process.cwd());
854
+ }
855
+
514
856
  console.log('');
515
857
  if (dryRun) {
516
858
  info(`Dry run complete. ${totalChanges} files would be installed.`);
@@ -559,11 +901,13 @@ function installCodex(options = {}) {
559
901
  * @param {Object} options - Install options
560
902
  */
561
903
  function installClaude(options = {}) {
562
- const { force = false, dryRun = false, global: isGlobal = false, filters = { stack: 'all', tracker: 'all' } } = options;
904
+ const { force = false, dryRun = false, copy = false, global: isGlobal = false, filters = { stack: 'all', tracker: 'all' } } = options;
563
905
  const scope = getInstallScope(options);
564
906
  const claudeDir = getClaudeTargetDir(isGlobal);
565
907
  const githubDir = getTargetDir(isGlobal);
566
908
  let totalChanges = 0;
909
+ /** @type {Record<string, { canonicalDir: string, agents: string[] }>} */
910
+ const installedSkillsMap = {};
567
911
 
568
912
  const makeFilter = (category) => (name) => {
569
913
  const basename = name.replace(/\.(prompt\.md|instructions\.md|md)$/, '');
@@ -607,12 +951,25 @@ function installClaude(options = {}) {
607
951
  }
608
952
 
609
953
  if (scope.shouldInstallSkills) {
610
- info('Installing skills...');
611
- const result = copyDir(path.join(TEMPLATES_DIR, 'shared', 'skills'), path.join(githubDir, 'skills'), { force, dryRun, dirFilter: makeFilter('skills') });
954
+ info('Installing skills (npx skills standard)...');
955
+ const result = installSkillsStandard({
956
+ isGlobal,
957
+ agentSkillsDir: path.join(claudeDir, 'skills'),
958
+ force,
959
+ dryRun,
960
+ copy,
961
+ dirFilter: makeFilter('skills'),
962
+ });
612
963
  totalChanges += getChangeCount(result, dryRun);
964
+ for (const [name, meta] of Object.entries(result.installedSkills)) {
965
+ if (!installedSkillsMap[name]) {
966
+ installedSkillsMap[name] = { canonicalDir: meta.canonicalDir, agents: [] };
967
+ }
968
+ installedSkillsMap[name].agents.push(path.relative(process.cwd(), path.join(claudeDir, 'skills')));
969
+ }
613
970
 
614
971
  if (!dryRun && result.written > 0) {
615
- success(`Installed ${result.written} skill files`);
972
+ success(`Installed ${result.written} skill files/links to .claude/skills/`);
616
973
  }
617
974
  }
618
975
 
@@ -639,6 +996,19 @@ function installClaude(options = {}) {
639
996
  const configResult = ensureConfigFile({ dryRun, global: isGlobal });
640
997
  totalChanges += getChangeCount(configResult, dryRun);
641
998
 
999
+ // Write lockfile and update .gitignore for project (non-global, non-dry-run) installs.
1000
+ if (!isGlobal && !dryRun && scope.shouldInstallSkills && Object.keys(installedSkillsMap).length > 0) {
1001
+ const skillsForLock = {};
1002
+ for (const [name, meta] of Object.entries(installedSkillsMap)) {
1003
+ skillsForLock[name] = {
1004
+ computedHash: computeSkillHash(meta.canonicalDir),
1005
+ agents: meta.agents,
1006
+ };
1007
+ }
1008
+ writeLockfile({ skills: skillsForLock, config: filters, packageVersion: VERSION });
1009
+ appendSkillsToGitignore(process.cwd());
1010
+ }
1011
+
642
1012
  console.log('');
643
1013
  if (dryRun) {
644
1014
  info(`Dry run complete. ${totalChanges} files would be installed.`);
@@ -661,6 +1031,158 @@ function installClaude(options = {}) {
661
1031
  console.log('');
662
1032
  }
663
1033
 
1034
+ /**
1035
+ * Restore skills from the lockfile.
1036
+ * Reads agents-toolkit-lock.json, reinstalls all skills, and verifies hashes.
1037
+ * Restore always overwrites existing skill files — that is its purpose.
1038
+ * @param {Object} [options]
1039
+ * @param {boolean} [options.dryRun] - Only report planned changes
1040
+ * @param {boolean} [options.copy] - Copy instead of symlink
1041
+ */
1042
+ function restore(options = {}) {
1043
+ const { dryRun = false, copy = false } = options;
1044
+ log('\nšŸ”„ Agents Toolkit Restore\n', 'bright');
1045
+
1046
+ const lockfile = readLockfile();
1047
+ if (!lockfile) {
1048
+ error(`No ${LOCKFILE_NAME} found. Run "install" first to generate it.`);
1049
+ process.exit(1);
1050
+ }
1051
+
1052
+ if (lockfile.packageVersion !== VERSION) {
1053
+ warn(`Lockfile was created with v${lockfile.packageVersion}, current package is v${VERSION}.`);
1054
+ warn('Run "update" to refresh the lockfile for the current version.');
1055
+ }
1056
+
1057
+ if (dryRun) {
1058
+ info('Dry run mode - no files will be restored\n');
1059
+ }
1060
+
1061
+ const { stack = 'all', tracker = 'all' } = lockfile.config || {};
1062
+ const filters = { stack, tracker };
1063
+ const makeFilter = (category) => (name) => {
1064
+ const basename = name.replace(/\.(prompt\.md|instructions\.md|md)$/, '');
1065
+ return shouldIncludeFile(basename, category, filters);
1066
+ };
1067
+
1068
+ // Determine which agent dirs were recorded in the lockfile.
1069
+ const agentDirs = new Set();
1070
+ for (const meta of Object.values(lockfile.skills || {})) {
1071
+ for (const agentDir of (meta.agents || [])) {
1072
+ agentDirs.add(agentDir);
1073
+ }
1074
+ }
1075
+
1076
+ // Reinstall into each unique agent skills dir.
1077
+ let totalRestored = 0;
1078
+ for (const agentDir of agentDirs) {
1079
+ const agentSkillsDir = path.join(process.cwd(), agentDir);
1080
+ const result = installSkillsStandard({
1081
+ isGlobal: false,
1082
+ agentSkillsDir,
1083
+ force: true, // restore always overwrites — that is its purpose
1084
+ dryRun,
1085
+ copy,
1086
+ dirFilter: makeFilter('skills'),
1087
+ });
1088
+ totalRestored += dryRun ? result.planned : result.written;
1089
+ }
1090
+
1091
+ if (dryRun) {
1092
+ info(`Dry run complete. ${totalRestored} files would be restored.`);
1093
+ return;
1094
+ }
1095
+
1096
+ // Verify hashes match the lockfile.
1097
+ const canonicalDir = getAgentsSkillsDir(false);
1098
+ let allMatch = true;
1099
+ for (const [name, meta] of Object.entries(lockfile.skills || {})) {
1100
+ const canonicalSkillDir = path.join(canonicalDir, name);
1101
+ const hash = computeSkillHash(canonicalSkillDir);
1102
+ if (hash !== meta.computedHash) {
1103
+ warn(`Hash mismatch for skill "${name}" — expected ${meta.computedHash?.slice(0, 12)}… got ${hash?.slice(0, 12)}…`);
1104
+ allMatch = false;
1105
+ }
1106
+ }
1107
+
1108
+ console.log('');
1109
+ if (allMatch) {
1110
+ success(`Restored ${Object.keys(lockfile.skills || {}).length} skills successfully.`);
1111
+ } else {
1112
+ warn(`Restored with hash mismatches. Run "update" to refresh the lockfile.`);
1113
+ }
1114
+ console.log('');
1115
+ }
1116
+
1117
+ /**
1118
+ * Show the status of installed skills relative to the lockfile.
1119
+ * Exits with code 1 if any skill is missing or has a hash mismatch.
1120
+ */
1121
+ function status() {
1122
+ log('\nšŸ“Š Agents Toolkit Status\n', 'bright');
1123
+
1124
+ const lockfile = readLockfile();
1125
+ if (!lockfile) {
1126
+ error(`No ${LOCKFILE_NAME} found. Run "install" first to generate it.`);
1127
+ process.exit(1);
1128
+ }
1129
+
1130
+ const canonicalDir = getAgentsSkillsDir(false);
1131
+ const skills = lockfile.skills || {};
1132
+ let hasIssues = false;
1133
+
1134
+ if (Object.keys(skills).length === 0) {
1135
+ info('No skills recorded in lockfile.');
1136
+ return;
1137
+ }
1138
+
1139
+ if (lockfile.packageVersion !== VERSION) {
1140
+ warn(`Lockfile package version: v${lockfile.packageVersion} — current: v${VERSION}`);
1141
+ }
1142
+
1143
+ console.log('');
1144
+ const COL_NAME = 28;
1145
+ const COL_STATUS = 14;
1146
+ const header = `${'Skill'.padEnd(COL_NAME)} ${'Status'.padEnd(COL_STATUS)} Hash`;
1147
+ log(header, 'cyan');
1148
+ log('─'.repeat(header.length), 'cyan');
1149
+
1150
+ for (const [name, meta] of Object.entries(skills)) {
1151
+ const canonicalSkillDir = path.join(canonicalDir, name);
1152
+ const hash = computeSkillHash(canonicalSkillDir);
1153
+
1154
+ let statusLabel;
1155
+ let statusColor;
1156
+
1157
+ if (hash === null) {
1158
+ statusLabel = 'missing';
1159
+ statusColor = 'red';
1160
+ hasIssues = true;
1161
+ } else if (hash !== meta.computedHash) {
1162
+ statusLabel = 'modified';
1163
+ statusColor = 'yellow';
1164
+ hasIssues = true;
1165
+ } else {
1166
+ statusLabel = 'up-to-date';
1167
+ statusColor = 'green';
1168
+ }
1169
+
1170
+ const hashDisplay = hash ? hash.slice(0, 12) + '…' : '—';
1171
+ const line = `${name.padEnd(COL_NAME)} ${statusLabel.padEnd(COL_STATUS)} ${hashDisplay}`;
1172
+ log(line, statusColor);
1173
+ }
1174
+
1175
+ console.log('');
1176
+
1177
+ if (hasIssues) {
1178
+ warn('Some skills are out of sync. Run "restore" or "update" to fix.');
1179
+ process.exit(1);
1180
+ } else {
1181
+ success(`All ${Object.keys(skills).length} skills are up-to-date.`);
1182
+ }
1183
+ console.log('');
1184
+ }
1185
+
664
1186
  /**
665
1187
  * List available prompts
666
1188
  */
@@ -679,7 +1201,7 @@ function list() {
679
1201
  .map(f => f.replace('.prompt.md', ''));
680
1202
 
681
1203
  log('Workflow Prompts:', 'cyan');
682
- const workflowPrompts = ['analyze-ticket', 'create-plan', 'work-ticket', 'prepare-pr', 'create-pr', 'finalize-pr'];
1204
+ const workflowPrompts = ['analyze-ticket', 'create-plan', 'work-ticket', 'prepare-pr', 'create-pr', 'finalize-pr', 'analyze-github-issue', 'work-github-issue', 'create-github-pr', 'finalize-github-pr'];
683
1205
  workflowPrompts.forEach((p, i) => {
684
1206
  if (prompts.includes(p)) {
685
1207
  console.log(` ${i + 1}. ${p}`);
@@ -715,6 +1237,17 @@ function list() {
715
1237
  console.log(` • ${s}`);
716
1238
  });
717
1239
  }
1240
+
1241
+ console.log('');
1242
+ log('Hooks:', 'cyan');
1243
+ const hooksDir = path.join(TEMPLATES_DIR, 'shared', 'hooks');
1244
+ if (fs.existsSync(hooksDir)) {
1245
+ const hooks = fs.readdirSync(hooksDir)
1246
+ .filter(f => f.endsWith('.json'));
1247
+ hooks.forEach(h => {
1248
+ console.log(` • ${h.replace('.json', '')}`);
1249
+ });
1250
+ }
718
1251
  console.log('');
719
1252
  }
720
1253
 
@@ -727,8 +1260,10 @@ function showHelp() {
727
1260
 
728
1261
  log('Commands:', 'cyan');
729
1262
  console.log(' install Install prompts (default target: copilot)');
1263
+ console.log(' restore Restore skills from agents-toolkit-lock.json');
1264
+ console.log(' status Check if installed skills match the lockfile');
1265
+ console.log(' update Update existing prompts and refresh the lockfile');
730
1266
  console.log(' list List available prompts');
731
- console.log(' update Update existing prompts (alias for install --force)');
732
1267
  console.log(' help Show this help message');
733
1268
 
734
1269
  console.log('');
@@ -745,6 +1280,8 @@ function showHelp() {
745
1280
  console.log(' --instructions-only Only install instructions');
746
1281
  console.log(' --partials-only Only install partials');
747
1282
  console.log(' --skills-only Only install skills');
1283
+ console.log(' --hooks-only Only install hooks (PostToolUse validation scripts)');
1284
+ console.log(' --copy Copy skills instead of symlinking to .agents/skills/');
748
1285
  console.log(' --dry-run Show what would be installed');
749
1286
 
750
1287
  console.log('');
@@ -821,7 +1358,9 @@ function parseArgs() {
821
1358
  partialsOnly: flags.includes('--partials-only'),
822
1359
  skillsOnly: flags.includes('--skills-only'),
823
1360
  instructionsOnly: flags.includes('--instructions-only'),
1361
+ hooksOnly: flags.includes('--hooks-only'),
824
1362
  dryRun: flags.includes('--dry-run'),
1363
+ copy: flags.includes('--copy'),
825
1364
  claude: flags.includes('--claude'),
826
1365
  codex: flags.includes('--codex'),
827
1366
  append: flags.includes('--append'),
@@ -965,6 +1504,12 @@ function main() {
965
1504
  install({ ...options, filters });
966
1505
  }
967
1506
  break;
1507
+ case 'restore':
1508
+ restore(options);
1509
+ break;
1510
+ case 'status':
1511
+ status();
1512
+ break;
968
1513
  case 'update':
969
1514
  if (target === 'claude') {
970
1515
  installClaude({ ...options, force: true, filters });