@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.
Files changed (61) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +21 -0
  3. package/dist/commands/add.d.ts +2 -0
  4. package/dist/commands/add.js +100 -0
  5. package/dist/commands/init.d.ts +2 -0
  6. package/dist/commands/init.js +32 -0
  7. package/dist/commands/install.d.ts +13 -0
  8. package/dist/commands/install.js +192 -0
  9. package/dist/commands/list.d.ts +2 -0
  10. package/dist/commands/list.js +106 -0
  11. package/dist/commands/registry-cmd.d.ts +2 -0
  12. package/dist/commands/registry-cmd.js +122 -0
  13. package/dist/commands/search.d.ts +2 -0
  14. package/dist/commands/search.js +49 -0
  15. package/dist/commands/skill-create.d.ts +2 -0
  16. package/dist/commands/skill-create.js +143 -0
  17. package/dist/commands/skill-diff.d.ts +8 -0
  18. package/dist/commands/skill-diff.js +120 -0
  19. package/dist/commands/skill-open.d.ts +3 -0
  20. package/dist/commands/skill-open.js +82 -0
  21. package/dist/commands/skill-status.d.ts +2 -0
  22. package/dist/commands/skill-status.js +136 -0
  23. package/dist/commands/skill-version.d.ts +6 -0
  24. package/dist/commands/skill-version.js +119 -0
  25. package/dist/commands/skill.d.ts +2 -0
  26. package/dist/commands/skill.js +13 -0
  27. package/dist/commands/uninstall.d.ts +2 -0
  28. package/dist/commands/uninstall.js +53 -0
  29. package/dist/commands/upgrade.d.ts +2 -0
  30. package/dist/commands/upgrade.js +103 -0
  31. package/dist/index.d.ts +2 -0
  32. package/dist/index.js +33 -0
  33. package/dist/lib/agents.d.ts +14 -0
  34. package/dist/lib/agents.js +83 -0
  35. package/dist/lib/consent.d.ts +3 -0
  36. package/dist/lib/consent.js +48 -0
  37. package/dist/lib/fs-security.d.ts +8 -0
  38. package/dist/lib/fs-security.js +27 -0
  39. package/dist/lib/goodboy-file.d.ts +24 -0
  40. package/dist/lib/goodboy-file.js +99 -0
  41. package/dist/lib/local-registry-adapter.d.ts +18 -0
  42. package/dist/lib/local-registry-adapter.js +71 -0
  43. package/dist/lib/logger.d.ts +7 -0
  44. package/dist/lib/logger.js +28 -0
  45. package/dist/lib/manifest.d.ts +4 -0
  46. package/dist/lib/manifest.js +86 -0
  47. package/dist/lib/registry-adapter.d.ts +56 -0
  48. package/dist/lib/registry-adapter.js +15 -0
  49. package/dist/lib/registry-entry.d.ts +16 -0
  50. package/dist/lib/registry-entry.js +74 -0
  51. package/dist/lib/registry.d.ts +8 -0
  52. package/dist/lib/registry.js +114 -0
  53. package/dist/lib/skill-validator.d.ts +11 -0
  54. package/dist/lib/skill-validator.js +122 -0
  55. package/dist/lib/store.d.ts +13 -0
  56. package/dist/lib/store.js +62 -0
  57. package/dist/lib/validation.d.ts +1 -0
  58. package/dist/lib/validation.js +1 -0
  59. package/dist/types/index.d.ts +2 -0
  60. package/dist/types/index.js +1 -0
  61. package/package.json +67 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 GoodBoy Contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,21 @@
