dsh-skill-importer 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/lib/index.js ADDED
@@ -0,0 +1,463 @@
1
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ //#region src/frontmatter.ts
5
+ /** Kebab-case name rule shared with `dsh-skill` (`^[a-z0-9]+(?:-[a-z0-9]+)*$`). */
6
+ const KEBAB_CASE$1 = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
7
+ /** True when the name satisfies the harness skill-name rule. */
8
+ function isValidSkillName(name) {
9
+ return KEBAB_CASE$1.test(name);
10
+ }
11
+ const BOOLEAN_TRUE = /* @__PURE__ */ new Set([
12
+ "true",
13
+ "yes",
14
+ "on",
15
+ "1"
16
+ ]);
17
+ const BOOLEAN_FALSE = /* @__PURE__ */ new Set([
18
+ "false",
19
+ "no",
20
+ "off",
21
+ "0"
22
+ ]);
23
+ /** Parse one frontmatter scalar: quoted strings, booleans, or plain text. */
24
+ function parseScalar(raw) {
25
+ const value = raw.trim();
26
+ if (value.length === 0) return void 0;
27
+ const lower = value.toLowerCase();
28
+ if (BOOLEAN_TRUE.has(lower)) return true;
29
+ if (BOOLEAN_FALSE.has(lower)) return false;
30
+ if (value.startsWith("\"") && value.endsWith("\"") && value.length >= 2 || value.startsWith("'") && value.endsWith("'") && value.length >= 2) return value.slice(1, -1);
31
+ return value;
32
+ }
33
+ /**
34
+ * Parse a skill Markdown file's frontmatter block.
35
+ * @param text - full file text.
36
+ * @returns parsed frontmatter and body; a file without a leading `---` block
37
+ * yields an empty frontmatter with the whole text as body.
38
+ */
39
+ function parseSkillFile(text) {
40
+ const frontmatter = {};
41
+ if (!text.startsWith("---\n")) return {
42
+ frontmatter,
43
+ body: text
44
+ };
45
+ const end = text.indexOf("\n---", 4);
46
+ if (end < 0) return {
47
+ frontmatter,
48
+ body: text
49
+ };
50
+ const block = text.slice(4, end);
51
+ const body = text.slice(end + 4).replace(/^\n/, "");
52
+ for (const line of block.split("\n")) {
53
+ const colon = line.indexOf(":");
54
+ if (colon <= 0) continue;
55
+ const key = line.slice(0, colon).trim();
56
+ const value = parseScalar(line.slice(colon + 1));
57
+ if (value === void 0) continue;
58
+ switch (key) {
59
+ case "name":
60
+ if (typeof value === "string") frontmatter.name = value;
61
+ break;
62
+ case "description":
63
+ if (typeof value === "string") frontmatter.description = value;
64
+ break;
65
+ case "whenToUse":
66
+ if (typeof value === "string") frontmatter.whenToUse = value;
67
+ break;
68
+ case "disable-model-invocation":
69
+ if (typeof value === "boolean") frontmatter.disableModelInvocation = value;
70
+ break;
71
+ case "user-invocable": if (typeof value === "boolean") frontmatter.userInvocable = value;
72
+ }
73
+ }
74
+ return {
75
+ frontmatter,
76
+ body
77
+ };
78
+ }
79
+ //#endregion
80
+ //#region src/server.ts
81
+ /**
82
+ * Host-side skill importer logic: direct filesystem writes, skill-root
83
+ * scanning, and URL fetching. Pure Node — no Cordis imports — so the route
84
+ * handlers stay unit-testable and the plugin body is only wiring.
85
+ *
86
+ * This is the "direct write" path: the host process owns the filesystem
87
+ * (no agent sandbox, no approval), so an import lands the file immediately
88
+ * and the skill-filesystem watcher discovers it in place.
89
+ */
90
+ /** Hard cap for one imported skill body (matches the client preview limit). */
91
+ const MAX_CONTENT_BYTES = 262144;
92
+ /** Cap for one request body read (JSON overhead above the content cap). */
93
+ const MAX_BODY_BYTES = 1048576;
94
+ const KEBAB_CASE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
95
+ /** The harness home (`$DSH_HOME`, defaulting to `~/.dsh`). */
96
+ function dshHomeDir() {
97
+ return process.env.DSH_HOME ?? join(homedir(), ".dsh");
98
+ }
99
+ /** Absolute skill root for one target under one workspace. */
100
+ function skillRoot(target, workspacePath) {
101
+ switch (target) {
102
+ case "user": return join(dshHomeDir(), "skills");
103
+ case "project-agents": return join(workspacePath, ".agents", "skills");
104
+ case "project-dsh": return join(workspacePath, ".dsh", "skills");
105
+ }
106
+ }
107
+ /** Discovery rank per root (lower wins), mirroring dsh-skill-filesystem. */
108
+ const ROOT_RANK = {
109
+ "project-dsh": 100,
110
+ "project-agents": 200,
111
+ user: 400
112
+ };
113
+ /** Scan order: project roots first so project skills win duplicate names. */
114
+ const SCAN_ORDER = [
115
+ "project-dsh",
116
+ "project-agents",
117
+ "user"
118
+ ];
119
+ /**
120
+ * Quote one frontmatter string value when plain YAML would mis-parse it:
121
+ * a value containing `: ` (or any colon), leading/trailing whitespace, a
122
+ * leading YAML special character, or ` #` would fail or change meaning
123
+ * under the strict YAML parser `dsh-skill-filesystem` uses — such a file is
124
+ * silently skipped by discovery. `JSON.stringify` emits a YAML-compatible
125
+ * double-quoted scalar.
126
+ */
127
+ function yamlScalar(value) {
128
+ if (value.length === 0 || /^[\s]/.test(value) || /[\s]$/.test(value) || /:/.test(value) || /#/.test(value) || /^[!&*{}\[\],|>'"%@`?]/.test(value)) return JSON.stringify(value);
129
+ return value;
130
+ }
131
+ /**
132
+ * Rebuild a skill file's frontmatter in the canonical, strictly-YAML-valid
133
+ * form the harness's provider parses. Unknown keys are dropped (the harness
134
+ * only consumes name/description/whenToUse and the two invocation flags);
135
+ * the body is preserved verbatim (trimmed).
136
+ */
137
+ function normalizeSkillText(text) {
138
+ const { frontmatter, body } = parseSkillFile(text);
139
+ if (frontmatter.name === void 0 || frontmatter.description === void 0) throw new Error("frontmatter 缺少 name 或 description 字段");
140
+ const lines = ["---"];
141
+ lines.push(`name: ${frontmatter.name}`);
142
+ lines.push(`description: ${yamlScalar(frontmatter.description)}`);
143
+ if (frontmatter.whenToUse !== void 0) lines.push(`whenToUse: ${yamlScalar(frontmatter.whenToUse)}`);
144
+ if (frontmatter.disableModelInvocation !== void 0) lines.push(`disable-model-invocation: ${frontmatter.disableModelInvocation}`);
145
+ if (frontmatter.userInvocable !== void 0) lines.push(`user-invocable: ${frontmatter.userInvocable}`);
146
+ lines.push("---");
147
+ const normalizedBody = body.trim();
148
+ return lines.join("\n") + (normalizedBody.length > 0 ? `\n\n${normalizedBody}\n` : "\n");
149
+ }
150
+ /**
151
+ * Write one skill file atomically: `<root>/<name>/SKILL.md`, created via a
152
+ * same-directory temp file plus rename so a crash never leaves a torn file.
153
+ * The content's frontmatter is normalized first so the harness's strict YAML
154
+ * discovery always finds the skill.
155
+ * @param name - kebab-case skill name (validated).
156
+ * @param target - which skill root to write into.
157
+ * @param content - full Markdown text (frontmatter included).
158
+ * @param workspacePath - canonical workspace path; required for project targets.
159
+ * @returns the absolute path of the written file.
160
+ */
161
+ function writeSkillFile(name, target, content, workspacePath) {
162
+ if (!KEBAB_CASE.test(name)) throw new Error("技能名必须是 kebab-case(小写字母、数字、短横线)");
163
+ if (Buffer.byteLength(content, "utf8") > 262144) throw new Error(`内容超过 ${MAX_CONTENT_BYTES / 1024} KB`);
164
+ if (target !== "user" && workspacePath === void 0) throw new Error("项目目标需要 workspacePath(当前工作区路径)");
165
+ const normalized = normalizeSkillText(content);
166
+ const directory = join(skillRoot(target, workspacePath ?? ""), name);
167
+ mkdirSync(directory, { recursive: true });
168
+ const file = join(directory, "SKILL.md");
169
+ const temporary = `${file}.tmp`;
170
+ writeFileSync(temporary, normalized, "utf8");
171
+ renameSync(temporary, file);
172
+ return file;
173
+ }
174
+ /** Scan one skill root and fold its skills into the name-keyed map (rank-aware). */
175
+ function scanRoot(root, source, out) {
176
+ if (!existsSync(root)) return;
177
+ for (const entry of readdirSync(root, { withFileTypes: true })) {
178
+ const entryPath = join(root, entry.name);
179
+ let file;
180
+ if (entry.isDirectory()) {
181
+ const candidate = join(entryPath, "SKILL.md");
182
+ if (existsSync(candidate)) file = candidate;
183
+ } else if (entry.isFile() && entry.name.endsWith(".md")) file = entryPath;
184
+ if (file === void 0) continue;
185
+ const { frontmatter } = parseSkillFile(readFileSync(file, "utf8"));
186
+ if (frontmatter.name === void 0 || frontmatter.description === void 0) continue;
187
+ if (!isValidSkillName(frontmatter.name)) continue;
188
+ const existing = out.get(frontmatter.name);
189
+ if (existing !== void 0 && ROOT_RANK[existing.source] <= ROOT_RANK[source]) continue;
190
+ out.set(frontmatter.name, {
191
+ name: frontmatter.name,
192
+ description: frontmatter.description,
193
+ ...frontmatter.whenToUse !== void 0 ? { whenToUse: frontmatter.whenToUse } : {},
194
+ modelInvocable: frontmatter.disableModelInvocation !== true,
195
+ userInvocable: frontmatter.userInvocable !== false,
196
+ source
197
+ });
198
+ }
199
+ }
200
+ /**
201
+ * List every installed skill across every registered workspace's project
202
+ * roots plus the user root. No rank deduplication: the management surface
203
+ * shows every location's copy (the framework's own catalog still applies
204
+ * rank at discovery time). Display order groups by source, then name.
205
+ * @param workspacePaths - canonical paths of the registered workspaces.
206
+ */
207
+ function listSkills(workspacePaths) {
208
+ const rows = [];
209
+ for (const target of SCAN_ORDER) {
210
+ const out = /* @__PURE__ */ new Map();
211
+ for (const path of target === "user" ? [dshHomeDir()] : workspacePaths) scanRoot(skillRoot(target, path), target, out);
212
+ rows.push(...out.values());
213
+ }
214
+ return rows.sort((a, b) => a.name.localeCompare(b.name));
215
+ }
216
+ /**
217
+ * Delete one installed skill (its whole bundle directory `<root>/<name>/`,
218
+ * or the flat `<root>/<name>.md`), scoped to the skill roots only.
219
+ * @param name - kebab-case skill name (validated).
220
+ * @param source - which root the copy lives in.
221
+ * @param workspacePath - canonical workspace path; required for project sources.
222
+ * @returns true when something was removed, false when nothing matched.
223
+ */
224
+ function deleteSkillFile(name, source, workspacePath) {
225
+ if (!KEBAB_CASE.test(name)) throw new Error("技能名必须是 kebab-case(小写字母、数字、短横线)");
226
+ if (source !== "user" && workspacePath === void 0) throw new Error("项目来源需要 workspacePath(当前工作区路径)");
227
+ const root = skillRoot(source, workspacePath ?? "");
228
+ const directory = join(root, name);
229
+ if (existsSync(directory)) {
230
+ rmSync(directory, {
231
+ recursive: true,
232
+ force: true
233
+ });
234
+ return true;
235
+ }
236
+ const flat = join(root, `${name}.md`);
237
+ if (existsSync(flat)) {
238
+ rmSync(flat);
239
+ return true;
240
+ }
241
+ return false;
242
+ }
243
+ /** Strip HTML down to a plain-Markdown-ish text body (best effort). */
244
+ function extractHtmlText(html) {
245
+ const withoutBlocks = html.replace(/<script[\s\S]*?<\/script>/gi, "").replace(/<style[\s\S]*?<\/style>/gi, "").replace(/<!--[\s\S]*?-->/g, "");
246
+ const title = withoutBlocks.match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1]?.trim() ?? "";
247
+ const body = withoutBlocks.replace(/<[^>]+>/g, " ").replace(/&nbsp;/g, " ").replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, "\"").replace(/[ \t]{2,}/g, " ").replace(/\n{3,}/g, "\n\n").trim();
248
+ return title.length > 0 ? `# ${title}\n\n${body}` : body;
249
+ }
250
+ /**
251
+ * Fetch a URL's content for import. Markdown/plain responses pass through
252
+ * verbatim; HTML is roughly extracted to text (the import UI recommends
253
+ * `.md` sources).
254
+ * @param url - the source URL.
255
+ * @returns the text to write as the skill body.
256
+ */
257
+ async function fetchUrlContent(url) {
258
+ const response = await fetch(url, {
259
+ redirect: "follow",
260
+ signal: AbortSignal.timeout(15e3)
261
+ });
262
+ if (!response.ok) throw new Error(`抓取失败:HTTP ${response.status}`);
263
+ const type = response.headers.get("content-type") ?? "";
264
+ const text = await response.text();
265
+ return type.includes("html") ? extractHtmlText(text) : text;
266
+ }
267
+ /**
268
+ * Resolve one import request into a written file path. Shared by the file
269
+ * and URL routes; URL imports fetch first, then reuse the same write path.
270
+ * @param request - validated import body.
271
+ * @returns the absolute path of the written SKILL.md.
272
+ */
273
+ async function resolveImport(request) {
274
+ const content = "content" in request ? request.content : await fetchUrlContent(request.url);
275
+ return writeSkillFile(request.name, request.target, content, request.workspacePath);
276
+ }
277
+ /** Read and parse a JSON request body within a byte cap. */
278
+ function readJsonBody(req, limit = MAX_BODY_BYTES) {
279
+ return new Promise((resolve, reject) => {
280
+ const chunks = [];
281
+ let size = 0;
282
+ req.on("data", (chunk) => {
283
+ size += chunk.length;
284
+ if (size > limit) {
285
+ reject(/* @__PURE__ */ new Error("请求体过大"));
286
+ req.destroy();
287
+ return;
288
+ }
289
+ chunks.push(chunk);
290
+ });
291
+ req.on("end", () => {
292
+ try {
293
+ resolve(JSON.parse(Buffer.concat(chunks).toString("utf8")));
294
+ } catch {
295
+ reject(/* @__PURE__ */ new Error("无效的 JSON"));
296
+ }
297
+ });
298
+ req.on("error", reject);
299
+ });
300
+ }
301
+ /**
302
+ * Origin fence: the routes are served on the harness's loopback-only web
303
+ * server, so only the browser page itself (or a local curl) reaches them.
304
+ * A cross-origin page (any other website) is refused. Requests without an
305
+ * Origin header (curl, same-origin GET) pass.
306
+ */
307
+ function originAllowed(req) {
308
+ const origin = req.headers.origin;
309
+ if (origin === void 0) return true;
310
+ try {
311
+ const hostname = new URL(origin).hostname;
312
+ return hostname === "127.0.0.1" || hostname === "localhost";
313
+ } catch {
314
+ return false;
315
+ }
316
+ }
317
+ /** Send one JSON response. */
318
+ function sendJson(res, status, body) {
319
+ const payload = JSON.stringify(body);
320
+ res.writeHead(status, {
321
+ "content-type": "application/json; charset=utf-8",
322
+ "content-length": Buffer.byteLength(payload)
323
+ });
324
+ res.end(payload);
325
+ }
326
+ /** Send a JSON error response with the given status. */
327
+ function sendError(res, status, error) {
328
+ sendJson(res, status, {
329
+ ok: false,
330
+ error
331
+ });
332
+ }
333
+ //#endregion
334
+ //#region src/index.ts
335
+ /** Route paths this plugin owns (exact matches; the client fetches the same literals). */
336
+ const ROUTES = {
337
+ health: "/skill-importer/health",
338
+ list: "/skill-importer/list",
339
+ import: "/skill-importer/import",
340
+ importUrl: "/skill-importer/import-url",
341
+ delete: "/skill-importer/delete"
342
+ };
343
+ /** Required services: the harness web server's route registry and the workspace registry. */
344
+ const inject = ["webServer", "workspaceRegistry"];
345
+ const handle = (fn) => (req, res) => {
346
+ fn(req, res).catch((error) => {
347
+ if (res.writableEnded) return;
348
+ sendError(res, 500, error instanceof Error ? error.message : String(error));
349
+ });
350
+ };
351
+ /**
352
+ * Host plugin body: register the importer routes.
353
+ * @param ctx - host plugin context (provides `ctx.webServer`).
354
+ */
355
+ function apply(ctx) {
356
+ const projectPaths = () => ctx.workspaceRegistry.list().map((workspace) => workspace.path);
357
+ const routes = [
358
+ {
359
+ kind: "exact",
360
+ path: ROUTES.health,
361
+ handler: (_req, res) => sendJson(res, 200, { ok: true })
362
+ },
363
+ {
364
+ kind: "exact",
365
+ path: ROUTES.list,
366
+ handler: handle(async (_req, res) => {
367
+ sendJson(res, 200, {
368
+ ok: true,
369
+ skills: listSkills(projectPaths())
370
+ });
371
+ })
372
+ },
373
+ {
374
+ kind: "exact",
375
+ path: ROUTES.import,
376
+ handler: handle(async (req, res) => {
377
+ if (!originAllowed(req)) {
378
+ sendError(res, 403, "origin 不被允许");
379
+ return;
380
+ }
381
+ const body = await readJsonBody(req);
382
+ if (typeof body.name !== "string" || typeof body.content !== "string" || body.target !== "user" && body.target !== "project-agents" && body.target !== "project-dsh") {
383
+ sendError(res, 400, "请求缺少 name / content / target 字段");
384
+ return;
385
+ }
386
+ const workspacePath = requireWorkspace(body.target, body.workspacePath);
387
+ sendJson(res, 200, {
388
+ ok: true,
389
+ path: await resolveImport({
390
+ name: body.name,
391
+ target: body.target,
392
+ content: body.content,
393
+ workspacePath
394
+ })
395
+ });
396
+ })
397
+ },
398
+ {
399
+ kind: "exact",
400
+ path: ROUTES.importUrl,
401
+ handler: handle(async (req, res) => {
402
+ if (!originAllowed(req)) {
403
+ sendError(res, 403, "origin 不被允许");
404
+ return;
405
+ }
406
+ const body = await readJsonBody(req);
407
+ if (typeof body.name !== "string" || typeof body.url !== "string" || body.target !== "user" && body.target !== "project-agents" && body.target !== "project-dsh") {
408
+ sendError(res, 400, "请求缺少 name / url / target 字段");
409
+ return;
410
+ }
411
+ const workspacePath = requireWorkspace(body.target, body.workspacePath);
412
+ sendJson(res, 200, {
413
+ ok: true,
414
+ path: await resolveImport({
415
+ name: body.name,
416
+ target: body.target,
417
+ url: body.url,
418
+ workspacePath
419
+ })
420
+ });
421
+ })
422
+ },
423
+ {
424
+ kind: "exact",
425
+ path: ROUTES.delete,
426
+ handler: handle(async (req, res) => {
427
+ if (!originAllowed(req)) {
428
+ sendError(res, 403, "origin 不被允许");
429
+ return;
430
+ }
431
+ const body = await readJsonBody(req);
432
+ if (typeof body.name !== "string" || body.source !== "user" && body.source !== "project-agents" && body.source !== "project-dsh") {
433
+ sendError(res, 400, "请求缺少 name / source 字段");
434
+ return;
435
+ }
436
+ const workspacePath = requireWorkspace(body.source, body.workspacePath);
437
+ sendJson(res, 200, {
438
+ ok: true,
439
+ removed: deleteSkillFile(body.name, body.source, workspacePath)
440
+ });
441
+ })
442
+ }
443
+ ];
444
+ /**
445
+ * Resolve and validate the workspace for a project target: the path must
446
+ * come from the client and must be a registered workspace (never derived
447
+ * from the host process cwd, which may differ from the user's workspace).
448
+ */
449
+ function requireWorkspace(target, candidate) {
450
+ if (target === "user") return void 0;
451
+ if (typeof candidate !== "string") throw new Error("项目目标需要 workspacePath(当前工作区路径)");
452
+ if (!projectPaths().includes(candidate)) throw new Error("workspacePath 不是已注册的工作区");
453
+ return candidate;
454
+ }
455
+ ctx.effect(() => {
456
+ const disposers = routes.map((route) => ctx.webServer.register(route));
457
+ return () => {
458
+ for (const dispose of disposers) dispose();
459
+ };
460
+ }, "dsh-skill-importer: routes");
461
+ }
462
+ //#endregion
463
+ export { ROUTES, apply, inject };
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Skill importer settings section, styled after the harness's General
3
+ * settings rows: one Setting-Cell per block (title + control, hairline
4
+ * separator), design tokens throughout, full zh/en copy.
5
+ *
6
+ * Rows: entry-points description · installed list · file/URL import · target
7
+ * directory · import action. Pure presentation — all data and actions arrive
8
+ * through the four props shares.
9
+ */
10
+ import type { ReactNode } from 'react';
11
+ import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots';
12
+ import type { SkillImporterInjected } from './index.ts';
13
+ /** Props the renderer binds for the section. */
14
+ export type SkillImporterProps = PropsRuntime<'settings.section'> & PropsLocale<'skills.importer'> & InjectFace<SkillImporterInjected>;
15
+ /** The settings section's page body. */
16
+ export declare function SkillImporterSection({ t, useSkills, useWorkspaces, actions }: SkillImporterProps): ReactNode;
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Composer skill picker: a compact pill trigger in the composer's tool row
3
+ * (`conversation.input.left`, right after the resident chrome), styled like
4
+ * the permission (access-mode) select beside it. Opens a top-anchored panel
5
+ * with a name-filter search box; each row shows a first-letter avatar and
6
+ * the bare skill name (no leading `/`). Picking a skill prefixes the draft
7
+ * with `/name ` — the native text gesture — highlighted via the plugin's
8
+ * lexicon contribution.
9
+ */
10
+ import type { ReactNode } from 'react';
11
+ import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots';
12
+ import type { SkillImporterInjected } from './index.ts';
13
+ /** Props the renderer binds for the picker. */
14
+ export type SkillsPickerProps = PropsRuntime<'conversation.input.left'> & PropsLocale<'skills.importer'> & InjectFace<SkillImporterInjected>;
15
+ /** One row of the composer skill picker. */
16
+ export declare function SkillsPicker({ t, useSkills, useInput, inputActions, actions }: SkillsPickerProps): ReactNode;
@@ -0,0 +1,39 @@
1
+ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client';
2
+ import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots';
3
+ import type { SkillImporterProps } from './SkillImporterSection.tsx';
4
+ import type { SkillsPickerProps } from './SkillsPicker.tsx';
5
+ import { type SkillImporterKey } from './locales.ts';
6
+ import type { DeleteRequest, ImportRequest, ImportUrlRequest, SkillListResponse } from '../types.ts';
7
+ declare module '@deepseek-ai/dsh-client-ui-slots' {
8
+ interface LocaleNamespaceMap {
9
+ /** The skill importer section's copy. */
10
+ 'skills.importer': SkillImporterKey;
11
+ }
12
+ }
13
+ /** Installed skill catalog snapshot (global — not per-session anymore). */
14
+ export type SkillCatalogSnapshot = SkillListResponse["skills"];
15
+ /** Registration-side business face for the section. */
16
+ export interface SkillImporterInjected {
17
+ hooks: {
18
+ /** Last successful `/skill-importer/list` result. */
19
+ skills: HostObservable<SkillCatalogSnapshot>;
20
+ };
21
+ actions: {
22
+ /** Re-fetch the installed catalog; resolves to the fresh rows. */
23
+ refreshSkills: () => Promise<SkillCatalogSnapshot>;
24
+ /** Write one skill file via the host route; resolves to the written path. */
25
+ importSkill: (request: ImportRequest) => Promise<string>;
26
+ /** Fetch a URL and write the skill via the host route; resolves to the written path. */
27
+ importUrl: (request: ImportUrlRequest) => Promise<string>;
28
+ /** Delete one installed skill copy via the host route. */
29
+ deleteSkill: (request: DeleteRequest) => Promise<boolean>;
30
+ };
31
+ }
32
+ /** Required services (cordis fiber inject). */
33
+ export declare const inject: string[];
34
+ /**
35
+ * Client plugin body: register the section and its dictionaries.
36
+ * @param ctx - the browser plugin context.
37
+ */
38
+ export declare function apply(ctx: ClientContext): void;
39
+ export type { SkillImporterProps, SkillsPickerProps };
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Locale dictionaries for the skill importer settings section.
3
+ *
4
+ * Product copy is Chinese; English is the fallback dictionary.
5
+ * The key-set source of truth is the zh dictionary (`keyof typeof zh`),
6
+ * matching the locale contract's namespace-key union shape.
7
+ */
8
+ /** Dictionary namespace owned by this plugin. */
9
+ export declare const NS = "skills.importer";
10
+ /** Simplified Chinese dictionary (the key-set source of truth). */
11
+ export declare const zh: {
12
+ nav: string;
13
+ pickerLabel: string;
14
+ pickerPlaceholder: string;
15
+ pickerSearchPlaceholder: string;
16
+ pickerEmpty: string;
17
+ commandDescription: string;
18
+ entryTitle: string;
19
+ entryDescription: string;
20
+ installedTitle: string;
21
+ refresh: string;
22
+ installedEmpty: string;
23
+ sourceAgents: string;
24
+ sourceDsh: string;
25
+ sourceUser: string;
26
+ delete: string;
27
+ deleteConfirm: string;
28
+ deleteFailed: string;
29
+ deleted: string;
30
+ installedFailed: string;
31
+ userOnly: string;
32
+ modelOnly: string;
33
+ importTitle: string;
34
+ fileTab: string;
35
+ urlTab: string;
36
+ chooseFile: string;
37
+ fileHelp: string;
38
+ previewTitle: string;
39
+ previewName: string;
40
+ previewDescription: string;
41
+ previewWhenToUse: string;
42
+ previewMissingName: string;
43
+ previewInvalidName: string;
44
+ previewMissingDescription: string;
45
+ previewOk: string;
46
+ fileTooLarge: string;
47
+ unreadableFile: string;
48
+ urlLabel: string;
49
+ urlNameLabel: string;
50
+ urlNameHelp: string;
51
+ targetLabel: string;
52
+ targetUser: string;
53
+ targetProjectAgents: string;
54
+ targetProjectDsh: string;
55
+ targetProjectNoWorkspace: string;
56
+ noWorkspace: string;
57
+ import: string;
58
+ importing: string;
59
+ imported: string;
60
+ importFailed: string;
61
+ hostUnreachable: string;
62
+ };
63
+ /** The skill importer namespace key union. */
64
+ export type SkillImporterKey = keyof typeof zh;
65
+ /** English dictionary, checked complete against the zh key set. */
66
+ export declare const en: {
67
+ nav: string;
68
+ pickerLabel: string;
69
+ pickerPlaceholder: string;
70
+ pickerSearchPlaceholder: string;
71
+ pickerEmpty: string;
72
+ commandDescription: string;
73
+ entryTitle: string;
74
+ entryDescription: string;
75
+ installedTitle: string;
76
+ refresh: string;
77
+ installedEmpty: string;
78
+ sourceAgents: string;
79
+ sourceDsh: string;
80
+ sourceUser: string;
81
+ delete: string;
82
+ deleteConfirm: string;
83
+ deleteFailed: string;
84
+ deleted: string;
85
+ installedFailed: string;
86
+ userOnly: string;
87
+ modelOnly: string;
88
+ importTitle: string;
89
+ fileTab: string;
90
+ urlTab: string;
91
+ chooseFile: string;
92
+ fileHelp: string;
93
+ previewTitle: string;
94
+ previewName: string;
95
+ previewDescription: string;
96
+ previewWhenToUse: string;
97
+ previewMissingName: string;
98
+ previewInvalidName: string;
99
+ previewMissingDescription: string;
100
+ previewOk: string;
101
+ fileTooLarge: string;
102
+ unreadableFile: string;
103
+ urlLabel: string;
104
+ urlNameLabel: string;
105
+ urlNameHelp: string;
106
+ targetLabel: string;
107
+ targetUser: string;
108
+ targetProjectAgents: string;
109
+ targetProjectDsh: string;
110
+ targetProjectNoWorkspace: string;
111
+ noWorkspace: string;
112
+ import: string;
113
+ importing: string;
114
+ imported: string;
115
+ importFailed: string;
116
+ hostUnreachable: string;
117
+ };
@@ -0,0 +1,5 @@
1
+ /**
2
+ * URL-derived kebab-case skill name (pure, browser-safe).
3
+ */
4
+ /** Kebab-case name for a URL import (derived from the URL's last path segment). */
5
+ export declare function nameFromUrl(url: string): string;