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.
- package/dist/src/agents/adapter.d.ts +6 -0
- package/dist/src/agents/claude.js +2 -2
- package/dist/src/agents/codex-utils.d.ts +24 -0
- package/dist/src/agents/codex-utils.js +195 -0
- package/dist/src/agents/codex.d.ts +3 -4
- package/dist/src/agents/codex.js +63 -198
- package/dist/src/agents/command-spec.d.ts +3 -0
- package/dist/src/agents/command-spec.js +3 -0
- package/dist/src/agents/opencode.js +2 -2
- package/dist/src/agents/pi-context.d.ts +40 -0
- package/dist/src/agents/pi-context.js +177 -0
- package/dist/src/agents/pi.d.ts +1 -7
- package/dist/src/agents/pi.js +39 -186
- package/dist/src/agents/platform-instructions.js +3 -0
- package/dist/src/commands/daemon-windows.d.ts +16 -0
- package/dist/src/commands/daemon-windows.js +99 -0
- package/dist/src/commands/daemon.d.ts +1 -8
- package/dist/src/commands/daemon.js +2 -96
- package/dist/src/manager/prompt.d.ts +1 -1
- package/dist/src/manager/prompt.js +4 -1
- package/dist/src/native-fusion/opencode-parser.d.ts +29 -0
- package/dist/src/native-fusion/opencode-parser.js +121 -0
- package/dist/src/native-fusion/parser-common.d.ts +24 -0
- package/dist/src/native-fusion/parser-common.js +264 -0
- package/dist/src/native-fusion/parsers.d.ts +1 -29
- package/dist/src/native-fusion/parsers.js +33 -383
- package/dist/src/region.js +4 -3
- package/dist/src/session/handlers/chat.js +14 -4
- package/dist/src/session/handlers/control.js +1 -3
- package/dist/src/session/handlers/fs.d.ts +2 -0
- package/dist/src/session/handlers/fs.js +260 -16
- package/dist/src/session/handlers/skills.d.ts +5 -0
- package/dist/src/session/handlers/skills.js +50 -0
- package/dist/src/session/manager.js +17 -1
- package/dist/src/session/types.d.ts +10 -0
- package/dist/src/skills/registry.d.ts +12 -0
- package/dist/src/skills/registry.js +128 -0
- 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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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.
|
|
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.
|
|
43
|
+
"@shennian/wire": "0.1.5"
|
|
44
44
|
},
|
|
45
45
|
"devDependencies": {
|
|
46
46
|
"@types/node": "^20",
|