Skip to content

Commit 84cae5a

Browse files
committed
Merge task/t6-skills
2 parents d2bd2ae + 6064fbb commit 84cae5a

3 files changed

Lines changed: 289 additions & 6 deletions

File tree

src/commands/skills.ts

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,47 @@
11
import { Command } from "commander";
2-
import { notImplemented } from "./notImplemented";
2+
import { addSkill, listSkills } from "../skills/registry.js";
3+
4+
const toErrorMessage = (error: unknown): string =>
5+
error instanceof Error ? error.message : String(error);
36

47
export const registerSkillsCommand = (program: Command) => {
5-
program
6-
.command("skills")
7-
.description("Skill management")
8-
.action(() => {
9-
notImplemented("skills");
8+
const skills = program.command("skills").description("Skill management");
9+
10+
skills
11+
.command("list")
12+
.description("List installed skills")
13+
.action(async () => {
14+
try {
15+
const registry = await listSkills();
16+
if (registry.length === 0) {
17+
console.log("No skills installed.");
18+
return;
19+
}
20+
21+
for (const skill of registry) {
22+
console.log(`${skill.name}@${skill.version}`);
23+
console.log(` ${skill.description}`);
24+
console.log(` path: ${skill.root}`);
25+
}
26+
} catch (error) {
27+
console.error(`Failed to list skills: ${toErrorMessage(error)}`);
28+
process.exitCode = 1;
29+
}
30+
});
31+
32+
skills
33+
.command("add")
34+
.description("Add a skill")
35+
.argument("<path>", "Path to a skill directory or design-skill.json")
36+
.action(async (inputPath: string) => {
37+
try {
38+
const { skill, replaced } = await addSkill(inputPath);
39+
const verb = replaced ? "Updated" : "Added";
40+
console.log(`${verb} ${skill.name}@${skill.version}`);
41+
console.log(` path: ${skill.root}`);
42+
} catch (error) {
43+
console.error(`Failed to add skill: ${toErrorMessage(error)}`);
44+
process.exitCode = 1;
45+
}
1046
});
1147
};

src/skills/registry.ts

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import fs from "node:fs/promises";
2+
import os from "node:os";
3+
import path from "node:path";
4+
import { parseSkillManifest, type SkillManifest } from "./schema.js";
5+
6+
export const SKILLS_DIR = path.join(os.homedir(), ".dws", "skills");
7+
export const SKILLS_REGISTRY_PATH = path.join(SKILLS_DIR, "registry.json");
8+
export const SKILL_MANIFEST_NAME = "design-skill.json";
9+
10+
export type RegisteredSkill = SkillManifest & {
11+
root: string;
12+
manifestPath: string;
13+
addedAt: string;
14+
};
15+
16+
const toErrorMessage = (error: unknown): string =>
17+
error instanceof Error ? error.message : String(error);
18+
19+
const isErrnoException = (error: unknown): error is NodeJS.ErrnoException =>
20+
typeof error === "object" && error !== null && "code" in error;
21+
22+
const resolveManifestPath = async (inputPath: string): Promise<string> => {
23+
const resolved = path.resolve(inputPath);
24+
let stats;
25+
try {
26+
stats = await fs.stat(resolved);
27+
} catch (error) {
28+
if (isErrnoException(error) && error.code === "ENOENT") {
29+
throw new Error(`Path not found: ${resolved}`);
30+
}
31+
throw new Error(`Failed to access path: ${toErrorMessage(error)}`);
32+
}
33+
34+
if (stats.isDirectory()) {
35+
const manifestPath = path.join(resolved, SKILL_MANIFEST_NAME);
36+
try {
37+
const manifestStats = await fs.stat(manifestPath);
38+
if (!manifestStats.isFile()) {
39+
throw new Error(`Expected a file at ${manifestPath}`);
40+
}
41+
} catch (error) {
42+
if (isErrnoException(error) && error.code === "ENOENT") {
43+
throw new Error(`Missing ${SKILL_MANIFEST_NAME} in ${resolved}`);
44+
}
45+
throw new Error(`Failed to access manifest: ${toErrorMessage(error)}`);
46+
}
47+
return manifestPath;
48+
}
49+
50+
if (stats.isFile()) {
51+
if (path.basename(resolved) !== SKILL_MANIFEST_NAME) {
52+
throw new Error(`Expected ${SKILL_MANIFEST_NAME}, got ${path.basename(resolved)}`);
53+
}
54+
return resolved;
55+
}
56+
57+
throw new Error(`Unsupported path type: ${resolved}`);
58+
};
59+
60+
const resolveSkillPath = (root: string, target: string): string =>
61+
path.isAbsolute(target) ? target : path.resolve(root, target);
62+
63+
const ensurePathExists = async (label: string, targetPath: string): Promise<void> => {
64+
try {
65+
await fs.stat(targetPath);
66+
} catch (error) {
67+
if (isErrnoException(error) && error.code === "ENOENT") {
68+
throw new Error(`${label} not found: ${targetPath}`);
69+
}
70+
throw new Error(`Failed to access ${label}: ${toErrorMessage(error)}`);
71+
}
72+
};
73+
74+
const validateSkillFiles = async (
75+
manifest: SkillManifest,
76+
root: string,
77+
): Promise<void> => {
78+
const entryPath = resolveSkillPath(root, manifest.entry);
79+
await ensurePathExists("Entry", entryPath);
80+
81+
await Promise.all(
82+
manifest.assets.map(async (asset) => {
83+
const assetPath = resolveSkillPath(root, asset);
84+
await ensurePathExists("Asset", assetPath);
85+
}),
86+
);
87+
};
88+
89+
const readRegistry = async (): Promise<RegisteredSkill[]> => {
90+
try {
91+
const raw = await fs.readFile(SKILLS_REGISTRY_PATH, "utf8");
92+
const parsed = JSON.parse(raw);
93+
if (!Array.isArray(parsed)) {
94+
return [];
95+
}
96+
return parsed as RegisteredSkill[];
97+
} catch (error) {
98+
if (isErrnoException(error) && error.code === "ENOENT") {
99+
return [];
100+
}
101+
throw new Error(`Failed to read skill registry: ${toErrorMessage(error)}`);
102+
}
103+
};
104+
105+
const writeRegistry = async (skills: RegisteredSkill[]): Promise<void> => {
106+
await fs.mkdir(SKILLS_DIR, { recursive: true, mode: 0o700 });
107+
const payload = `${JSON.stringify(skills, null, 2)}\n`;
108+
await fs.writeFile(SKILLS_REGISTRY_PATH, payload, { mode: 0o600 });
109+
await fs.chmod(SKILLS_REGISTRY_PATH, 0o600);
110+
};
111+
112+
export const listSkills = async (): Promise<RegisteredSkill[]> => readRegistry();
113+
114+
export const addSkill = async (
115+
inputPath: string,
116+
): Promise<{ skill: RegisteredSkill; replaced: boolean }> => {
117+
const manifestPath = await resolveManifestPath(inputPath);
118+
const root = path.dirname(manifestPath);
119+
120+
let manifestRaw: string;
121+
try {
122+
manifestRaw = await fs.readFile(manifestPath, "utf8");
123+
} catch (error) {
124+
throw new Error(`Failed to read manifest: ${toErrorMessage(error)}`);
125+
}
126+
127+
let manifestJson: unknown;
128+
try {
129+
manifestJson = JSON.parse(manifestRaw);
130+
} catch (error) {
131+
throw new Error(`Invalid JSON in ${manifestPath}: ${toErrorMessage(error)}`);
132+
}
133+
134+
const manifest = parseSkillManifest(manifestJson);
135+
await validateSkillFiles(manifest, root);
136+
137+
const skills = await readRegistry();
138+
const existingIndex = skills.findIndex(
139+
(skill) => skill.name === manifest.name && skill.version === manifest.version,
140+
);
141+
142+
const skill: RegisteredSkill = {
143+
...manifest,
144+
root,
145+
manifestPath,
146+
addedAt: new Date().toISOString(),
147+
};
148+
149+
let replaced = false;
150+
if (existingIndex >= 0) {
151+
skills[existingIndex] = skill;
152+
replaced = true;
153+
} else {
154+
skills.push(skill);
155+
}
156+
157+
await writeRegistry(skills);
158+
return { skill, replaced };
159+
};

src/skills/schema.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
export type SkillManifest = {
2+
name: string;
3+
version: string;
4+
description: string;
5+
entry: string;
6+
assets: string[];
7+
defaults: Record<string, unknown>;
8+
};
9+
10+
type ValidationResult =
11+
| { ok: true; value: SkillManifest }
12+
| { ok: false; errors: string[] };
13+
14+
const isRecord = (value: unknown): value is Record<string, unknown> =>
15+
typeof value === "object" && value !== null && !Array.isArray(value);
16+
17+
const isNonEmptyString = (value: unknown): value is string =>
18+
typeof value === "string" && value.trim().length > 0;
19+
20+
export const validateSkillManifest = (value: unknown): ValidationResult => {
21+
if (!isRecord(value)) {
22+
return { ok: false, errors: ["Manifest must be an object."] };
23+
}
24+
25+
const record = value as Record<string, unknown>;
26+
const errors: string[] = [];
27+
28+
const name = isNonEmptyString(record.name) ? record.name.trim() : "";
29+
const version = isNonEmptyString(record.version) ? record.version.trim() : "";
30+
const description = isNonEmptyString(record.description)
31+
? record.description.trim()
32+
: "";
33+
const entry = isNonEmptyString(record.entry) ? record.entry.trim() : "";
34+
const assets = Array.isArray(record.assets) ? record.assets : [];
35+
const defaults = isRecord(record.defaults) ? record.defaults : {};
36+
37+
if (!isNonEmptyString(record.name)) {
38+
errors.push("Missing or invalid name.");
39+
}
40+
41+
if (!isNonEmptyString(record.version)) {
42+
errors.push("Missing or invalid version.");
43+
}
44+
45+
if (!isNonEmptyString(record.description)) {
46+
errors.push("Missing or invalid description.");
47+
}
48+
49+
if (!isNonEmptyString(record.entry)) {
50+
errors.push("Missing or invalid entry.");
51+
}
52+
53+
if (!Array.isArray(record.assets)) {
54+
errors.push("Missing assets (expected an array of strings).");
55+
} else if (record.assets.length === 0) {
56+
errors.push("Assets must include at least one file.");
57+
} else if (record.assets.some((item) => !isNonEmptyString(item))) {
58+
errors.push("Assets must be non-empty strings.");
59+
}
60+
61+
if (!isRecord(record.defaults)) {
62+
errors.push("Missing or invalid defaults (expected an object).");
63+
}
64+
65+
if (errors.length > 0) {
66+
return { ok: false, errors };
67+
}
68+
69+
return {
70+
ok: true,
71+
value: {
72+
name: name.trim(),
73+
version: version.trim(),
74+
description: description.trim(),
75+
entry: entry.trim(),
76+
assets: assets.map((item) => String(item).trim()),
77+
defaults: { ...defaults },
78+
},
79+
};
80+
};
81+
82+
export const parseSkillManifest = (value: unknown): SkillManifest => {
83+
const result = validateSkillManifest(value);
84+
if (!result.ok) {
85+
throw new Error(`Invalid design-skill.json: ${result.errors.join(" ")}`);
86+
}
87+
return result.value;
88+
};

0 commit comments

Comments
 (0)