@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/README.md +160 -33
- package/bin/cli.js +506 -13
- package/package.json +1 -1
- package/src/index.js +44 -9
- package/templates/shared/hooks/lint-format.json +1 -0
- package/templates/shared/hooks/validate-tsx.json +1 -0
- package/templates/shared/prompts/create-github-pr.prompt.md +147 -0
- package/templates/shared/prompts/finalize-github-pr.prompt.md +129 -0
- package/templates/shared/prompts/work-github-issue.prompt.md +1 -1
- package/templates/shared/skills/README.md +25 -3
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
+
"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",
|