@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,18 @@
|
|
|
1
|
+
import type { RegistryAdapter } from './registry-adapter.js';
|
|
2
|
+
import type { RegistryEntry } from './registry-entry.js';
|
|
3
|
+
import type { GoodBoyManifest } from '../types/index.js';
|
|
4
|
+
/**
|
|
5
|
+
* Phase 1 implementation of RegistryAdapter.
|
|
6
|
+
* Resolves skills from a local git-based registry.
|
|
7
|
+
* Configured via GOODBOY_REGISTRY environment variable.
|
|
8
|
+
* Replace with RemoteRegistryAdapter in Phase 3.
|
|
9
|
+
*/
|
|
10
|
+
export declare class LocalRegistryAdapter implements RegistryAdapter {
|
|
11
|
+
resolveSkill(name: string): Promise<string>;
|
|
12
|
+
listInstalled(): Promise<GoodBoyManifest[]>;
|
|
13
|
+
listRegistry(): Promise<RegistryEntry[]>;
|
|
14
|
+
search(query: string): Promise<GoodBoyManifest[]>;
|
|
15
|
+
getRegistryLocation(): string;
|
|
16
|
+
getSkillsLocation(): string;
|
|
17
|
+
private matchesQuery;
|
|
18
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { getRegistryPath, getSkillsPath, resolveSkill, listInstalled, listRegistry, } from './registry.js';
|
|
3
|
+
import { resolveLatestVersion, resolveVersionPath } from './registry-entry.js';
|
|
4
|
+
import { readManifest, validateManifest } from './manifest.js';
|
|
5
|
+
import { logger } from './logger.js';
|
|
6
|
+
/**
|
|
7
|
+
* Phase 1 implementation of RegistryAdapter.
|
|
8
|
+
* Resolves skills from a local git-based registry.
|
|
9
|
+
* Configured via GOODBOY_REGISTRY environment variable.
|
|
10
|
+
* Replace with RemoteRegistryAdapter in Phase 3.
|
|
11
|
+
*/
|
|
12
|
+
export class LocalRegistryAdapter {
|
|
13
|
+
resolveSkill(name) {
|
|
14
|
+
return resolveSkill(name);
|
|
15
|
+
}
|
|
16
|
+
listInstalled() {
|
|
17
|
+
return listInstalled();
|
|
18
|
+
}
|
|
19
|
+
listRegistry() {
|
|
20
|
+
return listRegistry();
|
|
21
|
+
}
|
|
22
|
+
async search(query) {
|
|
23
|
+
let entries;
|
|
24
|
+
let registryPath;
|
|
25
|
+
try {
|
|
26
|
+
entries = await listRegistry();
|
|
27
|
+
registryPath = getRegistryPath();
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return [];
|
|
31
|
+
}
|
|
32
|
+
if (entries.length === 0)
|
|
33
|
+
return [];
|
|
34
|
+
const queryLower = query.toLowerCase();
|
|
35
|
+
const results = [];
|
|
36
|
+
for (const entry of entries) {
|
|
37
|
+
const latestVersion = resolveLatestVersion(entry);
|
|
38
|
+
if (!latestVersion)
|
|
39
|
+
continue;
|
|
40
|
+
const skillDir = join(registryPath, entry.name);
|
|
41
|
+
const versionPath = resolveVersionPath(entry, latestVersion, skillDir);
|
|
42
|
+
const manifestPath = join(versionPath, 'manifest.json');
|
|
43
|
+
try {
|
|
44
|
+
const data = await readManifest(manifestPath);
|
|
45
|
+
const manifest = validateManifest(data);
|
|
46
|
+
if (this.matchesQuery(manifest, queryLower)) {
|
|
47
|
+
results.push(manifest);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
logger.warn(`Skipping "${entry.name}": ${err instanceof Error ? err.message : 'invalid manifest'}`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return results;
|
|
55
|
+
}
|
|
56
|
+
getRegistryLocation() {
|
|
57
|
+
return getRegistryPath();
|
|
58
|
+
}
|
|
59
|
+
getSkillsLocation() {
|
|
60
|
+
return getSkillsPath();
|
|
61
|
+
}
|
|
62
|
+
matchesQuery(skill, queryLower) {
|
|
63
|
+
return (skill.name.toLowerCase().includes(queryLower) ||
|
|
64
|
+
skill.description.toLowerCase().includes(queryLower) ||
|
|
65
|
+
(Array.isArray(skill.keywords) &&
|
|
66
|
+
skill.keywords.some((kw) => kw.toLowerCase().includes(queryLower))) ||
|
|
67
|
+
(Array.isArray(skill.tags) &&
|
|
68
|
+
skill.tags.some((t) => t.toLowerCase().includes(queryLower))) ||
|
|
69
|
+
(skill.category !== undefined && skill.category.toLowerCase().includes(queryLower)));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
export const logger = {
|
|
4
|
+
info(msg) {
|
|
5
|
+
process.stdout.write(chalk.gray(msg) + '\n');
|
|
6
|
+
},
|
|
7
|
+
success(msg) {
|
|
8
|
+
process.stderr.write(chalk.green(`✓ ${msg}`) + '\n');
|
|
9
|
+
},
|
|
10
|
+
warn(msg) {
|
|
11
|
+
process.stderr.write(chalk.yellow(`⚠ ${msg}`) + '\n');
|
|
12
|
+
},
|
|
13
|
+
error(msg) {
|
|
14
|
+
process.stderr.write(chalk.red(`✗ ${msg}`) + '\n');
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
function redactHomePath(message) {
|
|
18
|
+
return message.replace(homedir(), '~');
|
|
19
|
+
}
|
|
20
|
+
export function sanitiseError(error) {
|
|
21
|
+
if (error instanceof Error) {
|
|
22
|
+
return redactHomePath(error.message);
|
|
23
|
+
}
|
|
24
|
+
if (typeof error === 'string') {
|
|
25
|
+
return redactHomePath(error);
|
|
26
|
+
}
|
|
27
|
+
return 'An unexpected error occurred';
|
|
28
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { GoodBoyManifest } from '../types/index.js';
|
|
2
|
+
export declare function readManifest(filePath: string): Promise<unknown>;
|
|
3
|
+
export declare function validateManifest(data: unknown): GoodBoyManifest;
|
|
4
|
+
export declare function writeManifest(filePath: string, data: GoodBoyManifest): Promise<void>;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
import { statSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
import { Ajv } from 'ajv';
|
|
5
|
+
import * as addFormatsPkg from 'ajv-formats';
|
|
6
|
+
const addFormats = addFormatsPkg.default;
|
|
7
|
+
const _require = createRequire(import.meta.url);
|
|
8
|
+
const MAX_MANIFEST_BYTES = 512 * 1024; // 512 KB
|
|
9
|
+
const MAX_NESTING_DEPTH = 10;
|
|
10
|
+
let _validator = null;
|
|
11
|
+
function getValidator() {
|
|
12
|
+
if (_validator)
|
|
13
|
+
return _validator;
|
|
14
|
+
const ajv = new Ajv({ strict: true, allErrors: true });
|
|
15
|
+
addFormats(ajv);
|
|
16
|
+
const schema = _require('@goodboyjs/schema/src/manifest.schema.json');
|
|
17
|
+
_validator = ajv.compile(schema);
|
|
18
|
+
return _validator;
|
|
19
|
+
}
|
|
20
|
+
// Heuristic nesting depth check: counts opening brackets/braces.
|
|
21
|
+
// This is intentionally fast and runs before JSON.parse() to guard against
|
|
22
|
+
// deeply nested payloads that could exhaust the stack. Brackets inside
|
|
23
|
+
// string values inflate the count slightly but legitimate manifests are
|
|
24
|
+
// well within the limit.
|
|
25
|
+
function estimateNestingDepth(jsonString) {
|
|
26
|
+
let depth = 0;
|
|
27
|
+
let maxDepth = 0;
|
|
28
|
+
for (const ch of jsonString) {
|
|
29
|
+
if (ch === '{' || ch === '[') {
|
|
30
|
+
depth++;
|
|
31
|
+
if (depth > maxDepth)
|
|
32
|
+
maxDepth = depth;
|
|
33
|
+
}
|
|
34
|
+
else if (ch === '}' || ch === ']') {
|
|
35
|
+
depth--;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return maxDepth;
|
|
39
|
+
}
|
|
40
|
+
export async function readManifest(filePath) {
|
|
41
|
+
const resolved = resolve(filePath);
|
|
42
|
+
let size;
|
|
43
|
+
try {
|
|
44
|
+
size = statSync(resolved).size;
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
throw new Error(`manifest.json not found`);
|
|
48
|
+
}
|
|
49
|
+
if (size > MAX_MANIFEST_BYTES) {
|
|
50
|
+
throw new Error(`manifest.json exceeds the 512 KB size limit`);
|
|
51
|
+
}
|
|
52
|
+
let raw;
|
|
53
|
+
try {
|
|
54
|
+
raw = readFileSync(resolved, 'utf-8');
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
throw new Error(`Cannot read manifest.json: permission denied`);
|
|
58
|
+
}
|
|
59
|
+
if (estimateNestingDepth(raw) > MAX_NESTING_DEPTH) {
|
|
60
|
+
throw new Error(`Manifest structure is invalid: nesting depth exceeds maximum allowed (${MAX_NESTING_DEPTH})`);
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
return JSON.parse(raw);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
throw new Error(`manifest.json contains invalid JSON`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
export function validateManifest(data) {
|
|
70
|
+
const validate = getValidator();
|
|
71
|
+
if (!validate(data)) {
|
|
72
|
+
/* c8 ignore next 2 -- ajv always populates errors[] after a failed validate(); ?? fallbacks are unreachable */
|
|
73
|
+
const lines = (validate.errors ?? []).map((e) => ` ${e.instancePath || '(root)'}: ${e.message ?? 'validation failed'}`);
|
|
74
|
+
throw new Error(`Invalid manifest:\n${lines.join('\n')}`);
|
|
75
|
+
}
|
|
76
|
+
return data;
|
|
77
|
+
}
|
|
78
|
+
export async function writeManifest(filePath, data) {
|
|
79
|
+
const resolved = resolve(filePath);
|
|
80
|
+
try {
|
|
81
|
+
writeFileSync(resolved, JSON.stringify(data, null, 2) + '\n', 'utf-8');
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
throw new Error(`Cannot write manifest.json: check directory permissions`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { GoodBoyManifest } from '../types/index.js';
|
|
2
|
+
import type { RegistryEntry } from './registry-entry.js';
|
|
3
|
+
/**
|
|
4
|
+
* RegistryAdapter defines the contract between GoodBoy commands
|
|
5
|
+
* and any registry implementation.
|
|
6
|
+
*
|
|
7
|
+
* Phase 1: implemented by LocalRegistryAdapter (git-based)
|
|
8
|
+
* Phase 3: implemented by RemoteRegistryAdapter (@goodboyjs/registry-client)
|
|
9
|
+
*
|
|
10
|
+
* Commands must only import this interface — never a concrete adapter.
|
|
11
|
+
* This ensures the Phase 3 swap requires zero changes to command code.
|
|
12
|
+
*/
|
|
13
|
+
export interface RegistryAdapter {
|
|
14
|
+
/**
|
|
15
|
+
* Resolve a skill by name and return its filesystem path.
|
|
16
|
+
* Throws if the skill is not found or name is invalid.
|
|
17
|
+
*/
|
|
18
|
+
resolveSkill(name: string): Promise<string>;
|
|
19
|
+
/**
|
|
20
|
+
* Return manifests for all installed skills.
|
|
21
|
+
* Skips silently any skill with a missing or invalid manifest.
|
|
22
|
+
*/
|
|
23
|
+
listInstalled(): Promise<GoodBoyManifest[]>;
|
|
24
|
+
/**
|
|
25
|
+
* Search available skills by query string.
|
|
26
|
+
* Matches against name, description, and keywords.
|
|
27
|
+
* Case insensitive.
|
|
28
|
+
*/
|
|
29
|
+
search(query: string): Promise<GoodBoyManifest[]>;
|
|
30
|
+
/**
|
|
31
|
+
* Return the resolved registry path or remote base URL.
|
|
32
|
+
* Used for display and diagnostic purposes only.
|
|
33
|
+
* May throw if GOODBOY_REGISTRY is set but invalid.
|
|
34
|
+
*/
|
|
35
|
+
getRegistryLocation(): string;
|
|
36
|
+
/**
|
|
37
|
+
* Return the resolved skills installation path.
|
|
38
|
+
* Used for display and diagnostic purposes only.
|
|
39
|
+
*/
|
|
40
|
+
getSkillsLocation(): string;
|
|
41
|
+
/**
|
|
42
|
+
* Return all entries in the local registry.
|
|
43
|
+
* Returns [] if the registry does not exist or cannot be read.
|
|
44
|
+
*/
|
|
45
|
+
listRegistry(): Promise<RegistryEntry[]>;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Returns the appropriate RegistryAdapter for the current phase.
|
|
49
|
+
*
|
|
50
|
+
* Phase 1: always returns LocalRegistryAdapter.
|
|
51
|
+
* Phase 3: will inspect config or environment to decide between
|
|
52
|
+
* LocalRegistryAdapter and RemoteRegistryAdapter.
|
|
53
|
+
*
|
|
54
|
+
* Commands must use this factory — never instantiate adapters directly.
|
|
55
|
+
*/
|
|
56
|
+
export declare function createRegistryAdapter(): RegistryAdapter;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { LocalRegistryAdapter } from './local-registry-adapter.js';
|
|
2
|
+
/**
|
|
3
|
+
* Returns the appropriate RegistryAdapter for the current phase.
|
|
4
|
+
*
|
|
5
|
+
* Phase 1: always returns LocalRegistryAdapter.
|
|
6
|
+
* Phase 3: will inspect config or environment to decide between
|
|
7
|
+
* LocalRegistryAdapter and RemoteRegistryAdapter.
|
|
8
|
+
*
|
|
9
|
+
* Commands must use this factory — never instantiate adapters directly.
|
|
10
|
+
*/
|
|
11
|
+
export function createRegistryAdapter() {
|
|
12
|
+
// Phase 1: local registry only
|
|
13
|
+
// Phase 3: return RemoteRegistryAdapter when GOODBOY_REGISTRY_URL is set
|
|
14
|
+
return new LocalRegistryAdapter();
|
|
15
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export interface RegistryVersionEntry {
|
|
2
|
+
path: string;
|
|
3
|
+
addedAt: string;
|
|
4
|
+
yanked: boolean;
|
|
5
|
+
}
|
|
6
|
+
export interface RegistryEntry {
|
|
7
|
+
name: string;
|
|
8
|
+
latest: string;
|
|
9
|
+
versions: Record<string, RegistryVersionEntry>;
|
|
10
|
+
}
|
|
11
|
+
export declare function readRegistryEntry(skillDir: string): Promise<RegistryEntry | null>;
|
|
12
|
+
export declare function writeRegistryEntry(skillDir: string, entry: RegistryEntry): Promise<void>;
|
|
13
|
+
export declare function createRegistryEntry(skillName: string, version: string, versionPath: string): RegistryEntry;
|
|
14
|
+
export declare function addVersionToEntry(entry: RegistryEntry, version: string, versionPath: string): RegistryEntry;
|
|
15
|
+
export declare function resolveLatestVersion(entry: RegistryEntry): string | null;
|
|
16
|
+
export declare function resolveVersionPath(entry: RegistryEntry, version: string, registrySkillDir: string): string;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Internal module — registry-entry.json read/write and version resolution.
|
|
3
|
+
* @internal
|
|
4
|
+
*/
|
|
5
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
const ENTRY_FILE = 'registry-entry.json';
|
|
8
|
+
export async function readRegistryEntry(skillDir) {
|
|
9
|
+
const entryPath = join(skillDir, ENTRY_FILE);
|
|
10
|
+
try {
|
|
11
|
+
const raw = await readFile(entryPath, 'utf-8');
|
|
12
|
+
return JSON.parse(raw);
|
|
13
|
+
}
|
|
14
|
+
catch (err) {
|
|
15
|
+
if (err.code === 'ENOENT')
|
|
16
|
+
return null;
|
|
17
|
+
throw err;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export async function writeRegistryEntry(skillDir, entry) {
|
|
21
|
+
const entryPath = join(skillDir, ENTRY_FILE);
|
|
22
|
+
await writeFile(entryPath, JSON.stringify(entry, null, 2) + '\n', 'utf-8');
|
|
23
|
+
}
|
|
24
|
+
export function createRegistryEntry(skillName, version, versionPath) {
|
|
25
|
+
return {
|
|
26
|
+
name: skillName,
|
|
27
|
+
latest: version,
|
|
28
|
+
versions: {
|
|
29
|
+
[version]: {
|
|
30
|
+
path: versionPath,
|
|
31
|
+
addedAt: new Date().toISOString(),
|
|
32
|
+
yanked: false,
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
export function addVersionToEntry(entry, version, versionPath) {
|
|
38
|
+
return {
|
|
39
|
+
...entry,
|
|
40
|
+
latest: version,
|
|
41
|
+
versions: {
|
|
42
|
+
...entry.versions,
|
|
43
|
+
[version]: {
|
|
44
|
+
path: versionPath,
|
|
45
|
+
addedAt: new Date().toISOString(),
|
|
46
|
+
yanked: false,
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
export function resolveLatestVersion(entry) {
|
|
52
|
+
const versions = Object.keys(entry.versions).sort((a, b) => {
|
|
53
|
+
const [aMaj = 0, aMin = 0, aPat = 0] = a.split('.').map(Number);
|
|
54
|
+
const [bMaj = 0, bMin = 0, bPat = 0] = b.split('.').map(Number);
|
|
55
|
+
/* c8 ignore next — equal versions are unreachable (Record keys are unique) */
|
|
56
|
+
return (bMaj - aMaj) || (bMin - aMin) || (bPat - aPat);
|
|
57
|
+
});
|
|
58
|
+
for (const ver of versions) {
|
|
59
|
+
if (!entry.versions[ver].yanked)
|
|
60
|
+
return ver;
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
export function resolveVersionPath(entry, version, registrySkillDir) {
|
|
65
|
+
const versionEntry = entry.versions[version];
|
|
66
|
+
if (!versionEntry) {
|
|
67
|
+
throw new Error(`Version "${version}" not found in registry entry for "${entry.name}"`);
|
|
68
|
+
}
|
|
69
|
+
const { path: vp } = versionEntry;
|
|
70
|
+
if (vp.startsWith('https://') || vp.startsWith('http://')) {
|
|
71
|
+
return vp;
|
|
72
|
+
}
|
|
73
|
+
return join(registrySkillDir, vp);
|
|
74
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { RegistryEntry } from './registry-entry.js';
|
|
2
|
+
import type { GoodBoyManifest } from '../types/index.js';
|
|
3
|
+
export declare function getRegistryPath(): string;
|
|
4
|
+
export declare function getSkillsPath(): string;
|
|
5
|
+
export declare function ensureRegistryExists(): void;
|
|
6
|
+
export declare function resolveSkill(name: string, version?: string): Promise<string>;
|
|
7
|
+
export declare function listRegistry(): Promise<RegistryEntry[]>;
|
|
8
|
+
export declare function listInstalled(): Promise<GoodBoyManifest[]>;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Internal module — do not import directly from command files.
|
|
3
|
+
* Use RegistryAdapter via createRegistryAdapter() instead.
|
|
4
|
+
* @internal
|
|
5
|
+
*/
|
|
6
|
+
import { existsSync, mkdirSync, readdirSync } from 'node:fs';
|
|
7
|
+
import { join, resolve, isAbsolute, sep } from 'node:path';
|
|
8
|
+
import { homedir } from 'node:os';
|
|
9
|
+
import { readManifest, validateManifest } from './manifest.js';
|
|
10
|
+
import { readRegistryEntry, resolveLatestVersion, resolveVersionPath, } from './registry-entry.js';
|
|
11
|
+
import { logger } from './logger.js';
|
|
12
|
+
import { SKILL_NAME_RE } from './validation.js';
|
|
13
|
+
export function getRegistryPath() {
|
|
14
|
+
const env = process.env['GOODBOY_REGISTRY'];
|
|
15
|
+
if (env !== undefined && env.length > 0) {
|
|
16
|
+
// Reject traversal sequences before any filesystem call
|
|
17
|
+
if (env.includes('..')) {
|
|
18
|
+
throw new Error(`GOODBOY_REGISTRY must not contain path traversal sequences`);
|
|
19
|
+
}
|
|
20
|
+
if (!isAbsolute(env)) {
|
|
21
|
+
throw new Error(`GOODBOY_REGISTRY must be an absolute path`);
|
|
22
|
+
}
|
|
23
|
+
if (!existsSync(env)) {
|
|
24
|
+
logger.warn(`GOODBOY_REGISTRY path does not exist: "${env}". Falling back to default.`);
|
|
25
|
+
return join(homedir(), '.goodboy', 'registry');
|
|
26
|
+
}
|
|
27
|
+
return resolve(env);
|
|
28
|
+
}
|
|
29
|
+
return join(homedir(), '.goodboy', 'registry');
|
|
30
|
+
}
|
|
31
|
+
export function getSkillsPath() {
|
|
32
|
+
return join(homedir(), '.goodboy', 'skills');
|
|
33
|
+
}
|
|
34
|
+
export function ensureRegistryExists() {
|
|
35
|
+
const registryPath = getRegistryPath();
|
|
36
|
+
if (!existsSync(registryPath)) {
|
|
37
|
+
mkdirSync(registryPath, { recursive: true, mode: 0o700 });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export async function resolveSkill(name, version) {
|
|
41
|
+
// Normalize to detect URL-encoded traversal (e.g. ..%2F) and null bytes
|
|
42
|
+
// before any filesystem operation.
|
|
43
|
+
const nullStripped = name.replace(/\0/g, '');
|
|
44
|
+
let decoded;
|
|
45
|
+
try {
|
|
46
|
+
decoded = decodeURIComponent(nullStripped);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
decoded = nullStripped;
|
|
50
|
+
}
|
|
51
|
+
const normalized = decoded.trim();
|
|
52
|
+
if (normalized !== name) {
|
|
53
|
+
throw new Error(`Skill name contains invalid characters`);
|
|
54
|
+
}
|
|
55
|
+
if (!SKILL_NAME_RE.test(name)) {
|
|
56
|
+
throw new Error(`Invalid skill name "${name}": must match ^[a-z0-9-]+$`);
|
|
57
|
+
}
|
|
58
|
+
const registryPath = getRegistryPath();
|
|
59
|
+
const skillDir = resolve(join(registryPath, name));
|
|
60
|
+
// Traversal guard: resolved path must be directly inside the registry dir.
|
|
61
|
+
const expectedPrefix = registryPath + sep;
|
|
62
|
+
/* c8 ignore next 3 — defense-in-depth: SKILL_NAME_RE blocks all traversal chars, this is unreachable through the public API */
|
|
63
|
+
if (!skillDir.startsWith(expectedPrefix) || !resolve(skillDir).startsWith(expectedPrefix)) {
|
|
64
|
+
throw new Error(`Refused: resolved skill path escapes the registry directory`);
|
|
65
|
+
}
|
|
66
|
+
const entry = await readRegistryEntry(skillDir);
|
|
67
|
+
if (!entry) {
|
|
68
|
+
throw new Error(`Skill "${name}" not found in registry`);
|
|
69
|
+
}
|
|
70
|
+
const resolvedVersion = version ?? resolveLatestVersion(entry);
|
|
71
|
+
if (!resolvedVersion) {
|
|
72
|
+
throw new Error(`Skill "${name}" has no available versions`);
|
|
73
|
+
}
|
|
74
|
+
return resolveVersionPath(entry, resolvedVersion, skillDir);
|
|
75
|
+
}
|
|
76
|
+
export async function listRegistry() {
|
|
77
|
+
const registryPath = getRegistryPath();
|
|
78
|
+
if (!existsSync(registryPath))
|
|
79
|
+
return [];
|
|
80
|
+
const dirEntries = readdirSync(registryPath, { withFileTypes: true });
|
|
81
|
+
const results = [];
|
|
82
|
+
for (const dirEntry of dirEntries) {
|
|
83
|
+
if (!dirEntry.isDirectory())
|
|
84
|
+
continue;
|
|
85
|
+
const skillDir = join(registryPath, dirEntry.name);
|
|
86
|
+
const entry = await readRegistryEntry(skillDir);
|
|
87
|
+
if (entry)
|
|
88
|
+
results.push(entry);
|
|
89
|
+
}
|
|
90
|
+
return results;
|
|
91
|
+
}
|
|
92
|
+
export async function listInstalled() {
|
|
93
|
+
const skillsPath = getSkillsPath();
|
|
94
|
+
if (!existsSync(skillsPath)) {
|
|
95
|
+
// 0o700: skills are user-private, no group/world read
|
|
96
|
+
mkdirSync(skillsPath, { recursive: true, mode: 0o700 });
|
|
97
|
+
return [];
|
|
98
|
+
}
|
|
99
|
+
const entries = readdirSync(skillsPath, { withFileTypes: true });
|
|
100
|
+
const manifests = [];
|
|
101
|
+
for (const entry of entries) {
|
|
102
|
+
if (!entry.isDirectory())
|
|
103
|
+
continue;
|
|
104
|
+
const manifestPath = join(skillsPath, entry.name, 'manifest.json');
|
|
105
|
+
try {
|
|
106
|
+
const data = await readManifest(manifestPath);
|
|
107
|
+
manifests.push(validateManifest(data));
|
|
108
|
+
}
|
|
109
|
+
catch (err) {
|
|
110
|
+
logger.warn(`Skipping "${entry.name}": ${err instanceof Error ? err.message : 'invalid manifest'}`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return manifests;
|
|
114
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export type ValidationSeverity = 'error' | 'warning';
|
|
2
|
+
export interface ValidationIssue {
|
|
3
|
+
severity: ValidationSeverity;
|
|
4
|
+
message: string;
|
|
5
|
+
}
|
|
6
|
+
export interface ValidationResult {
|
|
7
|
+
valid: boolean;
|
|
8
|
+
issues: ValidationIssue[];
|
|
9
|
+
}
|
|
10
|
+
export declare function validateSkillDirectory(skillPath: string): Promise<ValidationResult>;
|
|
11
|
+
export declare function formatValidationResult(result: ValidationResult, skillName: string): void;
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { readManifest, validateManifest } from './manifest.js';
|
|
5
|
+
import { logger } from './logger.js';
|
|
6
|
+
function parseFrontmatter(content) {
|
|
7
|
+
const lines = content.split('\n');
|
|
8
|
+
if (lines[0]?.trim() !== '---') {
|
|
9
|
+
return { hasDelimiters: false, hasClosingDelimiter: false, body: content };
|
|
10
|
+
}
|
|
11
|
+
const closeIdx = lines.findIndex((l, i) => i > 0 && l.trim() === '---');
|
|
12
|
+
if (closeIdx === -1) {
|
|
13
|
+
return { hasDelimiters: true, hasClosingDelimiter: false, body: '' };
|
|
14
|
+
}
|
|
15
|
+
const frontmatterLines = lines.slice(1, closeIdx);
|
|
16
|
+
const body = lines.slice(closeIdx + 1).join('\n').trim();
|
|
17
|
+
const result = {};
|
|
18
|
+
for (const line of frontmatterLines) {
|
|
19
|
+
const colonIdx = line.indexOf(':');
|
|
20
|
+
if (colonIdx === -1)
|
|
21
|
+
continue;
|
|
22
|
+
const key = line.slice(0, colonIdx).trim();
|
|
23
|
+
const value = line.slice(colonIdx + 1).trim().replace(/^["']|["']$/g, '');
|
|
24
|
+
if (key === 'name')
|
|
25
|
+
result.name = value;
|
|
26
|
+
if (key === 'description')
|
|
27
|
+
result.description = value;
|
|
28
|
+
}
|
|
29
|
+
return { ...result, hasDelimiters: true, hasClosingDelimiter: true, body };
|
|
30
|
+
}
|
|
31
|
+
export async function validateSkillDirectory(skillPath) {
|
|
32
|
+
const issues = [];
|
|
33
|
+
// --- manifest.json checks ---
|
|
34
|
+
const manifestPath = join(skillPath, 'manifest.json');
|
|
35
|
+
let manifest = null;
|
|
36
|
+
if (!existsSync(manifestPath)) {
|
|
37
|
+
issues.push({ severity: 'error', message: 'manifest.json not found' });
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
try {
|
|
41
|
+
const raw = await readManifest(manifestPath);
|
|
42
|
+
try {
|
|
43
|
+
manifest = validateManifest(raw);
|
|
44
|
+
}
|
|
45
|
+
catch (err) {
|
|
46
|
+
/* c8 ignore next */
|
|
47
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
48
|
+
issues.push({ severity: 'error', message: `manifest.json fails schema validation: ${msg}` });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
catch (err) {
|
|
52
|
+
/* c8 ignore next */
|
|
53
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
54
|
+
issues.push({ severity: 'error', message: `manifest.json is not valid JSON: ${msg}` });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
// manifest field warnings (only if manifest was valid)
|
|
58
|
+
if (manifest) {
|
|
59
|
+
if (manifest.description.length < 20) {
|
|
60
|
+
issues.push({ severity: 'warning', message: 'manifest description is very short (< 20 characters)' });
|
|
61
|
+
}
|
|
62
|
+
if (!manifest.keywords || manifest.keywords.length === 0) {
|
|
63
|
+
issues.push({ severity: 'warning', message: 'manifest has no keywords' });
|
|
64
|
+
}
|
|
65
|
+
if (!manifest.category) {
|
|
66
|
+
issues.push({ severity: 'warning', message: 'manifest has no category' });
|
|
67
|
+
}
|
|
68
|
+
if (!manifest.tags || manifest.tags.length === 0) {
|
|
69
|
+
issues.push({ severity: 'warning', message: 'manifest has no tags' });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
// --- SKILL.md checks ---
|
|
73
|
+
const skillMdPath = join(skillPath, 'SKILL.md');
|
|
74
|
+
if (!existsSync(skillMdPath)) {
|
|
75
|
+
issues.push({ severity: 'error', message: 'SKILL.md not found' });
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
const content = await readFile(skillMdPath, 'utf-8');
|
|
79
|
+
const fm = parseFrontmatter(content);
|
|
80
|
+
if (!fm.hasDelimiters) {
|
|
81
|
+
issues.push({ severity: 'error', message: 'SKILL.md has no frontmatter (missing opening --- delimiter)' });
|
|
82
|
+
}
|
|
83
|
+
else if (!fm.hasClosingDelimiter) {
|
|
84
|
+
issues.push({ severity: 'error', message: 'SKILL.md frontmatter is not closed (missing closing --- delimiter)' });
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
if (!fm.name) {
|
|
88
|
+
issues.push({ severity: 'error', message: 'SKILL.md frontmatter is missing the name field' });
|
|
89
|
+
}
|
|
90
|
+
if (!fm.description) {
|
|
91
|
+
issues.push({ severity: 'error', message: 'SKILL.md frontmatter is missing the description field' });
|
|
92
|
+
}
|
|
93
|
+
if (manifest && fm.name && fm.name !== manifest.name) {
|
|
94
|
+
issues.push({
|
|
95
|
+
severity: 'error',
|
|
96
|
+
message: `SKILL.md frontmatter name "${fm.name}" does not match manifest.json name "${manifest.name}"`,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
if (fm.body.length < 50) {
|
|
100
|
+
issues.push({ severity: 'warning', message: 'SKILL.md body is empty or very short (< 50 characters)' });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const valid = issues.every((i) => i.severity !== 'error');
|
|
105
|
+
return { valid, issues };
|
|
106
|
+
}
|
|
107
|
+
export function formatValidationResult(result, skillName) {
|
|
108
|
+
const errors = result.issues.filter((i) => i.severity === 'error');
|
|
109
|
+
const warnings = result.issues.filter((i) => i.severity === 'warning');
|
|
110
|
+
if (errors.length > 0) {
|
|
111
|
+
logger.error(`Validation errors for "${skillName}":`);
|
|
112
|
+
for (const issue of errors) {
|
|
113
|
+
logger.error(` • ${issue.message}`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (warnings.length > 0) {
|
|
117
|
+
logger.warn(`Validation warnings for "${skillName}":`);
|
|
118
|
+
for (const issue of warnings) {
|
|
119
|
+
logger.warn(` • ${issue.message}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export declare function getGoodboyHome(): string;
|
|
2
|
+
export declare function getStorePath(): string;
|
|
3
|
+
export declare function ensureStoreExists(): void;
|
|
4
|
+
/**
|
|
5
|
+
* Copy a skill from `sourcePath` into the store.
|
|
6
|
+
* Returns the absolute path of the installed store entry.
|
|
7
|
+
*/
|
|
8
|
+
export declare function installToStore(skillName: string, sourcePath: string): Promise<string>;
|
|
9
|
+
/**
|
|
10
|
+
* Remove a skill from the store.
|
|
11
|
+
* No-op if the skill is not present.
|
|
12
|
+
*/
|
|
13
|
+
export declare function removeFromStore(skillName: string): void;
|