@ocis/myagent-cli 0.2.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.
Files changed (91) hide show
  1. package/README.md +357 -0
  2. package/dist/agent/context.d.ts +33 -0
  3. package/dist/agent/context.js +169 -0
  4. package/dist/agent/modes.d.ts +21 -0
  5. package/dist/agent/modes.js +84 -0
  6. package/dist/agent/prompt-builder.d.ts +9 -0
  7. package/dist/agent/prompt-builder.js +31 -0
  8. package/dist/agent/sessions.d.ts +38 -0
  9. package/dist/agent/sessions.js +130 -0
  10. package/dist/agent/todo.d.ts +18 -0
  11. package/dist/agent/todo.js +61 -0
  12. package/dist/agent/turn.d.ts +309 -0
  13. package/dist/agent/turn.js +1253 -0
  14. package/dist/approval/policy.d.ts +81 -0
  15. package/dist/approval/policy.js +157 -0
  16. package/dist/config.d.ts +49 -0
  17. package/dist/config.js +156 -0
  18. package/dist/git/status.d.ts +89 -0
  19. package/dist/git/status.js +226 -0
  20. package/dist/headless.d.ts +72 -0
  21. package/dist/headless.js +330 -0
  22. package/dist/index.d.ts +60 -0
  23. package/dist/index.js +511 -0
  24. package/dist/protocol/client.d.ts +123 -0
  25. package/dist/protocol/client.js +250 -0
  26. package/dist/protocol/sse-frames.d.ts +6 -0
  27. package/dist/protocol/sse-frames.js +75 -0
  28. package/dist/protocol/types.d.ts +200 -0
  29. package/dist/protocol/types.js +8 -0
  30. package/dist/runtime.d.ts +38 -0
  31. package/dist/runtime.js +166 -0
  32. package/dist/sanitize.d.ts +1 -0
  33. package/dist/sanitize.js +21 -0
  34. package/dist/skills/discovery.d.ts +24 -0
  35. package/dist/skills/discovery.js +109 -0
  36. package/dist/tools/binary.d.ts +2 -0
  37. package/dist/tools/binary.js +22 -0
  38. package/dist/tools/diff.d.ts +1 -0
  39. package/dist/tools/diff.js +49 -0
  40. package/dist/tools/find.d.ts +2 -0
  41. package/dist/tools/find.js +61 -0
  42. package/dist/tools/fs.d.ts +2 -0
  43. package/dist/tools/fs.js +276 -0
  44. package/dist/tools/glob.d.ts +6 -0
  45. package/dist/tools/glob.js +131 -0
  46. package/dist/tools/grep.d.ts +3 -0
  47. package/dist/tools/grep.js +228 -0
  48. package/dist/tools/paths.d.ts +27 -0
  49. package/dist/tools/paths.js +124 -0
  50. package/dist/tools/registry.d.ts +13 -0
  51. package/dist/tools/registry.js +38 -0
  52. package/dist/tools/shell.d.ts +2 -0
  53. package/dist/tools/shell.js +136 -0
  54. package/dist/tools/skills.d.ts +2 -0
  55. package/dist/tools/skills.js +36 -0
  56. package/dist/tools/todo.d.ts +2 -0
  57. package/dist/tools/todo.js +43 -0
  58. package/dist/tools/transfer.d.ts +2 -0
  59. package/dist/tools/transfer.js +145 -0
  60. package/dist/tools/truncate.d.ts +12 -0
  61. package/dist/tools/truncate.js +46 -0
  62. package/dist/tools/types.d.ts +85 -0
  63. package/dist/tools/types.js +63 -0
  64. package/dist/ui/app.d.ts +39 -0
  65. package/dist/ui/app.js +1061 -0
  66. package/dist/ui/colors.d.ts +100 -0
  67. package/dist/ui/colors.js +169 -0
  68. package/dist/ui/components.d.ts +267 -0
  69. package/dist/ui/components.js +811 -0
  70. package/dist/ui/diff.d.ts +37 -0
  71. package/dist/ui/diff.js +143 -0
  72. package/dist/ui/format.d.ts +28 -0
  73. package/dist/ui/format.js +76 -0
  74. package/dist/ui/help.d.ts +6 -0
  75. package/dist/ui/help.js +45 -0
  76. package/dist/ui/highlight.d.ts +20 -0
  77. package/dist/ui/highlight.js +210 -0
  78. package/dist/ui/logo.d.ts +24 -0
  79. package/dist/ui/logo.js +106 -0
  80. package/dist/ui/model-list.d.ts +10 -0
  81. package/dist/ui/model-list.js +33 -0
  82. package/dist/ui/quit-confirm.d.ts +8 -0
  83. package/dist/ui/quit-confirm.js +40 -0
  84. package/dist/ui/select-popup.d.ts +30 -0
  85. package/dist/ui/select-popup.js +54 -0
  86. package/dist/ui/theme.d.ts +4 -0
  87. package/dist/ui/theme.js +41 -0
  88. package/dist/ui/tool-view.d.ts +20 -0
  89. package/dist/ui/tool-view.js +326 -0
  90. package/package.json +44 -0
  91. package/skills/git-commit/SKILL.md +27 -0
