@yep-ai/cli 1.0.0 → 1.0.1

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/README.md CHANGED
@@ -5,15 +5,14 @@
5
5
  ## 安装与使用
6
6
 
7
7
  ```sh
8
- npm install -g @yep-ai/cli
9
- yep --help
8
+ npx --yes @yep-ai/cli@latest --help
10
9
  ```
11
10
 
12
- 也可以无需全局安装直接运行:
11
+ 通过 npx 直接运行 npm 最新版,无需全局安装:
13
12
 
14
13
  ```sh
15
- npx --yes @yep-ai/cli --help
16
- npx --yes @yep-ai/cli device start --server https://yep.nihao.cool
14
+ npx --yes @yep-ai/cli@latest --help
15
+ npx --yes @yep-ai/cli@latest device start --server https://yep.nihao.cool
17
16
  ```
18
17
 
19
18
  本包包含完整实现。`yep-cli` 是计划中的同版本入口包,当前尚未发布本项目版本,请使用 `@yep-ai/cli`。
@@ -26,31 +25,31 @@ npx --yes @yep-ai/cli device start --server https://yep.nihao.cool
26
25
  npm install
27
26
  npm run yep -- --help
28
27
  npm run pack:cli
29
- npm install --prefix ./tmp/yep-install ./dist/cli/scoped/yep-ai-cli-1.0.0.tgz ./dist/cli/unscoped/yep-cli-1.0.0.tgz
28
+ npm install --prefix ./tmp/yep-install ./dist/cli/scoped/yep-ai-cli-1.0.1.tgz ./dist/cli/unscoped/yep-cli-1.0.1.tgz
30
29
  ./tmp/yep-install/node_modules/.bin/yep --version
31
30
  ```
32
31
 
33
- 完整实现包为 `scoped/yep-ai-cli-1.0.0.tgz`,入口包为 `unscoped/yep-cli-1.0.0.tgz`。本地验证时可一起指定两份文件,无需从 npm 下载实现包。输出的 `manifest.json` 记录包名、版本、依赖和 SHA-256。
32
+ 完整实现包为 `scoped/yep-ai-cli-1.0.1.tgz`,入口包为 `unscoped/yep-cli-1.0.1.tgz`。本地验证时可一起指定两份文件,无需从 npm 下载实现包。输出的 `manifest.json` 记录包名、版本、依赖和 SHA-256。
34
33
 
35
34
  ## 市场与 Git 内容版本
36
35
 
37
36
  ```sh
38
- yep login --registry https://yep.nihao.cool
39
- yep whoami
40
- yep search assistant
41
- yep info @creator/demo
42
- yep client set codex,claude
43
- yep install @creator/demo --client codex
44
- yep list
45
- yep list --remote
46
- yep list --mine
47
- yep uninstall @creator/demo --client codex
48
- yep publish ./my-skill
49
- yep history @creator/demo
50
- yep diff @creator/demo --base published
51
- yep publish ./my-agent --kind agent
52
- yep submit @creator/demo --kind agent
53
- yep withdraw @creator/demo --kind agent
37
+ npx --yes @yep-ai/cli@latest login --registry https://yep.nihao.cool
38
+ npx --yes @yep-ai/cli@latest whoami
39
+ npx --yes @yep-ai/cli@latest search assistant
40
+ npx --yes @yep-ai/cli@latest info @creator/demo
41
+ npx --yes @yep-ai/cli@latest client set codex,claude
42
+ npx --yes @yep-ai/cli@latest install @creator/demo --client codex
43
+ npx --yes @yep-ai/cli@latest list
44
+ npx --yes @yep-ai/cli@latest list --remote
45
+ npx --yes @yep-ai/cli@latest list --mine
46
+ npx --yes @yep-ai/cli@latest uninstall @creator/demo --client codex
47
+ npx --yes @yep-ai/cli@latest publish ./my-skill
48
+ npx --yes @yep-ai/cli@latest history @creator/demo
49
+ npx --yes @yep-ai/cli@latest diff @creator/demo --base published
50
+ npx --yes @yep-ai/cli@latest publish ./my-agent --kind agent
51
+ npx --yes @yep-ai/cli@latest submit @creator/demo --kind agent
52
+ npx --yes @yep-ai/cli@latest withdraw @creator/demo --kind agent
54
53
  ```
55
54
 
56
55
  默认资源类型为 `skill`,也支持 `--kind agent|plugin`。Skill 私有保存后从网页申请共享审核;Agent/Plugin 的发布会提交审核。审核通过才进入公开市场。`history` 和 `diff` 访问服务端保留的真实 Git 版本。
@@ -60,14 +59,18 @@ yep withdraw @creator/demo --kind agent
60
59
  ## 设备节点
61
60
 
62
61
  ```sh
