@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/README.md +195 -33
- package/bin/cli.js +556 -11
- package/package.json +1 -1
- package/src/index.js +46 -9
- package/templates/shared/hooks/lint-format.json +12 -0
- package/templates/shared/hooks/scripts/lint-format.sh +64 -0
- package/templates/shared/hooks/scripts/validate-tsx.sh +77 -0
- package/templates/shared/hooks/validate-tsx.json +12 -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
|
|
@@ -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 =
|
|
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 =
|
|
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 });
|