skillshelf 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 +208 -0
- package/package.json +42 -0
- package/src/adapters/inference/agent.ts +253 -0
- package/src/adapters/inference/api.ts +309 -0
- package/src/cli.ts +127 -0
- package/src/commands/add.ts +222 -0
- package/src/commands/drop.ts +89 -0
- package/src/commands/index.ts +46 -0
- package/src/commands/infer.ts +282 -0
- package/src/commands/init.ts +124 -0
- package/src/commands/ls.ts +80 -0
- package/src/commands/new.ts +163 -0
- package/src/commands/outdated.ts +113 -0
- package/src/commands/search.ts +61 -0
- package/src/commands/show.ts +70 -0
- package/src/commands/status.ts +117 -0
- package/src/commands/update.ts +267 -0
- package/src/commands/use.ts +100 -0
- package/src/config.ts +107 -0
- package/src/core/bundle.ts +62 -0
- package/src/core/core.test.ts +68 -0
- package/src/core/crawl.ts +267 -0
- package/src/core/dedupe.ts +67 -0
- package/src/core/fetch.ts +545 -0
- package/src/core/indexgen.ts +89 -0
- package/src/core/library.ts +101 -0
- package/src/core/overlay.ts +63 -0
- package/src/core/provenance.ts +130 -0
- package/src/lib/frontmatter.test.ts +58 -0
- package/src/lib/frontmatter.ts +231 -0
- package/src/lib/fs.ts +186 -0
- package/src/types.ts +186 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// `skl drop <bundle>` — remove the symlinks that `skl use <bundle>` created in
|
|
2
|
+
// ./.claude/skills/. Only removes symlinks that actually point at this bundle's
|
|
3
|
+
// skills; never touches real files or links to unrelated skills. Idempotent.
|
|
4
|
+
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import type { Ctx } from "../types.ts";
|
|
7
|
+
import { resolveBundle } from "../core/bundle.ts";
|
|
8
|
+
import { isSymlink, realpathOrSelf, realpathOrSelfAsync, removeSymlink } from "../lib/fs.ts";
|
|
9
|
+
|
|
10
|
+
export const meta = {
|
|
11
|
+
name: "drop",
|
|
12
|
+
summary: "Remove a bundle's symlinks from ./.claude/skills/",
|
|
13
|
+
usage: "skl drop <bundle> [--json]",
|
|
14
|
+
} as const;
|
|
15
|
+
|
|
16
|
+
interface DropResult {
|
|
17
|
+
name: string;
|
|
18
|
+
link: string;
|
|
19
|
+
status: "removed" | "absent" | "skipped";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function projectSkillsDir(): string {
|
|
23
|
+
return join(process.cwd(), ".claude", "skills");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function run(argv: string[], ctx: Ctx): Promise<number> {
|
|
27
|
+
try {
|
|
28
|
+
const json = argv.includes("--json");
|
|
29
|
+
const bundleName = argv.find((a) => !a.startsWith("-"));
|
|
30
|
+
|
|
31
|
+
if (!bundleName) {
|
|
32
|
+
ctx.error("usage: " + meta.usage);
|
|
33
|
+
return 1;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Include retired so a bundle that was `use`d (which excludes retired) and a
|
|
37
|
+
// later drop stay symmetric on the active set — we match on the same active set.
|
|
38
|
+
const skills = await ctx.loadLibrary();
|
|
39
|
+
const bundle = await resolveBundle(
|
|
40
|
+
skills.filter((s) => !s.retired),
|
|
41
|
+
bundleName,
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
const skillsDir = projectSkillsDir();
|
|
45
|
+
const results: DropResult[] = [];
|
|
46
|
+
|
|
47
|
+
for (const s of bundle.skills) {
|
|
48
|
+
const link = join(skillsDir, s.name);
|
|
49
|
+
|
|
50
|
+
if (!isSymlink(link)) {
|
|
51
|
+
results.push({ name: s.name, link, status: "absent" });
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Only remove if the symlink resolves to THIS skill's path. A divergent
|
|
56
|
+
// link (pointing elsewhere) is left alone.
|
|
57
|
+
const cur = realpathOrSelf(link);
|
|
58
|
+
const want = await realpathOrSelfAsync(s.path);
|
|
59
|
+
if (cur !== want) {
|
|
60
|
+
results.push({ name: s.name, link, status: "skipped" });
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const removed = await removeSymlink(link);
|
|
65
|
+
results.push({ name: s.name, link, status: removed ? "removed" : "absent" });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const removedCount = results.filter((r) => r.status === "removed").length;
|
|
69
|
+
|
|
70
|
+
if (json) {
|
|
71
|
+
ctx.json({ bundle: bundle.name, skillsDir, results, removed: removedCount });
|
|
72
|
+
} else {
|
|
73
|
+
if (bundle.skills.length === 0) {
|
|
74
|
+
ctx.log(`Bundle '${bundleName}' has no skills; nothing to drop.`);
|
|
75
|
+
} else {
|
|
76
|
+
ctx.log(`Dropping bundle '${bundle.name}' from ${skillsDir}`);
|
|
77
|
+
for (const r of results) {
|
|
78
|
+
ctx.log(` ${r.name} [${r.status}]`);
|
|
79
|
+
}
|
|
80
|
+
ctx.log(`Removed ${removedCount} symlink(s).`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return 0;
|
|
85
|
+
} catch (err) {
|
|
86
|
+
ctx.error(`skl drop failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
87
|
+
return 1;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// `skl index` — regenerate INDEX.md (catalog grouped by primary domain) at
|
|
2
|
+
// the library root, via core/indexgen.
|
|
3
|
+
|
|
4
|
+
import type { Ctx } from "../types.ts";
|
|
5
|
+
import { writeIndex, generateIndex } from "../core/indexgen.ts";
|
|
6
|
+
|
|
7
|
+
export const meta = {
|
|
8
|
+
name: "index",
|
|
9
|
+
summary: "Regenerate INDEX.md (catalog grouped by domain)",
|
|
10
|
+
usage: "skl index [--json]",
|
|
11
|
+
} as const;
|
|
12
|
+
|
|
13
|
+
export async function run(argv: string[], ctx: Ctx): Promise<number> {
|
|
14
|
+
try {
|
|
15
|
+
const json = argv.includes("--json");
|
|
16
|
+
const skills = await ctx.loadLibrary();
|
|
17
|
+
|
|
18
|
+
const generatedAt = new Date().toISOString();
|
|
19
|
+
const path = await writeIndex(ctx.config.libraryPath, skills, {
|
|
20
|
+
generatedAt,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const active = skills.filter((s) => !s.retired).length;
|
|
24
|
+
const retired = skills.length - active;
|
|
25
|
+
|
|
26
|
+
if (json) {
|
|
27
|
+
ctx.json({
|
|
28
|
+
path,
|
|
29
|
+
skills: skills.length,
|
|
30
|
+
active,
|
|
31
|
+
retired,
|
|
32
|
+
generatedAt,
|
|
33
|
+
bytes: generateIndex(skills, { generatedAt }).length,
|
|
34
|
+
});
|
|
35
|
+
return 0;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
ctx.log(
|
|
39
|
+
`Wrote ${path} (${active} active${retired ? `, ${retired} retired` : ""}).`,
|
|
40
|
+
);
|
|
41
|
+
return 0;
|
|
42
|
+
} catch (err) {
|
|
43
|
+
ctx.error(`index failed: ${(err as Error).message}`);
|
|
44
|
+
return 1;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
// `skl infer` — re-run the domain taxonomy pass over the library.
|
|
2
|
+
//
|
|
3
|
+
// Dual-mode, LLM-FREE core:
|
|
4
|
+
// skl infer --emit print {instruction, schema, corpus} for a
|
|
5
|
+
// host agent to reason over (no LLM call here).
|
|
6
|
+
// skl infer --apply <file.json> write the agent's proposal into each skill's
|
|
7
|
+
// <name>.shelf.json overlay (never upstream).
|
|
8
|
+
// skl infer --provider openai API mode: POST the corpus to an
|
|
9
|
+
// OpenAI-compatible endpoint and apply the
|
|
10
|
+
// strict-JSON result automatically.
|
|
11
|
+
//
|
|
12
|
+
// API mode is provider-agnostic. Config resolves (highest precedence first)
|
|
13
|
+
// from CLI flags (--base-url, --model, --provider) > env vars
|
|
14
|
+
// (SKILLSHELF_LLM_BASE_URL / _API_KEY / _MODEL, then OPENAI_* fallbacks) >
|
|
15
|
+
// optional dotenv at $SKILLSHELF_ENV_FILE (default ./.env). --provider is sugar
|
|
16
|
+
// for a default base URL only (openai, openrouter, groq, ollama, custom); the
|
|
17
|
+
// API key always comes from the environment/dotenv.
|
|
18
|
+
//
|
|
19
|
+
// Auto-detect when no explicit mode/provider is given:
|
|
20
|
+
// - inside a Claude Code agent ($CLAUDECODE) -> default to --emit guidance.
|
|
21
|
+
// - no agent and no provider -> error clearly.
|
|
22
|
+
|
|
23
|
+
import type { Ctx } from "../types.ts";
|
|
24
|
+
import {
|
|
25
|
+
buildEmitPayload,
|
|
26
|
+
normalizeProposal,
|
|
27
|
+
applyProposal,
|
|
28
|
+
type ApplyResult,
|
|
29
|
+
} from "../adapters/inference/agent.ts";
|
|
30
|
+
import {
|
|
31
|
+
resolveProvider,
|
|
32
|
+
inferViaApi,
|
|
33
|
+
knownProviders,
|
|
34
|
+
} from "../adapters/inference/api.ts";
|
|
35
|
+
|
|
36
|
+
export const meta = {
|
|
37
|
+
name: "infer",
|
|
38
|
+
summary: "Re-run AI domain taxonomy over the library (emit/apply/provider modes)",
|
|
39
|
+
usage:
|
|
40
|
+
"skl infer [--emit | --apply <file.json> | --provider <name>] [--base-url <url>] [--model <id>] [--include-retired] [--json]",
|
|
41
|
+
} as const;
|
|
42
|
+
|
|
43
|
+
interface Args {
|
|
44
|
+
emit: boolean;
|
|
45
|
+
applyFile: string | null;
|
|
46
|
+
provider: string | null;
|
|
47
|
+
baseUrl: string | null;
|
|
48
|
+
model: string | null;
|
|
49
|
+
includeRetired: boolean;
|
|
50
|
+
json: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function parseArgs(argv: string[]): { args: Args } | { error: string } {
|
|
54
|
+
const args: Args = {
|
|
55
|
+
emit: false,
|
|
56
|
+
applyFile: null,
|
|
57
|
+
provider: null,
|
|
58
|
+
baseUrl: null,
|
|
59
|
+
model: null,
|
|
60
|
+
includeRetired: false,
|
|
61
|
+
json: false,
|
|
62
|
+
};
|
|
63
|
+
for (let i = 0; i < argv.length; i++) {
|
|
64
|
+
const a = argv[i]!;
|
|
65
|
+
switch (a) {
|
|
66
|
+
case "--emit":
|
|
67
|
+
args.emit = true;
|
|
68
|
+
break;
|
|
69
|
+
case "--apply": {
|
|
70
|
+
const f = argv[++i];
|
|
71
|
+
if (!f) return { error: "--apply requires a <file.json> path" };
|
|
72
|
+
args.applyFile = f;
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
case "--provider": {
|
|
76
|
+
const p = argv[++i];
|
|
77
|
+
if (!p) return { error: "--provider requires a name" };
|
|
78
|
+
args.provider = p;
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
case "--base-url": {
|
|
82
|
+
const b = argv[++i];
|
|
83
|
+
if (!b) return { error: "--base-url requires a url" };
|
|
84
|
+
args.baseUrl = b;
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
case "--model": {
|
|
88
|
+
const m = argv[++i];
|
|
89
|
+
if (!m) return { error: "--model requires an id" };
|
|
90
|
+
args.model = m;
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
case "--include-retired":
|
|
94
|
+
args.includeRetired = true;
|
|
95
|
+
break;
|
|
96
|
+
case "--json":
|
|
97
|
+
args.json = true;
|
|
98
|
+
break;
|
|
99
|
+
default:
|
|
100
|
+
if (a.startsWith("--apply=")) args.applyFile = a.slice("--apply=".length);
|
|
101
|
+
else if (a.startsWith("--provider=")) args.provider = a.slice("--provider=".length);
|
|
102
|
+
else if (a.startsWith("--base-url=")) args.baseUrl = a.slice("--base-url=".length);
|
|
103
|
+
else if (a.startsWith("--model=")) args.model = a.slice("--model=".length);
|
|
104
|
+
else return { error: `unknown argument: ${a}` };
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return { args };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function isAgentContext(): boolean {
|
|
111
|
+
return (
|
|
112
|
+
!!process.env.CLAUDECODE ||
|
|
113
|
+
!!process.env.CLAUDE_CODE ||
|
|
114
|
+
!!process.env.CLAUDE_AGENT ||
|
|
115
|
+
!!process.env.ANTHROPIC_AGENT
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export async function run(argv: string[], ctx: Ctx): Promise<number> {
|
|
120
|
+
const parsed = parseArgs(argv);
|
|
121
|
+
if ("error" in parsed) {
|
|
122
|
+
ctx.error(`skl infer: ${parsed.error}`);
|
|
123
|
+
ctx.error(`usage: ${meta.usage}`);
|
|
124
|
+
return 1;
|
|
125
|
+
}
|
|
126
|
+
const args = parsed.args;
|
|
127
|
+
|
|
128
|
+
// API mode is requested by --provider or --base-url.
|
|
129
|
+
const apiMode = !!args.provider || !!args.baseUrl;
|
|
130
|
+
|
|
131
|
+
// Mutually-exclusive modes (apply / api are explicit and primary).
|
|
132
|
+
const modeCount =
|
|
133
|
+
(args.applyFile ? 1 : 0) + (apiMode ? 1 : 0) + (args.emit ? 1 : 0);
|
|
134
|
+
if (modeCount > 1) {
|
|
135
|
+
ctx.error("skl infer: choose only one of --emit, --apply, --provider/--base-url");
|
|
136
|
+
return 1;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
const skills = await ctx.loadLibrary();
|
|
141
|
+
|
|
142
|
+
// --- APPLY MODE -------------------------------------------------------
|
|
143
|
+
if (args.applyFile) {
|
|
144
|
+
let text: string;
|
|
145
|
+
try {
|
|
146
|
+
text = await Bun.file(args.applyFile).text();
|
|
147
|
+
} catch {
|
|
148
|
+
ctx.error(`skl infer: cannot read proposal file: ${args.applyFile}`);
|
|
149
|
+
return 1;
|
|
150
|
+
}
|
|
151
|
+
let rawJson: unknown;
|
|
152
|
+
try {
|
|
153
|
+
rawJson = JSON.parse(text);
|
|
154
|
+
} catch (e) {
|
|
155
|
+
ctx.error(
|
|
156
|
+
`skl infer: proposal file is not valid JSON: ${
|
|
157
|
+
e instanceof Error ? e.message : String(e)
|
|
158
|
+
}`,
|
|
159
|
+
);
|
|
160
|
+
return 1;
|
|
161
|
+
}
|
|
162
|
+
const proposal = normalizeProposal(rawJson);
|
|
163
|
+
if (proposal.assignments.length === 0) {
|
|
164
|
+
ctx.error("skl infer: proposal contained no assignments");
|
|
165
|
+
return 1;
|
|
166
|
+
}
|
|
167
|
+
const result = await applyProposal(skills, proposal);
|
|
168
|
+
return reportApply(result, args.json, ctx);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// --- API MODE (--provider / --base-url) ------------------------------
|
|
172
|
+
if (apiMode) {
|
|
173
|
+
const prov = await resolveProvider(args.provider ?? undefined, {
|
|
174
|
+
base: args.baseUrl ?? undefined,
|
|
175
|
+
model: args.model ?? undefined,
|
|
176
|
+
});
|
|
177
|
+
if ("error" in prov) {
|
|
178
|
+
ctx.error(`skl infer: ${prov.error}`);
|
|
179
|
+
return 1;
|
|
180
|
+
}
|
|
181
|
+
const { buildCorpus } = await import("../adapters/inference/agent.ts");
|
|
182
|
+
const corpus = await buildCorpus(skills, { includeRetired: args.includeRetired });
|
|
183
|
+
if (corpus.skills.length === 0) {
|
|
184
|
+
ctx.error("skl infer: library is empty — nothing to infer");
|
|
185
|
+
return 1;
|
|
186
|
+
}
|
|
187
|
+
const inferred = await inferViaApi(corpus, prov.config);
|
|
188
|
+
if ("error" in inferred) {
|
|
189
|
+
ctx.error(`skl infer: ${inferred.error}`);
|
|
190
|
+
return 1;
|
|
191
|
+
}
|
|
192
|
+
if (inferred.proposal.assignments.length === 0) {
|
|
193
|
+
ctx.error("skl infer: gateway returned no assignments");
|
|
194
|
+
return 1;
|
|
195
|
+
}
|
|
196
|
+
const result = await applyProposal(skills, inferred.proposal);
|
|
197
|
+
return reportApply(result, args.json, ctx, prov.config.name, prov.config.model);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// --- EMIT MODE (explicit or agent-auto-detect) -----------------------
|
|
201
|
+
if (args.emit || isAgentContext()) {
|
|
202
|
+
const payload = await buildEmitPayload(skills, {
|
|
203
|
+
includeRetired: args.includeRetired,
|
|
204
|
+
});
|
|
205
|
+
if (payload.corpus.skills.length === 0) {
|
|
206
|
+
ctx.error("skl infer: library is empty — nothing to infer");
|
|
207
|
+
return 1;
|
|
208
|
+
}
|
|
209
|
+
// emit is inherently machine output; --json is the same single-line form.
|
|
210
|
+
if (args.json) {
|
|
211
|
+
ctx.json(payload);
|
|
212
|
+
} else {
|
|
213
|
+
// pretty multi-line for a human/agent reading stdout
|
|
214
|
+
ctx.log(JSON.stringify(payload, null, 2));
|
|
215
|
+
}
|
|
216
|
+
if (!args.emit) {
|
|
217
|
+
ctx.error(
|
|
218
|
+
`skl infer: agent context detected — emitted ${payload.corpus.skills.length} skills. ` +
|
|
219
|
+
"Reason over `corpus`, produce a proposal that matches `schema`, " +
|
|
220
|
+
"then run: skl infer --apply <file.json>",
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
return 0;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// --- NO MODE, NO AGENT -> error --------------------------------------
|
|
227
|
+
ctx.error(
|
|
228
|
+
"skl infer: no inference mode available. Provide one of:\n" +
|
|
229
|
+
" --emit print corpus for a host agent to reason over\n" +
|
|
230
|
+
" --apply <file.json> apply an agent proposal into overlays\n" +
|
|
231
|
+
` --provider <name> call an OpenAI-compatible endpoint (${knownProviders().join(", ")})\n` +
|
|
232
|
+
" --base-url <url> call a custom OpenAI-compatible endpoint\n" +
|
|
233
|
+
"(auto-emit only activates inside a Claude Code agent context.)",
|
|
234
|
+
);
|
|
235
|
+
return 1;
|
|
236
|
+
} catch (e) {
|
|
237
|
+
ctx.error(`skl infer: ${e instanceof Error ? e.message : String(e)}`);
|
|
238
|
+
return 1;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function reportApply(
|
|
243
|
+
result: ApplyResult,
|
|
244
|
+
asJson: boolean,
|
|
245
|
+
ctx: Ctx,
|
|
246
|
+
provider?: string,
|
|
247
|
+
model?: string,
|
|
248
|
+
): number {
|
|
249
|
+
if (asJson) {
|
|
250
|
+
ctx.json({
|
|
251
|
+
ok: true,
|
|
252
|
+
provider: provider ?? null,
|
|
253
|
+
model: model ?? null,
|
|
254
|
+
applied: result.applied,
|
|
255
|
+
unmatched: result.unmatched,
|
|
256
|
+
skipped: result.skipped,
|
|
257
|
+
counts: {
|
|
258
|
+
applied: result.applied.length,
|
|
259
|
+
unmatched: result.unmatched.length,
|
|
260
|
+
skipped: result.skipped.length,
|
|
261
|
+
},
|
|
262
|
+
});
|
|
263
|
+
return 0;
|
|
264
|
+
}
|
|
265
|
+
if (provider) ctx.log(`Inference via ${provider}${model ? ` (${model})` : ""}:`);
|
|
266
|
+
for (const a of result.applied) {
|
|
267
|
+
const addedNote = a.added.length ? ` (+${a.added.join(", ")})` : " (no change)";
|
|
268
|
+
ctx.log(` ${a.name}: ${a.domains.join(", ")}${addedNote}`);
|
|
269
|
+
}
|
|
270
|
+
ctx.log(
|
|
271
|
+
`Applied ${result.applied.length} overlay update${
|
|
272
|
+
result.applied.length === 1 ? "" : "s"
|
|
273
|
+
}.`,
|
|
274
|
+
);
|
|
275
|
+
if (result.unmatched.length) {
|
|
276
|
+
ctx.log(`Unmatched (no such skill): ${result.unmatched.join(", ")}`);
|
|
277
|
+
}
|
|
278
|
+
if (result.skipped.length) {
|
|
279
|
+
ctx.log(`Skipped (no domains proposed): ${result.skipped.join(", ")}`);
|
|
280
|
+
}
|
|
281
|
+
return 0;
|
|
282
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// `skl init` — first-run setup:
|
|
2
|
+
// 1. ensure ~/.skillshelf/ exists and write a config.json (library + globalCore)
|
|
3
|
+
// unless one already exists (never clobbers without --force).
|
|
4
|
+
// 2. ensure the library dir exists.
|
|
5
|
+
// 3. symlink the thin global-core skills (bundle "global-core") into the
|
|
6
|
+
// global-core target (~/.claude/skills) so they auto-trigger every session.
|
|
7
|
+
// Idempotent. Safe to re-run.
|
|
8
|
+
|
|
9
|
+
import { dirname, join } from "node:path";
|
|
10
|
+
import { existsSync } from "node:fs";
|
|
11
|
+
import type { Ctx } from "../types.ts";
|
|
12
|
+
import { DEFAULT_CONFIG_FILE } from "../config.ts";
|
|
13
|
+
import { resolveBundle } from "../core/bundle.ts";
|
|
14
|
+
import { activeSkills } from "../core/library.ts";
|
|
15
|
+
import {
|
|
16
|
+
ensureDir,
|
|
17
|
+
safeSymlink,
|
|
18
|
+
isSymlink,
|
|
19
|
+
realpathOrSelf,
|
|
20
|
+
realpathOrSelfAsync,
|
|
21
|
+
} from "../lib/fs.ts";
|
|
22
|
+
|
|
23
|
+
export const meta = {
|
|
24
|
+
name: "init",
|
|
25
|
+
summary: "Set up ~/.skillshelf config + library and link the global-core skills",
|
|
26
|
+
usage: "skl init [--force] [--json]",
|
|
27
|
+
} as const;
|
|
28
|
+
|
|
29
|
+
const GLOBAL_CORE_BUNDLE = "global-core";
|
|
30
|
+
|
|
31
|
+
interface CoreLink {
|
|
32
|
+
name: string;
|
|
33
|
+
target: string;
|
|
34
|
+
link: string;
|
|
35
|
+
status: "linked" | "already" | "conflict";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function run(argv: string[], ctx: Ctx): Promise<number> {
|
|
39
|
+
try {
|
|
40
|
+
const json = argv.includes("--json");
|
|
41
|
+
const force = argv.includes("--force");
|
|
42
|
+
|
|
43
|
+
const configFile = DEFAULT_CONFIG_FILE;
|
|
44
|
+
const libraryPath = ctx.config.libraryPath;
|
|
45
|
+
const globalCoreTarget = ctx.config.globalCoreTarget;
|
|
46
|
+
|
|
47
|
+
// 1. config dir + file
|
|
48
|
+
await ensureDir(dirname(configFile));
|
|
49
|
+
let configWritten = false;
|
|
50
|
+
if (!existsSync(configFile) || force) {
|
|
51
|
+
const body = JSON.stringify({ library: libraryPath, globalCore: globalCoreTarget }, null, 2) + "\n";
|
|
52
|
+
await Bun.write(configFile, body);
|
|
53
|
+
configWritten = true;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// 2. library dir
|
|
57
|
+
await ensureDir(libraryPath);
|
|
58
|
+
|
|
59
|
+
// 3. link global-core skills
|
|
60
|
+
let skills: Awaited<ReturnType<Ctx["loadLibrary"]>> = [];
|
|
61
|
+
try {
|
|
62
|
+
skills = await ctx.loadLibrary();
|
|
63
|
+
} catch {
|
|
64
|
+
skills = [];
|
|
65
|
+
}
|
|
66
|
+
const bundle = await resolveBundle(activeSkills(skills), GLOBAL_CORE_BUNDLE);
|
|
67
|
+
|
|
68
|
+
const coreLinks: CoreLink[] = [];
|
|
69
|
+
if (bundle.skills.length > 0) {
|
|
70
|
+
await ensureDir(globalCoreTarget);
|
|
71
|
+
for (const s of bundle.skills) {
|
|
72
|
+
const link = join(globalCoreTarget, s.name);
|
|
73
|
+
const target = s.path;
|
|
74
|
+
let status: CoreLink["status"] = "linked";
|
|
75
|
+
|
|
76
|
+
if (isSymlink(link)) {
|
|
77
|
+
const cur = realpathOrSelf(link);
|
|
78
|
+
const want = await realpathOrSelfAsync(target);
|
|
79
|
+
if (cur === want) status = "already";
|
|
80
|
+
} else if (existsSync(link)) {
|
|
81
|
+
// Real file already there — leave it alone.
|
|
82
|
+
coreLinks.push({ name: s.name, target, link, status: "conflict" });
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
await safeSymlink(target, link);
|
|
87
|
+
coreLinks.push({ name: s.name, target, link, status });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const conflicts = coreLinks.filter((l) => l.status === "conflict");
|
|
92
|
+
|
|
93
|
+
if (json) {
|
|
94
|
+
ctx.json({
|
|
95
|
+
configFile,
|
|
96
|
+
configWritten,
|
|
97
|
+
libraryPath,
|
|
98
|
+
globalCoreTarget,
|
|
99
|
+
globalCoreBundle: GLOBAL_CORE_BUNDLE,
|
|
100
|
+
coreLinks,
|
|
101
|
+
});
|
|
102
|
+
} else {
|
|
103
|
+
ctx.log("skillshelf initialized.");
|
|
104
|
+
ctx.log(` config: ${configFile}${configWritten ? " (written)" : " (kept existing)"}`);
|
|
105
|
+
ctx.log(` library: ${libraryPath}`);
|
|
106
|
+
ctx.log(` global-core: ${globalCoreTarget}`);
|
|
107
|
+
if (bundle.skills.length === 0) {
|
|
108
|
+
ctx.log(` (no skills tagged '${GLOBAL_CORE_BUNDLE}' yet — nothing to link)`);
|
|
109
|
+
} else {
|
|
110
|
+
ctx.log(` linked ${coreLinks.length} global-core skill(s):`);
|
|
111
|
+
for (const l of coreLinks) {
|
|
112
|
+
const tag =
|
|
113
|
+
l.status === "linked" ? "linked" : l.status === "already" ? "ok" : "SKIP (real file present)";
|
|
114
|
+
ctx.log(` ${l.name} [${tag}]`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return conflicts.length > 0 ? 1 : 0;
|
|
120
|
+
} catch (err) {
|
|
121
|
+
ctx.error(`skl init failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
122
|
+
return 1;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// `skl ls [bundle]` — one-line listing of the whole library, or a single
|
|
2
|
+
// bundle (tag query). Excludes retired by default; `--all` includes them.
|
|
3
|
+
|
|
4
|
+
import type { Ctx, Skill } from "../types.ts";
|
|
5
|
+
import { activeSkills } from "../core/library.ts";
|
|
6
|
+
import { resolveBundle } from "../core/bundle.ts";
|
|
7
|
+
|
|
8
|
+
export const meta = {
|
|
9
|
+
name: "ls",
|
|
10
|
+
summary: "One-line listing of the library, or one bundle",
|
|
11
|
+
usage: "skl ls [bundle] [--all] [--json]",
|
|
12
|
+
} as const;
|
|
13
|
+
|
|
14
|
+
function oneLine(desc: string, max = 100): string {
|
|
15
|
+
const flat = desc.replace(/\s+/g, " ").trim();
|
|
16
|
+
return flat.length <= max ? flat : flat.slice(0, max - 1).trimEnd() + "…";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function emitHuman(ctx: Ctx, skills: Skill[]): void {
|
|
20
|
+
if (skills.length === 0) {
|
|
21
|
+
ctx.log("(no skills)");
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
for (const s of skills) {
|
|
25
|
+
const tag = s.retired ? " (retired)" : "";
|
|
26
|
+
const dom =
|
|
27
|
+
s.primaryDomain && !s.retired ? ` [${s.primaryDomain}]` : "";
|
|
28
|
+
ctx.log(`${s.name}${dom}${tag} — ${oneLine(s.description)}`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function toJson(skills: Skill[]): unknown {
|
|
33
|
+
return skills.map((s) => ({
|
|
34
|
+
name: s.name,
|
|
35
|
+
description: s.description,
|
|
36
|
+
primaryDomain: s.primaryDomain,
|
|
37
|
+
domains: s.domains,
|
|
38
|
+
path: s.path,
|
|
39
|
+
retired: s.retired,
|
|
40
|
+
}));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function run(argv: string[], ctx: Ctx): Promise<number> {
|
|
44
|
+
try {
|
|
45
|
+
const json = argv.includes("--json");
|
|
46
|
+
const all = argv.includes("--all");
|
|
47
|
+
const positional = argv.filter((a) => !a.startsWith("--"));
|
|
48
|
+
const bundleName = positional[0];
|
|
49
|
+
|
|
50
|
+
const skills = await ctx.loadLibrary();
|
|
51
|
+
|
|
52
|
+
if (bundleName) {
|
|
53
|
+
const bundle = await resolveBundle(skills, bundleName, {
|
|
54
|
+
includeRetired: all,
|
|
55
|
+
});
|
|
56
|
+
if (json) {
|
|
57
|
+
ctx.json({ bundle: bundle.name, skills: toJson(bundle.skills) });
|
|
58
|
+
return 0;
|
|
59
|
+
}
|
|
60
|
+
if (bundle.skills.length === 0) {
|
|
61
|
+
ctx.log(`Bundle "${bundle.name}" has no skills.`);
|
|
62
|
+
return 0;
|
|
63
|
+
}
|
|
64
|
+
ctx.log(`# ${bundle.name} (${bundle.skills.length})`);
|
|
65
|
+
emitHuman(ctx, bundle.skills);
|
|
66
|
+
return 0;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const listed = all ? skills : activeSkills(skills);
|
|
70
|
+
if (json) {
|
|
71
|
+
ctx.json(toJson(listed));
|
|
72
|
+
return 0;
|
|
73
|
+
}
|
|
74
|
+
emitHuman(ctx, listed);
|
|
75
|
+
return 0;
|
|
76
|
+
} catch (err) {
|
|
77
|
+
ctx.error(`ls failed: ${(err as Error).message}`);
|
|
78
|
+
return 1;
|
|
79
|
+
}
|
|
80
|
+
}
|