@silverassist/agents-toolkit 2.3.0 → 2.5.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);
@@ -49,7 +51,7 @@ const DEFAULT_CONFIG = {
49
51
  */
50
52
  const FILE_CATEGORIES = {
51
53
  instructions: {
52
- react: ['css-styling', 'react-components', 'server-actions', 'tests', 'typescript'],
54
+ react: ['caching', 'css-styling', 'react-components', 'seo-ai-optimization', 'server-actions', 'tests', 'typescript'],
53
55
  wordpress: ['php-standards', 'wordpress-plugin-architecture', 'testing-standards'],
54
56
  universal: ['documentation-language', 'github-workflow'],
55
57
  },
@@ -59,18 +61,21 @@ 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',
62
- 'review-code', 'fix-issues', 'add-tests', 'prepare-release',
64
+ 'create-github-pr', 'finalize-github-pr',
65
+ 'review-code', 'fix-issues', 'add-tests', 'prepare-github-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: {
71
+ react: ['release-node'],
72
+ wordpress: ['release-wordpress'],
68
73
  jira: ['jira-integration'],
69
74
  github: ['github-integration'],
70
75
  universal: ['git-operations', 'pr-template', 'validations', 'documentation'],
71
76
  },
72
77
  skills: {
73
- react: ['component-architecture', 'testing-patterns'],
78
+ react: ['component-architecture', 'nextjs-caching', 'testing-patterns'],
74
79
  wordpress: ['create-component', 'plugin-creation', 'quality-checks', 'testing'],
75
80
  universal: ['domain-driven-design', 'release-management'],
76
81
  },
@@ -179,6 +184,242 @@ function getClaudeTargetDir(global = false) {
179
184
  return path.join(process.cwd(), '.claude');
180
185
  }
181
186
 
187
+ /**
188
+ * Get the canonical skills directory following the `npx skills` standard.
189
+ * Skills live here once (single source of truth) and each agent's skills
190
+ * directory symlinks to it.
191
+ * @param {boolean} global - Use user-level ~/.agents/skills/
192
+ * @returns {string} Path to .agents/skills directory
193
+ */
194
+ function getAgentsSkillsDir(global = false) {
195
+ const base = global ? getHomeDir() : process.cwd();
196
+ return path.join(base, '.agents', 'skills');
197
+ }
198
+
199
+ /** Path to the project-level lockfile. */
200
+ const LOCKFILE_NAME = 'agents-toolkit-lock.json';
201
+
202
+ /**
203
+ * Compute a hex-encoded SHA-256 hash of a skill's SKILL.md content.
204
+ * Matches the algorithm used by `npx skills` (Vercel).
205
+ * @param {string} skillDir - Absolute path to the skill directory
206
+ * @returns {string|null} Hex hash string, or null if SKILL.md is missing
207
+ */
208
+ function computeSkillHash(skillDir) {
209
+ const skillMdPath = path.join(skillDir, 'SKILL.md');
210
+ if (!fs.existsSync(skillMdPath)) return null;
211
+ const content = fs.readFileSync(skillMdPath, 'utf-8');
212
+ return crypto.createHash('sha256').update(content).digest('hex');
213
+ }
214
+
215
+ /**
216
+ * Read and parse the project lockfile.
217
+ * @param {string} [cwd] - Directory to look in (defaults to process.cwd())
218
+ * @returns {Object|null} Parsed lockfile, or null if absent or invalid
219
+ */
220
+ function readLockfile(cwd = process.cwd()) {
221
+ const lockPath = path.join(cwd, LOCKFILE_NAME);
222
+ if (!fs.existsSync(lockPath)) return null;
223
+ try {
224
+ return JSON.parse(fs.readFileSync(lockPath, 'utf-8'));
225
+ } catch {
226
+ return null;
227
+ }
228
+ }
229
+
230
+ /**
231
+ * Write the project lockfile, merging new skills with any existing entries.
232
+ * This preserves skills installed by a previous `install --target` run so that
233
+ * running `install` followed by `install --claude` does not lose the first
234
+ * target's entries in the lockfile.
235
+ * @param {Object} params
236
+ * @param {Record<string, {computedHash: string|null, agents: string[]}>} params.skills - Newly installed skills map
237
+ * @param {{ stack: string, tracker: string }} params.config - Active install config
238
+ * @param {string} params.packageVersion - Current package version
239
+ * @param {string} [params.cwd] - Directory to write to (defaults to process.cwd())
240
+ */
241
+ function writeLockfile({ skills, config, packageVersion, cwd = process.cwd() }) {
242
+ const lockPath = path.join(cwd, LOCKFILE_NAME);
243
+
244
+ // Merge with any existing lockfile so successive multi-target installs
245
+ // (e.g. `install` then `install --claude`) accumulate entries.
246
+ const existing = readLockfile(cwd);
247
+ const mergedSkills = Object.assign({}, existing?.skills ?? {});
248
+
249
+ for (const [name, meta] of Object.entries(skills)) {
250
+ const prev = mergedSkills[name];
251
+ // Merge agents arrays: union of previous and new agent dirs.
252
+ const prevAgents = prev?.agents ?? [];
253
+ const allAgents = Array.from(new Set([...prevAgents, ...meta.agents]));
254
+ mergedSkills[name] = {
255
+ source: '@silverassist/agents-toolkit',
256
+ packageVersion,
257
+ computedHash: meta.computedHash,
258
+ agents: allAgents,
259
+ };
260
+ }
261
+
262
+ const lockfile = {
263
+ version: 1,
264
+ packageVersion,
265
+ config,
266
+ skills: mergedSkills,
267
+ };
268
+ fs.writeFileSync(lockPath, JSON.stringify(lockfile, null, 2) + '\n', 'utf-8');
269
+ success(`Wrote ${LOCKFILE_NAME}`);
270
+ }
271
+
272
+ /**
273
+ * Append skills-managed entries to .gitignore if not already present.
274
+ * Never runs during dry-run or global installs.
275
+ * @param {string} cwd - Project root directory
276
+ */
277
+ function appendSkillsToGitignore(cwd) {
278
+ const gitignorePath = path.join(cwd, '.gitignore');
279
+ const block = [
280
+ '',
281
+ '# agents-toolkit managed — regenerate with: npx @silverassist/agents-toolkit restore',
282
+ '.agents/skills/',
283
+ '.github/skills/',
284
+ '.claude/skills/',
285
+ ].join('\n');
286
+
287
+ let existing = '';
288
+ if (fs.existsSync(gitignorePath)) {
289
+ existing = fs.readFileSync(gitignorePath, 'utf-8');
290
+ }
291
+
292
+ // Only append if none of the three managed paths are already present.
293
+ if (
294
+ existing.includes('.agents/skills/') ||
295
+ existing.includes('.github/skills/') ||
296
+ existing.includes('.claude/skills/')
297
+ ) {
298
+ return;
299
+ }
300
+
301
+ fs.writeFileSync(gitignorePath, existing + block + '\n', 'utf-8');
302
+ info('Updated .gitignore with agents-toolkit managed paths');
303
+ }
304
+
305
+ /**
306
+ * Link a single skill from the canonical store into an agent's skills
307
+ * directory, following the `npx skills` standard (symlink with copy fallback).
308
+ * @param {string} canonicalSkillDir - Absolute path to the canonical skill folder
309
+ * @param {string} agentSkillLinkPath - Absolute path where the agent expects the skill
310
+ * @param {Object} options - Link options
311
+ * @param {boolean} [options.dryRun] - Only report what would happen
312
+ * @param {boolean} [options.force] - Replace an existing non-matching entry
313
+ * @param {boolean} [options.copy] - Force a copy instead of a symlink
314
+ * @returns {{ written: number, planned: number }} Change counters
315
+ */
316
+ function linkSkill(canonicalSkillDir, agentSkillLinkPath, options = {}) {
317
+ const { dryRun = false, force = false, copy = false } = options;
318
+ const totals = { written: 0, planned: 0 };
319
+
320
+ const relTarget = path.relative(path.dirname(agentSkillLinkPath), canonicalSkillDir);
321
+ const rel = (p) => path.relative(process.cwd(), p);
322
+
323
+ // Inspect any existing entry at the link path.
324
+ let existing = null;
325
+ try {
326
+ existing = fs.lstatSync(agentSkillLinkPath);
327
+ } catch {
328
+ existing = null;
329
+ }
330
+
331
+ if (existing) {
332
+ // Already a symlink pointing at our canonical store — nothing to do.
333
+ if (existing.isSymbolicLink()) {
334
+ const current = fs.readlinkSync(agentSkillLinkPath);
335
+ const resolved = path.resolve(path.dirname(agentSkillLinkPath), current);
336
+ if (resolved === path.resolve(canonicalSkillDir) && !copy) {
337
+ return totals;
338
+ }
339
+ }
340
+ if (!force) {
341
+ warn(`Skipping existing skill: ${rel(agentSkillLinkPath)}`);
342
+ return totals;
343
+ }
344
+ if (!dryRun) {
345
+ fs.rmSync(agentSkillLinkPath, { recursive: true, force: true });
346
+ }
347
+ }
348
+
349
+ totals.planned++;
350
+
351
+ if (dryRun) {
352
+ info(copy ? `Would copy skill: ${rel(agentSkillLinkPath)}` : `Would link: ${rel(agentSkillLinkPath)} -> ${relTarget}`);
353
+ return totals;
354
+ }
355
+
356
+ fs.mkdirSync(path.dirname(agentSkillLinkPath), { recursive: true });
357
+
358
+ const doCopy = () => {
359
+ copyDir(canonicalSkillDir, agentSkillLinkPath, { force: true });
360
+ };
361
+
362
+ if (copy) {
363
+ doCopy();
364
+ } else {
365
+ try {
366
+ fs.symlinkSync(relTarget, agentSkillLinkPath, 'dir');
367
+ } catch {
368
+ // Symlinks unsupported (e.g. Windows without developer mode) — copy instead.
369
+ doCopy();
370
+ }
371
+ }
372
+
373
+ totals.written++;
374
+ return totals;
375
+ }
376
+
377
+ /**
378
+ * Install skills following the `npx skills` standard: copy each skill once
379
+ * into the canonical .agents/skills store, then symlink the agent's skills
380
+ * directory entries to that store.
381
+ * @param {Object} params - Install parameters
382
+ * @param {boolean} params.isGlobal - Install at user level
383
+ * @param {string} params.agentSkillsDir - Agent skills dir (.claude/skills or .github/skills)
384
+ * @param {boolean} [params.force] - Overwrite existing files/links
385
+ * @param {boolean} [params.dryRun] - Only report planned changes
386
+ * @param {boolean} [params.copy] - Copy instead of symlink
387
+ * @param {(name: string) => boolean} [params.dirFilter] - Skill folder filter
388
+ * @returns {{ written: number, planned: number, installedSkills: Record<string, { canonicalDir: string }> }} Aggregated change counters and installed skill metadata
389
+ */
390
+ function installSkillsStandard({ isGlobal, agentSkillsDir, force = false, dryRun = false, copy = false, dirFilter = null }) {
391
+ const totals = { written: 0, planned: 0, installedSkills: {} };
392
+ const skillsSrc = path.join(TEMPLATES_DIR, 'shared', 'skills');
393
+ const canonicalDir = getAgentsSkillsDir(isGlobal);
394
+
395
+ if (!fs.existsSync(skillsSrc)) {
396
+ return totals;
397
+ }
398
+
399
+ // 1. Populate the canonical store once (filtered by stack).
400
+ const canonicalResult = copyDir(skillsSrc, canonicalDir, { force, dryRun, dirFilter });
401
+ totals.written += canonicalResult.written;
402
+ totals.planned += canonicalResult.planned;
403
+
404
+ // 2. Symlink each included skill folder into the agent's skills directory.
405
+ const entries = fs.readdirSync(skillsSrc, { withFileTypes: true });
406
+ for (const entry of entries) {
407
+ if (!entry.isDirectory()) continue;
408
+ if (dirFilter && !dirFilter(entry.name)) continue;
409
+
410
+ const canonicalSkillDir = path.join(canonicalDir, entry.name);
411
+ const agentSkillLinkPath = path.join(agentSkillsDir, entry.name);
412
+ const linkResult = linkSkill(canonicalSkillDir, agentSkillLinkPath, { dryRun, force, copy });
413
+ totals.written += linkResult.written;
414
+ totals.planned += linkResult.planned;
415
+
416
+ // Track for lockfile even during dry-run (so callers know what would be installed).
417
+ totals.installedSkills[entry.name] = { canonicalDir: canonicalSkillDir };
418
+ }
419
+
420
+ return totals;
421
+ }
422
+
182
423
  /**
183
424
  * Strip GitHub Copilot frontmatter from a prompt file
184
425
  * Removes the ---\nagent: ...\ndescription: ...\n--- block
@@ -303,7 +544,38 @@ function getChangeCount(result, dryRun) {
303
544
  return dryRun ? result.planned : result.written;
304
545
  }
305
546
 
306
- function installHooks({ targetDir, force = false, dryRun = false }) {
547
+ /**
548
+ * Finalize installed hook configs so Copilot can resolve the script command.
549
+ * Copilot runs a hook `command` from the workspace root by default, not from
550
+ * the hooks directory, so the relative `scripts/<name>.sh` command needs a
551
+ * `cwd` pointing at the actual hooks dir. Also ensures the required
552
+ * `version: 1` field is present.
553
+ * @param {string} hooksDest - Absolute path to the installed hooks directory
554
+ * @param {boolean} isGlobal - Whether this is a global (~/.copilot) install
555
+ */
556
+ function finalizeHookConfigs(hooksDest, isGlobal) {
557
+ // Global installs have no workspace anchor → use the absolute hooks path.
558
+ // Project installs use a path relative to the workspace root so the config
559
+ // stays portable/committable across machines and teammates.
560
+ const cwd = isGlobal
561
+ ? hooksDest
562
+ : path.relative(process.cwd(), hooksDest).split(path.sep).join('/');
563
+
564
+ const jsonFiles = fs.readdirSync(hooksDest).filter((f) => f.endsWith('.json'));
565
+ for (const file of jsonFiles) {
566
+ const filePath = path.join(hooksDest, file);
567
+ const config = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
568
+ config.version = 1;
569
+ for (const events of Object.values(config.hooks || {})) {
570
+ for (const entry of events) {
571
+ entry.cwd = cwd;
572
+ }
573
+ }
574
+ fs.writeFileSync(filePath, JSON.stringify(config, null, 2) + '\n');
575
+ }
576
+ }
577
+
578
+ function installHooks({ targetDir, force = false, dryRun = false, global: isGlobal = false }) {
307
579
  const hooksSrc = path.join(TEMPLATES_DIR, 'shared', 'hooks');
308
580
  const hooksDest = path.join(targetDir, 'hooks');
309
581
 
@@ -315,7 +587,7 @@ function installHooks({ targetDir, force = false, dryRun = false }) {
315
587
  // Copy hook JSON configs and scripts
316
588
  const result = copyDir(hooksSrc, hooksDest, { force, dryRun });
317
589
 
318
- // Make scripts executable (non-dry-run only)
590
+ // Make scripts executable and finalize configs (non-dry-run only)
319
591
  if (!dryRun) {
320
592
  const scriptsDir = path.join(hooksDest, 'scripts');
321
593
  if (fs.existsSync(scriptsDir)) {
@@ -325,6 +597,9 @@ function installHooks({ targetDir, force = false, dryRun = false }) {
325
597
  fs.chmodSync(scriptPath, 0o755);
326
598
  }
327
599
  }
600
+ if (fs.existsSync(hooksDest)) {
601
+ finalizeHookConfigs(hooksDest, isGlobal);
602
+ }
328
603
  }
329
604
 
330
605
  if (!dryRun && result.written > 0) {
@@ -472,6 +747,7 @@ function installGitBasedTarget(options = {}, target = 'copilot') {
472
747
  force = false,
473
748
  append = false,
474
749
  dryRun = false,
750
+ copy = false,
475
751
  global: isGlobal = false,
476
752
  filters = { stack: 'all', tracker: 'all' },
477
753
  } = options;
@@ -479,6 +755,8 @@ function installGitBasedTarget(options = {}, target = 'copilot') {
479
755
  const targetDir = getTargetDir(isGlobal);
480
756
  const scope = getInstallScope(options);
481
757
  let totalChanges = 0;
758
+ /** @type {Record<string, { canonicalDir: string, agents: string[] }>} */
759
+ const installedSkillsMap = {};
482
760
 
483
761
  const makeFilter = (category) => (name) => {
484
762
  const basename = name.replace(/\.(prompt\.md|instructions\.md|md)$/, '');
@@ -519,18 +797,32 @@ function installGitBasedTarget(options = {}, target = 'copilot') {
519
797
  }
520
798
 
521
799
  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') });
800
+ info('Installing skills (npx skills standard)...');
801
+ const result = installSkillsStandard({
802
+ isGlobal,
803
+ agentSkillsDir: path.join(targetDir, 'skills'),
804
+ force,
805
+ dryRun,
806
+ copy,
807
+ dirFilter: makeFilter('skills'),
808
+ });
524
809
  totalChanges += getChangeCount(result, dryRun);
810
+ // Merge installed skills for lockfile (agent dir = .github/skills or ~/.copilot/skills).
811
+ for (const [name, meta] of Object.entries(result.installedSkills)) {
812
+ if (!installedSkillsMap[name]) {
813
+ installedSkillsMap[name] = { canonicalDir: meta.canonicalDir, agents: [] };
814
+ }
815
+ installedSkillsMap[name].agents.push(path.relative(process.cwd(), path.join(targetDir, 'skills')));
816
+ }
525
817
 
526
818
  if (!dryRun && result.written > 0) {
527
- success(`Installed ${result.written} skill files`);
819
+ success(`Installed ${result.written} skill files/links`);
528
820
  }
529
821
  }
530
822
 
531
823
  if (scope.shouldInstallHooks) {
532
824
  info('Installing hooks...');
533
- const hooksResult = installHooks({ targetDir, force, dryRun });
825
+ const hooksResult = installHooks({ targetDir, force, dryRun, global: isGlobal });
534
826
  totalChanges += getChangeCount(hooksResult, dryRun);
535
827
  }
536
828
 
@@ -550,6 +842,19 @@ function installGitBasedTarget(options = {}, target = 'copilot') {
550
842
  totalChanges += getChangeCount(agentsResult, dryRun);
551
843
  }
552
844
 
845
+ // Write lockfile and update .gitignore for project (non-global, non-dry-run) installs.
846
+ if (!isGlobal && !dryRun && scope.shouldInstallSkills && Object.keys(installedSkillsMap).length > 0) {
847
+ const skillsForLock = {};
848
+ for (const [name, meta] of Object.entries(installedSkillsMap)) {
849
+ skillsForLock[name] = {
850
+ computedHash: computeSkillHash(meta.canonicalDir),
851
+ agents: meta.agents,
852
+ };
853
+ }
854
+ writeLockfile({ skills: skillsForLock, config: filters, packageVersion: VERSION });
855
+ appendSkillsToGitignore(process.cwd());
856
+ }
857
+
553
858
  console.log('');
554
859
  if (dryRun) {
555
860
  info(`Dry run complete. ${totalChanges} files would be installed.`);
@@ -598,11 +903,13 @@ function installCodex(options = {}) {
598
903
  * @param {Object} options - Install options
599
904
  */
600
905
  function installClaude(options = {}) {
601
- const { force = false, dryRun = false, global: isGlobal = false, filters = { stack: 'all', tracker: 'all' } } = options;
906
+ const { force = false, dryRun = false, copy = false, global: isGlobal = false, filters = { stack: 'all', tracker: 'all' } } = options;
602
907
  const scope = getInstallScope(options);
603
908
  const claudeDir = getClaudeTargetDir(isGlobal);
604
909
  const githubDir = getTargetDir(isGlobal);
605
910
  let totalChanges = 0;
911
+ /** @type {Record<string, { canonicalDir: string, agents: string[] }>} */
912
+ const installedSkillsMap = {};
606
913
 
607
914
  const makeFilter = (category) => (name) => {
608
915
  const basename = name.replace(/\.(prompt\.md|instructions\.md|md)$/, '');
@@ -646,12 +953,25 @@ function installClaude(options = {}) {
646
953
  }
647
954
 
648
955
  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') });
956
+ info('Installing skills (npx skills standard)...');
957
+ const result = installSkillsStandard({
958
+ isGlobal,
959
+ agentSkillsDir: path.join(claudeDir, 'skills'),
960
+ force,
961
+ dryRun,
962
+ copy,
963
+ dirFilter: makeFilter('skills'),
964
+ });
651
965
  totalChanges += getChangeCount(result, dryRun);
966
+ for (const [name, meta] of Object.entries(result.installedSkills)) {
967
+ if (!installedSkillsMap[name]) {
968
+ installedSkillsMap[name] = { canonicalDir: meta.canonicalDir, agents: [] };
969
+ }
970
+ installedSkillsMap[name].agents.push(path.relative(process.cwd(), path.join(claudeDir, 'skills')));
971
+ }
652
972
 
653
973
  if (!dryRun && result.written > 0) {
654
- success(`Installed ${result.written} skill files`);
974
+ success(`Installed ${result.written} skill files/links to .claude/skills/`);
655
975
  }
656
976
  }
657
977
 
@@ -678,6 +998,19 @@ function installClaude(options = {}) {
678
998
  const configResult = ensureConfigFile({ dryRun, global: isGlobal });
679
999
  totalChanges += getChangeCount(configResult, dryRun);
680
1000
 
1001
+ // Write lockfile and update .gitignore for project (non-global, non-dry-run) installs.
1002
+ if (!isGlobal && !dryRun && scope.shouldInstallSkills && Object.keys(installedSkillsMap).length > 0) {
1003
+ const skillsForLock = {};
1004
+ for (const [name, meta] of Object.entries(installedSkillsMap)) {
1005
+ skillsForLock[name] = {
1006
+ computedHash: computeSkillHash(meta.canonicalDir),
1007
+ agents: meta.agents,
1008
+ };
1009
+ }
1010
+ writeLockfile({ skills: skillsForLock, config: filters, packageVersion: VERSION });
1011
+ appendSkillsToGitignore(process.cwd());
1012
+ }
1013
+
681
1014
  console.log('');
682
1015
  if (dryRun) {
683
1016
  info(`Dry run complete. ${totalChanges} files would be installed.`);
@@ -700,6 +1033,158 @@ function installClaude(options = {}) {
700
1033
  console.log('');
701
1034
  }
702
1035
 
1036
+ /**
1037
+ * Restore skills from the lockfile.
1038
+ * Reads agents-toolkit-lock.json, reinstalls all skills, and verifies hashes.
1039
+ * Restore always overwrites existing skill files — that is its purpose.
1040
+ * @param {Object} [options]
1041
+ * @param {boolean} [options.dryRun] - Only report planned changes
1042
+ * @param {boolean} [options.copy] - Copy instead of symlink
1043
+ */
1044
+ function restore(options = {}) {
1045
+ const { dryRun = false, copy = false } = options;
1046
+ log('\n🔄 Agents Toolkit Restore\n', 'bright');
1047
+
1048
+ const lockfile = readLockfile();
1049
+ if (!lockfile) {
1050
+ error(`No ${LOCKFILE_NAME} found. Run "install" first to generate it.`);
1051
+ process.exit(1);
1052
+ }
1053
+
1054
+ if (lockfile.packageVersion !== VERSION) {
1055
+ warn(`Lockfile was created with v${lockfile.packageVersion}, current package is v${VERSION}.`);
1056
+ warn('Run "update" to refresh the lockfile for the current version.');
1057
+ }
1058
+
1059
+ if (dryRun) {
1060
+ info('Dry run mode - no files will be restored\n');
1061
+ }
1062
+
1063
+ const { stack = 'all', tracker = 'all' } = lockfile.config || {};
1064
+ const filters = { stack, tracker };
1065
+ const makeFilter = (category) => (name) => {
1066
+ const basename = name.replace(/\.(prompt\.md|instructions\.md|md)$/, '');
1067
+ return shouldIncludeFile(basename, category, filters);
1068
+ };
1069
+
1070
+ // Determine which agent dirs were recorded in the lockfile.
1071
+ const agentDirs = new Set();
1072
+ for (const meta of Object.values(lockfile.skills || {})) {
1073
+ for (const agentDir of (meta.agents || [])) {
1074
+ agentDirs.add(agentDir);
1075
+ }
1076
+ }
1077
+
1078
+ // Reinstall into each unique agent skills dir.
1079
+ let totalRestored = 0;
1080
+ for (const agentDir of agentDirs) {
1081
+ const agentSkillsDir = path.join(process.cwd(), agentDir);
1082
+ const result = installSkillsStandard({
1083
+ isGlobal: false,
1084
+ agentSkillsDir,
1085
+ force: true, // restore always overwrites — that is its purpose
1086
+ dryRun,
1087
+ copy,
1088
+ dirFilter: makeFilter('skills'),
1089
+ });
1090
+ totalRestored += dryRun ? result.planned : result.written;
1091
+ }
1092
+
1093
+ if (dryRun) {
1094
+ info(`Dry run complete. ${totalRestored} files would be restored.`);
1095
+ return;
1096
+ }
1097
+
1098
+ // Verify hashes match the lockfile.
1099
+ const canonicalDir = getAgentsSkillsDir(false);
1100
+ let allMatch = true;
1101
+ for (const [name, meta] of Object.entries(lockfile.skills || {})) {
1102
+ const canonicalSkillDir = path.join(canonicalDir, name);
1103
+ const hash = computeSkillHash(canonicalSkillDir);
1104
+ if (hash !== meta.computedHash) {
1105
+ warn(`Hash mismatch for skill "${name}" — expected ${meta.computedHash?.slice(0, 12)}… got ${hash?.slice(0, 12)}…`);
1106
+ allMatch = false;
1107
+ }
1108
+ }
1109
+
1110
+ console.log('');
1111
+ if (allMatch) {
1112
+ success(`Restored ${Object.keys(lockfile.skills || {}).length} skills successfully.`);
1113
+ } else {
1114
+ warn(`Restored with hash mismatches. Run "update" to refresh the lockfile.`);
1115
+ }
1116
+ console.log('');
1117
+ }
1118
+
1119
+ /**
1120
+ * Show the status of installed skills relative to the lockfile.
1121
+ * Exits with code 1 if any skill is missing or has a hash mismatch.
1122
+ */
1123
+ function status() {
1124
+ log('\n📊 Agents Toolkit Status\n', 'bright');
1125
+
1126
+ const lockfile = readLockfile();
1127
+ if (!lockfile) {
1128
+ error(`No ${LOCKFILE_NAME} found. Run "install" first to generate it.`);
1129
+ process.exit(1);
1130
+ }
1131
+
1132
+ const canonicalDir = getAgentsSkillsDir(false);
1133
+ const skills = lockfile.skills || {};
1134
+ let hasIssues = false;
1135
+
1136
+ if (Object.keys(skills).length === 0) {
1137
+ info('No skills recorded in lockfile.');
1138
+ return;
1139
+ }
1140
+
1141
+ if (lockfile.packageVersion !== VERSION) {
1142
+ warn(`Lockfile package version: v${lockfile.packageVersion} — current: v${VERSION}`);
1143
+ }
1144
+
1145
+ console.log('');
1146
+ const COL_NAME = 28;
1147
+ const COL_STATUS = 14;
1148
+ const header = `${'Skill'.padEnd(COL_NAME)} ${'Status'.padEnd(COL_STATUS)} Hash`;
1149
+ log(header, 'cyan');
1150
+ log('─'.repeat(header.length), 'cyan');
1151
+
1152
+ for (const [name, meta] of Object.entries(skills)) {
1153
+ const canonicalSkillDir = path.join(canonicalDir, name);
1154
+ const hash = computeSkillHash(canonicalSkillDir);
1155
+
1156
+ let statusLabel;
1157
+ let statusColor;
1158
+
1159
+ if (hash === null) {
1160
+ statusLabel = 'missing';
1161
+ statusColor = 'red';
1162
+ hasIssues = true;
1163
+ } else if (hash !== meta.computedHash) {
1164
+ statusLabel = 'modified';
1165
+ statusColor = 'yellow';
1166
+ hasIssues = true;
1167
+ } else {
1168
+ statusLabel = 'up-to-date';
1169
+ statusColor = 'green';
1170
+ }
1171
+
1172
+ const hashDisplay = hash ? hash.slice(0, 12) + '…' : '—';
1173
+ const line = `${name.padEnd(COL_NAME)} ${statusLabel.padEnd(COL_STATUS)} ${hashDisplay}`;
1174
+ log(line, statusColor);
1175
+ }
1176
+
1177
+ console.log('');
1178
+
1179
+ if (hasIssues) {
1180
+ warn('Some skills are out of sync. Run "restore" or "update" to fix.');
1181
+ process.exit(1);
1182
+ } else {
1183
+ success(`All ${Object.keys(skills).length} skills are up-to-date.`);
1184
+ }
1185
+ console.log('');
1186
+ }
1187
+
703
1188
  /**
704
1189
  * List available prompts
705
1190
  */
@@ -718,7 +1203,7 @@ function list() {
718
1203
  .map(f => f.replace('.prompt.md', ''));
719
1204
 
720
1205
  log('Workflow Prompts:', 'cyan');
721
- const workflowPrompts = ['analyze-ticket', 'create-plan', 'work-ticket', 'prepare-pr', 'create-pr', 'finalize-pr'];
1206
+ 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
1207
  workflowPrompts.forEach((p, i) => {
723
1208
  if (prompts.includes(p)) {
724
1209
  console.log(` ${i + 1}. ${p}`);
@@ -777,8 +1262,10 @@ function showHelp() {
777
1262
 
778
1263
  log('Commands:', 'cyan');
779
1264
  console.log(' install Install prompts (default target: copilot)');
1265
+ console.log(' restore Restore skills from agents-toolkit-lock.json');
1266
+ console.log(' status Check if installed skills match the lockfile');
1267
+ console.log(' update Update existing prompts and refresh the lockfile');
780
1268
  console.log(' list List available prompts');
781
- console.log(' update Update existing prompts (alias for install --force)');
782
1269
  console.log(' help Show this help message');
783
1270
 
784
1271
  console.log('');
@@ -796,6 +1283,7 @@ function showHelp() {
796
1283
  console.log(' --partials-only Only install partials');
797
1284
  console.log(' --skills-only Only install skills');
798
1285
  console.log(' --hooks-only Only install hooks (PostToolUse validation scripts)');
1286
+ console.log(' --copy Copy skills instead of symlinking to .agents/skills/');
799
1287
  console.log(' --dry-run Show what would be installed');
800
1288
 
801
1289
  console.log('');
@@ -874,6 +1362,7 @@ function parseArgs() {
874
1362
  instructionsOnly: flags.includes('--instructions-only'),
875
1363
  hooksOnly: flags.includes('--hooks-only'),
876
1364
  dryRun: flags.includes('--dry-run'),
1365
+ copy: flags.includes('--copy'),
877
1366
  claude: flags.includes('--claude'),
878
1367
  codex: flags.includes('--codex'),
879
1368
  append: flags.includes('--append'),
@@ -1017,6 +1506,12 @@ function main() {
1017
1506
  install({ ...options, filters });
1018
1507
  }
1019
1508
  break;
1509
+ case 'restore':
1510
+ restore(options);
1511
+ break;
1512
+ case 'status':
1513
+ status();
1514
+ break;
1020
1515
  case 'update':
1021
1516
  if (target === 'claude') {
1022
1517
  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.5.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",