shennian 0.2.63 → 0.2.65

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/dist/src/agents/adapter.d.ts +6 -0
  2. package/dist/src/agents/claude.js +2 -2
  3. package/dist/src/agents/codex-utils.d.ts +24 -0
  4. package/dist/src/agents/codex-utils.js +195 -0
  5. package/dist/src/agents/codex.d.ts +3 -4
  6. package/dist/src/agents/codex.js +63 -198
  7. package/dist/src/agents/command-spec.d.ts +3 -0
  8. package/dist/src/agents/command-spec.js +3 -0
  9. package/dist/src/agents/opencode.js +2 -2
  10. package/dist/src/agents/pi-context.d.ts +40 -0
  11. package/dist/src/agents/pi-context.js +177 -0
  12. package/dist/src/agents/pi.d.ts +1 -7
  13. package/dist/src/agents/pi.js +39 -186
  14. package/dist/src/agents/platform-instructions.js +3 -0
  15. package/dist/src/commands/daemon-windows.d.ts +16 -0
  16. package/dist/src/commands/daemon-windows.js +99 -0
  17. package/dist/src/commands/daemon.d.ts +1 -8
  18. package/dist/src/commands/daemon.js +2 -96
  19. package/dist/src/manager/prompt.d.ts +1 -1
  20. package/dist/src/manager/prompt.js +4 -1
  21. package/dist/src/native-fusion/opencode-parser.d.ts +29 -0
  22. package/dist/src/native-fusion/opencode-parser.js +121 -0
  23. package/dist/src/native-fusion/parser-common.d.ts +24 -0
  24. package/dist/src/native-fusion/parser-common.js +264 -0
  25. package/dist/src/native-fusion/parsers.d.ts +1 -29
  26. package/dist/src/native-fusion/parsers.js +33 -383
  27. package/dist/src/region.js +4 -3
  28. package/dist/src/session/handlers/chat.js +14 -4
  29. package/dist/src/session/handlers/control.js +1 -3
  30. package/dist/src/session/handlers/fs.d.ts +2 -0
  31. package/dist/src/session/handlers/fs.js +260 -16
  32. package/dist/src/session/handlers/skills.d.ts +5 -0
  33. package/dist/src/session/handlers/skills.js +50 -0
  34. package/dist/src/session/manager.js +17 -1
  35. package/dist/src/session/types.d.ts +10 -0
  36. package/dist/src/skills/registry.d.ts +12 -0
  37. package/dist/src/skills/registry.js +128 -0
  38. package/package.json +2 -2
@@ -4,9 +4,70 @@ import fs from 'node:fs';
4
4
  import os from 'node:os';
5
5
  import path from 'node:path';
6
6
  const FILE_SYSTEM_ROOTS_PATH = '__roots__';
7
+ const MAX_FOLDER_UPLOAD_FILES = 2000;
8
+ const MAX_FOLDER_UPLOAD_TOTAL_SIZE = 1024 * 1024 * 1024;
7
9
  function isWindowsAbsolutePath(pathValue) {
8
10
  return /^[A-Za-z]:([\\/]|$)/.test(pathValue) || /^\\\\[^\\]+\\[^\\]+/.test(pathValue);
9
11
  }