63
- yep device login --server https://yep.nihao.cool
64
- yep device start "/path/to/documents" --server https://yep.nihao.cool
65
- yep device status
66
- yep device logout
62
+ npx --yes @yep-ai/cli@latest device login --server https://yep.nihao.cool
63
+ npx --yes @yep-ai/cli@latest device start "/path/to/documents" --server https://yep.nihao.cool
64
+ npx --yes @yep-ai/cli@latest device status
65
+ npx --yes @yep-ai/cli@latest device logout
67
66
  ```
68
67
 
69
68
  设备通过 HTTPS 配对和 WSS 出站连接;网页批准设备后,还需在某场咨询中选择目录和权限。目录不会整体上传。最多指定 5 个目录;指定目录时仅提供受限内置命令。不指定目录时,命令拥有当前系统用户的 shell 权限,可访问其他文件和网络。进程退出后节点离线,不安装后台服务。设备授权最长 24 小时,可以从网页或 CLI 撤销。
70
69
 
70
+ 节点上线后,可在「设备节点」点击「打开终端」,直接选择启动目录并发送命令,无需创建咨询。终端实时展示标准输出、错误输出和退出码,可以停止命令、清屏和重连。`cd 子目录`、`cd ..` 与 `cd` 可切换工作目录;每条命令独立执行,shell 变量不会跨命令保留。CLI 终端不提供交互式输入,`vim`、`top`、口令提示等需要真实终端的程序请使用 SSH 网页终端。单条命令最长运行 30 秒、输出最多 64 KiB;关闭终端、撤销授权或断线会停止正在执行的命令。
71
+
72
+ 指定启动目录时仅支持 `pwd`、`ls` / `dir`、`echo`、`cat` / `type`、`head`、`tail`、`grep` / `findstr`、`wc -l` 与终端内的 `cd`,不能使用管道、重定向或外部程序。已有旧版节点若提示不支持终端,请停止旧进程,将原启动命令中的 `yep` 替换为 `npx --yes @yep-ai/cli@latest`,保留原目录参数与 `--server` 地址。
73
+
71
74
  `--server` 只接受 HTTPS 源站(回环开发地址允许 HTTP),显式指定后记住该源站;令牌按源站隔离。市场登录不等于设备授权。
72
75
 
73
76
  ## 配置
@@ -86,4 +89,4 @@ yep device logout
86
89
 
87
90
  市场凭据保存为 `~/.config/yep/config.json`;设备身份和各源站的授权保存在 `~/.config/yep/devices/`。目录权限 `0700`、凭据文件 `0600`;Windows 使用当前用户专属 ACL。市场和设备令牌分别管理。域名改变后需要在新源站重新登录、配对,不会把旧源站令牌发送到新地址。
88
91
 
89
- 执行 `yep --help` 或 `yep device --help` 查看全部选项。
92
+ 执行 `npx --yes @yep-ai/cli@latest --help` 或 `npx --yes @yep-ai/cli@latest device --help` 查看全部选项。
@@ -4,7 +4,7 @@ import os from "node:os";
4
4
  import { createHash, randomUUID } from "node:crypto";
5
5
  import { spawn } from "node:child_process";
6
6
  import WebSocket from "ws";
7
- import { rootDescriptor, resolveSafe, list, execute } from "./tools.js";
7
+ import { rootDescriptor, resolveSafe, list, execute, terminalDirectory } from "./tools.js";
8
8
  const PROTOCOL = 2;
9
9
  const DEFAULT_SERVER = "https://yep.nihao.cool";
10
10
  function serverOrigin(value) {
@@ -185,16 +185,18 @@ async function systemRoots(privateDir) {
185
185
  }));
186
186
  }
187
187
  class DeviceAgent {
188
- constructor(roots, { restricted = true, privateDir, token, connectionId, expiresAt } = {}) {
188
+ constructor(roots, { restricted = true, privateDir, token, connectionId, expiresAt, onTerminalOutput } = {}) {
189
189
  this.roots = roots;
190
190
  this.restricted = restricted;
191
191
  this.privateDir = privateDir;
192
192
  this.token = token;
193
193
  this.connectionId = connectionId;
194
194
  this.expiresAt = expiresAt;
195
+ this.onTerminalOutput = onTerminalOutput;
195
196
  this.closed = false;
196
197
  this.bindings = /* @__PURE__ */ new Map();
197
198
  this.active = /* @__PURE__ */ new Map();
199
+ this.terminalSessions = new Map();
198
200
  }
199
201
  publicRoots() {
200
202
  return this.roots.map(({ id, name }) => ({ id, name }));
@@ -202,7 +204,7 @@ class DeviceAgent {
202
204
  cancel(frame = {}) {
203
205
  const waiting = [];
204
206
  for (const [id, op] of this.active) {
205
- if (!frame.id && !frame.roundId && !frame.bindingId || frame.id === id || frame.roundId === op.roundId || frame.bindingId === op.bindingId) {
207
+ if (frame.sessionId ? frame.sessionId === op.sessionId && (!frame.id || frame.id === id) : !frame.id && !frame.roundId && !frame.bindingId || frame.id && frame.id === id || frame.roundId && frame.roundId === op.roundId || frame.bindingId && frame.bindingId === op.bindingId) {
206
208
  op.abort.abort();
207
209
  waiting.push(op.done);
208
210
  }
@@ -213,6 +215,62 @@ class DeviceAgent {
213
215
  this.closed = true;
214
216
  this.cancel();
215
217
  this.bindings.clear();
218
+ for (const session of this.terminalSessions.values()) session.closed = true;
219
+ this.terminalSessions.clear();
220
+ }
221
+ async terminalFrame(frame) {
222
+ const base = { id: frame.id, sessionId: frame.sessionId };
223
+ if (typeof frame.sessionId !== "string" || !frame.sessionId || frame.sessionId.length > 200) throw new Error("Invalid terminal session");
224
+ if (["terminal_cancel", "terminal_close"].includes(frame.type)) {
225
+ if (frame.type === "terminal_close") {
226
+ const session = this.terminalSessions.get(frame.sessionId);
227
+ if (session) session.closed = true;
228
+ this.terminalSessions.delete(frame.sessionId);
229
+ }
230
+ await this.cancel({ sessionId: frame.sessionId, ...(frame.type === "terminal_cancel" && frame.id ? { id: frame.id } : {}) });
231
+ return null;
232
+ }
233
+ if (typeof frame.id !== "string" || !frame.id || frame.id.length > 200 || this.active.has(frame.id) || this.active.size >= 8) throw new Error("Too many or duplicate local operations");
234
+ if (typeof frame.command !== "string" || !frame.command.trim() || frame.command.length > 4096 || frame.command.includes("\0")) throw new Error("Invalid command");
235
+ const root = this.roots.find(item => item.id === frame.folderId);
236
+ if (!root) throw new Error("Unknown terminal root");
237
+ let session = this.terminalSessions.get(frame.sessionId);
238
+ if (!session) {
239
+ if (this.terminalSessions.size >= 8) throw new Error("Too many terminal sessions");
240
+ this.terminalSessions.set(frame.sessionId, session = { closed: false });
241
+ }
242
+ if ([...this.active.values()].some(op => op.sessionId === frame.sessionId)) throw new Error("Terminal command is still running");
243
+ const abort = new AbortController();
244
+ let complete;
245
+ const done = new Promise(resolve => { complete = resolve; });
246
+ this.active.set(frame.id, { abort, sessionId: frame.sessionId, done });
247
+ const valid = () => !this.closed && !session.closed && !abort.signal.aborted;
248
+ try {
249
+ const cwd = frame.path ?? "";
250
+ const changed = await terminalDirectory(root, cwd, frame.command, this.restricted);
251
+ if (!valid()) throw new Error("Operation canceled");
252
+ const output = changed !== null ? { stdout: "", stderr: "", exitCode: 0 } : await execute(root, { operation: "exec", path: cwd, command: frame.command }, {
253
+ restricted: this.restricted, signal: abort.signal, secrets: [this.token],
254
+ onOutput: (stream, data) => {
255
+ if (!valid()) return;
256
+ for (let offset = 0; offset < data.length;) {
257
+ let end = Math.min(data.length, offset + 4096);
258
+ const last = data.charCodeAt(end - 1);
259
+ if (end < data.length && last >= 0xd800 && last <= 0xdbff) end--;
260
+ this.onTerminalOutput?.({ ...base, type: "terminal_output", stream, data: data.slice(offset, end) });
261
+ offset = end;
262
+ }
263
+ },
264
+ });
265
+ if (!valid()) throw new Error("Operation canceled");
266
+ return { ...base, type: "terminal_result", folderId: root.id, path: changed ?? cwd, output };
267
+ } catch (error) {
268
+ if (abort.signal.aborted) return { ...base, type: "terminal_result", folderId: root.id, path: frame.path ?? "", output: error.output ?? { stdout: "", stderr: "", exitCode: null, canceled: true } };
269
+ throw error;
270
+ } finally {
271
+ this.active.delete(frame.id);
272
+ complete();
273
+ }
216
274
  }
217
275
  async frame(frame) {
218
276
  if (this.closed) throw new Error("Stale device connection");
@@ -220,6 +278,7 @@ class DeviceAgent {
220
278
  this.close();
221
279
  throw new Error("Device authorization expired");
222
280
  }
281
+ if (["terminal_exec", "terminal_cancel", "terminal_close"].includes(frame.type)) return this.terminalFrame(frame);
223
282
  const base = { id: frame.id, consultationId: frame.consultationId, bindingId: frame.bindingId, roundId: frame.roundId };
224
283
  if (frame.type === "cancel") {
225
284
  await this.cancel(frame);
@@ -303,7 +362,9 @@ async function start(server, config, credential, directories, { name, noOpen, si
303
362
  const url = new URL("/api/local-cli/connect", server);
304
363
  url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
305
364
  const ws = new WebSocket(url, { headers: { Authorization: `Bearer ${credential.token}` }, maxPayload: 15 * 1024 * 1024, handshakeTimeout: 15e3, followRedirects: false });
306
- const agent = new DeviceAgent(roots, { restricted, privateDir: config.directory, token: credential.token, expiresAt: credential.expiresAt });
365
+ const agent = new DeviceAgent(roots, { restricted, privateDir: config.directory, token: credential.token, expiresAt: credential.expiresAt,
366
+ onTerminalOutput: frame => { if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(frame)); },
367
+ });
307
368
  let ready = false;
308
369
  let heartbeat;
309
370
  const cleanup = () => {
@@ -318,7 +379,7 @@ async function start(server, config, credential, directories, { name, noOpen, si
318
379
  };
319
380
  signal?.addEventListener("abort", abort, { once: true });
320
381
  ws.on("open", () => {
321
- ws.send(JSON.stringify({ type: "hello", protocol: PROTOCOL, consentVersion: "yep-device-v2", directoryMode: restricted ? "restricted" : "system", folders: agent.publicRoots(), commandsEnabled: true, fileSelection: true }));
382
+ ws.send(JSON.stringify({ type: "hello", protocol: PROTOCOL, consentVersion: "yep-device-v2", directoryMode: restricted ? "restricted" : "system", folders: agent.publicRoots(), commandsEnabled: true, fileSelection: true, capabilities: ["terminal"] }));
322
383
  });
323
384
  ws.on("unexpected-response", (_req, res) => {
324
385
  if ([401, 403].includes(res.statusCode)) {
@@ -336,7 +397,7 @@ async function start(server, config, credential, directories, { name, noOpen, si
336
397
  agent.connectionId = frame.connectionId;
337
398
  ready = true;
338
399
  delay = 1e3;
339
- console.log("设备已上线。请在网页选择目录;退出此终端将断开。");
400
+ console.log("设备已上线。可在网页设备节点中打开终端,或在咨询中选择目录;退出此进程将断开。");
340
401
  heartbeat = setInterval(() => {
341
402
  if (Date.now() >= Date.parse(credential.expiresAt)) {
342
403
  fatal = new Error("Device authorization expired");
@@ -367,7 +428,7 @@ async function start(server, config, credential, directories, { name, noOpen, si
367
428
  if (frame?.id && ws.readyState === WebSocket.OPEN) {
368
429
  let error = String(e.message).split(credential.token).join("[REDACTED]").split(config.directory).join("[私有目录]");
369
430
  for (const root of [...agent.roots].sort((a, b) => b.absolute.length - a.absolute.length)) error = error.split(root.absolute).join("[授权目录]");
370
- ws.send(JSON.stringify({ type: "result", id: frame.id, bindingId: frame.bindingId, consultationId: frame.consultationId, error: error.slice(0, 8192) }));
431
+ ws.send(JSON.stringify({ type: frame.type?.startsWith("terminal_") ? "terminal_error" : "result", id: frame.id, sessionId: frame.sessionId, bindingId: frame.bindingId, consultationId: frame.consultationId, error: error.slice(0, 8192) }));
371
432
  } else {
372
433
  fatal = e;
373
434
  ws.close();
@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
2
2
  import { constants as FS } from "node:fs";
3
3
  import path from "node:path";
4
4
  import { spawn } from "node:child_process";
5
+ import { StringDecoder } from "node:string_decoder";
5
6
  const MAX_FILE = 10 * 1024 * 1024;
6
7
  const MAX_OUTPUT = 64 * 1024;
7
8
  function safeRelative(value = "") {
@@ -189,19 +190,92 @@ async function builtin(root, cwd, command) {
189
190
  if (cmd === "wc" && args.length === 2 && args[0] === "-l") return { text: String((await read(args[1])).split("\n").length - 1) + "\n" };
190
191
  throw new Error("Unsupported restricted command");
191
192
  }
192
- async function execute(root, request, { restricted = true, signal, secrets = [] } = {}) {
193
+ function parseTerminalDirectory(command, windows = process.platform === "win32") {
194
+ if (!/^\s*cd(?:\s|$)/iu.test(command)) return null;
195
+ if (/[\0\r\n]/u.test(command)) throw new Error("Usage: cd [directory]; run other commands separately");
196
+ let target = command.trim().slice(2).trim();
197
+ if (windows && /^\/d(?:\s|$)/iu.test(target)) target = target.slice(2).trim();
198
+ if (!target) return "";
199
+ const quote = target[0];
200
+ if (quote === '"' || quote === "'") {
201
+ // A directory name is interpreted literally, never as shell source.
202
+ // Windows backslashes must survive quotation rather than become escapes.
203
+ if (target.at(-1) !== quote || target.slice(1, -1).includes(quote)) throw new Error("Usage: cd [directory]; run other commands separately");
204
+ return target.slice(1, -1);
205
+ }
206
+ if (/[\s|&;<>$`%!*?()[\]{}"']/u.test(target)) throw new Error("Usage: cd [directory]; run other commands separately");
207
+ return target;
208
+ }
209
+ async function terminalDirectory(root, cwd, command, restricted) {
210
+ let target;
211
+ try {
212
+ target = parseTerminalDirectory(command);
213
+ } catch (error) {
214
+ // Only a standalone literal cd changes the simulated terminal directory.
215
+ // In system mode, compound commands and shell expressions run normally in
216
+ // their own shell, whose working directory lasts for that command only.
217
+ if (!restricted) return null;
218
+ throw error;
219
+ }
220
+ if (target === null) return null;
221
+ await resolveSafe(root, cwd, "directory");
222
+ if (!restricted && (target === "~" || target.startsWith("~/"))) {
223
+ target = path.join(process.env.USERPROFILE || process.env.HOME || root.absolute, target.slice(2));
224
+ }
225
+ let relative;
226
+ if (path.isAbsolute(target)) {
227
+ if (restricted) throw new Error("Use a relative directory inside the authorized root");
228
+ relative = path.relative(root.absolute, path.resolve(target)).split(path.sep).join("/");
229
+ } else {
230
+ if (process.platform === "win32") target = target.replaceAll("\\", "/");
231
+ relative = target ? path.posix.normalize([cwd, target].filter(Boolean).join("/")) : "";
232
+ }
233
+ if (relative === ".") relative = "";
234
+ await resolveSafe(root, relative, "directory");
235
+ return relative;
236
+ }
237
+ // Retain an unfinished secret prefix across chunks so streaming cannot expose a
238
+ // token that happened to cross a stdout/stderr packet boundary.
239
+ function outputRedactor(secrets, emit) {
240
+ const values = [...new Set(secrets.filter(value => typeof value === "string" && value))].sort((a, b) => b.length - a.length);
241
+ let pending = "";
242
+ return (text, final = false, truncated = false) => {
243
+ pending += text;
244
+ for (const secret of values) pending = pending.split(secret).join("[REDACTED]");
245
+ let tail = 0;
246
+ for (const secret of values) {
247
+ for (let length = Math.min(secret.length - 1, pending.length); length > tail; length--) {
248
+ if (pending.endsWith(secret.slice(0, length))) { tail = length; break; }
249
+ }
250
+ }
251
+ if (final) {
252
+ if (truncated && tail) pending = pending.slice(0, -tail) + "[REDACTED]";
253
+ if (pending) emit(pending);
254
+ pending = "";
255
+ } else {
256
+ const ready = pending.slice(0, pending.length - tail);
257
+ pending = pending.slice(pending.length - tail);
258
+ if (ready) emit(ready);
259
+ }
260
+ };
261
+ }
262
+ async function execute(root, request, { restricted = true, signal, secrets = [], onOutput } = {}) {
263
+ if (signal?.aborted) throw new Error("Operation canceled");
193
264
  if (request.operation !== "exec") return fileOperation(root, request);
194
265
  if (restricted) {
195
266
  const result = await builtin(root, request.path ?? "", request.command);
196
- return { stdout: result.text, stderr: "", exitCode: 0, truncated: result.truncated ?? false };
267
+ if (signal?.aborted) throw new Error("Operation canceled");
268
+ let stdout = "";
269
+ outputRedactor(secrets, text => { stdout += text; onOutput?.("stdout", text); })(result.text, true, result.truncated);
270
+ return { stdout, stderr: "", exitCode: 0, truncated: result.truncated ?? false };
197
271
  }
198
272
  const { absolute } = await resolveSafe(root, request.path ?? "", "directory");
199
273
  if (typeof request.command !== "string" || request.command.length > 4096 || request.command.includes("\0")) throw new Error("Invalid command");
200
274
  return new Promise((resolve, reject) => {
201
275
  const env = { ...process.env };
202
276
  for (const k of Object.keys(env)) if (/TOKEN|SECRET|PASSWORD|API_KEY|AUTHORIZATION/iu.test(k)) delete env[k];
203
- const child = spawn(process.platform === "win32" ? process.env.ComSpec || "cmd.exe" : "/bin/sh", process.platform === "win32" ? ["/d", "/s", "/c", request.command] : ["-c", request.command], { cwd: absolute, env, detached: process.platform !== "win32", windowsHide: true });
204
- let stdout = "", stderr = "", bytes = 0, truncated = false, timedOut = false;
277
+ const child = spawn(process.platform === "win32" ? process.env.ComSpec || "cmd.exe" : "/bin/sh", process.platform === "win32" ? ["/d", "/s", "/c", request.command] : ["-c", request.command], { cwd: absolute, env, detached: process.platform !== "win32", windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
278
+ let stdout = "", stderr = "", bytes = 0, truncated = false, timedOut = false, streamError;
205
279
  const kill = () => {
206
280
  if (process.platform === "win32") {
207
281
  spawn("taskkill", ["/PID", String(child.pid), "/T", "/F"], { windowsHide: true, stdio: "ignore" });
@@ -220,13 +294,19 @@ async function execute(root, request, { restricted = true, signal, secrets = []
220
294
  const abort = () => kill();
221
295
  signal?.addEventListener("abort", abort, { once: true });
222
296
  if (signal?.aborted) kill();
297
+ const decoders = { stdout: new StringDecoder("utf8"), stderr: new StringDecoder("utf8") };
298
+ const redact = Object.fromEntries(["stdout", "stderr"].map(which => [which, outputRedactor(secrets, text => {
299
+ if (which === "stdout") stdout += text;
300
+ else stderr += text;
301
+ if (!signal?.aborted && !streamError) {
302
+ try { onOutput?.(which, text); } catch (error) { streamError = error; kill(); }
303
+ }
304
+ })]));
223
305
  const append = (which, chunk) => {
224
306
  const room = Math.max(0, MAX_OUTPUT - bytes);
225
- const s = chunk.subarray(0, room).toString("utf8");
226
307
  bytes += chunk.length;
227
308
  if (bytes > MAX_OUTPUT) truncated = true;
228
- if (which === "stdout") stdout += s;
229
- else stderr += s;
309
+ redact[which](decoders[which].write(chunk.subarray(0, room)));
230
310
  };
231
311
  child.stdout.on("data", (c) => append("stdout", c));
232
312
  child.stderr.on("data", (c) => append("stderr", c));
@@ -238,12 +318,13 @@ async function execute(root, request, { restricted = true, signal, secrets = []
238
318
  child.once("close", (code, sig) => {
239
319
  clearTimeout(timer);
240
320
  signal?.removeEventListener("abort", abort);
241
- if (signal?.aborted) return reject(new Error("Operation canceled"));
242
- for (const secret of secrets) if (secret) {
243
- stdout = stdout.split(secret).join("[REDACTED]");
244
- stderr = stderr.split(secret).join("[REDACTED]");
321
+ for (const which of ["stdout", "stderr"]) redact[which](decoders[which].end(), true, truncated);
322
+ if (signal?.aborted) {
323
+ const error = new Error("Operation canceled");
324
+ error.output = { stdout, stderr, exitCode: null, canceled: true, truncated, timedOut };
325
+ return reject(error);
245
326
  }
246
- ;
327
+ if (streamError) return reject(streamError);
247
328
  resolve({ stdout, stderr, exitCode: code, signal: sig, truncated, timedOut });
248
329
  });
249
330
  });
@@ -256,5 +337,7 @@ export {
256
337
  list,
257
338
  resolveSafe,
258
339
  rootDescriptor,
259
- safeRelative
340
+ safeRelative,
341
+ terminalDirectory,
342
+ parseTerminalDirectory
260
343
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yep-ai/cli",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "Yep Marketplace unified CLI: skills, agents, plugins and authorized device nodes.",
5
5
  "type": "module",
6
6
  "bin": {