1
+ # @goodboyjs/cli
2
+
3
+ The GoodBoy command-line interface — the `goodboy` binary.
4
+
5
+ ## Installation
6
+
7
+ ```sh
8
+ npm install -g @goodboyjs/cli
9
+ ```
10
+
11
+ ## Commands
12
+
13
+ See the [root README](../../README.md#commands) for the full command reference.
14
+
15
+ ## Development
16
+
17
+ ```sh
18
+ npm run build # compile TypeScript → dist/
19
+ npm run dev # run from source with tsx
20
+ npm run test # run the test suite
21
+ ```
@@ -0,0 +1,2 @@
1
+ import { Command } from 'commander';
2
+ export declare const addCommand: Command;
@@ -0,0 +1,100 @@
1
+ import { Command } from 'commander';
2
+ import { existsSync, mkdirSync, cpSync } from 'node:fs';
3
+ import { resolve, basename, join } from 'node:path';
4
+ import ora from 'ora';
5
+ import { SKILL_NAME_RE } from '../lib/validation.js';
6
+ import { validateSkillDirectory, formatValidationResult } from '../lib/skill-validator.js';
7
+ import { readManifest, validateManifest } from '../lib/manifest.js';
8
+ import { scanForSymlinks } from '../lib/fs-security.js';
9
+ import { getRegistryPath, ensureRegistryExists } from '../lib/registry.js';
10
+ import { readRegistryEntry, writeRegistryEntry, createRegistryEntry, addVersionToEntry, } from '../lib/registry-entry.js';
11
+ import { logger } from '../lib/logger.js';
12
+ /**
13
+ * Marks a failure branch that has already logged its own specific message
14
+ * (and, for validation failures, already printed the full issue list).
15
+ * The catch block below checks for this to avoid re-logging a second,
16
+ * misleading message before exiting.
17
+ */
18
+ class HandledFailure extends Error {
19
+ }
20
+ export const addCommand = new Command('add')
21
+ .description('Add a skill to the local registry')
22
+ .argument('<skill-path>', 'Path to the skill directory')
23
+ .option('-f, --force', 'Overwrite an existing version')
24
+ .action(async (skillPathArg, options) => {
25
+ const spinner = ora('Adding skill...').start();
26
+ try {
27
+ const skillPath = resolve(skillPathArg);
28
+ if (!existsSync(skillPath)) {
29
+ spinner.fail();
30
+ logger.error(`Skill path not found: "${skillPathArg}"`);
31
+ throw new HandledFailure();
32
+ }
33
+ const dirName = basename(skillPath);
34
+ if (!SKILL_NAME_RE.test(dirName)) {
35
+ spinner.fail();
36
+ logger.error(`Invalid skill directory name "${dirName}": must match ^[a-z0-9-]+$`);
37
+ throw new HandledFailure();
38
+ }
39
+ spinner.text = 'Validating skill directory...';
40
+ const result = await validateSkillDirectory(skillPath);
41
+ if (!result.valid) {
42
+ spinner.fail('Skill validation failed');
43
+ formatValidationResult(result, dirName);
44
+ throw new HandledFailure();
45
+ }
46
+ if (result.issues.some((i) => i.severity === 'warning')) {
47
+ spinner.stop();
48
+ formatValidationResult(result, dirName);
49
+ spinner.start('Continuing...');
50
+ }
51
+ spinner.text = 'Reading manifest...';
52
+ const rawManifest = await readManifest(join(skillPath, 'manifest.json'));
53
+ const manifest = validateManifest(rawManifest);
54
+ if (manifest.name !== dirName) {
55
+ spinner.fail();
56
+ logger.error(`Manifest name "${manifest.name}" does not match directory name "${dirName}"`);
57
+ throw new HandledFailure();
58
+ }
59
+ const version = manifest.version;
60
+ spinner.text = 'Scanning for symlinks...';
61
+ await scanForSymlinks(skillPath);
62
+ ensureRegistryExists();
63
+ const registryPath = getRegistryPath();
64
+ const skillRegistryDir = join(registryPath, manifest.name);
65
+ const versionRelPath = join('versions', version);
66
+ const versionAbsPath = join(skillRegistryDir, versionRelPath);
67
+ const existingEntry = await readRegistryEntry(skillRegistryDir);
68
+ if (existingEntry?.versions[version] !== undefined) {
69
+ if (!options.force) {
70
+ spinner.fail();
71
+ logger.error(`Version "${version}" of skill "${manifest.name}" already exists. Use --force to overwrite.`);
72
+ throw new HandledFailure();
73
+ }
74
+ logger.warn(`Overwriting existing version "${version}" of skill "${manifest.name}".`);
75
+ }
76
+ spinner.text = 'Copying skill files...';
77
+ mkdirSync(versionAbsPath, { recursive: true, mode: 0o700 });
78
+ cpSync(skillPath, versionAbsPath, { recursive: true });
79
+ if (!existsSync(skillRegistryDir)) {
80
+ mkdirSync(skillRegistryDir, { recursive: true, mode: 0o700 });
81
+ }
82
+ const entry = existingEntry
83
+ ? addVersionToEntry(existingEntry, version, versionRelPath)
84
+ : createRegistryEntry(manifest.name, version, versionRelPath);
85
+ await writeRegistryEntry(skillRegistryDir, entry);
86
+ spinner.succeed(`Skill "${manifest.name}@${version}" added to registry`);
87
+ }
88
+ catch (err) {
89
+ if (!(err instanceof HandledFailure)) {
90
+ spinner.fail();
91
+ if (err instanceof Error && err.message.toLowerCase().includes('symlink')) {
92
+ logger.error('Skill rejected: symlink pointing outside skill directory detected');
93
+ }
94
+ else {
95
+ logger.error(err instanceof Error ? err.message : 'Unknown error');
96
+ }
97
+ }
98
+ process.exit(1);
99
+ }
100
+ });
@@ -0,0 +1,2 @@
1
+ import { Command } from 'commander';
2
+ export declare const initCommand: Command;
@@ -0,0 +1,32 @@
1
+ import { Command } from 'commander';
2
+ import { readGoodBoyJson, writeGoodBoyJson } from '../lib/goodboy-file.js';
3
+ import { logger, sanitiseError } from '../lib/logger.js';
4
+ export const initCommand = new Command('init')
5
+ .description('Initialise GoodBoy in the current directory (creates goodboy.json)')
6
+ .option('--registry <url>', 'Set a custom registry URL in goodboy.json')
7
+ .action(async (options) => {
8
+ const cwd = process.cwd();
9
+ const existing = await readGoodBoyJson(cwd);
10
+ if (existing) {
11
+ logger.warn('goodboy.json already exists in this directory.');
12
+ logger.info("Run 'goodboy install <skill-name>' to add a skill.");
13
+ process.exit(0);
14
+ return;
15
+ }
16
+ const data = {
17
+ schema: '1.0.0',
18
+ ...(options.registry ? { registry: options.registry } : {}),
19
+ skills: {},
20
+ };
21
+ try {
22
+ await writeGoodBoyJson(cwd, data);
23
+ }
24
+ catch (err) {
25
+ logger.error(sanitiseError(err));
26
+ process.exit(1);
27
+ return;
28
+ }
29
+ logger.success(`Initialised goodboy.json in ${cwd}`);
30
+ logger.info("Run 'goodboy install <skill-name>' to add a skill.");
31
+ logger.info("Run 'goodboy skill create' to scaffold a new skill.");
32
+ });
@@ -0,0 +1,13 @@
1
+ import { Command } from 'commander';
2
+ export interface InstallOptions {
3
+ global?: boolean;
4
+ /** Set to false when Commander sees --no-commit (Commander maps --no-x to options.x = false) */
5
+ commit?: boolean;
6
+ claudeCode?: boolean;
7
+ codex?: boolean;
8
+ gemini?: boolean;
9
+ allAgents?: boolean;
10
+ }
11
+ export declare function installNamed(name: string, options: InstallOptions, cwd: string): Promise<void>;
12
+ export declare function installFromManifest(options: InstallOptions, cwd: string): Promise<void>;
13
+ export declare const installCommand: Command;
@@ -0,0 +1,192 @@
1
+ import { Command } from 'commander';
2
+ import { cpSync, mkdirSync, existsSync, statSync } from 'node:fs';
3
+ import { appendFile, readFile } from 'node:fs/promises';
4
+ import { join, sep } from 'node:path';
5
+ import ora from 'ora';
6
+ import { createRegistryAdapter } from '../lib/registry-adapter.js';
7
+ import { readManifest, validateManifest } from '../lib/manifest.js';
8
+ import { requestConsent } from '../lib/consent.js';
9
+ import { scanForSymlinks } from '../lib/fs-security.js';
10
+ import { logger, sanitiseError } from '../lib/logger.js';
11
+ import { SKILL_NAME_RE } from '../lib/validation.js';
12
+ import { readGoodBoyJson, addSkillToManifest, addSkillToLock, } from '../lib/goodboy-file.js';
13
+ import { resolveAgentFlags, createAgentSymlinks } from '../lib/agents.js';
14
+ import { installToStore, getGoodboyHome } from '../lib/store.js';
15
+ // Skills readable by Claude Code (group/world read allowed)
16
+ const PROJECT_SKILLS_DIR_MODE = 0o755;
17
+ function getProjectSkillsPath(cwd) {
18
+ return join(cwd, '.claude', 'skills');
19
+ }
20
+ async function ensureGitignoreEntry(cwd) {
21
+ const gitignorePath = join(cwd, '.gitignore');
22
+ const entry = '.claude/skills/';
23
+ let existing = '';
24
+ try {
25
+ existing = await readFile(gitignorePath, 'utf-8');
26
+ }
27
+ catch {
28
+ // .gitignore doesn't exist yet — will be created
29
+ }
30
+ const lines = existing.split('\n');
31
+ if (!lines.some((l) => l.trim() === entry)) {
32
+ const prefix = existing.length > 0 && !existing.endsWith('\n') ? '\n' : '';
33
+ await appendFile(gitignorePath, `${prefix}${entry}\n`, 'utf-8');
34
+ logger.info(`Added "${entry}" to .gitignore`);
35
+ }
36
+ }
37
+ async function installNamedProject(name, skillPath, cwd) {
38
+ const skillsPath = getProjectSkillsPath(cwd);
39
+ const destPath = join(skillsPath, name);
40
+ // Traversal guard
41
+ if (!destPath.startsWith(skillsPath + sep) && destPath !== skillsPath) {
42
+ throw new Error('Refused: destination path escapes the skills directory');
43
+ }
44
+ if (existsSync(destPath)) {
45
+ const s = statSync(destPath);
46
+ if (!s.isDirectory()) {
47
+ throw new Error('Refused: destination path exists but is not a directory');
48
+ }
49
+ }
50
+ mkdirSync(skillsPath, { recursive: true, mode: PROJECT_SKILLS_DIR_MODE });
51
+ if (existsSync(destPath)) {
52
+ cpSync(skillPath, destPath, { recursive: true, force: true });
53
+ }
54
+ else {
55
+ cpSync(skillPath, destPath, { recursive: true });
56
+ }
57
+ return destPath;
58
+ }
59
+ export async function installNamed(name, options, cwd) {
60
+ if (!SKILL_NAME_RE.test(name)) {
61
+ throw new Error(`Invalid skill name: "${name}". Must match ^[a-z0-9-]+$.`);
62
+ }
63
+ const registry = createRegistryAdapter();
64
+ const spinner = ora(`Resolving "${name}"…`).start();
65
+ let skillPath;
66
+ try {
67
+ skillPath = await registry.resolveSkill(name);
68
+ }
69
+ catch (err) {
70
+ spinner.fail(`Cannot locate skill "${name}"`);
71
+ throw err;
72
+ }
73
+ let manifest;
74
+ try {
75
+ const data = await readManifest(join(skillPath, 'manifest.json'));
76
+ manifest = validateManifest(data);
77
+ }
78
+ catch (err) {
79
+ spinner.fail('Manifest validation failed');
80
+ throw err;
81
+ }
82
+ if (manifest.name !== name) {
83
+ logger.info(`Note: manifest declares name "${manifest.name}", installing as "${name}".`);
84
+ }
85
+ spinner.stop();
86
+ const consented = await requestConsent(manifest);
87
+ if (!consented) {
88
+ logger.warn('Installation cancelled.');
89
+ return;
90
+ }
91
+ spinner.start(`Installing "${name}"…`);
92
+ try {
93
+ await scanForSymlinks(skillPath);
94
+ }
95
+ catch {
96
+ spinner.fail('Symlink check failed');
97
+ throw new Error('Skill rejected: symlink pointing outside skill directory detected');
98
+ }
99
+ let resolvedPath;
100
+ if (options.global) {
101
+ let storePath;
102
+ try {
103
+ storePath = await installToStore(name, skillPath);
104
+ }
105
+ catch (err) {
106
+ spinner.fail('Failed to install to store');
107
+ throw err;
108
+ }
109
+ const agents = resolveAgentFlags({
110
+ claudeCode: options.claudeCode,
111
+ codex: options.codex,
112
+ gemini: options.gemini,
113
+ allAgents: options.allAgents,
114
+ });
115
+ try {
116
+ await createAgentSymlinks({ agents, skillName: name, storePath });
117
+ }
118
+ catch (err) {
119
+ spinner.fail('Failed to create agent symlinks');
120
+ throw err;
121
+ }
122
+ resolvedPath = storePath;
123
+ const goodboyHome = getGoodboyHome();
124
+ await addSkillToManifest(goodboyHome, name, manifest.version);
125
+ await addSkillToLock(goodboyHome, name, manifest.version, resolvedPath);
126
+ }
127
+ else {
128
+ try {
129
+ resolvedPath = await installNamedProject(name, skillPath, cwd);
130
+ }
131
+ catch (err) {
132
+ spinner.fail('Failed to copy skill files');
133
+ throw err;
134
+ }
135
+ if (options.commit === false) {
136
+ await ensureGitignoreEntry(cwd);
137
+ }
138
+ await addSkillToManifest(cwd, name, manifest.version);
139
+ await addSkillToLock(cwd, name, manifest.version, resolvedPath);
140
+ }
141
+ spinner.succeed(`Installed "${name}" (${manifest.version})`);
142
+ }
143
+ export async function installFromManifest(options, cwd) {
144
+ const goodboy = await readGoodBoyJson(cwd);
145
+ if (!goodboy) {
146
+ throw new Error('No goodboy.json found in current directory. Run "goodboy install <skill-name>" to install a skill.');
147
+ }
148
+ const skills = Object.keys(goodboy.skills);
149
+ if (skills.length === 0) {
150
+ logger.info('No skills listed in goodboy.json.');
151
+ return;
152
+ }
153
+ const skillsPath = options.global ? undefined : getProjectSkillsPath(cwd);
154
+ const missing = skills.filter((name) => {
155
+ if (skillsPath === undefined)
156
+ return true;
157
+ return !existsSync(join(skillsPath, name));
158
+ });
159
+ if (missing.length === 0) {
160
+ logger.info('All skills already installed.');
161
+ return;
162
+ }
163
+ logger.info(`Installing ${missing.length} skill(s)…`);
164
+ for (const name of missing) {
165
+ await installNamed(name, options, cwd);
166
+ }
167
+ }
168
+ export const installCommand = new Command('install')
169
+ .alias('i')
170
+ .description('Install a skill from the registry, or restore all from goodboy.json')
171
+ .argument('[skill-name]', 'Skill to install (omit to restore from goodboy.json)')
172
+ .option('-g, --global', 'Install to global store (~/.goodboy/skills/)')
173
+ .option('--no-commit', 'Add .claude/skills/ to .gitignore (goodboy.json/lock are still written)')
174
+ .option('--claude-code', 'Link into ~/.claude/skills/ (default when -g and no agent flag)')
175
+ .option('--codex', 'Link into ~/.codex/skills/')
176
+ .option('--gemini', 'Link into ~/.gemini/skills/')
177
+ .option('--all-agents', 'Link into all agent skill directories')
178
+ .action(async (skillName, options) => {
179
+ const cwd = process.cwd();
180
+ try {
181
+ if (skillName !== undefined) {
182
+ await installNamed(skillName, options, cwd);
183
+ }
184
+ else {
185
+ await installFromManifest(options, cwd);
186
+ }
187
+ }
188
+ catch (err) {
189
+ logger.error(sanitiseError(err));
190
+ process.exit(1);
191
+ }
192
+ });
@@ -0,0 +1,2 @@
1
+ import { Command } from 'commander';
2
+ export declare const listCommand: Command;
@@ -0,0 +1,106 @@
1
+ import { Command } from 'commander';
2
+ import { existsSync } from 'node:fs';
3
+ import { readdir } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ import Table from 'cli-table3';
6
+ import chalk from 'chalk';
7
+ import { createRegistryAdapter } from '../lib/registry-adapter.js';
8
+ import { readManifest, validateManifest } from '../lib/manifest.js';
9
+ import { readGoodBoyJson } from '../lib/goodboy-file.js';
10
+ import { logger, sanitiseError } from '../lib/logger.js';
11
+ function statusColor(status) {
12
+ switch (status) {
13
+ case 'stable': return chalk.green(status);
14
+ case 'experimental': return chalk.yellow(status);
15
+ case 'deprecated': return chalk.red(status);
16
+ default: return chalk.gray(status);
17
+ }
18
+ }
19
+ function scopeColor(scope) {
20
+ return scope === 'project' ? chalk.cyan(scope) : chalk.magenta(scope);
21
+ }
22
+ async function readSkillsFromDir(dir, scope) {
23
+ if (!existsSync(dir))
24
+ return [];
25
+ const entries = await readdir(dir, { withFileTypes: true });
26
+ const rows = [];
27
+ for (const entry of entries) {
28
+ if (!entry.isDirectory())
29
+ continue;
30
+ const manifestPath = join(dir, entry.name, 'manifest.json');
31
+ try {
32
+ const data = await readManifest(manifestPath);
33
+ const manifest = validateManifest(data);
34
+ rows.push({ manifest, scope });
35
+ }
36
+ catch {
37
+ // silently skip unreadable/invalid skills
38
+ }
39
+ }
40
+ return rows;
41
+ }
42
+ async function run(options) {
43
+ const cwd = process.cwd();
44
+ const rows = [];
45
+ const showProject = !options.global || options.all;
46
+ const showGlobal = options.global === true || options.all === true;
47
+ if (showProject) {
48
+ const hasGoodBoyJson = (await readGoodBoyJson(cwd)) !== null;
49
+ if (!hasGoodBoyJson) {
50
+ if (options.all) {
51
+ logger.info('Project skills: no goodboy.json in this directory');
52
+ }
53
+ else {
54
+ logger.warn('No goodboy.json found in current directory.');
55
+ logger.info("This doesn't look like a GoodBoy project.");
56
+ logger.info('');
57
+ logger.info("Run 'goodboy init' to initialise GoodBoy here.");
58
+ logger.info("Run 'goodboy list -g' to see globally installed skills.");
59
+ return;
60
+ }
61
+ }
62
+ else {
63
+ const projectSkillsPath = join(cwd, '.claude', 'skills');
64
+ rows.push(...(await readSkillsFromDir(projectSkillsPath, 'project')));
65
+ }
66
+ }
67
+ if (showGlobal) {
68
+ const registry = createRegistryAdapter();
69
+ const globalSkillsPath = registry.getSkillsLocation();
70
+ rows.push(...(await readSkillsFromDir(globalSkillsPath, 'global')));
71
+ }
72
+ if (rows.length === 0) {
73
+ logger.info('No skills installed. Run `goodboy install <name>` to get started.');
74
+ return;
75
+ }
76
+ const table = new Table({
77
+ head: ['Name', 'Version', 'Description', 'Status', 'Scope'].map((h) => chalk.bold(h)),
78
+ colWidths: [20, 10, 36, 14, 10],
79
+ wordWrap: true,
80
+ style: { head: [], border: [] },
81
+ });
82
+ for (const { manifest, scope } of rows) {
83
+ table.push([
84
+ chalk.white(manifest.name),
85
+ chalk.gray(manifest.version),
86
+ manifest.description,
87
+ statusColor(manifest.status),
88
+ scopeColor(scope),
89
+ ]);
90
+ }
91
+ process.stdout.write(table.toString() + '\n');
92
+ logger.info(`\n${rows.length} skill${rows.length === 1 ? '' : 's'} installed`);
93
+ }
94
+ export const listCommand = new Command('list')
95
+ .description('List installed skills')
96
+ .option('-g, --global', 'List only globally installed skills')
97
+ .option('-a, --all', 'List both project and global skills')
98
+ .action(async (options) => {
99
+ try {
100
+ await run(options);
101
+ }
102
+ catch (err) {
103
+ logger.error(sanitiseError(err));
104
+ process.exit(1);
105
+ }
106
+ });
@@ -0,0 +1,2 @@
1
+ import { Command } from 'commander';
2
+ export declare const registryCommand: Command;
@@ -0,0 +1,122 @@
1
+ import { Command } from 'commander';
2
+ import { join } from 'node:path';
3
+ import { existsSync, rmSync } from 'node:fs';
4
+ import { createRegistryAdapter } from '../lib/registry-adapter.js';
5
+ import { validateSkillDirectory, formatValidationResult } from '../lib/skill-validator.js';
6
+ import { getRegistryPath, resolveSkill, } from '../lib/registry.js';
7
+ import { readRegistryEntry, writeRegistryEntry, resolveLatestVersion, } from '../lib/registry-entry.js';
8
+ import { SKILL_NAME_RE } from '../lib/validation.js';
9
+ import { logger } from '../lib/logger.js';
10
+ export const registryCommand = new Command('registry')
11
+ .description('Manage the local skill registry');
12
+ registryCommand
13
+ .command('list')
14
+ .description('List all registered skills')
15
+ .action(async () => {
16
+ const adapter = createRegistryAdapter();
17
+ const entries = await adapter.listRegistry();
18
+ if (entries.length === 0) {
19
+ logger.info('Registry is empty. Use `goodboy add <skill-path>` to add skills.');
20
+ return;
21
+ }
22
+ for (const entry of entries) {
23
+ const latest = resolveLatestVersion(entry) ?? '(none)';
24
+ const versionCount = Object.keys(entry.versions).length;
25
+ logger.info(`${entry.name} latest: ${latest} (${versionCount} version${versionCount === 1 ? '' : 's'})`);
26
+ }
27
+ });
28
+ registryCommand
29
+ .command('info <skill>')
30
+ .description('Show details about a registered skill')
31
+ .action(async (skillName) => {
32
+ if (!SKILL_NAME_RE.test(skillName)) {
33
+ logger.error(`Invalid skill name "${skillName}": must match ^[a-z0-9-]+$`);
34
+ process.exit(1);
35
+ }
36
+ const registryPath = getRegistryPath();
37
+ const skillDir = join(registryPath, skillName);
38
+ const entry = await readRegistryEntry(skillDir);
39
+ if (!entry) {
40
+ logger.error(`Skill "${skillName}" not found in registry`);
41
+ process.exit(1);
42
+ }
43
+ logger.info(`Name: ${entry.name}`);
44
+ logger.info(`Latest: ${entry.latest}`);
45
+ logger.info('Versions:');
46
+ for (const [ver, info] of Object.entries(entry.versions)) {
47
+ const yankedLabel = info.yanked ? ' [yanked]' : '';
48
+ logger.info(` ${ver}${yankedLabel} added: ${info.addedAt}`);
49
+ }
50
+ });
51
+ registryCommand
52
+ .command('validate <skill>')
53
+ .description('Validate a registered skill\'s structure')
54
+ .action(async (skillName) => {
55
+ if (!SKILL_NAME_RE.test(skillName)) {
56
+ logger.error(`Invalid skill name "${skillName}": must match ^[a-z0-9-]+$`);
57
+ process.exit(1);
58
+ }
59
+ let skillPath;
60
+ try {
61
+ skillPath = await resolveSkill(skillName);
62
+ }
63
+ catch (err) {
64
+ logger.error(err instanceof Error ? err.message : `Skill "${skillName}" not found`);
65
+ process.exit(1);
66
+ }
67
+ const result = await validateSkillDirectory(skillPath);
68
+ formatValidationResult(result, skillName);
69
+ if (result.valid) {
70
+ logger.success(`Skill "${skillName}" is valid`);
71
+ }
72
+ else {
73
+ process.exit(1);
74
+ }
75
+ });
76
+ registryCommand
77
+ .command('remove <skill>')
78
+ .description('Remove a skill (or specific version) from the registry')
79
+ .option('--version <version>', 'Remove only this version (default: remove all versions)')
80
+ .action(async (skillName, options) => {
81
+ if (!SKILL_NAME_RE.test(skillName)) {
82
+ logger.error(`Invalid skill name "${skillName}": must match ^[a-z0-9-]+$`);
83
+ process.exit(1);
84
+ }
85
+ const registryPath = getRegistryPath();
86
+ const skillDir = join(registryPath, skillName);
87
+ const entry = await readRegistryEntry(skillDir);
88
+ if (!entry) {
89
+ logger.error(`Skill "${skillName}" not found in registry`);
90
+ process.exit(1);
91
+ }
92
+ if (options.version) {
93
+ const ver = options.version;
94
+ if (!entry.versions[ver]) {
95
+ logger.error(`Version "${ver}" of skill "${skillName}" not found in registry`);
96
+ process.exit(1);
97
+ }
98
+ const versionPath = join(skillDir, entry.versions[ver].path);
99
+ if (existsSync(versionPath)) {
100
+ rmSync(versionPath, { recursive: true, force: true });
101
+ }
102
+ const { [ver]: _removed, ...remainingVersions } = entry.versions;
103
+ const remainingKeys = Object.keys(remainingVersions);
104
+ if (remainingKeys.length === 0) {
105
+ rmSync(skillDir, { recursive: true, force: true });
106
+ logger.success(`Skill "${skillName}" removed from registry (last version deleted)`);
107
+ }
108
+ else {
109
+ const newLatest = resolveLatestVersion({ ...entry, versions: remainingVersions }) ?? remainingKeys[0];
110
+ await writeRegistryEntry(skillDir, {
111
+ ...entry,
112
+ latest: newLatest,
113
+ versions: remainingVersions,
114
+ });
115
+ logger.success(`Version "${ver}" of skill "${skillName}" removed`);
116
+ }
117
+ }
118
+ else {
119
+ rmSync(skillDir, { recursive: true, force: true });
120
+ logger.success(`Skill "${skillName}" removed from registry`);
121
+ }
122
+ });
@@ -0,0 +1,2 @@
1
+ import { Command } from 'commander';
2
+ export declare const searchCommand: Command;