12
+ function pathApiForPath(pathValue) {
13
+ return isWindowsAbsolutePath(pathValue) ? path.win32 : path.posix;
14
+ }
15
+ function makeFsEntry(entryPath) {
16
+ const stat = fs.statSync(entryPath);
17
+ const api = pathApiForPath(entryPath);
18
+ return {
19
+ name: api.basename(entryPath),
20
+ path: entryPath,
21
+ isDir: stat.isDirectory(),
22
+ size: stat.isFile() ? stat.size : undefined,
23
+ modifiedAt: stat.mtimeMs,
24
+ };
25
+ }
26
+ function isSafeRelativeUploadPath(relativePath) {
27
+ if (!relativePath || relativePath === '.' || relativePath.trim() !== relativePath)
28
+ return false;
29
+ if (path.posix.isAbsolute(relativePath) || path.win32.isAbsolute(relativePath))
30
+ return false;
31
+ const normalized = relativePath.replace(/\\/g, '/');
32
+ if (normalized !== relativePath)
33
+ return false;
34
+ return normalized
35
+ .split('/')
36
+ .every((segment) => segment && segment !== '.' && segment !== '..' && !hasControlChar(segment));
37
+ }
38
+ function isSafeRenameName(name) {
39
+ const trimmed = name.trim();
40
+ if (!trimmed || trimmed !== name)
41
+ return false;
42
+ if (name === '.' || name === '..')
43
+ return false;
44
+ if (hasControlChar(name) || /[\\/:]/.test(name))
45
+ return false;
46
+ if (/[<>|"?*]/.test(name))
47
+ return false;
48
+ if (/[. ]$/.test(name))
49
+ return false;
50
+ const upper = name.split('.')[0]?.toUpperCase();
51
+ if (upper && /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/.test(upper))
52
+ return false;
53
+ return true;
54
+ }
55
+ function hasControlChar(value) {
56
+ return Array.from(value).some((char) => {
57
+ const code = char.charCodeAt(0);
58
+ return code >= 0x00 && code <= 0x1f;
59
+ });
60
+ }
61
+ function decodeManifest(value) {
62
+ if (!Array.isArray(value))
63
+ return [];
64
+ return value.map((item) => ({
65
+ relativePath: String(item.relativePath || ''),
66
+ size: Number(item.size || 0),
67
+ mimeType: item.mimeType,
68
+ modifiedAt: item.modifiedAt,
69
+ }));
70
+ }
10
71
  export async function handleFsLs(runtime, req) {
11
72
  const requestedPath = req.params.path || os.homedir();
12
73
  const rootPath = req.params.rootPath || requestedPath;
@@ -129,6 +190,83 @@ export async function handleFsRead(runtime, req) {
129
190
  runtime.client.sendRes({ type: 'res', id: req.id, ok: false, error: String(err) });
130
191
  }
131
192
  }
193
+ export async function handleFsWrite(runtime, req) {
194
+ const requestedPath = req.params.path;
195
+ const content = req.params.content;
196
+ const rootPath = req.params.rootPath || requestedPath;
197
+ if (!requestedPath || typeof content !== 'string') {
198
+ runtime.client.sendRes({ type: 'res', id: req.id, ok: false, error: 'path and content are required' });
199
+ return;
200
+ }
201
+ const resolved = runtime.resolveAuthorizedPath(requestedPath, rootPath);
202
+ if (!resolved.ok) {
203
+ runtime.client.sendRes({ type: 'res', id: req.id, ok: false, error: resolved.error });
204
+ return;
205
+ }
206
+ try {
207
+ const existing = fs.existsSync(resolved.path) ? fs.statSync(resolved.path) : null;
208
+ if (existing && !existing.isFile()) {
209
+ runtime.client.sendRes({ type: 'res', id: req.id, ok: false, error: 'Not a file' });
210
+ return;
211
+ }
212
+ fs.writeFileSync(resolved.path, content, 'utf-8');
213
+ const stat = fs.statSync(resolved.path);
214
+ runtime.client.sendRes({
215
+ type: 'res',
216
+ id: req.id,
217
+ ok: true,
218
+ payload: { path: resolved.path, size: stat.size, modifiedAt: stat.mtimeMs },
219
+ });
220
+ }
221
+ catch (err) {
222
+ runtime.client.sendRes({ type: 'res', id: req.id, ok: false, error: String(err) });
223
+ }
224
+ }
225
+ export async function handleFsRename(runtime, req) {
226
+ const requestedPath = req.params.path;
227
+ const newName = req.params.newName;
228
+ const rootPath = req.params.rootPath || requestedPath;
229
+ if (!requestedPath || !newName) {
230
+ runtime.client.sendRes({ type: 'res', id: req.id, ok: false, error: 'path and newName are required' });
231
+ return;
232
+ }
233
+ if (!isSafeRenameName(newName)) {
234
+ runtime.client.sendRes({ type: 'res', id: req.id, ok: false, error: 'Invalid newName' });
235
+ return;
236
+ }
237
+ const resolved = runtime.resolveAuthorizedPath(requestedPath, rootPath);
238
+ if (!resolved.ok) {
239
+ runtime.client.sendRes({ type: 'res', id: req.id, ok: false, error: resolved.error });
240
+ return;
241
+ }
242
+ try {
243
+ const api = pathApiForPath(resolved.path);
244
+ const newPath = api.join(api.dirname(resolved.path), newName);
245
+ const checkedNewPath = runtime.resolveAuthorizedPath(newPath, rootPath);
246
+ if (!checkedNewPath.ok) {
247
+ runtime.client.sendRes({ type: 'res', id: req.id, ok: false, error: checkedNewPath.error });
248
+ return;
249
+ }
250
+ if (fs.existsSync(checkedNewPath.path)) {
251
+ runtime.client.sendRes({ type: 'res', id: req.id, ok: false, error: 'Target already exists' });
252
+ return;
253
+ }
254
+ fs.renameSync(resolved.path, checkedNewPath.path);
255
+ runtime.client.sendRes({
256
+ type: 'res',
257
+ id: req.id,
258
+ ok: true,
259
+ payload: {
260
+ oldPath: resolved.path,
261
+ newPath: checkedNewPath.path,
262
+ entry: makeFsEntry(checkedNewPath.path),
263
+ },
264
+ });
265
+ }
266
+ catch (err) {
267
+ runtime.client.sendRes({ type: 'res', id: req.id, ok: false, error: String(err) });
268
+ }
269
+ }
132
270
  export async function handleFsTransfer(runtime, req) {
133
271
  const { name, targetPath, data, direct } = req.params;
134
272
  if (!name || !data) {
@@ -156,8 +294,11 @@ export async function handleFsTransfer(runtime, req) {
156
294
  }
157
295
  }
158
296
  export async function handleFsTransferStart(runtime, req) {
159
- const { name, targetPath, totalSize, direct } = req.params;
160
- if (!name || !totalSize) {
297
+ const { name, targetPath, totalSize, direct, kind, baseName } = req.params;
298
+ const manifest = decodeManifest(req.params.manifest);
299
+ const isFolder = kind === 'folder';
300
+ const transferName = isFolder ? (baseName || name) : name;
301
+ if (!transferName || (!isFolder && !totalSize)) {
161
302
  runtime.client.sendRes({ type: 'res', id: req.id, ok: false, error: 'name and totalSize are required' });
162
303
  return;
163
304
  }
@@ -173,10 +314,74 @@ export async function handleFsTransferStart(runtime, req) {
173
314
  if (!direct)
174
315
  fs.mkdirSync(destinationDir, { recursive: true });
175
316
  const transferId = `tf-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
317
+ if (isFolder) {
318
+ if (!manifest.length) {
319
+ runtime.client.sendRes({ type: 'res', id: req.id, ok: false, error: 'manifest is required for folder uploads' });
320
+ return;
321
+ }
322
+ if (manifest.length > MAX_FOLDER_UPLOAD_FILES) {
323
+ runtime.client.sendRes({ type: 'res', id: req.id, ok: false, error: `Too many files: ${manifest.length}` });
324
+ return;
325
+ }
326
+ const folderName = path.basename(transferName);
327
+ if (!isSafeRenameName(folderName)) {
328
+ runtime.client.sendRes({ type: 'res', id: req.id, ok: false, error: 'Invalid folder name' });
329
+ return;
330
+ }
331
+ const targetDir = path.join(destinationDir, folderName);
332
+ const checkedTargetDir = runtime.resolveAuthorizedPath(targetDir, rootPath);
333
+ if (!checkedTargetDir.ok) {
334
+ runtime.client.sendRes({ type: 'res', id: req.id, ok: false, error: checkedTargetDir.error });
335
+ return;
336
+ }
337
+ const seen = new Set();
338
+ let aggregateSize = 0;
339
+ const files = new Map();
340
+ for (const item of manifest) {
341
+ if (!isSafeRelativeUploadPath(item.relativePath)) {
342
+ runtime.client.sendRes({ type: 'res', id: req.id, ok: false, error: `Invalid relativePath: ${item.relativePath}` });
343
+ return;
344
+ }
345
+ if (!Number.isFinite(item.size) || item.size < 0) {
346
+ runtime.client.sendRes({ type: 'res', id: req.id, ok: false, error: `Invalid size: ${item.relativePath}` });
347
+ return;
348
+ }
349
+ if (seen.has(item.relativePath)) {
350
+ runtime.client.sendRes({ type: 'res', id: req.id, ok: false, error: `Duplicate relativePath: ${item.relativePath}` });
351
+ return;
352
+ }
353
+ seen.add(item.relativePath);
354
+ aggregateSize += item.size;
355
+ if (aggregateSize > MAX_FOLDER_UPLOAD_TOTAL_SIZE) {
356
+ runtime.client.sendRes({ type: 'res', id: req.id, ok: false, error: `Folder too large: ${aggregateSize} bytes` });
357
+ return;
358
+ }
359
+ const finalPath = path.join(checkedTargetDir.path, ...item.relativePath.split('/'));
360
+ const checkedFinalPath = runtime.resolveAuthorizedPath(finalPath, rootPath);
361
+ if (!checkedFinalPath.ok) {
362
+ runtime.client.sendRes({ type: 'res', id: req.id, ok: false, error: checkedFinalPath.error });
363
+ return;
364
+ }
365
+ const tempPath = path.join(os.tmpdir(), `.shennian-upload-${transferId}-${files.size}`);
366
+ fs.writeFileSync(tempPath, Buffer.alloc(0));
367
+ files.set(item.relativePath, { relativePath: item.relativePath, tempPath, targetPath: checkedFinalPath.path, size: item.size });
368
+ }
369
+ runtime.pendingTransfers.set(transferId, {
370
+ tempPath: '',
371
+ targetPath: checkedTargetDir.path,
372
+ totalSize: aggregateSize,
373
+ kind: 'folder',
374
+ rootPath,
375
+ targetDir: checkedTargetDir.path,
376
+ files,
377
+ });
378
+ runtime.client.sendRes({ type: 'res', id: req.id, ok: true, payload: { transferId, path: checkedTargetDir.path } });
379
+ return;
380
+ }
176
381
  const tempPath = path.join(os.tmpdir(), `.shennian-upload-${transferId}`);
177
- const finalPath = path.join(destinationDir, path.basename(name));
382
+ const finalPath = path.join(destinationDir, path.basename(transferName));
178
383
  fs.writeFileSync(tempPath, Buffer.alloc(0));
179
- runtime.pendingTransfers.set(transferId, { tempPath, targetPath: finalPath, totalSize });
384
+ runtime.pendingTransfers.set(transferId, { tempPath, targetPath: finalPath, totalSize, kind: 'file' });
180
385
  runtime.client.sendRes({ type: 'res', id: req.id, ok: true, payload: { transferId } });
181
386
  }
182
387
  catch (err) {
@@ -184,15 +389,26 @@ export async function handleFsTransferStart(runtime, req) {
184
389
  }
185
390
  }
186
391
  export async function handleFsTransferChunk(runtime, req) {
187
- const { transferId, offset, data } = req.params;
392
+ const { transferId, offset, data, relativePath } = req.params;
188
393
  const transfer = runtime.pendingTransfers.get(transferId);
189
394
  if (!transfer) {
190
395
  runtime.client.sendRes({ type: 'res', id: req.id, ok: false, error: 'Invalid transferId' });
191
396
  return;
192
397
  }
193
398
  try {
399
+ const target = transfer.kind === 'folder'
400
+ ? transfer.files?.get(String(relativePath || ''))
401
+ : { tempPath: transfer.tempPath, size: transfer.totalSize };
402
+ if (!target) {
403
+ runtime.client.sendRes({ type: 'res', id: req.id, ok: false, error: 'Invalid relativePath' });
404
+ return;
405
+ }
194
406
  const buffer = Buffer.from(data, 'base64');
195
- const fd = fs.openSync(transfer.tempPath, 'r+');
407
+ if (offset < 0 || offset + buffer.length > target.size) {
408
+ runtime.client.sendRes({ type: 'res', id: req.id, ok: false, error: 'Chunk exceeds declared size' });
409
+ return;
410
+ }
411
+ const fd = fs.openSync(target.tempPath, 'r+');
196
412
  try {
197
413
  fs.writeSync(fd, buffer, 0, buffer.length, offset);
198
414
  }
@@ -213,6 +429,22 @@ export async function handleFsTransferFinish(runtime, req) {
213
429
  return;
214
430
  }
215
431
  try {
432
+ if (transfer.kind === 'folder') {
433
+ let count = 0;
434
+ for (const file of transfer.files?.values() ?? []) {
435
+ fs.mkdirSync(path.dirname(file.targetPath), { recursive: true });
436
+ fs.renameSync(file.tempPath, file.targetPath);
437
+ count += 1;
438
+ }
439
+ runtime.pendingTransfers.delete(transferId);
440
+ runtime.client.sendRes({
441
+ type: 'res',
442
+ id: req.id,
443
+ ok: true,
444
+ payload: { path: transfer.targetDir ?? transfer.targetPath, count },
445
+ });
446
+ return;
447
+ }
216
448
  fs.renameSync(transfer.tempPath, transfer.targetPath);
217
449
  runtime.pendingTransfers.delete(transferId);
218
450
  runtime.client.sendRes({ type: 'res', id: req.id, ok: true, payload: { path: transfer.targetPath } });
@@ -233,11 +465,17 @@ export async function handleFsTransferAbort(runtime, req) {
233
465
  const { transferId } = req.params;
234
466
  const transfer = runtime.pendingTransfers.get(transferId);
235
467
  if (transfer) {
236
- try {
237
- fs.unlinkSync(transfer.tempPath);
238
- }
239
- catch {
240
- // ignore cleanup failures
468
+ const tempPaths = transfer.kind === 'folder'
469
+ ? Array.from(transfer.files?.values() ?? []).map((file) => file.tempPath)
470
+ : [transfer.tempPath];
471
+ for (const tempPath of tempPaths) {
472
+ try {
473
+ if (tempPath)
474
+ fs.unlinkSync(tempPath);
475
+ }
476
+ catch {
477
+ // ignore cleanup failures
478
+ }
241
479
  }
242
480
  runtime.pendingTransfers.delete(transferId);
243
481
  }
@@ -245,11 +483,17 @@ export async function handleFsTransferAbort(runtime, req) {
245
483
  }
246
484
  export function cleanupPendingTransfers(runtime) {
247
485
  for (const [, transfer] of runtime.pendingTransfers) {
248
- try {
249
- fs.unlinkSync(transfer.tempPath);
250
- }
251
- catch {
252
- // ignore cleanup failures
486
+ const tempPaths = transfer.kind === 'folder'
487
+ ? Array.from(transfer.files?.values() ?? []).map((file) => file.tempPath)
488
+ : [transfer.tempPath];
489
+ for (const tempPath of tempPaths) {
490
+ try {
491
+ if (tempPath)
492
+ fs.unlinkSync(tempPath);
493
+ }
494
+ catch {
495
+ // ignore cleanup failures
496
+ }
253
497
  }
254
498
  }
255
499
  runtime.pendingTransfers.clear();
@@ -0,0 +1,5 @@
1
+ import type { ReqFrame } from '@shennian/wire';
2
+ import type { SessionManagerRuntime } from '../types.js';
3
+ export declare function handleSkillList(runtime: SessionManagerRuntime, req: ReqFrame): Promise<void>;
4
+ export declare function handleSkillInstall(runtime: SessionManagerRuntime, req: ReqFrame): Promise<void>;
5
+ export declare function handleSkillUse(runtime: SessionManagerRuntime, req: ReqFrame): Promise<void>;
@@ -0,0 +1,50 @@
1
+ // @arch docs/features/skill-marketplace.md
2
+ // @test src/__tests__/skill-registry.test.ts
3
+ import { buildSkillUsePrompt, installSkillFromUrl, listInstalledSkills } from '../../skills/registry.js';
4
+ export async function handleSkillList(runtime, req) {
5
+ runtime.client.sendRes({
6
+ type: 'res',
7
+ id: req.id,
8
+ ok: true,
9
+ payload: { skills: listInstalledSkills() },
10
+ });
11
+ }
12
+ export async function handleSkillInstall(runtime, req) {
13
+ const installUrl = typeof req.params.installUrl === 'string' ? req.params.installUrl : '';
14
+ if (!installUrl) {
15
+ runtime.client.sendRes({ type: 'res', id: req.id, ok: false, error: 'installUrl is required' });
16
+ return;
17
+ }
18
+ const installed = await installSkillFromUrl(installUrl);
19
+ runtime.client.sendRes({
20
+ type: 'res',
21
+ id: req.id,
22
+ ok: true,
23
+ payload: { skill: installed },
24
+ });
25
+ }
26
+ export async function handleSkillUse(runtime, req) {
27
+ const skillId = typeof req.params.skillId === 'string' ? req.params.skillId : '';
28
+ const workDir = typeof req.params.workDir === 'string' ? req.params.workDir : process.cwd();
29
+ const attachments = Array.isArray(req.params.attachments)
30
+ ? req.params.attachments
31
+ .map((item) => {
32
+ const record = item;
33
+ const path = typeof record.path === 'string' ? record.path : '';
34
+ const name = typeof record.name === 'string' ? record.name : '';
35
+ const mimeType = typeof record.mimeType === 'string' ? record.mimeType : '';
36
+ return path && name ? { path, name, mimeType } : null;
37
+ })
38
+ .filter((item) => item != null)
39
+ : undefined;
40
+ if (!skillId) {
41
+ runtime.client.sendRes({ type: 'res', id: req.id, ok: false, error: 'skillId is required' });
42
+ return;
43
+ }
44
+ runtime.client.sendRes({
45
+ type: 'res',
46
+ id: req.id,
47
+ ok: true,
48
+ payload: { prompt: buildSkillUsePrompt(skillId, workDir, attachments) },
49
+ });
50
+ }
@@ -8,7 +8,8 @@ import { handleAgentsRefresh, handleModelsRefresh } from './handlers/agents.js';
8
8
  import { handleAgentConfigClear, handleAgentConfigGet, handleAgentConfigTest, handleAgentConfigUpsert, } from './handlers/agent-config.js';
9
9
  import { handleChatAbort, handleChatSend } from './handlers/chat.js';
10
10
  import { handleSessionRefresh } from './handlers/session-refresh.js';
11
- import { cleanupPendingTransfers, handleFsLs, handleFsRead, handleFsTransfer, handleFsTransferAbort, handleFsTransferChunk, handleFsTransferFinish, handleFsTransferStart, } from './handlers/fs.js';
11
+ import { cleanupPendingTransfers, handleFsLs, handleFsRead, handleFsRename, handleFsWrite, handleFsTransfer, handleFsTransferAbort, handleFsTransferChunk, handleFsTransferFinish, handleFsTransferStart, } from './handlers/fs.js';
12
+ import { handleSkillInstall, handleSkillList, handleSkillUse } from './handlers/skills.js';
12
13
  import { handleRegionProbe, handleRegionSwitch, handleUpgradeSetPolicy } from './handlers/control.js';
13
14
  import { ManagerRuntimeService, setManagerRuntimeService } from '../manager/runtime.js';
14
15
  import { ChatQueueManager } from './queue.js';
@@ -113,6 +114,21 @@ export class SessionManager {
113
114
  case 'fs.read':
114
115
  await handleFsRead(runtime, req);
115
116
  break;
117
+ case 'fs.write':
118
+ await handleFsWrite(runtime, req);
119
+ break;
120
+ case 'fs.rename':
121
+ await handleFsRename(runtime, req);
122
+ break;
123
+ case 'skill.list':
124
+ await handleSkillList(runtime, req);
125
+ break;
126
+ case 'skill.install':
127
+ await handleSkillInstall(runtime, req);
128
+ break;
129
+ case 'skill.use':
130
+ await handleSkillUse(runtime, req);
131
+ break;
116
132
  case 'fs.transfer':
117
133
  await handleFsTransfer(runtime, req);
118
134
  break;
@@ -25,6 +25,16 @@ export type PendingTransfer = {
25
25
  tempPath: string;
26
26
  targetPath: string;
27
27
  totalSize: number;
28
+ kind?: 'file' | 'folder';
29
+ rootPath?: string;
30
+ targetDir?: string;
31
+ files?: Map<string, PendingTransferFile>;
32
+ };
33
+ export type PendingTransferFile = {
34
+ relativePath: string;
35
+ tempPath: string;
36
+ targetPath: string;
37
+ size: number;
28
38
  };
29
39
  export type ChatQueueService = {
30
40
  handleEnqueue(req: import('@shennian/wire').ReqFrame): Promise<void>;
@@ -0,0 +1,12 @@
1
+ import type { InstalledShennianSkill } from '@shennian/wire';
2
+ export declare function getSkillsDir(): string;
3
+ export declare function getSkillDir(skillId: string): string;
4
+ export declare function listInstalledSkills(): InstalledShennianSkill[];
5
+ export declare function getInstalledSkill(skillId: string): InstalledShennianSkill | null;
6
+ export declare function installSkillFromUrl(installUrl: string): Promise<InstalledShennianSkill>;
7
+ export declare function buildSkillUsePrompt(skillId: string, workDir: string, attachments?: Array<{
8
+ path: string;
9
+ name: string;
10
+ mimeType: string;
11
+ }>): string;
12
+ export declare function buildInstalledSkillInstructions(): string;
@@ -0,0 +1,128 @@
1
+ // @arch docs/features/skill-marketplace.md
2
+ // @test src/__tests__/skill-registry.test.ts
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { resolveShennianPath } from '../config/index.js';
6
+ function safeSkillId(skillId) {
7
+ const normalized = skillId.trim();
8
+ if (!/^[a-z0-9][a-z0-9._-]{1,80}$/i.test(normalized)) {
9
+ throw new Error('Invalid skill id');
10
+ }
11
+ return normalized;
12
+ }
13
+ function assertSafeBundlePath(relativePath) {
14
+ const normalized = relativePath.replace(/\\/g, '/');
15
+ if (!normalized || normalized.startsWith('/') || normalized.includes('\0')) {
16
+ throw new Error(`Unsafe bundle path: ${relativePath}`);
17
+ }
18
+ const parts = normalized.split('/');
19
+ if (parts.some((part) => !part || part === '.' || part === '..')) {
20
+ throw new Error(`Unsafe bundle path: ${relativePath}`);
21
+ }
22
+ return normalized;
23
+ }
24
+ function readJsonFile(filePath) {
25
+ try {
26
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
27
+ }
28
+ catch {
29
+ return null;
30
+ }
31
+ }
32
+ export function getSkillsDir() {
33
+ const skillsDir = resolveShennianPath('skills');
34
+ fs.mkdirSync(skillsDir, { recursive: true });
35
+ return skillsDir;
36
+ }
37
+ export function getSkillDir(skillId) {
38
+ return path.join(getSkillsDir(), safeSkillId(skillId));
39
+ }
40
+ export function listInstalledSkills() {
41
+ const dir = getSkillsDir();
42
+ return fs.readdirSync(dir, { withFileTypes: true })
43
+ .filter((entry) => entry.isDirectory())
44
+ .map((entry) => {
45
+ const skillPath = path.join(dir, entry.name);
46
+ const manifest = readJsonFile(path.join(skillPath, 'skill.json'));
47
+ const install = readJsonFile(path.join(skillPath, '.shennian-install.json'));
48
+ if (!manifest?.id || !manifest.name)
49
+ return null;
50
+ return {
51
+ ...manifest,
52
+ installedAt: install?.installedAt ?? new Date(0).toISOString(),
53
+ path: skillPath,
54
+ };
55
+ })
56
+ .filter((item) => item != null)
57
+ .sort((left, right) => left.name.localeCompare(right.name));
58
+ }
59
+ export function getInstalledSkill(skillId) {
60
+ return listInstalledSkills().find((skill) => skill.id === skillId) ?? null;
61
+ }
62
+ async function fetchSkillBundle(installUrl) {
63
+ if (!installUrl.startsWith('http://') && !installUrl.startsWith('https://')) {
64
+ return readJsonFile(path.resolve(installUrl)) ?? (() => {
65
+ throw new Error('Invalid skill bundle');
66
+ })();
67
+ }
68
+ const res = await fetch(installUrl);
69
+ if (!res.ok)
70
+ throw new Error(`Skill download failed: ${res.status} ${res.statusText}`);
71
+ return await res.json();
72
+ }
73
+ function writeBundleFile(skillDir, file) {
74
+ const relativePath = assertSafeBundlePath(file.path);
75
+ const target = path.join(skillDir, relativePath);
76
+ fs.mkdirSync(path.dirname(target), { recursive: true });
77
+ fs.writeFileSync(target, file.content, 'utf8');
78
+ if (file.executable && process.platform !== 'win32') {
79
+ fs.chmodSync(target, 0o755);
80
+ }
81
+ }
82
+ export async function installSkillFromUrl(installUrl) {
83
+ const bundle = await fetchSkillBundle(installUrl);
84
+ if (!bundle?.manifest?.id || !Array.isArray(bundle.files)) {
85
+ throw new Error('Invalid skill bundle');
86
+ }
87
+ const skillDir = getSkillDir(bundle.manifest.id);
88
+ fs.rmSync(skillDir, { recursive: true, force: true });
89
+ fs.mkdirSync(skillDir, { recursive: true });
90
+ fs.writeFileSync(path.join(skillDir, 'skill.json'), JSON.stringify(bundle.manifest, null, 2) + '\n', 'utf8');
91
+ for (const file of bundle.files) {
92
+ writeBundleFile(skillDir, file);
93
+ }
94
+ fs.writeFileSync(path.join(skillDir, '.shennian-install.json'), JSON.stringify({ installedAt: new Date().toISOString(), installUrl }, null, 2) + '\n', 'utf8');
95
+ const installed = getInstalledSkill(bundle.manifest.id);
96
+ if (!installed)
97
+ throw new Error('Skill installation did not complete');
98
+ return installed;
99
+ }
100
+ export function buildSkillUsePrompt(skillId, workDir, attachments) {
101
+ const installed = getInstalledSkill(skillId);
102
+ if (!installed)
103
+ throw new Error(`Skill not installed: ${skillId}`);
104
+ const attachmentLines = (attachments ?? [])
105
+ .map((attachment) => `- ${attachment.name} (${attachment.mimeType}): ${attachment.path}`)
106
+ .join('\n');
107
+ return [
108
+ `Use the Shennian skill "${installed.name}".`,
109
+ '',
110
+ `Skill folder: ${installed.path}`,
111
+ `Before using it, read: ${path.join(installed.path, 'SKILL.md')}`,
112
+ `Working directory: ${workDir}`,
113
+ attachmentLines ? `\nCurrent attachments:\n${attachmentLines}` : '',
114
+ '',
115
+ 'Follow the skill output contract. Prefer local deterministic extraction first, then use agent document or vision capability only when needed.',
116
+ ].filter(Boolean).join('\n');
117
+ }
118
+ export function buildInstalledSkillInstructions() {
119
+ const skills = listInstalledSkills();
120
+ if (skills.length === 0)
121
+ return '';
122
+ const lines = skills.map((skill) => [
123
+ `- ${skill.id}: ${skill.description}`,
124
+ ` Skill folder: ${skill.path}`,
125
+ ` Read ${path.join(skill.path, 'SKILL.md')} before using this skill.`,
126
+ ].join('\n'));
127
+ return `## Shennian Skills\n\nInstalled skills are file-based capabilities managed by Shennian under ${getSkillsDir()}.\nWhen a user asks to use one, read its SKILL.md and follow its output contract.\n\n${lines.join('\n')}`;
128
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shennian",
3
- "version": "0.2.63",
3
+ "version": "0.2.65",
4
4
  "description": "Shennian — AI Agent Control Plane CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -40,7 +40,7 @@
40
40
  "commander": "^13.1.0",
41
41
  "qrcode-terminal": "^0.12.0",
42
42
  "ws": "^8.18.1",
43
- "@shennian/wire": "0.1.4"
43
+ "@shennian/wire": "0.1.5"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@types/node": "^20",