@@ -0,0 +1,124 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Workspace path confinement for client tools.
3
+ //
4
+ // Lexical containment alone is not enough: a symlink inside the workspace can
5
+ // point outside it. Resolution therefore realpaths every existing prefix and
6
+ // re-checks containment on the real location before allowing a read or write.
7
+ // `allowOutside` (--allow-outside) skips the checks entirely.
8
+ //
9
+ // Threat model: this is a guardrail against the agent's own path choices
10
+ // (accidental or prompt-injected escapes), NOT a sandbox against a hostile
11
+ // local process — the check and the subsequent open() are separate steps, so
12
+ // a concurrent writer can swap a symlink in between (TOCTOU), and local_shell
13
+ // can reach anywhere by construction anyway. Hardening to fd-based operations
14
+ // (O_NOFOLLOW) would only ever matter for multi-user machines running
15
+ // untrusted code alongside the CLI, which is out of scope.
16
+ // ---------------------------------------------------------------------------
17
+ import { realpath } from "node:fs/promises";
18
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
19
+ import { optionalString } from "./types.js";
20
+ export class PathOutsideWorkspaceError extends Error {
21
+ constructor(input) {
22
+ super(`Path "${input}" is outside the workspace. Pass --allow-outside to permit it.`);
23
+ this.name = "PathOutsideWorkspaceError";
24
+ }
25
+ }
26
+ export function isInside(root, candidate) {
27
+ const rel = relative(root, candidate);
28
+ return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
29
+ }
30
+ /** Realpath the deepest existing ancestor of `absolute` (null when none exists). */
31
+ async function realpathDeepestExisting(absolute) {
32
+ let current = absolute;
33
+ for (;;) {
34
+ try {
35
+ return await realpath(current);
36
+ }
37
+ catch {
38
+ const parent = dirname(current);
39
+ if (parent === current)
40
+ return null;
41
+ current = parent;
42
+ }
43
+ }
44
+ }
45
+ /**
46
+ * True when the path (or a symlink along it) escapes the workspace. Lexical
47
+ * containment alone is not enough: a symlink inside the workspace can point
48
+ * outside it, so every existing prefix is realpath-checked too.
49
+ */
50
+ export async function pathEscapesWorkspace(input, workspace) {
51
+ const root = resolve(workspace);
52
+ const absolute = isAbsolute(input) ? resolve(input) : resolve(root, input);
53
+ if (!isInside(root, absolute))
54
+ return true;
55
+ const realWorkspace = await realpathDeepestExisting(root) ?? root;
56
+ const segments = relative(root, absolute).split(/[\\/]+/).filter(Boolean);
57
+ let probe = root;
58
+ for (const segment of segments) {
59
+ probe = join(probe, segment);
60
+ const real = await realpathDeepestExisting(probe);
61
+ if (!real)
62
+ break;
63
+ if (!isInside(realWorkspace, real))
64
+ return true;
65
+ }
66
+ return false;
67
+ }
68
+ /**
69
+ * Resolve a tool-supplied path to an absolute path inside `workspace`.
70
+ * Throws PathOutsideWorkspaceError when the path (or a symlink along it)
71
+ * escapes the workspace and `allowOutside` is false.
72
+ */
73
+ export async function resolveToolPath(input, opts) {
74
+ const workspace = resolve(opts.workspace);
75
+ const absolute = isAbsolute(input) ? resolve(input) : resolve(workspace, input);
76
+ if (opts.allowOutside)
77
+ return absolute;
78
+ if (await pathEscapesWorkspace(input, workspace))
79
+ throw new PathOutsideWorkspaceError(input);
80
+ return absolute;
81
+ }
82
+ /** Workspace-relative display path (falls back to the absolute path). */
83
+ export function displayPath(workspace, absolute) {
84
+ const rel = relative(resolve(workspace), absolute);
85
+ return rel && !rel.startsWith("..") ? rel : absolute;
86
+ }
87
+ /** Client tools whose `path` argument addresses the filesystem. */
88
+ const PATH_TOOLS = new Set(["local_read", "local_write", "local_edit", "local_ls", "local_grep", "local_find"]);
89
+ /** Local-side path arguments of a call (empty = it cannot touch the local fs structurally). */
90
+ function localPathArgs(name, args) {
91
+ if (name === "local_transfer_files") {
92
+ // get writes `to` locally, put reads `from` locally, list has no local
93
+ // side. get's `to` defaults to the workspace path made relative
94
+ // (transferGet's `from.slice(1)` after normalization) — mirror that
95
+ // default here or an implicit destination escaping the workspace would
96
+ // skip the always-ask signal (the runtime confinement still blocks it,
97
+ // but the user would never be consulted).
98
+ if (args.direction === "get") {
99
+ const to = optionalString(args, "to") ?? optionalString(args, "from")?.replace(/^\/+/, "");
100
+ return to ? [to] : [];
101
+ }
102
+ if (args.direction === "put") {
103
+ const from = optionalString(args, "from");
104
+ return from ? [from] : [];
105
+ }
106
+ return [];
107
+ }
108
+ if (!PATH_TOOLS.has(name))
109
+ return [];
110
+ const path = optionalString(args, "path");
111
+ return path ? [path] : [];
112
+ }
113
+ /**
114
+ * Whether a tool request should be treated as reaching outside the workspace
115
+ * (and therefore asked about even in AUTO mode). Path-less tools like shell
116
+ * cannot be judged statically and are left to the approval mode.
117
+ */
118
+ export async function toolRequestOutsideWorkspace(name, args, workspace) {
119
+ for (const path of localPathArgs(name, args)) {
120
+ if (await pathEscapesWorkspace(path, workspace))
121
+ return true;
122
+ }
123
+ return false;
124
+ }
@@ -0,0 +1,13 @@
1
+ import type { ToolDefinition, ToolHandler } from "./types.js";
2
+ export declare function createAllTools(): ToolHandler[];
3
+ export declare class ToolRegistry {
4
+ private readonly tools;
5
+ constructor(tools?: ToolHandler[]);
6
+ get(name: string): ToolHandler | undefined;
7
+ all(): ToolHandler[];
8
+ /**
9
+ * Every tool definition in registration order — the mode-independent
10
+ * advertised set (stable schemas preserve provider prompt caching).
11
+ */
12
+ allDefinitions(): ToolDefinition[];
13
+ }
@@ -0,0 +1,38 @@
1
+ // -------------------------------------------------------------------
2
+ // Tool registry — aggregation and definition lookup. The advertised set is
3
+ // mode-independent (see agent/modes.ts), so registration order is stable.
4
+ // -------------------------------------------------------------------
5
+ import { fsTools } from "./fs.js";
6
+ import { shellToolInstance } from "./shell.js";
7
+ import { grepToolInstance } from "./grep.js";
8
+ import { findToolInstance } from "./find.js";
9
+ import { skillTool } from "./skills.js";
10
+ import { todoTool } from "./todo.js";
11
+ import { transferTool } from "./transfer.js";
12
+ export function createAllTools() {
13
+ return [...fsTools, grepToolInstance, findToolInstance, shellToolInstance, skillTool, todoTool, transferTool];
14
+ }
15
+ export class ToolRegistry {
16
+ tools = new Map();
17
+ constructor(tools = createAllTools()) {
18
+ for (const tool of tools) {
19
+ const name = tool.definition.function.name;
20
+ if (this.tools.has(name))
21
+ throw new Error(`Duplicate tool name: ${name}`);
22
+ this.tools.set(name, tool);
23
+ }
24
+ }
25
+ get(name) {
26
+ return this.tools.get(name);
27
+ }
28
+ all() {
29
+ return [...this.tools.values()];
30
+ }
31
+ /**
32
+ * Every tool definition in registration order — the mode-independent
33
+ * advertised set (stable schemas preserve provider prompt caching).
34
+ */
35
+ allDefinitions() {
36
+ return this.all().map((tool) => tool.definition);
37
+ }
38
+ }
@@ -0,0 +1,2 @@
1
+ import type { ToolHandler } from "./types.js";
2
+ export declare const shellToolInstance: ToolHandler;
@@ -0,0 +1,136 @@
1
+ // ---------------------------------------------------------------------------
2
+ // local_shell — shell execution on the user's machine, rooted at the workspace.
3
+ //
4
+ // Deliberately NOT named "bash": remote skill documents refer to the server's
5
+ // bash tool, and the two must not be conflated (this one runs on the CLI host;
6
+ // server-side instructions about services/daemons do not apply). The actual
7
+ // interpreter is /bin/sh (cmd.exe on Windows), so "shell" is also accurate.
8
+ //
9
+ // Cwd is fixed to the workspace (a command can still `cd` away — the approval
10
+ // layer and mode gating are the actual guardrails). Output is capped at 50KB
11
+ // UTF-8-safely; timeout and abort both kill the process tree best-effort.
12
+ // ---------------------------------------------------------------------------
13
+ import { defineTool, optionalNumber, requireString } from "./types.js";
14
+ import { truncateWithMarker } from "./truncate.js";
15
+ import { spawnProcess } from "../runtime.js";
16
+ const DEFAULT_TIMEOUT_MS = 120_000;
17
+ const MAX_TIMEOUT_MS = 600_000;
18
+ const MAX_OUTPUT_BYTES = 50 * 1024;
19
+ /**
20
+ * Per-stream in-memory cap. The final output is truncated to 50KB anyway, but
21
+ * the read used to buffer the ENTIRE stream first — `yes | …` could hold
22
+ * gigabytes in memory across the whole timeout window. Past the cap the
23
+ * stream keeps draining (discarding) so the child still completes and its
24
+ * side effects land: killing on overflow would break legitimately noisy
25
+ * commands (builds, test suites) whose output nobody reads past the cap.
26
+ */
27
+ const MAX_CAPTURE_BYTES = 1024 * 1024;
28
+ async function readStream(stream) {
29
+ if (!stream)
30
+ return "";
31
+ const reader = stream.getReader();
32
+ const decoder = new TextDecoder();
33
+ let text = "";
34
+ let seen = 0;
35
+ for (;;) {
36
+ const { done, value } = await reader.read();
37
+ if (done)
38
+ break;
39
+ if (seen < MAX_CAPTURE_BYTES) {
40
+ seen += value.byteLength;
41
+ text += decoder.decode(value, { stream: true });
42
+ }
43
+ // Past the cap: drain and discard.
44
+ }
45
+ return text + decoder.decode();
46
+ }
47
+ const shellTool = defineTool({
48
+ name: "local_shell",
49
+ description: "Run a shell command in the workspace root, on the user's machine. This is the LOCAL shell — not the myagent server's bash tool (server-side skill instructions about services/daemons do not apply). State does not persist between calls (each runs in a fresh shell). Output is truncated at 50KB.",
50
+ parameters: {
51
+ type: "object",
52
+ properties: {
53
+ command: { type: "string", description: "Shell command to run." },
54
+ timeout: { type: "number", description: `Timeout in ms (default ${DEFAULT_TIMEOUT_MS}, max ${MAX_TIMEOUT_MS}).` },
55
+ },
56
+ required: ["command"],
57
+ },
58
+ mutating: true,
59
+ summarize: (args) => {
60
+ const command = String(args.command ?? "");
61
+ return `shell: ${command.length > 60 ? command.slice(0, 57) + "…" : command}`;
62
+ },
63
+ run: async (args, ctx) => {
64
+ const command = requireString(args, "command");
65
+ const timeout = Math.min(Math.max(optionalNumber(args, "timeout") ?? DEFAULT_TIMEOUT_MS, 1000), MAX_TIMEOUT_MS);
66
+ if (ctx.signal?.aborted)
67
+ throw new Error("Command aborted before execution.");
68
+ const shell = process.platform === "win32" ? [process.env.COMSPEC ?? "cmd.exe", "/c", command] : ["/bin/sh", "-c", command];
69
+ const detached = process.platform !== "win32";
70
+ const proc = spawnProcess(shell, {
71
+ cwd: ctx.workspace,
72
+ env: process.env,
73
+ stdout: "pipe",
74
+ stderr: "pipe",
75
+ stdin: "ignore",
76
+ // POSIX: a new process group lets us kill the whole pipeline (a bare
77
+ // proc.kill() only signals the shell, and grandchildren keep the pipes
78
+ // open, delaying the timeout unwind).
79
+ detached,
80
+ });
81
+ let timedOut = false;
82
+ let aborted = false;
83
+ let killTimer = null;
84
+ const kill = () => {
85
+ try {
86
+ if (detached && proc.pid)
87
+ process.kill(-proc.pid, "SIGTERM");
88
+ else
89
+ proc.kill();
90
+ }
91
+ catch { /* already gone */ }
92
+ // Escalate if the process ignores SIGTERM.
93
+ killTimer = setTimeout(() => {
94
+ try {
95
+ if (detached && proc.pid)
96
+ process.kill(-proc.pid, "SIGKILL");
97
+ else
98
+ proc.kill(9);
99
+ }
100
+ catch { /* gone */ }
101
+ }, 3000);
102
+ killTimer.unref?.();
103
+ };
104
+ const timer = setTimeout(() => { timedOut = true; kill(); }, timeout);
105
+ const onAbort = () => { aborted = true; kill(); };
106
+ ctx.signal?.addEventListener("abort", onAbort, { once: true });
107
+ try {
108
+ const [stdout, stderr, exitCode] = await Promise.all([
109
+ readStream(proc.stdout),
110
+ readStream(proc.stderr),
111
+ proc.exited,
112
+ ]);
113
+ let output = stdout;
114
+ if (stderr)
115
+ output += (output ? "\n" : "") + stderr;
116
+ output = output.replace(/\n$/, "");
117
+ if (!output)
118
+ output = "(no output)";
119
+ output = truncateWithMarker(output, MAX_OUTPUT_BYTES);
120
+ if (timedOut)
121
+ output += `\n[timeout after ${timeout}ms]`;
122
+ else if (aborted)
123
+ output += "\n[aborted]";
124
+ else if (exitCode !== 0)
125
+ output += `\n[exit code: ${exitCode}]`;
126
+ return output;
127
+ }
128
+ finally {
129
+ clearTimeout(timer);
130
+ if (killTimer)
131
+ clearTimeout(killTimer);
132
+ ctx.signal?.removeEventListener("abort", onAbort);
133
+ }
134
+ },
135
+ });
136
+ export const shellToolInstance = shellTool;
@@ -0,0 +1,2 @@
1
+ import type { ToolHandler } from "./types.js";
2
+ export declare const skillTool: ToolHandler;
@@ -0,0 +1,36 @@
1
+ // -------------------------------------------------------------------
2
+ // load_local_skill — load one local SKILL.md by name.
3
+ // -------------------------------------------------------------------
4
+ import { join } from "node:path";
5
+ import { defineTool, optionalString } from "./types.js";
6
+ import { discoverSkills, formatSkillList } from "../skills/discovery.js";
7
+ const loadSkillTool = defineTool({
8
+ name: "load_local_skill",
9
+ description: "Load a skill from the user's machine by name and return its full instructions plus bundled file paths. The available skill catalog is in the system prompt; call this when a task matches a skill.",
10
+ parameters: {
11
+ type: "object",
12
+ properties: {
13
+ name: { type: "string", description: "Skill name from the available_skills catalog." },
14
+ },
15
+ required: ["name"],
16
+ },
17
+ summarize: (args) => `load skill ${String(args.name ?? "")}`,
18
+ run: async (args, ctx) => {
19
+ const skills = await discoverSkills(ctx.skillDirs);
20
+ const name = optionalString(args, "name");
21
+ if (!name) {
22
+ return `Available skills:\n${formatSkillList(skills)}\n\nCall again with a name to load one.`;
23
+ }
24
+ const skill = skills.find((s) => s.name === name);
25
+ if (!skill) {
26
+ const available = skills.map((s) => s.name).join(", ") || "none";
27
+ throw new Error(`No local skill named "${name}". Available: ${available}`);
28
+ }
29
+ const bundled = skill.files.filter((f) => f !== "SKILL.md");
30
+ const filesNote = bundled.length > 0
31
+ ? `\n\nBundled files (read with local_read using the path shown):\n${bundled.map((f) => `- ${join(skill.dir, f)}`).join("\n")}`
32
+ : "";
33
+ return skill.md + filesNote;
34
+ },
35
+ });
36
+ export const skillTool = loadSkillTool;
@@ -0,0 +1,2 @@
1
+ import type { ToolHandler } from "./types.js";
2
+ export declare const todoTool: ToolHandler;
@@ -0,0 +1,43 @@
1
+ // -------------------------------------------------------------------
2
+ // local_update_todo — the agent's task checklist.
3
+ //
4
+ // The whole list is sent on every call (same contract as myagent-studio);
5
+ // the CLI renders it in the TODO panel and persists it per session.
6
+ // -------------------------------------------------------------------
7
+ import { defineTool } from "./types.js";
8
+ import { parseTodoItems } from "../agent/todo.js";
9
+ const updateTodoTool = defineTool({
10
+ name: "local_update_todo",
11
+ description: "Update the session's TODO list. Send the ENTIRE list every time (it replaces the previous one). Use for multi-step work; mark the current item in_progress before starting it and completed immediately after finishing. Include it in the SAME assistant turn as your other tool calls (parallel tool calling) — a todo-only turn wastes a model round-trip.",
12
+ parameters: {
13
+ type: "object",
14
+ properties: {
15
+ todos: {
16
+ type: "array",
17
+ description: "The full list, in order.",
18
+ items: {
19
+ type: "object",
20
+ properties: {
21
+ content: { type: "string", description: "Task description." },
22
+ status: { type: "string", enum: ["pending", "in_progress", "completed"] },
23
+ },
24
+ required: ["content", "status"],
25
+ },
26
+ },
27
+ },
28
+ required: ["todos"],
29
+ },
30
+ summarize: (args) => {
31
+ const list = Array.isArray(args.todos) ? args.todos : [];
32
+ return `todo (${list.length} items)`;
33
+ },
34
+ run: async (args, ctx) => {
35
+ const items = parseTodoItems(args.todos);
36
+ ctx.todos.replace(items);
37
+ const done = items.filter((t) => t.status === "completed").length;
38
+ const current = items.find((t) => t.status === "in_progress");
39
+ return `Todo list updated (${done}/${items.length} completed)` +
40
+ (current ? ` — in progress: ${current.content}` : "");
41
+ },
42
+ });
43
+ export const todoTool = updateTodoTool;
@@ -0,0 +1,2 @@
1
+ import type { ToolHandler } from "./types.js";
2
+ export declare const transferTool: ToolHandler;
@@ -0,0 +1,145 @@
1
+ // ---------------------------------------------------------------------------
2
+ // local_transfer_files — single-file transfers between the user's machine and
3
+ // the myagent workspace, plus workspace directory listing.
4
+ //
5
+ // Uses the integration workspace API (/integrations/<id>/workspace/*) with the
6
+ // same API key the rest of the client uses; the server resolves and confines
7
+ // every workspace path. The local side goes through the usual workspace
8
+ // confinement (resolveToolPath) so outside paths need --allow-outside /
9
+ // approval.
10
+ //
11
+ // get workspace -> local (writes the machine: plan mode refuses, ASK asks)
12
+ // put local -> workspace (no machine side effects: plan-safe)
13
+ // list workspace directory (read-only)
14
+ //
15
+ // File content never enters the model transcript — every direction returns a
16
+ // one-line status. Both write directions overwrite silently.
17
+ // ---------------------------------------------------------------------------
18
+ import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
19
+ import { basename, dirname, isAbsolute, relative, resolve } from "node:path";
20
+ import { defineTool, optionalString, requireString } from "./types.js";
21
+ import { displayPath, resolveToolPath } from "./paths.js";
22
+ import { isBinaryBuffer } from "./binary.js";
23
+ /**
24
+ * Client-side upload cap: the server rejects requests whose Content-Length
25
+ * exceeds 50 MB, and base64 inflates the payload by 4/3 — so the source file
26
+ * must stay below ~37 MB to fit.
27
+ */
28
+ const PUT_MAX_LOCAL_BYTES = 37 * 1024 * 1024;
29
+ /** Directory listings are capped so a huge workspace cannot flood the context. */
30
+ const LIST_MAX_ENTRIES = 200;
31
+ function formatBytes(size) {
32
+ if (size < 1024)
33
+ return `${size} B`;
34
+ if (size < 1024 * 1024)
35
+ return `${(size / 1024).toFixed(1)} KB`;
36
+ return `${(size / 1024 / 1024).toFixed(1)} MB`;
37
+ }
38
+ /** Workspace paths are API-side absolute — normalise to a single leading slash. */
39
+ function normalizeWorkspacePath(path) {
40
+ return `/${path.replace(/^\/+/, "")}`.replace(/\/+$/, "") || "/";
41
+ }
42
+ /** `` `"from"` is required for this direction `` style errors. */
43
+ function requireField(args, field, direction) {
44
+ const value = optionalString(args, field);
45
+ if (!value)
46
+ throw new Error(`Invalid arguments: "${field}" is required for direction "${direction}".`);
47
+ return value;
48
+ }
49
+ function clientOrThrow(ctx) {
50
+ if (!ctx.client)
51
+ throw new Error("Workspace transfer is unavailable: no API client in this context.");
52
+ return ctx.client;
53
+ }
54
+ async function transferGet(args, ctx) {
55
+ const client = clientOrThrow(ctx);
56
+ const from = normalizeWorkspacePath(requireField(args, "from", "get"));
57
+ // Default destination: the same relative path under the local workspace.
58
+ const to = optionalString(args, "to") ?? from.slice(1);
59
+ if (!to)
60
+ throw new Error('Invalid arguments: "to" must name a local destination file.');
61
+ const file = await client.workspaceRead(from, { signal: ctx.signal });
62
+ const data = file.binary ? Buffer.from(file.content, "base64") : Buffer.from(file.content, "utf-8");
63
+ const target = await resolveToolPath(to, ctx);
64
+ await mkdir(dirname(target), { recursive: true });
65
+ await writeFile(target, data);
66
+ return `Saved ${formatBytes(data.length)} from workspace ${from} to ${displayPath(ctx.workspace, target)}${file.binary ? " (binary)" : ""}.`;
67
+ }
68
+ async function transferPut(args, ctx) {
69
+ const client = clientOrThrow(ctx);
70
+ const from = requireField(args, "from", "put");
71
+ const source = await resolveToolPath(from, ctx);
72
+ const info = await stat(source).catch(() => null);
73
+ if (!info)
74
+ throw new Error(`File not found: ${from}`);
75
+ if (info.isDirectory()) {
76
+ throw new Error(`"${from}" is a directory — only single files can be transferred (use direction "list" to browse the workspace, or loop per file).`);
77
+ }
78
+ if (info.size > PUT_MAX_LOCAL_BYTES) {
79
+ throw new Error(`File is too large (${formatBytes(info.size)}); the upload limit is ${formatBytes(PUT_MAX_LOCAL_BYTES)} after base64 encoding.`);
80
+ }
81
+ const buffer = await readFile(source);
82
+ const binary = isBinaryBuffer(buffer);
83
+ const to = normalizeWorkspacePath(optionalString(args, "to") ?? defaultWorkspacePath(source, ctx.workspace));
84
+ const result = await client.workspaceWrite(to, binary ? buffer.toString("base64") : buffer.toString("utf-8"), binary, { signal: ctx.signal });
85
+ return `Uploaded ${formatBytes(result.size || buffer.length)} from ${displayPath(ctx.workspace, source)} to workspace ${to}${binary ? " (binary)" : ""}.`;
86
+ }
87
+ /** Upload default: keep the file's workspace-relative path, else its basename. */
88
+ function defaultWorkspacePath(source, workspace) {
89
+ const rel = relative(resolve(workspace), source);
90
+ return rel && !rel.startsWith("..") && !isAbsolute(rel) ? rel : basename(source);
91
+ }
92
+ async function transferList(args, ctx) {
93
+ const client = clientOrThrow(ctx);
94
+ const path = normalizeWorkspacePath(optionalString(args, "path") ?? "/");
95
+ const entries = await client.workspaceList(path, { signal: ctx.signal });
96
+ if (entries.length === 0)
97
+ return `${path} is empty.`;
98
+ const sorted = [...entries].sort((a, b) => a.name.localeCompare(b.name));
99
+ const shown = sorted.slice(0, LIST_MAX_ENTRIES);
100
+ const lines = shown.map((entry) => entry.type === "directory" ? `${entry.name}/ (dir)` : `${entry.name} (${formatBytes(entry.size)})`);
101
+ if (sorted.length > shown.length)
102
+ lines.push(`… (${sorted.length - shown.length} more)`);
103
+ return [`${path} — ${entries.length} entr${entries.length === 1 ? "y" : "ies"}:`, ...lines].join("\n");
104
+ }
105
+ export const transferTool = defineTool({
106
+ name: "local_transfer_files",
107
+ description: [
108
+ "Transfer single files between the user's machine and the myagent workspace, or list workspace directories.",
109
+ 'direction "get": workspace → local (from = workspace path, to = local path, default: same relative path).',
110
+ 'direction "put": local → workspace (from = local path, to = workspace path, default: its workspace-relative path).',
111
+ 'direction "list": list a workspace directory (path, default "/").',
112
+ "Both write directions overwrite existing files. Single files only — use list and loop for directories.",
113
+ ].join(" "),
114
+ parameters: {
115
+ type: "object",
116
+ properties: {
117
+ direction: { type: "string", enum: ["get", "put", "list"], description: 'Transfer direction: "get" (download), "put" (upload), or "list".' },
118
+ from: { type: "string", description: 'get: workspace source path; put: local source path. Required for get/put.' },
119
+ to: { type: "string", description: "get: local destination (default: same relative path); put: workspace destination (default: workspace-relative path)." },
120
+ path: { type: "string", description: 'list: workspace directory to list (default "/").' },
121
+ },
122
+ required: ["direction"],
123
+ },
124
+ // Fail-safe default; the per-call hook narrows it to the mutating direction.
125
+ mutating: true,
126
+ isMutating: (args) => args.direction === "get",
127
+ summarize: (args) => {
128
+ const direction = optionalString(args, "direction") ?? "?";
129
+ if (direction === "list")
130
+ return `list ${optionalString(args, "path") ?? "/"}`;
131
+ const from = optionalString(args, "from") ?? "";
132
+ const to = optionalString(args, "to");
133
+ return to ? `${direction} ${from} → ${to}` : `${direction} ${from}`;
134
+ },
135
+ run: async (args, ctx) => {
136
+ const direction = requireString(args, "direction");
137
+ if (direction === "get")
138
+ return await transferGet(args, ctx);
139
+ if (direction === "put")
140
+ return await transferPut(args, ctx);
141
+ if (direction === "list")
142
+ return await transferList(args, ctx);
143
+ throw new Error(`Invalid arguments: "direction" must be one of get, put, list (got "${direction}").`);
144
+ },
145
+ });
@@ -0,0 +1,12 @@
1
+ export interface TruncateResult {
2
+ text: string;
3
+ truncated: boolean;
4
+ removedBytes: number;
5
+ }
6
+ /**
7
+ * Truncate `text` to at most `maxBytes` UTF-8 bytes without splitting a code
8
+ * point. The result never exceeds the limit.
9
+ */
10
+ export declare function truncateUtf8(text: string, maxBytes: number): TruncateResult;
11
+ /** Truncate and append a standard marker when anything was removed. */
12
+ export declare function truncateWithMarker(text: string, maxBytes: number): string;
@@ -0,0 +1,46 @@
1
+ // ---------------------------------------------------------------------------
2
+ // UTF-8-safe text truncation for tool output.
3
+ // ---------------------------------------------------------------------------
4
+ /** Byte length of a string in UTF-8 (without allocating a buffer). */
5
+ function utf8Length(text) {
6
+ let bytes = 0;
7
+ for (const ch of text) {
8
+ const code = ch.codePointAt(0);
9
+ if (code <= 0x7f)
10
+ bytes += 1;
11
+ else if (code <= 0x7ff)
12
+ bytes += 2;
13
+ else if (code <= 0xffff)
14
+ bytes += 3;
15
+ else
16
+ bytes += 4;
17
+ }
18
+ return bytes;
19
+ }
20
+ /**
21
+ * Truncate `text` to at most `maxBytes` UTF-8 bytes without splitting a code
22
+ * point. The result never exceeds the limit.
23
+ */
24
+ export function truncateUtf8(text, maxBytes) {
25
+ if (maxBytes < 0)
26
+ throw new Error("maxBytes must be >= 0");
27
+ let bytes = 0;
28
+ let endIndex = 0;
29
+ for (const ch of text) {
30
+ const code = ch.codePointAt(0);
31
+ const size = code <= 0x7f ? 1 : code <= 0x7ff ? 2 : code <= 0xffff ? 3 : 4;
32
+ if (bytes + size > maxBytes)
33
+ break;
34
+ bytes += size;
35
+ endIndex += ch.length;
36
+ }
37
+ if (endIndex >= text.length)
38
+ return { text, truncated: false, removedBytes: 0 };
39
+ const total = utf8Length(text);
40
+ return { text: text.slice(0, endIndex), truncated: true, removedBytes: total - bytes };
41
+ }
42
+ /** Truncate and append a standard marker when anything was removed. */
43
+ export function truncateWithMarker(text, maxBytes) {
44
+ const result = truncateUtf8(text, maxBytes);
45
+ return result.truncated ? `${result.text}\n\n… [+${result.removedBytes} bytes truncated]` : text;
46
+ }