@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
@@ -0,0 +1,119 @@
1
+ import { cp } from 'node:fs/promises';
2
+ import { join, resolve, sep } from 'node:path';
3
+ import ora from 'ora';
4
+ import { getRegistryPath } from '../lib/registry.js';
5
+ import { readRegistryEntry, writeRegistryEntry, resolveLatestVersion, resolveVersionPath, addVersionToEntry, } from '../lib/registry-entry.js';
6
+ import { readManifest, writeManifest, validateManifest } from '../lib/manifest.js';
7
+ import { SKILL_NAME_RE } from '../lib/validation.js';
8
+ import { logger, sanitiseError } from '../lib/logger.js';
9
+ const BUMP_LEVELS = ['patch', 'minor', 'major'];
10
+ export function bumpVersion(version, level) {
11
+ const [major = 0, minor = 0, patch = 0] = version.split('.').map((n) => parseInt(n, 10));
12
+ switch (level) {
13
+ case 'patch': return `${major}.${minor}.${patch + 1}`;
14
+ case 'minor': return `${major}.${minor + 1}.0`;
15
+ case 'major': return `${major + 1}.0.0`;
16
+ }
17
+ }
18
+ function assertWithin(target, base, label) {
19
+ const resolvedTarget = resolve(target);
20
+ const resolvedBase = resolve(base);
21
+ if (!resolvedTarget.startsWith(resolvedBase + sep)) {
22
+ throw new Error(`Refused: ${label} escapes the expected directory`);
23
+ }
24
+ }
25
+ async function showVersionInfo(skillName) {
26
+ const registryPath = getRegistryPath();
27
+ const skillDir = join(registryPath, skillName);
28
+ assertWithin(skillDir, registryPath, 'skill path');
29
+ const entry = await readRegistryEntry(skillDir);
30
+ if (!entry) {
31
+ throw new Error(`Skill "${skillName}" not found in registry`);
32
+ }
33
+ const versions = Object.keys(entry.versions).sort((a, b) => {
34
+ const [aMaj = 0, aMin = 0, aPat = 0] = a.split('.').map(Number);
35
+ const [bMaj = 0, bMin = 0, bPat = 0] = b.split('.').map(Number);
36
+ return bMaj - aMaj || bMin - aMin || bPat - aPat;
37
+ });
38
+ logger.info(`Registry: ${registryPath}`);
39
+ logger.info('');
40
+ logger.info(`${skillName} versions:`);
41
+ logger.info('');
42
+ for (const version of versions) {
43
+ const v = entry.versions[version];
44
+ const tag = version === entry.latest ? ' (latest)' : '';
45
+ const yanked = v.yanked ? ' [yanked]' : '';
46
+ const added = new Date(v.addedAt).toLocaleDateString();
47
+ logger.info(` ${version}${tag}${yanked} — added ${added}`);
48
+ }
49
+ logger.info('');
50
+ logger.info(`To create a new version: goodboy skill version ${skillName} --bump patch`);
51
+ }
52
+ async function createNewVersion(skillName, bump) {
53
+ if (!BUMP_LEVELS.includes(bump)) {
54
+ throw new Error(`Invalid bump level "${bump}": must be one of ${BUMP_LEVELS.join(', ')}`);
55
+ }
56
+ const level = bump;
57
+ const registryPath = getRegistryPath();
58
+ const skillDir = join(registryPath, skillName);
59
+ assertWithin(skillDir, registryPath, 'skill path');
60
+ const entry = await readRegistryEntry(skillDir);
61
+ if (!entry) {
62
+ throw new Error(`Skill "${skillName}" not found in registry`);
63
+ }
64
+ const currentLatest = resolveLatestVersion(entry);
65
+ if (!currentLatest) {
66
+ throw new Error('No installable version found (all versions are yanked)');
67
+ }
68
+ const newVersion = bumpVersion(currentLatest, level);
69
+ if (entry.versions[newVersion]) {
70
+ throw new Error(`Version ${newVersion} already exists in registry. ` +
71
+ `The registry uses immutable versions — existing versions cannot be replaced.`);
72
+ }
73
+ const sourceVersionDir = resolveVersionPath(entry, currentLatest, skillDir);
74
+ const newVersionDir = join(skillDir, 'versions', newVersion);
75
+ assertWithin(newVersionDir, skillDir, 'new version path');
76
+ const spinner = ora(`Creating ${skillName}@${newVersion}...`).start();
77
+ try {
78
+ await cp(sourceVersionDir, newVersionDir, { recursive: true });
79
+ const manifestPath = join(newVersionDir, 'manifest.json');
80
+ const rawManifest = await readManifest(manifestPath);
81
+ const manifest = validateManifest(rawManifest);
82
+ manifest.version = newVersion;
83
+ await writeManifest(manifestPath, manifest);
84
+ const updatedEntry = addVersionToEntry(entry, newVersion, join('versions', newVersion));
85
+ await writeRegistryEntry(skillDir, updatedEntry);
86
+ spinner.succeed();
87
+ }
88
+ catch (err) {
89
+ spinner.fail();
90
+ throw err;
91
+ }
92
+ logger.success(`Created ${skillName}@${newVersion} from ${currentLatest}`);
93
+ logger.info('');
94
+ logger.info(`Edit: goodboy skill open ${skillName}`);
95
+ logger.info(`Install: goodboy upgrade ${skillName}`);
96
+ }
97
+ export function registerSkillVersion(program) {
98
+ program
99
+ .command('version <skill-name>')
100
+ .description('Show version info or create a new version of a registry skill')
101
+ .option('--bump <level>', 'Create a new version: patch | minor | major')
102
+ .action(async (skillName, options) => {
103
+ try {
104
+ if (!SKILL_NAME_RE.test(skillName)) {
105
+ throw new Error(`Invalid skill name "${skillName}": must match ^[a-z0-9-]+$`);
106
+ }
107
+ if (options.bump) {
108
+ await createNewVersion(skillName, options.bump);
109
+ }
110
+ else {
111
+ await showVersionInfo(skillName);
112
+ }
113
+ }
114
+ catch (err) {
115
+ logger.error(sanitiseError(err));
116
+ process.exit(1);
117
+ }
118
+ });
119
+ }
@@ -0,0 +1,2 @@
1
+ import { Command } from 'commander';
2
+ export declare function registerSkillCommand(program: Command): void;
@@ -0,0 +1,13 @@
1
+ import { registerSkillCreate } from './skill-create.js';
2
+ import { registerSkillVersion } from './skill-version.js';
3
+ import { registerSkillOpen } from './skill-open.js';
4
+ import { registerSkillDiff } from './skill-diff.js';
5
+ import { registerSkillStatus } from './skill-status.js';
6
+ export function registerSkillCommand(program) {
7
+ const skill = program.command('skill').description('Manage skill creation and scaffolding');
8
+ registerSkillCreate(skill);
9
+ registerSkillVersion(skill);
10
+ registerSkillOpen(skill);
11
+ registerSkillDiff(skill);
12
+ registerSkillStatus(skill);
13
+ }
@@ -0,0 +1,2 @@
1
+ import { Command } from 'commander';
2
+ export declare const uninstallCommand: Command;
@@ -0,0 +1,53 @@
1
+ import { Command } from 'commander';
2
+ import { rmSync, existsSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import ora from 'ora';
5
+ import { logger, sanitiseError } from '../lib/logger.js';
6
+ import { SKILL_NAME_RE } from '../lib/validation.js';
7
+ import { removeSkillFromManifest, removeSkillFromLock, } from '../lib/goodboy-file.js';
8
+ import { removeAgentSymlinks, AGENT_SKILL_DIRS } from '../lib/agents.js';
9
+ import { removeFromStore, getGoodboyHome } from '../lib/store.js';
10
+ function getProjectSkillsPath(cwd) {
11
+ return join(cwd, '.claude', 'skills');
12
+ }
13
+ async function uninstallSkill(name, options, cwd) {
14
+ if (!SKILL_NAME_RE.test(name)) {
15
+ throw new Error(`Invalid skill name: "${name}". Must match ^[a-z0-9-]+$.`);
16
+ }
17
+ const spinner = ora(`Uninstalling "${name}"…`).start();
18
+ if (options.global) {
19
+ await removeAgentSymlinks(name, Object.keys(AGENT_SKILL_DIRS));
20
+ removeFromStore(name);
21
+ const goodboyHome = getGoodboyHome();
22
+ await removeSkillFromManifest(goodboyHome, name);
23
+ await removeSkillFromLock(goodboyHome, name);
24
+ }
25
+ else {
26
+ const destPath = join(getProjectSkillsPath(cwd), name);
27
+ if (existsSync(destPath)) {
28
+ rmSync(destPath, { recursive: true, force: true });
29
+ }
30
+ else {
31
+ spinner.warn(`"${name}" is not installed in this project`);
32
+ return;
33
+ }
34
+ await removeSkillFromManifest(cwd, name);
35
+ await removeSkillFromLock(cwd, name);
36
+ }
37
+ spinner.succeed(`Uninstalled "${name}"`);
38
+ }
39
+ export const uninstallCommand = new Command('uninstall')
40
+ .alias('rm')
41
+ .description('Remove an installed skill')
42
+ .argument('<skill-name>', 'Skill to remove')
43
+ .option('-g, --global', 'Remove from global store and all agent symlinks')
44
+ .action(async (skillName, options) => {
45
+ const cwd = process.cwd();
46
+ try {
47
+ await uninstallSkill(skillName, options, cwd);
48
+ }
49
+ catch (err) {
50
+ logger.error(sanitiseError(err));
51
+ process.exit(1);
52
+ }
53
+ });
@@ -0,0 +1,2 @@
1
+ import { Command } from 'commander';
2
+ export declare const upgradeCommand: Command;
@@ -0,0 +1,103 @@
1
+ import { Command } from 'commander';
2
+ import { cpSync, existsSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import ora from 'ora';
5
+ import { createRegistryAdapter } from '../lib/registry-adapter.js';
6
+ import { readManifest, validateManifest } from '../lib/manifest.js';
7
+ import { scanForSymlinks } from '../lib/fs-security.js';
8
+ import { logger, sanitiseError } from '../lib/logger.js';
9
+ import { SKILL_NAME_RE } from '../lib/validation.js';
10
+ import { readGoodBoyJson, getLockedVersion, addSkillToManifest, addSkillToLock, } from '../lib/goodboy-file.js';
11
+ import { getStorePath, getGoodboyHome } from '../lib/store.js';
12
+ function getProjectSkillsPath(cwd) {
13
+ return join(cwd, '.claude', 'skills');
14
+ }
15
+ async function upgradeSkill(name, options, cwd) {
16
+ if (!SKILL_NAME_RE.test(name)) {
17
+ throw new Error(`Invalid skill name: "${name}". Must match ^[a-z0-9-]+$.`);
18
+ }
19
+ const registry = createRegistryAdapter();
20
+ const spinner = ora(`Upgrading "${name}"…`).start();
21
+ let skillPath;
22
+ try {
23
+ skillPath = await registry.resolveSkill(name);
24
+ }
25
+ catch (err) {
26
+ spinner.fail(`Cannot locate skill "${name}" in registry`);
27
+ throw err;
28
+ }
29
+ let manifest;
30
+ try {
31
+ const data = await readManifest(join(skillPath, 'manifest.json'));
32
+ manifest = validateManifest(data);
33
+ }
34
+ catch (err) {
35
+ spinner.fail('Manifest validation failed');
36
+ throw err;
37
+ }
38
+ const lockDir = options.global ? getGoodboyHome() : cwd;
39
+ const lockedVersion = await getLockedVersion(lockDir, name);
40
+ if (lockedVersion !== null && lockedVersion === manifest.version) {
41
+ spinner.info(`"${name}" is already at the latest version (${manifest.version})`);
42
+ return;
43
+ }
44
+ try {
45
+ await scanForSymlinks(skillPath);
46
+ }
47
+ catch {
48
+ spinner.fail('Symlink check failed');
49
+ throw new Error('Skill rejected: symlink pointing outside skill directory detected');
50
+ }
51
+ const destPath = options.global
52
+ ? join(getStorePath(), name)
53
+ : join(getProjectSkillsPath(cwd), name);
54
+ if (!existsSync(destPath)) {
55
+ spinner.fail(`"${name}" is not installed`);
56
+ throw new Error(`Skill "${name}" is not installed. Run "goodboy install ${name}" first.`);
57
+ }
58
+ try {
59
+ cpSync(skillPath, destPath, { recursive: true, force: true });
60
+ }
61
+ catch (err) {
62
+ spinner.fail('Failed to copy skill files');
63
+ throw err;
64
+ }
65
+ await addSkillToManifest(lockDir, name, manifest.version);
66
+ await addSkillToLock(lockDir, name, manifest.version, destPath);
67
+ const from = lockedVersion !== null ? `${lockedVersion} → ` : '';
68
+ spinner.succeed(`Upgraded "${name}" (${from}${manifest.version})`);
69
+ }
70
+ async function upgradeAll(options, cwd) {
71
+ const dir = options.global ? getGoodboyHome() : cwd;
72
+ const goodboy = await readGoodBoyJson(dir);
73
+ if (!goodboy) {
74
+ throw new Error('No goodboy.json found in current directory.');
75
+ }
76
+ const skills = Object.keys(goodboy.skills);
77
+ if (skills.length === 0) {
78
+ logger.info('No skills listed in goodboy.json.');
79
+ return;
80
+ }
81
+ for (const name of skills) {
82
+ await upgradeSkill(name, options, cwd);
83
+ }
84
+ }
85
+ export const upgradeCommand = new Command('upgrade')
86
+ .description('Upgrade installed skills to the latest registry version')
87
+ .argument('[skill-name]', 'Skill to upgrade (omit to upgrade all from goodboy.json)')
88
+ .option('-g, --global', 'Upgrade in global store (~/.goodboy/skills/)')
89
+ .action(async (skillName, options) => {
90
+ const cwd = process.cwd();
91
+ try {
92
+ if (skillName !== undefined) {
93
+ await upgradeSkill(skillName, options, cwd);
94
+ }
95
+ else {
96
+ await upgradeAll(options, cwd);
97
+ }
98
+ }
99
+ catch (err) {
100
+ logger.error(sanitiseError(err));
101
+ process.exit(1);
102
+ }
103
+ });
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,33 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from 'commander';
3
+ import { createRequire } from 'node:module';
4
+ import { initCommand } from './commands/init.js';
5
+ import { registerSkillCommand } from './commands/skill.js';
6
+ import { installCommand } from './commands/install.js';
7
+ import { upgradeCommand } from './commands/upgrade.js';
8
+ import { uninstallCommand } from './commands/uninstall.js';
9
+ import { listCommand } from './commands/list.js';
10
+ import { searchCommand } from './commands/search.js';
11
+ import { addCommand } from './commands/add.js';
12
+ import { registryCommand } from './commands/registry-cmd.js';
13
+ const _require = createRequire(import.meta.url);
14
+ const pkg = _require('../package.json');
15
+ const program = new Command();
16
+ program
17
+ .name('goodboy')
18
+ .description('Personal skill registry and dispatcher for Claude Code')
19
+ .version(pkg.version, '-v, --version', 'Print version number')
20
+ .addHelpText('after', '\nDocs: https://github.com/xpera-ch/goodboy');
21
+ program.addCommand(initCommand);
22
+ registerSkillCommand(program);
23
+ program.addCommand(installCommand);
24
+ program.addCommand(upgradeCommand);
25
+ program.addCommand(uninstallCommand);
26
+ program.addCommand(listCommand);
27
+ program.addCommand(searchCommand);
28
+ program.addCommand(addCommand);
29
+ program.addCommand(registryCommand);
30
+ program.parseAsync(process.argv).catch((err) => {
31
+ process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}\n`);
32
+ process.exit(1);
33
+ });
@@ -0,0 +1,14 @@
1
+ export declare const AGENT_SKILL_DIRS: Record<string, string>;
2
+ export interface AgentLinkOptions {
3
+ agents: string[];
4
+ skillName: string;
5
+ storePath: string;
6
+ }
7
+ export declare function resolveAgentFlags(flags: {
8
+ claudeCode?: boolean;
9
+ codex?: boolean;
10
+ gemini?: boolean;
11
+ allAgents?: boolean;
12
+ }): string[];
13
+ export declare function createAgentSymlinks(options: AgentLinkOptions): Promise<void>;
14
+ export declare function removeAgentSymlinks(skillName: string, agents: string[]): Promise<void>;
@@ -0,0 +1,83 @@
1
+ import { mkdir, lstat, symlink, unlink, readlink } from 'node:fs/promises';
2
+ import { join, sep } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ import { logger } from './logger.js';
5
+ export const AGENT_SKILL_DIRS = {
6
+ 'claude-code': join(homedir(), '.claude', 'skills'),
7
+ 'codex': join(homedir(), '.codex', 'skills'),
8
+ 'gemini': join(homedir(), '.gemini', 'skills'),
9
+ };
10
+ const DEFAULT_AGENT = 'claude-code';
11
+ export function resolveAgentFlags(flags) {
12
+ if (flags.allAgents)
13
+ return Object.keys(AGENT_SKILL_DIRS);
14
+ const resolved = [];
15
+ if (flags.claudeCode)
16
+ resolved.push('claude-code');
17
+ if (flags.codex)
18
+ resolved.push('codex');
19
+ if (flags.gemini)
20
+ resolved.push('gemini');
21
+ if (resolved.length === 0)
22
+ return [DEFAULT_AGENT];
23
+ /* c8 ignore next 5 */
24
+ for (const name of resolved) {
25
+ if (!(name in AGENT_SKILL_DIRS)) {
26
+ throw new Error(`Unknown agent: "${name}"`);
27
+ }
28
+ }
29
+ return resolved;
30
+ }
31
+ export async function createAgentSymlinks(options) {
32
+ const { agents, skillName, storePath } = options;
33
+ for (const agent of agents) {
34
+ if (!(agent in AGENT_SKILL_DIRS)) {
35
+ throw new Error(`Unknown agent: "${agent}"`);
36
+ }
37
+ const agentDir = AGENT_SKILL_DIRS[agent];
38
+ const symlinkTarget = join(agentDir, skillName);
39
+ // Traversal guard — validated skillName should already be safe, but belt-and-suspenders
40
+ /* c8 ignore next 3 */
41
+ if (!symlinkTarget.startsWith(agentDir + sep) && symlinkTarget !== agentDir) {
42
+ throw new Error(`Refused: symlink target escapes agent skills directory`);
43
+ }
44
+ await mkdir(agentDir, { recursive: true, mode: 0o700 });
45
+ const stat = await lstat(symlinkTarget).catch(() => null);
46
+ if (stat !== null) {
47
+ if (stat.isSymbolicLink()) {
48
+ const existing = await readlink(symlinkTarget);
49
+ if (existing === storePath) {
50
+ logger.info(`${agent}: already linked correctly`);
51
+ continue;
52
+ }
53
+ await unlink(symlinkTarget);
54
+ }
55
+ else {
56
+ throw new Error(`Cannot create symlink: ${symlinkTarget} exists as a real directory. ` +
57
+ `Remove it manually before installing globally.`);
58
+ }
59
+ }
60
+ await symlink(storePath, symlinkTarget);
61
+ logger.success(`Linked to ${agent}: ${symlinkTarget}`);
62
+ }
63
+ }
64
+ export async function removeAgentSymlinks(skillName, agents) {
65
+ for (const agent of agents) {
66
+ if (!(agent in AGENT_SKILL_DIRS)) {
67
+ logger.warn(`Unknown agent "${agent}" — skipping`);
68
+ continue;
69
+ }
70
+ const agentDir = AGENT_SKILL_DIRS[agent];
71
+ const symlinkTarget = join(agentDir, skillName);
72
+ const stat = await lstat(symlinkTarget).catch(() => null);
73
+ if (stat === null)
74
+ continue;
75
+ if (stat.isSymbolicLink()) {
76
+ await unlink(symlinkTarget);
77
+ logger.info(`Removed ${agent} symlink: ${symlinkTarget}`);
78
+ }
79
+ else {
80
+ logger.warn(`${symlinkTarget} is a real directory — skipping (remove manually if needed)`);
81
+ }
82
+ }
83
+ }
@@ -0,0 +1,3 @@
1
+ import type { GoodBoyManifest } from '../types/index.js';
2
+ export declare function summarizePermissions(manifest: GoodBoyManifest): string[];
3
+ export declare function requestConsent(manifest: GoodBoyManifest): Promise<boolean>;
@@ -0,0 +1,48 @@
1
+ import { confirm } from '@inquirer/prompts';
2
+ import { logger } from './logger.js';
3
+ // Exhaustiveness enforced at compile time: if the schema's permissions enum grows,
4
+ // adding the missing key here becomes required to compile. The runtime guard in
5
+ // summarizePermissions() is the backstop for when types are stale (schema changed
6
+ // but generate:types was not re-run).
7
+ const PERMISSION_LABELS = {
8
+ read_files: 'Read files on disk',
9
+ write_files: 'Write files on disk',
10
+ network: 'Access the network',
11
+ shell: 'Run shell commands',
12
+ env: 'Read environment variables',
13
+ };
14
+ // Fixed output order matches schema enum declaration order, independent of object key order.
15
+ const PERMISSION_ORDER = [
16
+ 'read_files', 'write_files', 'network', 'shell', 'env',
17
+ ];
18
+ export function summarizePermissions(manifest) {
19
+ const perms = manifest.permissions ?? [];
20
+ // Runtime backstop against schema/type drift: if the schema gains a new permission
21
+ // value but generate:types has not been re-run, Ajv will accept the new value while
22
+ // PERMISSION_LABELS has no entry for it. Throw rather than silently omit it from
23
+ // the consent display — a missing permission in the prompt is worse than a crash.
24
+ for (const p of perms) {
25
+ if (PERMISSION_LABELS[p] === undefined) {
26
+ throw new Error(`Unknown permission value in manifest: "${p}". ` +
27
+ `This likely means the schema was updated without regenerating types — run npm run generate:types.`);
28
+ }
29
+ }
30
+ return PERMISSION_ORDER
31
+ .filter((p) => perms.includes(p))
32
+ .map((p) => PERMISSION_LABELS[p]);
33
+ }
34
+ export async function requestConsent(manifest) {
35
+ const lines = summarizePermissions(manifest);
36
+ if (lines.length === 0)
37
+ return true;
38
+ logger.info('');
39
+ logger.info(`Skill "${manifest.name}" requests the following permissions:`);
40
+ for (const line of lines) {
41
+ logger.info(` • ${line}`);
42
+ }
43
+ logger.info('');
44
+ return confirm({
45
+ message: `Allow "${manifest.name}" to install with these permissions?`,
46
+ default: false,
47
+ });
48
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Recursively scan `dirPath` for symlinks that point outside `dirPath`.
3
+ * Symlinks whose resolved target starts with `dirPath + sep` are permitted
4
+ * (internal cross-references within the skill). All other symlinks abort
5
+ * with a security error so a malicious skill cannot use them to escape the
6
+ * skill sandbox during copy.
7
+ */
8
+ export declare function scanForSymlinks(dirPath: string): Promise<void>;
@@ -0,0 +1,27 @@
1
+ import { readdir, readlink } from 'node:fs/promises';
2
+ import { join, resolve, sep } from 'node:path';
3
+ /**
4
+ * Recursively scan `dirPath` for symlinks that point outside `dirPath`.
5
+ * Symlinks whose resolved target starts with `dirPath + sep` are permitted
6
+ * (internal cross-references within the skill). All other symlinks abort
7
+ * with a security error so a malicious skill cannot use them to escape the
8
+ * skill sandbox during copy.
9
+ */
10
+ export async function scanForSymlinks(dirPath) {
11
+ const entries = await readdir(dirPath, { withFileTypes: true });
12
+ for (const entry of entries) {
13
+ const fullPath = join(dirPath, entry.name);
14
+ if (entry.isSymbolicLink()) {
15
+ const linkTarget = await readlink(fullPath);
16
+ const resolvedTarget = resolve(dirPath, linkTarget);
17
+ if (!resolvedTarget.startsWith(dirPath + sep) && resolvedTarget !== dirPath) {
18
+ throw new Error(`Security: skill contains a symlink pointing outside its directory: ` +
19
+ `${fullPath} → ${resolvedTarget}. Installation aborted.`);
20
+ }
21
+ // Symlink points inside the skill directory — permitted
22
+ }
23
+ else if (entry.isDirectory()) {
24
+ await scanForSymlinks(fullPath);
25
+ }
26
+ }
27
+ }
@@ -0,0 +1,24 @@
1
+ export interface GoodBoyJson {
2
+ schema: '1.0.0';
3
+ registry?: string;
4
+ skills: Record<string, string>;
5
+ }
6
+ export interface GoodBoyLockEntry {
7
+ version: string;
8
+ resolved: string;
9
+ integrity?: string;
10
+ }
11
+ export interface GoodBoyLock {
12
+ schema: '1.0.0';
13
+ generated: string;
14
+ skills: Record<string, GoodBoyLockEntry>;
15
+ }
16
+ export declare function readGoodBoyJson(dir: string): Promise<GoodBoyJson | null>;
17
+ export declare function writeGoodBoyJson(dir: string, data: GoodBoyJson): Promise<void>;
18
+ export declare function readGoodBoyLock(dir: string): Promise<GoodBoyLock | null>;
19
+ export declare function writeGoodBoyLock(dir: string, data: GoodBoyLock): Promise<void>;
20
+ export declare function addSkillToManifest(dir: string, skillName: string, version: string): Promise<void>;
21
+ export declare function addSkillToLock(dir: string, skillName: string, version: string, resolvedPath: string): Promise<void>;
22
+ export declare function removeSkillFromManifest(dir: string, skillName: string): Promise<void>;
23
+ export declare function removeSkillFromLock(dir: string, skillName: string): Promise<void>;
24
+ export declare function getLockedVersion(dir: string, skillName: string): Promise<string | null>;
@@ -0,0 +1,99 @@
1
+ import { readFile, writeFile } from 'node:fs/promises';
2
+ import { join, resolve, isAbsolute } from 'node:path';
3
+ const GOODBOY_JSON = 'goodboy.json';
4
+ const GOODBOY_LOCK = 'goodboy.lock';
5
+ function safeDir(dir) {
6
+ const resolved = resolve(dir);
7
+ /* c8 ignore next 3 */
8
+ if (!isAbsolute(resolved)) {
9
+ throw new Error(`Invalid directory path: "${dir}"`);
10
+ }
11
+ return resolved;
12
+ }
13
+ export async function readGoodBoyJson(dir) {
14
+ const filePath = join(safeDir(dir), GOODBOY_JSON);
15
+ try {
16
+ const raw = await readFile(filePath, 'utf-8');
17
+ let parsed;
18
+ try {
19
+ parsed = JSON.parse(raw);
20
+ }
21
+ catch {
22
+ throw new Error(`goodboy.json contains invalid JSON`);
23
+ }
24
+ const data = parsed;
25
+ if (data['schema'] !== '1.0.0') {
26
+ throw new Error(`goodboy.json has unsupported schema version: "${String(data['schema'])}"`);
27
+ }
28
+ return data;
29
+ }
30
+ catch (err) {
31
+ if (err.code === 'ENOENT')
32
+ return null;
33
+ throw err;
34
+ }
35
+ }
36
+ export async function writeGoodBoyJson(dir, data) {
37
+ const filePath = join(safeDir(dir), GOODBOY_JSON);
38
+ await writeFile(filePath, JSON.stringify(data, null, 2) + '\n', 'utf-8');
39
+ }
40
+ export async function readGoodBoyLock(dir) {
41
+ const filePath = join(safeDir(dir), GOODBOY_LOCK);
42
+ try {
43
+ const raw = await readFile(filePath, 'utf-8');
44
+ let parsed;
45
+ try {
46
+ parsed = JSON.parse(raw);
47
+ }
48
+ catch {
49
+ throw new Error(`goodboy.lock contains invalid JSON`);
50
+ }
51
+ return parsed;
52
+ }
53
+ catch (err) {
54
+ if (err.code === 'ENOENT')
55
+ return null;
56
+ throw err;
57
+ }
58
+ }
59
+ export async function writeGoodBoyLock(dir, data) {
60
+ const filePath = join(safeDir(dir), GOODBOY_LOCK);
61
+ const updated = { ...data, generated: new Date().toISOString() };
62
+ await writeFile(filePath, JSON.stringify(updated, null, 2) + '\n', 'utf-8');
63
+ }
64
+ export async function addSkillToManifest(dir, skillName, version) {
65
+ const existing = await readGoodBoyJson(dir);
66
+ const manifest = existing ?? { schema: '1.0.0', skills: {} };
67
+ manifest.skills[skillName] = `^${version}`;
68
+ await writeGoodBoyJson(dir, manifest);
69
+ }
70
+ export async function addSkillToLock(dir, skillName, version, resolvedPath) {
71
+ const existing = await readGoodBoyLock(dir);
72
+ const lock = existing ?? {
73
+ schema: '1.0.0',
74
+ generated: new Date().toISOString(),
75
+ skills: {},
76
+ };
77
+ lock.skills[skillName] = { version, resolved: resolvedPath };
78
+ await writeGoodBoyLock(dir, lock);
79
+ }
80
+ export async function removeSkillFromManifest(dir, skillName) {
81
+ const existing = await readGoodBoyJson(dir);
82
+ if (!existing)
83
+ return;
84
+ delete existing.skills[skillName];
85
+ await writeGoodBoyJson(dir, existing);
86
+ }
87
+ export async function removeSkillFromLock(dir, skillName) {
88
+ const existing = await readGoodBoyLock(dir);
89
+ if (!existing)
90
+ return;
91
+ delete existing.skills[skillName];
92
+ await writeGoodBoyLock(dir, existing);
93
+ }
94
+ export async function getLockedVersion(dir, skillName) {
95
+ const lock = await readGoodBoyLock(dir);
96
+ if (!lock)
97
+ return null;
98
+ return lock.skills[skillName]?.version ?? null;
99
+ }