@silverassist/agents-toolkit 2.3.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
@@ -303,7 +542,38 @@ function getChangeCount(result, dryRun) {
303
542
  return dryRun ? result.planned : result.written;
304
543
  }
305
544
 
306
- function installHooks({ targetDir, force = false, dryRun = false }) {
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 }) {
307
577
  const hooksSrc = path.join(TEMPLATES_DIR, 'shared', 'hooks');
308
578
  const hooksDest = path.join(targetDir, 'hooks');
309
579
 
@@ -315,7 +585,7 @@ function installHooks({ targetDir, force = false, dryRun = false }) {
315
585
  // Copy hook JSON configs and scripts
316
586
  const result = copyDir(hooksSrc, hooksDest, { force, dryRun });
317
587
 
318
- // Make scripts executable (non-dry-run only)
588
+ // Make scripts executable and finalize configs (non-dry-run only)
319
589
  if (!dryRun) {
320
590
  const scriptsDir = path.join(hooksDest, 'scripts');
321
591
  if (fs.existsSync(scriptsDir)) {
@@ -325,6 +595,9 @@ function installHooks({ targetDir, force = false, dryRun = false }) {
325
595
  fs.chmodSync(scriptPath, 0o755);
326
596
  }
327
597
  }
598
+ if (fs.existsSync(hooksDest)) {
599
+ finalizeHookConfigs(hooksDest, isGlobal);
600
+ }
328
601
  }
329
602
 
330
603
  if (!dryRun && result.written > 0) {
@@ -472,6 +745,7 @@ function installGitBasedTarget(options = {}, target = 'copilot') {
472
745
  force = false,
473
746
  append = false,
474
747
  dryRun = false,
748
+ copy = false,
475
749
  global: isGlobal = false,
476
750
  filters = { stack: 'all', tracker: 'all' },
477
751
  } = options;
@@ -479,6 +753,8 @@ function installGitBasedTarget(options = {}, target = 'copilot') {
479
753
  const targetDir = getTargetDir(isGlobal);
480
754
  const scope = getInstallScope(options);
481
755
  let totalChanges = 0;
756
+ /** @type {Record<string, { canonicalDir: string, agents: string[] }>} */
757
+ const installedSkillsMap = {};
482
758
 
483
759
  const makeFilter = (category) => (name) => {
484
760
  const basename = name.replace(/\.(prompt\.md|instructions\.md|md)$/, '');
@@ -519,18 +795,32 @@ function installGitBasedTarget(options = {}, target = 'copilot') {
519
795
  }
520
796
 
521
797
  if (scope.shouldInstallSkills) {
522
- info('Installing skills...');
523
- 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
+ });
524
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
+ }
525
815
 
526
816
  if (!dryRun && result.written > 0) {
527
- success(`Installed ${result.written} skill files`);
817
+ success(`Installed ${result.written} skill files/links`);
528
818
  }
529
819
  }
530
820
 
531
821
  if (scope.shouldInstallHooks) {
532
822
  info('Installing hooks...');
533
- const hooksResult = installHooks({ targetDir, force, dryRun });
823
+ const hooksResult = installHooks({ targetDir, force, dryRun, global: isGlobal });
534
824
  totalChanges += getChangeCount(hooksResult, dryRun);
535
825
  }
536
826
 
@@ -550,6 +840,19 @@ function installGitBasedTarget(options = {}, target = 'copilot') {
550
840
  totalChanges += getChangeCount(agentsResult, dryRun);
551
841
  }
552
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
+
553
856
  console.log('');
554
857
  if (dryRun) {
555
858
  info(`Dry run complete. ${totalChanges} files would be installed.`);
@@ -598,11 +901,13 @@ function installCodex(options = {}) {
598
901
  * @param {Object} options - Install options
599
902
  */
600
903
  function installClaude(options = {}) {
601
- 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;
602
905
  const scope = getInstallScope(options);
603
906
  const claudeDir = getClaudeTargetDir(isGlobal);
604
907
  const githubDir = getTargetDir(isGlobal);
605
908
  let totalChanges = 0;
909
+ /** @type {Record<string, { canonicalDir: string, agents: string[] }>} */
910
+ const installedSkillsMap = {};
606
911
 
607
912
  const makeFilter = (category) => (name) => {
608
913
  const basename = name.replace(/\.(prompt\.md|instructions\.md|md)$/, '');
@@ -646,12 +951,25 @@ function installClaude(options = {}) {
646
951
  }
647
952
 
648
953
  if (scope.shouldInstallSkills) {
649
- info('Installing skills...');
650
- 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
+ });
651
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
+ }
652
970
 
653
971
  if (!dryRun && result.written > 0) {
654
- success(`Installed ${result.written} skill files`);
972
+ success(`Installed ${result.written} skill files/links to .claude/skills/`);
655
973
  }
656
974
  }
657
975
 
@@ -678,6 +996,19 @@ function installClaude(options = {}) {
678
996
  const configResult = ensureConfigFile({ dryRun, global: isGlobal });
679
997
  totalChanges += getChangeCount(configResult, dryRun);
680
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
+
681
1012
  console.log('');
682
1013
  if (dryRun) {
683
1014
  info(`Dry run complete. ${totalChanges} files would be installed.`);
@@ -700,6 +1031,158 @@ function installClaude(options = {}) {
700
1031
  console.log('');
701
1032
  }
702
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
+
703
1186
  /**
704
1187
  * List available prompts
705
1188
  */
@@ -718,7 +1201,7 @@ function list() {
718
1201
  .map(f => f.replace('.prompt.md', ''));
719
1202
 
720
1203
  log('Workflow Prompts:', 'cyan');
721
- 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'];
722
1205
  workflowPrompts.forEach((p, i) => {
723
1206
  if (prompts.includes(p)) {
724
1207
  console.log(` ${i + 1}. ${p}`);
@@ -777,8 +1260,10 @@ function showHelp() {
777
1260
 
778
1261
  log('Commands:', 'cyan');
779
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');
780
1266
  console.log(' list List available prompts');
781
- console.log(' update Update existing prompts (alias for install --force)');
782
1267
  console.log(' help Show this help message');
783
1268
 
784
1269
  console.log('');
@@ -796,6 +1281,7 @@ function showHelp() {
796
1281
  console.log(' --partials-only Only install partials');
797
1282
  console.log(' --skills-only Only install skills');
798
1283
  console.log(' --hooks-only Only install hooks (PostToolUse validation scripts)');
1284
+ console.log(' --copy Copy skills instead of symlinking to .agents/skills/');
799
1285
  console.log(' --dry-run Show what would be installed');
800
1286
 
801
1287
  console.log('');
@@ -874,6 +1360,7 @@ function parseArgs() {
874
1360
  instructionsOnly: flags.includes('--instructions-only'),
875
1361
  hooksOnly: flags.includes('--hooks-only'),
876
1362
  dryRun: flags.includes('--dry-run'),
1363
+ copy: flags.includes('--copy'),
877
1364
  claude: flags.includes('--claude'),
878
1365
  codex: flags.includes('--codex'),
879
1366
  append: flags.includes('--append'),
@@ -1017,6 +1504,12 @@ function main() {
1017
1504
  install({ ...options, filters });
1018
1505
  }
1019
1506
  break;
1507
+ case 'restore':
1508
+ restore(options);
1509
+ break;
1510
+ case 'status':
1511
+ status();
1512
+ break;
1020
1513
  case 'update':
1021
1514
  if (target === 'claude') {
1022
1515
  installClaude({ ...options, force: true, filters });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@silverassist/agents-toolkit",
3
- "version": "2.3.0",
3
+ "version": "2.4.0",
4
4
  "description": "Reusable AI agent prompts for development workflows with Jira integration — supports GitHub Copilot, Claude Code, and Codex",
5
5
  "author": "Santiago Ramirez",
6
6
  "license": "PolyForm-Noncommercial-1.0.0",