@goodboyjs/cli 0.1.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/LICENSE +21 -0
- package/README.md +21 -0
- package/dist/commands/add.d.ts +2 -0
- package/dist/commands/add.js +100 -0
- package/dist/commands/init.d.ts +2 -0
- package/dist/commands/init.js +32 -0
- package/dist/commands/install.d.ts +13 -0
- package/dist/commands/install.js +192 -0
- package/dist/commands/list.d.ts +2 -0
- package/dist/commands/list.js +106 -0
- package/dist/commands/registry-cmd.d.ts +2 -0
- package/dist/commands/registry-cmd.js +122 -0
- package/dist/commands/search.d.ts +2 -0
- package/dist/commands/search.js +49 -0
- package/dist/commands/skill-create.d.ts +2 -0
- package/dist/commands/skill-create.js +143 -0
- package/dist/commands/skill-diff.d.ts +8 -0
- package/dist/commands/skill-diff.js +120 -0
- package/dist/commands/skill-open.d.ts +3 -0
- package/dist/commands/skill-open.js +82 -0
- package/dist/commands/skill-status.d.ts +2 -0
- package/dist/commands/skill-status.js +136 -0
- package/dist/commands/skill-version.d.ts +6 -0
- package/dist/commands/skill-version.js +119 -0
- package/dist/commands/skill.d.ts +2 -0
- package/dist/commands/skill.js +13 -0
- package/dist/commands/uninstall.d.ts +2 -0
- package/dist/commands/uninstall.js +53 -0
- package/dist/commands/upgrade.d.ts +2 -0
- package/dist/commands/upgrade.js +103 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +33 -0
- package/dist/lib/agents.d.ts +14 -0
- package/dist/lib/agents.js +83 -0
- package/dist/lib/consent.d.ts +3 -0
- package/dist/lib/consent.js +48 -0
- package/dist/lib/fs-security.d.ts +8 -0
- package/dist/lib/fs-security.js +27 -0
- package/dist/lib/goodboy-file.d.ts +24 -0
- package/dist/lib/goodboy-file.js +99 -0
- package/dist/lib/local-registry-adapter.d.ts +18 -0
- package/dist/lib/local-registry-adapter.js +71 -0
- package/dist/lib/logger.d.ts +7 -0
- package/dist/lib/logger.js +28 -0
- package/dist/lib/manifest.d.ts +4 -0
- package/dist/lib/manifest.js +86 -0
- package/dist/lib/registry-adapter.d.ts +56 -0
- package/dist/lib/registry-adapter.js +15 -0
- package/dist/lib/registry-entry.d.ts +16 -0
- package/dist/lib/registry-entry.js +74 -0
- package/dist/lib/registry.d.ts +8 -0
- package/dist/lib/registry.js +114 -0
- package/dist/lib/skill-validator.d.ts +11 -0
- package/dist/lib/skill-validator.js +122 -0
- package/dist/lib/store.d.ts +13 -0
- package/dist/lib/store.js +62 -0
- package/dist/lib/validation.d.ts +1 -0
- package/dist/lib/validation.js +1 -0
- package/dist/types/index.d.ts +2 -0
- package/dist/types/index.js +1 -0
- package/package.json +67 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import { createRegistryAdapter } from '../lib/registry-adapter.js';
|
|
4
|
+
import { logger } from '../lib/logger.js';
|
|
5
|
+
function highlight(text, query) {
|
|
6
|
+
if (!query)
|
|
7
|
+
return text;
|
|
8
|
+
const re = new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
|
|
9
|
+
return text.replace(re, (m) => chalk.bgYellow.black(m));
|
|
10
|
+
}
|
|
11
|
+
function renderSkill(skill, query) {
|
|
12
|
+
const name = highlight(skill.name, query);
|
|
13
|
+
const desc = highlight(skill.description, query);
|
|
14
|
+
const version = chalk.gray(`v${skill.version}`);
|
|
15
|
+
const category = skill.category !== undefined ? chalk.cyan(` [${skill.category}]`) : '';
|
|
16
|
+
process.stdout.write(` ${chalk.bold(name)} ${version}${category}\n`);
|
|
17
|
+
process.stdout.write(` ${desc}\n`);
|
|
18
|
+
if (Array.isArray(skill.keywords) && skill.keywords.length > 0) {
|
|
19
|
+
const kws = skill.keywords
|
|
20
|
+
.map((kw) => highlight(kw, query))
|
|
21
|
+
.join(chalk.gray(', '));
|
|
22
|
+
process.stdout.write(` ${chalk.gray('keywords:')} ${kws}\n`);
|
|
23
|
+
}
|
|
24
|
+
process.stdout.write('\n');
|
|
25
|
+
}
|
|
26
|
+
async function run(query) {
|
|
27
|
+
const registry = createRegistryAdapter();
|
|
28
|
+
const results = await registry.search(query);
|
|
29
|
+
if (results.length === 0) {
|
|
30
|
+
logger.info(`No skills in the registry match "${query}".`);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
for (const skill of results) {
|
|
34
|
+
renderSkill(skill, query);
|
|
35
|
+
}
|
|
36
|
+
logger.info(`${results.length} skill${results.length === 1 ? '' : 's'} matched`);
|
|
37
|
+
}
|
|
38
|
+
export const searchCommand = new Command('search')
|
|
39
|
+
.description('Search available skills in the registry by name, description, or keyword')
|
|
40
|
+
.argument('<query>', 'Search query')
|
|
41
|
+
.action(async (query) => {
|
|
42
|
+
try {
|
|
43
|
+
await run(query);
|
|
44
|
+
}
|
|
45
|
+
catch (err) {
|
|
46
|
+
logger.error(err instanceof Error ? err.message : String(err));
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
});
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { input, select } from '@inquirer/prompts';
|
|
2
|
+
import ora from 'ora';
|
|
3
|
+
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { writeManifest } from '../lib/manifest.js';
|
|
6
|
+
import { logger } from '../lib/logger.js';
|
|
7
|
+
import { SKILL_NAME_RE } from '../lib/validation.js';
|
|
8
|
+
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
9
|
+
// Schema enforces maxLength: 64 on name and maxLength: 1024 on description.
|
|
10
|
+
// These limits must stay in sync with manifest.schema.json.
|
|
11
|
+
const MAX_NAME_LENGTH = 64;
|
|
12
|
+
const MAX_DESCRIPTION_LENGTH = 1024;
|
|
13
|
+
function buildSkillMd(name, description) {
|
|
14
|
+
return `---
|
|
15
|
+
name: ${name}
|
|
16
|
+
description: ${description}
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
# ${name}
|
|
20
|
+
|
|
21
|
+
## Instructions
|
|
22
|
+
|
|
23
|
+
Describe when and how this skill should be used, and provide the
|
|
24
|
+
assistant with step-by-step guidance for completing the task.
|
|
25
|
+
`;
|
|
26
|
+
}
|
|
27
|
+
async function run() {
|
|
28
|
+
const name = await input({
|
|
29
|
+
message: 'Skill name:',
|
|
30
|
+
validate: (v) => {
|
|
31
|
+
const t = v.trim();
|
|
32
|
+
if (!SKILL_NAME_RE.test(t)) {
|
|
33
|
+
return 'Must match ^[a-z0-9-]+ (lowercase letters, numbers, hyphens only)';
|
|
34
|
+
}
|
|
35
|
+
if (t.length > MAX_NAME_LENGTH) {
|
|
36
|
+
return `Name must be ${MAX_NAME_LENGTH} characters or fewer`;
|
|
37
|
+
}
|
|
38
|
+
return true;
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
const skillDir = join(process.cwd(), name.trim());
|
|
42
|
+
if (existsSync(skillDir)) {
|
|
43
|
+
throw new Error(`Directory "${name.trim()}" already exists in the current directory. ` +
|
|
44
|
+
`Choose a different skill name or remove the existing directory.`);
|
|
45
|
+
}
|
|
46
|
+
const description = await input({
|
|
47
|
+
message: 'Description:',
|
|
48
|
+
validate: (v) => {
|
|
49
|
+
const t = v.trim();
|
|
50
|
+
if (t.length === 0)
|
|
51
|
+
return 'Description is required';
|
|
52
|
+
if (t.length > MAX_DESCRIPTION_LENGTH) {
|
|
53
|
+
return `Description must be ${MAX_DESCRIPTION_LENGTH} characters or fewer`;
|
|
54
|
+
}
|
|
55
|
+
return true;
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
const authorName = await input({
|
|
59
|
+
message: 'Author name:',
|
|
60
|
+
validate: (v) => (v.trim().length > 0 ? true : 'Author name is required'),
|
|
61
|
+
});
|
|
62
|
+
const authorEmail = await input({
|
|
63
|
+
message: 'Author email (optional):',
|
|
64
|
+
validate: (v) => {
|
|
65
|
+
const t = v.trim();
|
|
66
|
+
if (t.length === 0)
|
|
67
|
+
return true;
|
|
68
|
+
return EMAIL_RE.test(t) ? true : `"${t}" is not a valid email address`;
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
const category = (await select({
|
|
72
|
+
message: 'Category:',
|
|
73
|
+
choices: [
|
|
74
|
+
{ value: 'code', name: 'Code' },
|
|
75
|
+
{ value: 'writing', name: 'Writing' },
|
|
76
|
+
{ value: 'data', name: 'Data' },
|
|
77
|
+
{ value: 'devops', name: 'DevOps' },
|
|
78
|
+
{ value: 'testing', name: 'Testing' },
|
|
79
|
+
{ value: 'documentation', name: 'Documentation' },
|
|
80
|
+
{ value: 'productivity', name: 'Productivity' },
|
|
81
|
+
{ value: 'security', name: 'Security' },
|
|
82
|
+
{ value: 'research', name: 'Research' },
|
|
83
|
+
{ value: 'other', name: 'Other' },
|
|
84
|
+
],
|
|
85
|
+
}));
|
|
86
|
+
const license = await input({ message: 'License:', default: 'MIT' });
|
|
87
|
+
const manifest = {
|
|
88
|
+
name: name.trim(),
|
|
89
|
+
version: '0.1.0',
|
|
90
|
+
description: description.trim(),
|
|
91
|
+
author: {
|
|
92
|
+
name: authorName.trim(),
|
|
93
|
+
...(authorEmail.trim() ? { email: authorEmail.trim() } : {}),
|
|
94
|
+
},
|
|
95
|
+
license: license.trim() || 'MIT',
|
|
96
|
+
category,
|
|
97
|
+
schema_version: '1.0.0',
|
|
98
|
+
status: 'experimental',
|
|
99
|
+
visibility: 'private',
|
|
100
|
+
};
|
|
101
|
+
const manifestPath = join(skillDir, 'manifest.json');
|
|
102
|
+
const skillMdPath = join(skillDir, 'SKILL.md');
|
|
103
|
+
const spinner = ora('Creating skill scaffold…').start();
|
|
104
|
+
try {
|
|
105
|
+
mkdirSync(skillDir, { recursive: true });
|
|
106
|
+
for (const sub of ['scripts', 'references', 'assets']) {
|
|
107
|
+
mkdirSync(join(skillDir, sub), { recursive: true });
|
|
108
|
+
}
|
|
109
|
+
await writeManifest(manifestPath, manifest);
|
|
110
|
+
writeFileSync(skillMdPath, buildSkillMd(manifest.name, manifest.description), 'utf-8');
|
|
111
|
+
spinner.succeed('Created skill scaffold');
|
|
112
|
+
}
|
|
113
|
+
catch (err) {
|
|
114
|
+
spinner.fail('Failed to create skill scaffold');
|
|
115
|
+
throw err;
|
|
116
|
+
}
|
|
117
|
+
logger.info('');
|
|
118
|
+
logger.info(` Name: ${manifest.name}`);
|
|
119
|
+
logger.info(` Version: ${manifest.version}`);
|
|
120
|
+
logger.info(` Path: ${skillDir}`);
|
|
121
|
+
logger.info(' Created: manifest.json, SKILL.md, scripts/, references/, assets/');
|
|
122
|
+
logger.info('');
|
|
123
|
+
logger.info(` Run 'goodboy add ./${manifest.name}' to add this skill to your local registry.`);
|
|
124
|
+
logger.info(` Run 'goodboy install ${manifest.name}' to install it.`);
|
|
125
|
+
logger.success('Skill scaffold created');
|
|
126
|
+
}
|
|
127
|
+
export function registerSkillCreate(program) {
|
|
128
|
+
program
|
|
129
|
+
.command('create')
|
|
130
|
+
.description('Create a new skill with SKILL.md and manifest.json')
|
|
131
|
+
.action(async () => {
|
|
132
|
+
try {
|
|
133
|
+
await run();
|
|
134
|
+
}
|
|
135
|
+
catch (err) {
|
|
136
|
+
if (err instanceof Error && /force closed|force-closed/i.test(err.message)) {
|
|
137
|
+
process.exit(0);
|
|
138
|
+
}
|
|
139
|
+
logger.error(err instanceof Error ? err.message : String(err));
|
|
140
|
+
process.exit(1);
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
/**
|
|
3
|
+
* Naive LCS-based line diff. oldLabel/newLabel are accepted for signature
|
|
4
|
+
* symmetry with the caller's own header lines but aren't embedded in the
|
|
5
|
+
* returned lines — the caller prints "---"/"+++ " headers separately.
|
|
6
|
+
*/
|
|
7
|
+
export declare function computeDiff(oldContent: string, newContent: string, _oldLabel: string, _newLabel: string): string[];
|
|
8
|
+
export declare function registerSkillDiff(program: Command): void;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join, resolve, sep } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import chalk from 'chalk';
|
|
5
|
+
import { getRegistryPath } from '../lib/registry.js';
|
|
6
|
+
import { readRegistryEntry, resolveLatestVersion, resolveVersionPath } from '../lib/registry-entry.js';
|
|
7
|
+
import { SKILL_NAME_RE } from '../lib/validation.js';
|
|
8
|
+
import { logger, sanitiseError } from '../lib/logger.js';
|
|
9
|
+
function assertWithin(target, base, label) {
|
|
10
|
+
const resolvedTarget = resolve(target);
|
|
11
|
+
const resolvedBase = resolve(base);
|
|
12
|
+
if (!resolvedTarget.startsWith(resolvedBase + sep)) {
|
|
13
|
+
throw new Error(`Refused: ${label} escapes the expected directory`);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Naive LCS-based line diff. oldLabel/newLabel are accepted for signature
|
|
18
|
+
* symmetry with the caller's own header lines but aren't embedded in the
|
|
19
|
+
* returned lines — the caller prints "---"/"+++ " headers separately.
|
|
20
|
+
*/
|
|
21
|
+
export function computeDiff(oldContent, newContent, _oldLabel, _newLabel) {
|
|
22
|
+
if (oldContent === newContent)
|
|
23
|
+
return [];
|
|
24
|
+
const oldLines = oldContent.length > 0 ? oldContent.split('\n') : [];
|
|
25
|
+
const newLines = newContent.length > 0 ? newContent.split('\n') : [];
|
|
26
|
+
const m = oldLines.length;
|
|
27
|
+
const n = newLines.length;
|
|
28
|
+
const lcs = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
|
|
29
|
+
for (let i = m - 1; i >= 0; i--) {
|
|
30
|
+
for (let j = n - 1; j >= 0; j--) {
|
|
31
|
+
lcs[i][j] =
|
|
32
|
+
oldLines[i] === newLines[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const result = [];
|
|
36
|
+
let i = 0;
|
|
37
|
+
let j = 0;
|
|
38
|
+
while (i < m && j < n) {
|
|
39
|
+
if (oldLines[i] === newLines[j]) {
|
|
40
|
+
result.push(` ${oldLines[i]}`);
|
|
41
|
+
i++;
|
|
42
|
+
j++;
|
|
43
|
+
}
|
|
44
|
+
else if (lcs[i + 1][j] >= lcs[i][j + 1]) {
|
|
45
|
+
result.push(chalk.red(`- ${oldLines[i]}`));
|
|
46
|
+
i++;
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
result.push(chalk.green(`+ ${newLines[j]}`));
|
|
50
|
+
j++;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
while (i < m) {
|
|
54
|
+
result.push(chalk.red(`- ${oldLines[i]}`));
|
|
55
|
+
i++;
|
|
56
|
+
}
|
|
57
|
+
while (j < n) {
|
|
58
|
+
result.push(chalk.green(`+ ${newLines[j]}`));
|
|
59
|
+
j++;
|
|
60
|
+
}
|
|
61
|
+
return result;
|
|
62
|
+
}
|
|
63
|
+
export function registerSkillDiff(program) {
|
|
64
|
+
program
|
|
65
|
+
.command('diff <skill-name>')
|
|
66
|
+
.description('Show diff between installed skill and registry latest')
|
|
67
|
+
.option('-g, --global', 'Diff against globally installed skill')
|
|
68
|
+
.action(async (skillName, options) => {
|
|
69
|
+
try {
|
|
70
|
+
if (!SKILL_NAME_RE.test(skillName)) {
|
|
71
|
+
throw new Error(`Invalid skill name "${skillName}": must match ^[a-z0-9-]+$`);
|
|
72
|
+
}
|
|
73
|
+
const installedBase = options.global
|
|
74
|
+
? join(homedir(), '.goodboy', 'skills')
|
|
75
|
+
: join(process.cwd(), '.claude', 'skills');
|
|
76
|
+
const installedPath = join(installedBase, skillName, 'SKILL.md');
|
|
77
|
+
assertWithin(installedPath, installedBase, 'installed skill path');
|
|
78
|
+
const registryBase = getRegistryPath();
|
|
79
|
+
const skillDir = join(registryBase, skillName);
|
|
80
|
+
assertWithin(skillDir, registryBase, 'skill path');
|
|
81
|
+
const entry = await readRegistryEntry(skillDir);
|
|
82
|
+
if (!entry) {
|
|
83
|
+
throw new Error(`Skill "${skillName}" not found in registry`);
|
|
84
|
+
}
|
|
85
|
+
const latest = resolveLatestVersion(entry);
|
|
86
|
+
if (!latest) {
|
|
87
|
+
throw new Error(`Skill "${skillName}" has no available versions`);
|
|
88
|
+
}
|
|
89
|
+
const versionDir = resolveVersionPath(entry, latest, skillDir);
|
|
90
|
+
const registrySkillMdPath = join(versionDir, 'SKILL.md');
|
|
91
|
+
assertWithin(registrySkillMdPath, versionDir, 'registry SKILL.md path');
|
|
92
|
+
if (!existsSync(installedPath)) {
|
|
93
|
+
logger.warn(`${skillName} is not installed in this scope.`);
|
|
94
|
+
logger.info(`Run 'goodboy install ${skillName}' to install it.`);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const installedContent = readFileSync(installedPath, 'utf-8');
|
|
98
|
+
const registryContent = readFileSync(registrySkillMdPath, 'utf-8');
|
|
99
|
+
if (installedContent === registryContent) {
|
|
100
|
+
logger.success(`${skillName} — installed copy matches registry@${latest}`);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
logger.warn(`${skillName} — installed copy differs from registry@${latest}`);
|
|
104
|
+
logger.info(`--- installed (.claude/skills/${skillName}/SKILL.md)`);
|
|
105
|
+
logger.info(`+++ registry (${registrySkillMdPath})`);
|
|
106
|
+
logger.info('');
|
|
107
|
+
const diffLines = computeDiff(installedContent, registryContent, installedPath, registrySkillMdPath);
|
|
108
|
+
for (const line of diffLines) {
|
|
109
|
+
logger.info(line);
|
|
110
|
+
}
|
|
111
|
+
logger.warn("Changes in .claude/skills/ will be lost on 'goodboy upgrade'.");
|
|
112
|
+
logger.info('To preserve changes: copy them to the registry version first,');
|
|
113
|
+
logger.info(`then run 'goodboy skill version ${skillName} --bump patch' to create a new version.`);
|
|
114
|
+
}
|
|
115
|
+
catch (err) {
|
|
116
|
+
logger.error(sanitiseError(err));
|
|
117
|
+
process.exit(1);
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { join, resolve, sep, basename } from 'node:path';
|
|
4
|
+
import { getRegistryPath } from '../lib/registry.js';
|
|
5
|
+
import { readRegistryEntry, resolveLatestVersion, resolveVersionPath } from '../lib/registry-entry.js';
|
|
6
|
+
import { SKILL_NAME_RE } from '../lib/validation.js';
|
|
7
|
+
import { logger, sanitiseError } from '../lib/logger.js';
|
|
8
|
+
const EDITOR_CANDIDATES = ['code', 'cursor', 'nano', 'vim', 'vi'];
|
|
9
|
+
function assertWithin(target, base, label) {
|
|
10
|
+
const resolvedTarget = resolve(target);
|
|
11
|
+
const resolvedBase = resolve(base);
|
|
12
|
+
if (!resolvedTarget.startsWith(resolvedBase + sep)) {
|
|
13
|
+
throw new Error(`Refused: ${label} escapes the expected directory`);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export function resolveEditor() {
|
|
17
|
+
const envEditor = process.env['EDITOR'];
|
|
18
|
+
if (envEditor && envEditor.trim().length > 0) {
|
|
19
|
+
return envEditor;
|
|
20
|
+
}
|
|
21
|
+
for (const candidate of EDITOR_CANDIDATES) {
|
|
22
|
+
const result = spawnSync(candidate, ['--version'], { stdio: 'ignore', timeout: 2000 });
|
|
23
|
+
if (result.status === 0) {
|
|
24
|
+
return candidate;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
throw new Error('No editor found. Set the EDITOR environment variable:\n' +
|
|
28
|
+
' export EDITOR=nano\n' +
|
|
29
|
+
' export EDITOR=code');
|
|
30
|
+
}
|
|
31
|
+
export function registerSkillOpen(program) {
|
|
32
|
+
program
|
|
33
|
+
.command('open <skill-name>')
|
|
34
|
+
.description('Open the latest registry version of a skill in your editor')
|
|
35
|
+
.option('--version <version>', 'Open a specific version instead of latest')
|
|
36
|
+
.action(async (skillName, options) => {
|
|
37
|
+
try {
|
|
38
|
+
if (!SKILL_NAME_RE.test(skillName)) {
|
|
39
|
+
throw new Error(`Invalid skill name "${skillName}": must match ^[a-z0-9-]+$`);
|
|
40
|
+
}
|
|
41
|
+
const registryPath = getRegistryPath();
|
|
42
|
+
const skillDir = join(registryPath, skillName);
|
|
43
|
+
assertWithin(skillDir, registryPath, 'skill path');
|
|
44
|
+
const entry = await readRegistryEntry(skillDir);
|
|
45
|
+
if (!entry) {
|
|
46
|
+
throw new Error(`Skill "${skillName}" not found in registry`);
|
|
47
|
+
}
|
|
48
|
+
const version = options.version ?? resolveLatestVersion(entry);
|
|
49
|
+
if (!version) {
|
|
50
|
+
throw new Error(`Skill "${skillName}" has no available versions`);
|
|
51
|
+
}
|
|
52
|
+
const versionDir = resolveVersionPath(entry, version, skillDir);
|
|
53
|
+
const skillMdPath = join(versionDir, 'SKILL.md');
|
|
54
|
+
assertWithin(skillMdPath, versionDir, 'SKILL.md path');
|
|
55
|
+
if (!existsSync(skillMdPath)) {
|
|
56
|
+
throw new Error(`SKILL.md not found for "${skillName}@${version}"`);
|
|
57
|
+
}
|
|
58
|
+
const editor = resolveEditor();
|
|
59
|
+
logger.info(`Opening ${skillName}@${version} in ${basename(editor)}...`);
|
|
60
|
+
logger.info(`File: ${skillMdPath}`);
|
|
61
|
+
logger.warn('⚠ Edit the registry copy only. Changes to .claude/skills/ will be overwritten by upgrade.');
|
|
62
|
+
const proc = spawn(editor, [skillMdPath], { stdio: 'inherit' });
|
|
63
|
+
await new Promise((resolvePromise, reject) => {
|
|
64
|
+
proc.on('close', (code) => {
|
|
65
|
+
if (code === 0 || code === null)
|
|
66
|
+
resolvePromise();
|
|
67
|
+
else
|
|
68
|
+
reject(new Error(`${editor} exited with code ${code}`));
|
|
69
|
+
});
|
|
70
|
+
proc.on('error', reject);
|
|
71
|
+
});
|
|
72
|
+
logger.info('');
|
|
73
|
+
logger.success(`Done editing ${skillName}@${version}`);
|
|
74
|
+
logger.info(`Run 'goodboy upgrade ${skillName}' to install the updated version.`);
|
|
75
|
+
logger.info(`Run 'goodboy skill diff ${skillName}' to see what changed.`);
|
|
76
|
+
}
|
|
77
|
+
catch (err) {
|
|
78
|
+
logger.error(sanitiseError(err));
|
|
79
|
+
process.exit(1);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join, resolve, sep } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import Table from 'cli-table3';
|
|
5
|
+
import chalk from 'chalk';
|
|
6
|
+
import { readGoodBoyJson, getLockedVersion } from '../lib/goodboy-file.js';
|
|
7
|
+
import { getRegistryPath } from '../lib/registry.js';
|
|
8
|
+
import { readRegistryEntry, resolveLatestVersion, resolveVersionPath } from '../lib/registry-entry.js';
|
|
9
|
+
import { readManifest, validateManifest } from '../lib/manifest.js';
|
|
10
|
+
import { SKILL_NAME_RE } from '../lib/validation.js';
|
|
11
|
+
import { logger, sanitiseError } from '../lib/logger.js';
|
|
12
|
+
function assertWithin(target, base, label) {
|
|
13
|
+
const resolvedTarget = resolve(target);
|
|
14
|
+
const resolvedBase = resolve(base);
|
|
15
|
+
if (!resolvedTarget.startsWith(resolvedBase + sep)) {
|
|
16
|
+
throw new Error(`Refused: ${label} escapes the expected directory`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
function stateColor(state) {
|
|
20
|
+
switch (state) {
|
|
21
|
+
case 'up to date': return chalk.green(state);
|
|
22
|
+
case 'upgrade available': return chalk.cyan(state);
|
|
23
|
+
case 'modified': return chalk.yellow(state);
|
|
24
|
+
case 'not installed': return chalk.red(state);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
async function getInstalledVersion(skillsBase, skillName) {
|
|
28
|
+
const manifestPath = join(skillsBase, skillName, 'manifest.json');
|
|
29
|
+
if (!existsSync(manifestPath))
|
|
30
|
+
return null;
|
|
31
|
+
try {
|
|
32
|
+
const raw = await readManifest(manifestPath);
|
|
33
|
+
const manifest = validateManifest(raw);
|
|
34
|
+
return manifest.version;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
async function computeRow(skillName, skillsBase, manifestDir) {
|
|
41
|
+
const installedVersion = await getInstalledVersion(skillsBase, skillName);
|
|
42
|
+
const lockedVersion = await getLockedVersion(manifestDir, skillName);
|
|
43
|
+
const registryPath = getRegistryPath();
|
|
44
|
+
const skillDir = join(registryPath, skillName);
|
|
45
|
+
const entry = await readRegistryEntry(skillDir);
|
|
46
|
+
const registryLatest = entry ? resolveLatestVersion(entry) : null;
|
|
47
|
+
let drifted = false;
|
|
48
|
+
if (installedVersion !== null && entry && registryLatest !== null) {
|
|
49
|
+
const installedSkillMdPath = join(skillsBase, skillName, 'SKILL.md');
|
|
50
|
+
if (existsSync(installedSkillMdPath)) {
|
|
51
|
+
const versionDir = resolveVersionPath(entry, registryLatest, skillDir);
|
|
52
|
+
const registrySkillMdPath = join(versionDir, 'SKILL.md');
|
|
53
|
+
if (existsSync(registrySkillMdPath)) {
|
|
54
|
+
const installedContent = readFileSync(installedSkillMdPath, 'utf-8');
|
|
55
|
+
const registryContent = readFileSync(registrySkillMdPath, 'utf-8');
|
|
56
|
+
drifted = installedContent !== registryContent;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
let state;
|
|
61
|
+
if (installedVersion === null) {
|
|
62
|
+
state = 'not installed';
|
|
63
|
+
}
|
|
64
|
+
else if (registryLatest !== null && installedVersion !== registryLatest) {
|
|
65
|
+
state = 'upgrade available';
|
|
66
|
+
}
|
|
67
|
+
else if (drifted) {
|
|
68
|
+
state = 'modified';
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
state = 'up to date';
|
|
72
|
+
}
|
|
73
|
+
return { name: skillName, installedVersion, registryLatest, lockedVersion, state };
|
|
74
|
+
}
|
|
75
|
+
async function run(options) {
|
|
76
|
+
const skillsBase = options.global
|
|
77
|
+
? join(homedir(), '.goodboy', 'skills')
|
|
78
|
+
: join(process.cwd(), '.claude', 'skills');
|
|
79
|
+
const manifestDir = options.global ? join(homedir(), '.goodboy') : process.cwd();
|
|
80
|
+
const goodboy = await readGoodBoyJson(manifestDir);
|
|
81
|
+
if (!goodboy) {
|
|
82
|
+
logger.warn("No goodboy.json found. Run 'goodboy init' to initialise GoodBoy.");
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const skillNames = Object.keys(goodboy.skills);
|
|
86
|
+
if (skillNames.length === 0) {
|
|
87
|
+
logger.info('No skills listed in goodboy.json.');
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const rows = [];
|
|
91
|
+
for (const name of skillNames) {
|
|
92
|
+
if (!SKILL_NAME_RE.test(name)) {
|
|
93
|
+
logger.warn(`Skipping invalid skill name in goodboy.json: "${name}"`);
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
const skillDir = join(skillsBase, name);
|
|
97
|
+
assertWithin(skillDir, skillsBase, 'skill path');
|
|
98
|
+
rows.push(await computeRow(name, skillsBase, manifestDir));
|
|
99
|
+
}
|
|
100
|
+
const table = new Table({
|
|
101
|
+
head: ['Skill', 'Installed', 'Registry', 'Locked', 'State'].map((h) => chalk.bold(h)),
|
|
102
|
+
style: { head: [], border: [] },
|
|
103
|
+
});
|
|
104
|
+
for (const row of rows) {
|
|
105
|
+
table.push([
|
|
106
|
+
row.name,
|
|
107
|
+
row.installedVersion ?? '—',
|
|
108
|
+
row.registryLatest ?? '—',
|
|
109
|
+
row.lockedVersion ?? '—',
|
|
110
|
+
stateColor(row.state),
|
|
111
|
+
]);
|
|
112
|
+
}
|
|
113
|
+
process.stdout.write(table.toString() + '\n');
|
|
114
|
+
if (rows.some((r) => r.state === 'modified')) {
|
|
115
|
+
logger.warn("\n⚠ Modified skills will lose changes on 'goodboy upgrade'.");
|
|
116
|
+
logger.info("Run 'goodboy skill diff <name>' to see what changed.");
|
|
117
|
+
}
|
|
118
|
+
if (rows.some((r) => r.state === 'upgrade available')) {
|
|
119
|
+
logger.info("\nRun 'goodboy upgrade' to install latest versions.");
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
export function registerSkillStatus(program) {
|
|
123
|
+
program
|
|
124
|
+
.command('status')
|
|
125
|
+
.description('Show installed skills with version and drift state')
|
|
126
|
+
.option('-g, --global', 'Show global skills status')
|
|
127
|
+
.action(async (options) => {
|
|
128
|
+
try {
|
|
129
|
+
await run(options);
|
|
130
|
+
}
|
|
131
|
+
catch (err) {
|
|
132
|
+
logger.error(sanitiseError(err));
|
|
133
|
+
process.exit(1);
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
declare const BUMP_LEVELS: readonly ["patch", "minor", "major"];
|
|
3
|
+
type BumpLevel = (typeof BUMP_LEVELS)[number];
|
|
4
|
+
export declare function bumpVersion(version: string, level: BumpLevel): string;
|
|
5
|
+
export declare function registerSkillVersion(program: Command): void;
|
|
6
|
+
export {};
|