nolo-cli 0.1.15 → 0.1.17

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.
@@ -3,6 +3,7 @@ export const AGENT_RUNTIME_PACKAGE_ID = "agent-runtime";
3
3
  export { createRuntimeHostDescriptor } from "./hostAdapter";
4
4
  export { runLocalAgentTurn } from "./localLoop";
5
5
  export { resolveAgentRuntimeDecision } from "./runtimeDecision";
6
+ export { buildAgentRuntimeDecisionInput } from "./runtimeFacts";
6
7
  export type {
7
8
  AgentRuntimeAgentConfig,
8
9
  AgentRuntimeHostAdapter,
@@ -3,14 +3,16 @@ import type {
3
3
  } from "./hostAdapter";
4
4
  import type {
5
5
  AgentRuntimeChatMessage,
6
+ AgentRuntimeMessageContent,
6
7
  AgentRuntimeResult,
7
8
  } from "./types";
8
9
 
9
10
  export type LocalAgentTurnInput = {
10
11
  adapter: AgentRuntimeHostAdapter;
11
12
  agentRef: string;
12
- input: string;
13
+ input: AgentRuntimeMessageContent;
13
14
  continueDialogId?: string;
15
+ maxToolRounds?: number;
14
16
  };
15
17
 
16
18
  export type LocalAgentTurnResult = AgentRuntimeResult & {
@@ -20,7 +22,7 @@ export type LocalAgentTurnResult = AgentRuntimeResult & {
20
22
  function buildMessages(args: {
21
23
  prompt?: string;
22
24
  history: AgentRuntimeChatMessage[];
23
- input: string;
25
+ input: AgentRuntimeMessageContent;
24
26
  }): AgentRuntimeChatMessage[] {
25
27
  return [
26
28
  ...(args.prompt?.trim()
@@ -48,15 +50,48 @@ export async function runLocalAgentTurn(
48
50
  input: input.input,
49
51
  });
50
52
  const provider = await input.adapter.resolveProvider(agentConfig);
51
- const result = await provider.complete(messages);
53
+ const maxToolRounds = input.maxToolRounds ?? 6;
54
+ let toolCallCount = 0;
55
+ let result: AgentRuntimeResult;
56
+ for (let round = 0; round <= maxToolRounds; round += 1) {
57
+ result = await provider.complete(messages);
58
+ const toolCalls = result.tool_calls ?? [];
59
+ if (toolCalls.length === 0) break;
60
+ if (round === maxToolRounds) {
61
+ throw new Error(`Local agent exceeded max tool rounds: ${maxToolRounds}`);
62
+ }
63
+ toolCallCount += toolCalls.length;
64
+ messages.push({
65
+ role: "assistant",
66
+ content: result.content || null,
67
+ tool_calls: toolCalls,
68
+ });
69
+ for (const toolCall of toolCalls) {
70
+ const toolResult = await input.adapter.executeTool({
71
+ id: toolCall.id,
72
+ name: toolCall.function.name,
73
+ arguments: toolCall.function.arguments,
74
+ });
75
+ messages.push({
76
+ role: "tool",
77
+ content: toolResult.content,
78
+ tool_call_id: toolCall.id,
79
+ });
80
+ }
81
+ }
82
+ result = result!;
52
83
  const saved = await input.adapter.saveTurn({
53
84
  agentKey: agentConfig.key,
54
85
  messages,
55
- result,
86
+ result: {
87
+ ...result,
88
+ ...(toolCallCount > 0 ? { toolCallCount } : {}),
89
+ },
56
90
  });
57
91
 
58
92
  return {
59
93
  ...result,
94
+ ...(toolCallCount > 0 ? { toolCallCount } : {}),
60
95
  dialogId: saved.dialogId,
61
96
  };
62
97
  }
@@ -0,0 +1,40 @@
1
+ import type {
2
+ AgentRuntimeDecisionInput,
3
+ AgentRuntimeHost,
4
+ AgentRuntimeRequestedMode,
5
+ } from "./types";
6
+
7
+ export type AgentRuntimeCapabilityFacts = {
8
+ host: AgentRuntimeHost;
9
+ requestedMode?: AgentRuntimeRequestedMode;
10
+ syncRequested?: boolean;
11
+ capabilities: string[];
12
+ requiresServer?: boolean;
13
+ serverFallbackAvailable: boolean;
14
+ };
15
+
16
+ export function buildAgentRuntimeDecisionInput(
17
+ facts: AgentRuntimeCapabilityFacts
18
+ ): AgentRuntimeDecisionInput {
19
+ const capabilities = new Set(facts.capabilities);
20
+ const hasLocalAgentConfig = capabilities.has("leveldb-agent-config") || capabilities.has("agent-config");
21
+ const hasLocalProvider = capabilities.has("local-provider") || capabilities.has("provider");
22
+ const hasLocalPersistence = capabilities.has("leveldb-persistence") || capabilities.has("persistence");
23
+ const missingLocalCapabilities = [
24
+ ...(hasLocalAgentConfig ? [] : ["agent-config"]),
25
+ ...(hasLocalProvider ? [] : ["provider"]),
26
+ ...(hasLocalPersistence ? [] : ["persistence"]),
27
+ ];
28
+
29
+ return {
30
+ host: facts.host,
31
+ ...(facts.requestedMode ? { requestedMode: facts.requestedMode } : {}),
32
+ syncRequested: Boolean(facts.syncRequested),
33
+ hasLocalAgentConfig,
34
+ hasLocalProvider,
35
+ hasLocalPersistence,
36
+ missingLocalCapabilities,
37
+ requiresServer: Boolean(facts.requiresServer),
38
+ serverFallbackAvailable: facts.serverFallbackAvailable,
39
+ };
40
+ }
@@ -51,6 +51,7 @@ export interface AgentRuntimeResult {
51
51
  outputPrice?: number;
52
52
  usage?: Record<string, any>;
53
53
  trace?: AgentRuntimeChatMessage[];
54
+ tool_calls?: AgentRuntimeToolCall[];
54
55
  runtimeToolNames?: string[];
55
56
  toolCallCount?: number;
56
57
  policyState?: unknown;
@@ -0,0 +1,146 @@
1
+ import { DEFAULT_NOLO_SERVER_URL } from "./defaultServer";
2
+ import { getDefaultCliLocalRuntimeDb } from "./localRuntimeDb";
3
+ import type { CliLocalRuntimeDb } from "./client/localRuntimeAdapter";
4
+
5
+ type EnvLike = Record<string, string | undefined>;
6
+
7
+ type OutputLike = {
8
+ write(chunk: string): unknown;
9
+ };
10
+
11
+ type AgentPullCommandDeps = {
12
+ env?: EnvLike;
13
+ output?: OutputLike;
14
+ db?: CliLocalRuntimeDb;
15
+ fetchImpl?: typeof fetch;
16
+ };
17
+
18
+ export type ParsedAgentPullArgs = {
19
+ agentKey: string;
20
+ };
21
+
22
+ function readFlagValue(args: string[], flag: string) {
23
+ const index = args.indexOf(flag);
24
+ return index >= 0 ? args[index + 1] : undefined;
25
+ }
26
+
27
+ function positionalArgs(args: string[]) {
28
+ const values: string[] = [];
29
+ for (let index = 0; index < args.length; index += 1) {
30
+ const arg = args[index];
31
+ if (arg.startsWith("--")) {
32
+ index += 1;
33
+ continue;
34
+ }
35
+ values.push(arg);
36
+ }
37
+ return values;
38
+ }
39
+
40
+ export function parseAgentPullArgs(args: string[]): ParsedAgentPullArgs | null {
41
+ const agentKey = readFlagValue(args, "--agent") ?? positionalArgs(args)[0];
42
+ if (!agentKey?.trim()) return null;
43
+ return { agentKey: agentKey.trim() };
44
+ }
45
+
46
+ function resolveServerUrl(env: EnvLike) {
47
+ return (env.NOLO_SERVER || env.BASE_URL || DEFAULT_NOLO_SERVER_URL).replace(/\/+$/, "");
48
+ }
49
+
50
+ function resolveAuthToken(env: EnvLike) {
51
+ return env.AUTH_TOKEN || env.AUTH || env.BENCHMARK_AUTH_TOKEN || "";
52
+ }
53
+
54
+ function extractToolNames(record: any) {
55
+ if (Array.isArray(record?.toolNames)) return record.toolNames.filter((tool: unknown) => typeof tool === "string");
56
+ if (!Array.isArray(record?.tools)) return [];
57
+ return record.tools
58
+ .map((tool: any) => typeof tool === "string" ? tool : tool?.name)
59
+ .filter((tool: unknown): tool is string => typeof tool === "string" && tool.length > 0);
60
+ }
61
+
62
+ function normalizeAgentRecord(agentKey: string, record: any) {
63
+ return {
64
+ ...record,
65
+ dbKey: record?.dbKey || record?.key || agentKey,
66
+ key: record?.key || record?.dbKey || agentKey,
67
+ ...(typeof record?.provider === "string"
68
+ ? { provider: record.provider }
69
+ : typeof record?.apiSource === "string"
70
+ ? { provider: record.apiSource }
71
+ : {}),
72
+ toolNames: extractToolNames(record),
73
+ cachedBy: "nolo-cli",
74
+ cachedAt: new Date().toISOString(),
75
+ };
76
+ }
77
+
78
+ async function readRemoteAgent(args: {
79
+ agentKey: string;
80
+ serverUrl: string;
81
+ authToken: string;
82
+ fetchImpl: typeof fetch;
83
+ }) {
84
+ const res = await args.fetchImpl(
85
+ `${args.serverUrl}/api/v1/db/read/${encodeURIComponent(args.agentKey)}`,
86
+ {
87
+ headers: {
88
+ ...(args.authToken ? { Authorization: `Bearer ${args.authToken}` } : {}),
89
+ },
90
+ }
91
+ );
92
+ const data = await res.json().catch(() => ({}));
93
+ if (!res.ok) {
94
+ throw new Error(`HTTP ${res.status} ${JSON.stringify(data)}`);
95
+ }
96
+ return data?.data ?? data;
97
+ }
98
+
99
+ function writeUsage(output: OutputLike) {
100
+ output.write(
101
+ "Usage: nolo agent pull <agent>\n" +
102
+ " nolo agent pull --agent <agent>\n"
103
+ );
104
+ }
105
+
106
+ export async function runAgentPullCommand(args: string[], deps: AgentPullCommandDeps = {}) {
107
+ const output = deps.output ?? process.stdout;
108
+ const env = deps.env ?? process.env;
109
+ const parsed = parseAgentPullArgs(args);
110
+ if (!parsed) {
111
+ writeUsage(output);
112
+ return 1;
113
+ }
114
+
115
+ const serverUrl = resolveServerUrl(env);
116
+ const authToken = resolveAuthToken(env);
117
+ const fetchImpl = deps.fetchImpl ?? fetch;
118
+ const db = deps.db ?? await getDefaultCliLocalRuntimeDb();
119
+
120
+ let record: any;
121
+ try {
122
+ record = await readRemoteAgent({
123
+ agentKey: parsed.agentKey,
124
+ serverUrl,
125
+ authToken,
126
+ fetchImpl,
127
+ });
128
+ } catch (error) {
129
+ output.write(
130
+ `[nolo] Failed to pull ${parsed.agentKey} from ${serverUrl}: ${
131
+ error instanceof Error ? error.message : String(error)
132
+ }\n`
133
+ );
134
+ return 1;
135
+ }
136
+
137
+ if (!record || typeof record !== "object") {
138
+ output.write(`[nolo] Server returned an empty agent record for ${parsed.agentKey}.\n`);
139
+ return 1;
140
+ }
141
+
142
+ const localRecord = normalizeAgentRecord(parsed.agentKey, record);
143
+ await db.put(parsed.agentKey, localRecord);
144
+ output.write(`[nolo] cached ${parsed.agentKey} in local LevelDB\n`);
145
+ return 0;
146
+ }
@@ -1,6 +1,8 @@
1
1
  import { DEFAULT_NOLO_SERVER_URL } from "./defaultServer";
2
2
  import { runAgentTurn, type RunAgentTurnResult } from "./client/agentRun";
3
3
  import type { AgentRuntimeRequestedMode } from "./agentRuntimeLocal";
4
+ import { existsSync, readFileSync } from "node:fs";
5
+ import { extname, resolve } from "node:path";
4
6
 
5
7
  type EnvLike = Record<string, string | undefined>;
6
8
 
@@ -18,6 +20,8 @@ type AgentRunCommandDeps = {
18
20
  type ParsedAgentRunArgs = {
19
21
  agentKey: string;
20
22
  message: string;
23
+ imageUrls: string[];
24
+ allowShell: boolean;
21
25
  runtimeMode?: AgentRuntimeRequestedMode;
22
26
  continueDialogId?: string;
23
27
  };
@@ -28,6 +32,17 @@ function readFlagValue(args: string[], flag: string) {
28
32
  return args[index + 1];
29
33
  }
30
34
 
35
+ function readRepeatedFlagValues(args: string[], flag: string) {
36
+ const values: string[] = [];
37
+ for (let index = 0; index < args.length; index += 1) {
38
+ if (args[index] === flag && args[index + 1]) {
39
+ values.push(args[index + 1]);
40
+ index += 1;
41
+ }
42
+ }
43
+ return values;
44
+ }
45
+
31
46
  function runtimeModeFromArgs(args: string[]): AgentRuntimeRequestedMode | undefined {
32
47
  if (args.includes("--local")) return "local";
33
48
  if (args.includes("--server")) return "server";
@@ -37,9 +52,15 @@ function runtimeModeFromArgs(args: string[]): AgentRuntimeRequestedMode | undefi
37
52
 
38
53
  function positionalArgs(args: string[]) {
39
54
  const values: string[] = [];
55
+ const valuelessFlags = new Set([
56
+ "--local",
57
+ "--server",
58
+ "--auto",
59
+ "--dangerously-allow-shell",
60
+ ]);
40
61
  for (let index = 0; index < args.length; index += 1) {
41
62
  const arg = args[index];
42
- if (arg === "--local" || arg === "--server" || arg === "--auto") continue;
63
+ if (valuelessFlags.has(arg)) continue;
43
64
  if (arg.startsWith("--")) {
44
65
  index += 1;
45
66
  continue;
@@ -56,14 +77,42 @@ export function parseAgentRunArgs(args: string[]): ParsedAgentRunArgs | null {
56
77
  if (!agentKey || !message.trim()) return null;
57
78
  const runtimeMode = runtimeModeFromArgs(args);
58
79
  const continueDialogId = readFlagValue(args, "--continue") ?? readFlagValue(args, "--dialog");
80
+ const imageUrls = [
81
+ ...readRepeatedFlagValues(args, "--image"),
82
+ ...readRepeatedFlagValues(args, "--image-url"),
83
+ ];
59
84
  return {
60
85
  agentKey,
61
86
  message: message.trim(),
87
+ imageUrls,
88
+ allowShell: args.includes("--dangerously-allow-shell"),
62
89
  ...(runtimeMode ? { runtimeMode } : {}),
63
90
  ...(continueDialogId ? { continueDialogId } : {}),
64
91
  };
65
92
  }
66
93
 
94
+ function mimeTypeForPath(path: string) {
95
+ switch (extname(path).toLowerCase()) {
96
+ case ".jpg":
97
+ case ".jpeg":
98
+ return "image/jpeg";
99
+ case ".webp":
100
+ return "image/webp";
101
+ case ".gif":
102
+ return "image/gif";
103
+ default:
104
+ return "image/png";
105
+ }
106
+ }
107
+
108
+ export function normalizeCliImageInput(input: string) {
109
+ if (/^(https?:|data:|file:)/i.test(input)) return input;
110
+ const absolutePath = resolve(input);
111
+ if (!existsSync(absolutePath)) return input;
112
+ const base64 = readFileSync(absolutePath).toString("base64");
113
+ return `data:${mimeTypeForPath(absolutePath)};base64,${base64}`;
114
+ }
115
+
67
116
  function resolveServerUrl(env: EnvLike) {
68
117
  return (env.NOLO_SERVER || env.BASE_URL || DEFAULT_NOLO_SERVER_URL).replace(/\/+$/, "");
69
118
  }
@@ -71,7 +120,7 @@ function resolveServerUrl(env: EnvLike) {
71
120
  function writeUsage(output: OutputLike) {
72
121
  output.write(
73
122
  "Usage: nolo agent run <agent> <message> [--local|--server|--auto] [--continue <dialogId>]\n" +
74
- " nolo agent run --agent <agent> --msg <message> [--local|--server|--auto]\n"
123
+ " nolo agent run --agent <agent> --msg <message> [--image <url-or-path>] [--dangerously-allow-shell] [--local|--server|--auto]\n"
75
124
  );
76
125
  }
77
126
 
@@ -85,13 +134,17 @@ export async function runAgentRunCommand(args: string[], deps: AgentRunCommandDe
85
134
  }
86
135
 
87
136
  const runner = deps.runner ?? runAgentTurn;
137
+ const runEnv = parsed.allowShell
138
+ ? { ...env, NOLO_LOCAL_SHELL_MODE: "worktree" }
139
+ : env;
88
140
  const result: RunAgentTurnResult = await runner({
89
141
  agentName: parsed.agentKey,
90
142
  agentKey: parsed.agentKey,
91
143
  serverUrl: resolveServerUrl(env),
92
144
  message: parsed.message,
145
+ imageUrls: parsed.imageUrls.map(normalizeCliImageInput),
93
146
  scriptDir: deps.scriptDir,
94
- env,
147
+ env: runEnv,
95
148
  output,
96
149
  ...(parsed.runtimeMode ? { runtimeMode: parsed.runtimeMode } : {}),
97
150
  ...(parsed.continueDialogId ? { continueDialogId: parsed.continueDialogId } : {}),
@@ -73,7 +73,34 @@ describe("cli agent run client", () => {
73
73
  expect(result).toEqual({ exitCode: 0, dialogId: "dialog-1" });
74
74
  expect(output.text()).toContain("nolo -> working");
75
75
  expect(output.text()).toContain("nolo > hi");
76
- expect(output.text()).not.toContain("tokens=");
76
+ expect(output.text()).not.toContain("tokens=");
77
+ });
78
+
79
+ test("sends image inputs as multimodal userInput over HTTP", async () => {
80
+ const output = new CaptureOutput();
81
+ const requests: Array<{ body: any }> = [];
82
+
83
+ await runAgentTurn({
84
+ agentName: "vision",
85
+ agentKey: "agent-pub-vision",
86
+ serverUrl: "https://nolo.chat",
87
+ message: "describe this",
88
+ imageUrls: ["https://example.com/a.png"],
89
+ scriptDir: "C:/missing/scripts",
90
+ env: { AUTH_TOKEN: "token-123" },
91
+ output,
92
+ runtimeMode: "server",
93
+ scriptPathExists: () => false,
94
+ fetchImpl: async (_url, init) => {
95
+ requests.push({ body: JSON.parse(String(init?.body)) });
96
+ return Response.json({ content: "ok" });
97
+ },
98
+ });
99
+
100
+ expect(requests[0]?.body.userInput).toEqual([
101
+ { type: "text", text: "describe this" },
102
+ { type: "image_url", image_url: { url: "https://example.com/a.png" } },
103
+ ]);
77
104
  });
78
105
 
79
106
  test("runs forced local turns through the injected runtime adapter without HTTP", async () => {
@@ -121,6 +148,50 @@ describe("cli agent run client", () => {
121
148
  expect(output.text()).toContain("frontend > local:polish notifications");
122
149
  });
123
150
 
151
+ test("auto mode prefers a working local runtime before HTTP", async () => {
152
+ const output = new CaptureOutput();
153
+ const httpCalls: string[] = [];
154
+
155
+ const result = await runAgentTurn({
156
+ agentName: "frontend",
157
+ agentKey: "frontend-local",
158
+ serverUrl: "https://nolo.chat",
159
+ message: "polish notifications",
160
+ scriptDir: "C:/missing/scripts",
161
+ env: { AUTH_TOKEN: "token-123" },
162
+ output,
163
+ runtimeMode: "auto",
164
+ localRuntimeAdapter: {
165
+ host: "cli",
166
+ capabilities: ["leveldb-agent-config", "local-provider"],
167
+ loadAgentConfig: async (agentRef) => ({
168
+ key: agentRef,
169
+ name: "Frontend",
170
+ prompt: "Fix UI",
171
+ model: "fake-local",
172
+ }),
173
+ loadDialogHistory: async () => [],
174
+ saveTurn: async () => ({ dialogId: "dialog-auto-local" }),
175
+ resolveProvider: async () => ({
176
+ model: "fake-local",
177
+ complete: async () => ({ content: "auto local ok", model: "fake-local" }),
178
+ }),
179
+ executeTool: async () => {
180
+ throw new Error("no tools expected");
181
+ },
182
+ },
183
+ scriptPathExists: () => false,
184
+ fetchImpl: async (url) => {
185
+ httpCalls.push(String(url));
186
+ return Response.json({ content: "server" });
187
+ },
188
+ });
189
+
190
+ expect(result).toEqual({ exitCode: 0, dialogId: "dialog-auto-local" });
191
+ expect(httpCalls).toEqual([]);
192
+ expect(output.text()).toContain("frontend -> working locally");
193
+ });
194
+
124
195
  test("builds the default local adapter when env requests local mode", async () => {
125
196
  const output = new CaptureOutput();
126
197
  const builtModes: string[] = [];
@@ -11,12 +11,13 @@ type OutputLike = {
11
11
  write(chunk: string): unknown;
12
12
  };
13
13
 
14
- type RunAgentTurnOptions = {
15
- agentName: string;
16
- agentKey: string;
17
- serverUrl: string;
18
- message: string;
19
- continueDialogId?: string;
14
+ type RunAgentTurnOptions = {
15
+ agentName: string;
16
+ agentKey: string;
17
+ serverUrl: string;
18
+ message: string;
19
+ imageUrls?: string[];
20
+ continueDialogId?: string;
20
21
  scriptDir: string;
21
22
  env: EnvLike;
22
23
  output: OutputLike;
@@ -62,6 +63,28 @@ function buildDefaultLocalRuntimeAdapter(options: RunAgentTurnOptions) {
62
63
  fetchImpl: options.fetchImpl,
63
64
  });
64
65
  }
66
+
67
+ function buildUserInputContent(message: string, imageUrls: string[] = []) {
68
+ if (imageUrls.length === 0) return message;
69
+ return [
70
+ ...(message.trim() ? [{ type: "text" as const, text: message }] : []),
71
+ ...imageUrls.map((url) => ({
72
+ type: "image_url" as const,
73
+ image_url: { url },
74
+ })),
75
+ ];
76
+ }
77
+
78
+ function shouldAttemptAutoLocal(options: RunAgentTurnOptions) {
79
+ if (options.localRuntimeAdapter || options.localRuntimeAdapterFactory) return true;
80
+ return Boolean(
81
+ options.env.NOLO_LOCAL_OPENAI_API_KEY ||
82
+ options.env.OPENAI_API_KEY ||
83
+ options.env.NOLO_LOCAL_OPENAI_BASE_URL ||
84
+ options.env.OPENAI_BASE_URL ||
85
+ options.env.NOLO_LOCAL_AGENT_KEY
86
+ );
87
+ }
65
88
 
66
89
  function formatUsage(usage: any, dialogId: unknown) {
67
90
  const parts: string[] = [];
@@ -146,9 +169,9 @@ async function runHttpAgentTurn(options: RunAgentTurnOptions, authToken: string)
146
169
  "Content-Type": "application/json",
147
170
  },
148
171
  body: JSON.stringify({
149
- agentKey: options.agentKey,
150
- userInput: options.message,
151
- runtimeContext: {
172
+ agentKey: options.agentKey,
173
+ userInput: buildUserInputContent(options.message, options.imageUrls),
174
+ runtimeContext: {
152
175
  surface: "cli",
153
176
  host: "terminal",
154
177
  runtime: "bun",
@@ -204,6 +227,13 @@ async function runHttpAgentTurn(options: RunAgentTurnOptions, authToken: string)
204
227
  }
205
228
 
206
229
  async function runInjectedLocalAgentTurn(options: RunAgentTurnOptions) {
230
+ return runLocalAgentTurnForCli(options, { reportFailure: true });
231
+ }
232
+
233
+ async function runLocalAgentTurnForCli(
234
+ options: RunAgentTurnOptions,
235
+ settings: { reportFailure: boolean }
236
+ ) {
207
237
  const adapter =
208
238
  options.localRuntimeAdapter ||
209
239
  options.localRuntimeAdapterFactory?.(options.env) ||
@@ -218,7 +248,7 @@ async function runInjectedLocalAgentTurn(options: RunAgentTurnOptions) {
218
248
  const result = await runLocalAgentTurn({
219
249
  adapter,
220
250
  agentRef: options.agentKey,
221
- input: options.message,
251
+ input: buildUserInputContent(options.message, options.imageUrls),
222
252
  continueDialogId: options.continueDialogId,
223
253
  });
224
254
  const content = result.content.trim();
@@ -229,12 +259,14 @@ async function runInjectedLocalAgentTurn(options: RunAgentTurnOptions) {
229
259
  }
230
260
  return { exitCode: 0, dialogId: result.dialogId };
231
261
  } catch (error) {
232
- options.output.write(
233
- `[nolo] Local agent run failed: ${
234
- error instanceof Error ? error.message : String(error)
235
- }\n`
236
- );
237
- return { exitCode: 1 };
262
+ if (settings.reportFailure) {
263
+ options.output.write(
264
+ `[nolo] Local agent run failed: ${
265
+ error instanceof Error ? error.message : String(error)
266
+ }\n`
267
+ );
268
+ }
269
+ return { exitCode: 1, localError: error };
238
270
  }
239
271
  }
240
272
 
@@ -340,11 +372,22 @@ export async function runAgentTurn(options: RunAgentTurnOptions) {
340
372
  const authToken = resolveAuthToken(options.env);
341
373
  const scriptPath = join(options.scriptDir, "chatWithAgent.ts");
342
374
  const scriptPathExists = (options.scriptPathExists ?? existsSync)(scriptPath);
375
+ const runtimeMode = resolveRequestedRuntimeMode(options);
343
376
 
344
- if (resolveRequestedRuntimeMode(options) === "local") {
377
+ if (runtimeMode === "local") {
345
378
  return runInjectedLocalAgentTurn(options);
346
379
  }
347
380
 
381
+ if (runtimeMode === "auto" && shouldAttemptAutoLocal(options)) {
382
+ const localResult = await runLocalAgentTurnForCli(options, { reportFailure: false });
383
+ if (localResult.exitCode === 0) {
384
+ return {
385
+ exitCode: localResult.exitCode,
386
+ ...(localResult.dialogId ? { dialogId: localResult.dialogId } : {}),
387
+ };
388
+ }
389
+ }
390
+
348
391
  if (shouldUseScriptBridge({ hasAuthToken: Boolean(authToken), scriptPathExists })) {
349
392
  return runScriptBridge(options, scriptPath);
350
393
  }
@@ -120,7 +120,50 @@ describe("CLI local runtime adapter", () => {
120
120
  });
121
121
  });
122
122
 
123
- test("rejects tools until local tool policy is implemented", async () => {
123
+ test("passes image_url message parts through to OpenAI-compatible providers", async () => {
124
+ const requests: Array<{ body: any }> = [];
125
+ const adapter = createCliLocalRuntimeAdapter({
126
+ env: {
127
+ OPENAI_API_KEY: "sk-local",
128
+ NOLO_LOCAL_OPENAI_BASE_URL: "http://127.0.0.1:11434/v1",
129
+ },
130
+ db: {
131
+ get: async () => ({
132
+ dbKey: "agent-local-vision",
133
+ prompt: "Describe images.",
134
+ model: "gpt-4.1-mini",
135
+ }),
136
+ put: async () => {},
137
+ batch: async () => {},
138
+ iterator: () => (async function* () {})(),
139
+ },
140
+ fetchImpl: async (_url, init) => {
141
+ requests.push({ body: JSON.parse(String(init?.body)) });
142
+ return Response.json({
143
+ choices: [{ message: { content: "image ok" } }],
144
+ });
145
+ },
146
+ });
147
+
148
+ await runLocalAgentTurn({
149
+ adapter,
150
+ agentRef: "vision",
151
+ input: [
152
+ { type: "text", text: "describe this" },
153
+ { type: "image_url", image_url: { url: "https://example.com/a.png" } },
154
+ ],
155
+ });
156
+
157
+ expect(requests[0]?.body.messages.at(-1)).toEqual({
158
+ role: "user",
159
+ content: [
160
+ { type: "text", text: "describe this" },
161
+ { type: "image_url", image_url: { url: "https://example.com/a.png" } },
162
+ ],
163
+ });
164
+ });
165
+
166
+ test("rejects tools unless the local tool policy allows and registers them", async () => {
124
167
  const adapter = createCliLocalRuntimeAdapter({
125
168
  env: {},
126
169
  fetchImpl: async () => Response.json({}),
@@ -130,6 +173,113 @@ describe("CLI local runtime adapter", () => {
130
173
  id: "call-1",
131
174
  name: "execShell",
132
175
  arguments: "{}",
133
- })).rejects.toThrow("Local runtime tools are not enabled");
176
+ })).rejects.toThrow("execShell requires NOLO_LOCAL_SHELL_MODE");
177
+ });
178
+
179
+ test("executes explicitly allowed registered local tools declared by the agent", async () => {
180
+ const store = new Map<string, any>([
181
+ ["agent-user-1-reader", {
182
+ dbKey: "agent-user-1-reader",
183
+ id: "reader",
184
+ prompt: "Read files.",
185
+ toolNames: ["readFile"],
186
+ }],
187
+ ]);
188
+ const adapter = createCliLocalRuntimeAdapter({
189
+ env: {
190
+ NOLO_LOCAL_USER_ID: "user-1",
191
+ NOLO_LOCAL_ALLOWED_TOOLS: "readFile",
192
+ },
193
+ db: {
194
+ get: async (key) => {
195
+ if (!store.has(key)) throw new Error(`not found: ${key}`);
196
+ return store.get(key);
197
+ },
198
+ put: async () => {},
199
+ batch: async () => {},
200
+ iterator: () => (async function* () {})(),
201
+ },
202
+ localToolExecutors: {
203
+ readFile: async (call) => ({ content: `read:${call.arguments}` }),
204
+ },
205
+ fetchImpl: async () => Response.json({}),
206
+ });
207
+
208
+ await adapter.loadAgentConfig("reader");
209
+ const result = await adapter.executeTool({
210
+ id: "call-1",
211
+ name: "readFile",
212
+ arguments: "{\"path\":\"README.md\"}",
213
+ });
214
+
215
+ expect(result.content).toContain("README.md");
216
+ });
217
+
218
+ test("advertises execShell to OpenAI-compatible providers when the agent declares it", async () => {
219
+ const requests: Array<{ body: any }> = [];
220
+ const adapter = createCliLocalRuntimeAdapter({
221
+ env: {
222
+ OPENAI_API_KEY: "sk-local",
223
+ NOLO_LOCAL_OPENAI_BASE_URL: "http://127.0.0.1:11434/v1",
224
+ NOLO_LOCAL_SHELL_MODE: "worktree",
225
+ },
226
+ db: {
227
+ get: async () => ({
228
+ dbKey: "agent-local-shell",
229
+ prompt: "Use shell.",
230
+ model: "gpt-4.1-mini",
231
+ toolNames: ["execShell"],
232
+ }),
233
+ put: async () => {},
234
+ batch: async () => {},
235
+ iterator: () => (async function* () {})(),
236
+ },
237
+ fetchImpl: async (_url, init) => {
238
+ requests.push({ body: JSON.parse(String(init?.body)) });
239
+ return Response.json({
240
+ choices: [{ message: { content: "done" } }],
241
+ });
242
+ },
243
+ });
244
+
245
+ await runLocalAgentTurn({
246
+ adapter,
247
+ agentRef: "shell",
248
+ input: "pwd",
249
+ });
250
+
251
+ expect(requests[0]?.body.tools).toEqual([{
252
+ type: "function",
253
+ function: expect.objectContaining({
254
+ name: "execShell",
255
+ }),
256
+ }]);
257
+ });
258
+
259
+ test("runs execShell locally in explicit worktree shell mode", async () => {
260
+ const adapter = createCliLocalRuntimeAdapter({
261
+ env: { NOLO_LOCAL_SHELL_MODE: "worktree" },
262
+ db: {
263
+ get: async () => ({
264
+ dbKey: "agent-local-shell",
265
+ toolNames: ["execShell"],
266
+ }),
267
+ put: async () => {},
268
+ batch: async () => {},
269
+ iterator: () => (async function* () {})(),
270
+ },
271
+ cwd: import.meta.dir,
272
+ fetchImpl: async () => Response.json({}),
273
+ });
274
+
275
+ await adapter.loadAgentConfig("shell");
276
+ const result = await adapter.executeTool({
277
+ id: "call-1",
278
+ name: "execShell",
279
+ arguments: "{\"cmd\":\"pwd\"}",
280
+ });
281
+
282
+ expect(result.content).toContain(import.meta.dir);
283
+ expect(result.metadata).toMatchObject({ exitCode: 0 });
134
284
  });
135
285
  });
@@ -5,6 +5,7 @@ import type {
5
5
  } from "../agentRuntimeLocal";
6
6
  import type { AgentRuntimeChatMessage } from "../../agent-runtime";
7
7
  import { getDefaultCliLocalRuntimeDb } from "../localRuntimeDb";
8
+ import { executeLocalToolWithPolicy } from "./localToolPolicy";
8
9
 
9
10
  type EnvLike = Record<string, string | undefined>;
10
11
 
@@ -21,6 +22,8 @@ export type CliLocalRuntimeAdapterDeps = {
21
22
  now?: () => number;
22
23
  createId?: () => string;
23
24
  fetchImpl?: typeof fetch;
25
+ cwd?: string;
26
+ localToolExecutors?: Record<string, (call: any) => Promise<{ content: string; metadata?: Record<string, unknown> }>>;
24
27
  };
25
28
 
26
29
  async function defaultLocalRuntimeDb(): Promise<CliLocalRuntimeDb> {
@@ -44,6 +47,77 @@ function resolveLocalUserId(env: EnvLike) {
44
47
  return env.NOLO_LOCAL_USER_ID || env.NOLO_USER_ID || "local";
45
48
  }
46
49
 
50
+ function buildExecShellTool() {
51
+ return {
52
+ type: "function",
53
+ function: {
54
+ name: "execShell",
55
+ description: "Run a shell command in the current isolated local workspace.",
56
+ parameters: {
57
+ type: "object",
58
+ properties: {
59
+ cmd: {
60
+ type: "string",
61
+ description: "Shell command to run.",
62
+ },
63
+ },
64
+ required: ["cmd"],
65
+ },
66
+ },
67
+ };
68
+ }
69
+
70
+ function buildOpenAiTools(toolNames: string[] = []) {
71
+ return toolNames.includes("execShell") ? [buildExecShellTool()] : [];
72
+ }
73
+
74
+ function parseToolArguments(raw: string) {
75
+ try {
76
+ const parsed = JSON.parse(raw || "{}");
77
+ return parsed && typeof parsed === "object" ? parsed : {};
78
+ } catch {
79
+ return {};
80
+ }
81
+ }
82
+
83
+ async function executeShellCommand(args: {
84
+ call: any;
85
+ cwd: string;
86
+ }) {
87
+ const parsed = parseToolArguments(String(args.call.arguments ?? ""));
88
+ const cmd = String(parsed.cmd ?? parsed.command ?? "").trim();
89
+ if (!cmd) throw new Error("execShell requires a non-empty cmd argument.");
90
+ const proc = Bun.spawn(["/bin/sh", "-lc", cmd], {
91
+ cwd: args.cwd,
92
+ stdout: "pipe",
93
+ stderr: "pipe",
94
+ stdin: "ignore",
95
+ });
96
+ const [stdout, stderr, exitCode] = await Promise.all([
97
+ new Response(proc.stdout).text(),
98
+ new Response(proc.stderr).text(),
99
+ proc.exited,
100
+ ]);
101
+ return {
102
+ content: [
103
+ stdout.trim() ? `stdout:\n${stdout.trim()}` : "",
104
+ stderr.trim() ? `stderr:\n${stderr.trim()}` : "",
105
+ `exitCode: ${exitCode}`,
106
+ ].filter(Boolean).join("\n\n"),
107
+ metadata: { exitCode },
108
+ };
109
+ }
110
+
111
+ function buildLocalToolExecutors(deps: CliLocalRuntimeAdapterDeps) {
112
+ return {
113
+ execShell: (call: any) => executeShellCommand({
114
+ call,
115
+ cwd: deps.cwd ?? process.cwd(),
116
+ }),
117
+ ...(deps.localToolExecutors ?? {}),
118
+ };
119
+ }
120
+
47
121
  async function resolveDb(deps: CliLocalRuntimeAdapterDeps) {
48
122
  return deps.db ?? defaultLocalRuntimeDb();
49
123
  }
@@ -80,13 +154,7 @@ async function readAgentFromDb(args: {
80
154
  function toOpenAiMessages(messages: AgentRuntimeChatMessage[]) {
81
155
  return messages.map((message) => ({
82
156
  role: message.role,
83
- content: typeof message.content === "string"
84
- ? message.content
85
- : Array.isArray(message.content)
86
- ? message.content
87
- .map((part) => part.type === "text" ? part.text : `[image:${part.image_url.url}]`)
88
- .join("\n")
89
- : "",
157
+ content: message.content ?? "",
90
158
  }));
91
159
  }
92
160
 
@@ -124,6 +192,14 @@ async function writeDialog(args: {
124
192
  const nowIso = new Date(now).toISOString();
125
193
  const dialogKey = `dialog-${args.userId}-${dialogId}`;
126
194
  const lastUser = [...args.input.messages].reverse().find((message) => message.role === "user");
195
+ const lastUserText = typeof lastUser?.content === "string"
196
+ ? lastUser.content
197
+ : Array.isArray(lastUser?.content)
198
+ ? lastUser.content
199
+ .filter((part) => part.type === "text")
200
+ .map((part) => part.text)
201
+ .join(" ")
202
+ : "";
127
203
  const ops: Array<{ type: "put"; key: string; value: any }> = [
128
204
  {
129
205
  type: "put",
@@ -135,8 +211,8 @@ async function writeDialog(args: {
135
211
  userId: args.userId,
136
212
  cybots: [args.input.agentKey],
137
213
  primaryAgentKey: args.input.agentKey,
138
- title: typeof lastUser?.content === "string" && lastUser.content.trim()
139
- ? lastUser.content.trim().slice(0, 80)
214
+ title: lastUserText.trim()
215
+ ? lastUserText.trim().slice(0, 80)
140
216
  : "Local agent run",
141
217
  status: "done",
142
218
  triggerType: "cli-local",
@@ -186,15 +262,21 @@ export function createCliLocalRuntimeAdapter(
186
262
  const createId = deps.createId ?? createFallbackId;
187
263
  const fetchImpl = deps.fetchImpl ?? fetch;
188
264
  const userId = resolveLocalUserId(deps.env);
265
+ let activeAgentToolNames: string[] = [];
266
+ const localToolExecutors = buildLocalToolExecutors(deps);
189
267
 
190
268
  return {
191
269
  host: "cli",
192
270
  capabilities: ["leveldb-agent-config", "local-provider", "leveldb-persistence"],
193
- loadAgentConfig: async (agentRef) => readAgentFromDb({
194
- agentRef,
195
- db: await resolveDb(deps),
196
- userId,
197
- }),
271
+ loadAgentConfig: async (agentRef) => {
272
+ const agentConfig = await readAgentFromDb({
273
+ agentRef,
274
+ db: await resolveDb(deps),
275
+ userId,
276
+ });
277
+ activeAgentToolNames = agentConfig?.toolNames ?? [];
278
+ return agentConfig;
279
+ },
198
280
  loadDialogHistory: async (dialogId) => readDialogMessages({
199
281
  dialogId,
200
282
  db: await resolveDb(deps),
@@ -209,6 +291,7 @@ export function createCliLocalRuntimeAdapter(
209
291
  resolveProvider: async (agentConfig) => ({
210
292
  model: agentConfig.model || "gpt-4.1-mini",
211
293
  complete: async (messages) => {
294
+ const tools = buildOpenAiTools(agentConfig.toolNames ?? []);
212
295
  const res = await fetchImpl(`${resolveOpenAiCompatibleBaseUrl(deps.env)}/chat/completions`, {
213
296
  method: "POST",
214
297
  headers: {
@@ -221,24 +304,30 @@ export function createCliLocalRuntimeAdapter(
221
304
  model: agentConfig.model || "gpt-4.1-mini",
222
305
  messages: toOpenAiMessages(messages),
223
306
  stream: false,
307
+ ...(tools.length > 0 ? { tools } : {}),
224
308
  }),
225
309
  });
226
310
  const data = await res.json().catch(() => ({}));
227
311
  if (!res.ok) {
228
312
  throw new Error(`local provider failed: HTTP ${res.status} ${JSON.stringify(data)}`);
229
313
  }
230
- const content = String(data?.choices?.[0]?.message?.content ?? "");
314
+ const choiceMessage = data?.choices?.[0]?.message ?? {};
315
+ const content = String(choiceMessage?.content ?? "");
231
316
  return {
232
317
  content,
233
318
  model: agentConfig.model || "gpt-4.1-mini",
234
319
  provider: agentConfig.provider || "openai-compatible",
320
+ ...(Array.isArray(choiceMessage?.tool_calls) ? { tool_calls: choiceMessage.tool_calls } : {}),
235
321
  usage: data?.usage,
236
322
  trace: messages,
237
323
  };
238
324
  },
239
325
  }),
240
- executeTool: async () => {
241
- throw new Error("Local runtime tools are not enabled for text-only CLI runs.");
242
- },
326
+ executeTool: async (call) => executeLocalToolWithPolicy({
327
+ env: deps.env,
328
+ agentToolNames: activeAgentToolNames,
329
+ call,
330
+ executors: localToolExecutors,
331
+ }),
243
332
  };
244
333
  }
@@ -0,0 +1,48 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { executeLocalToolWithPolicy, resolveLocalToolPolicy } from "./localToolPolicy";
4
+
5
+ describe("CLI local tool policy", () => {
6
+ test("allows execShell in explicit worktree shell mode", () => {
7
+ expect(resolveLocalToolPolicy({
8
+ env: { NOLO_LOCAL_SHELL_MODE: "worktree" },
9
+ agentToolNames: ["execShell"],
10
+ toolName: "execShell",
11
+ })).toEqual({ allowed: true, toolName: "execShell" });
12
+ });
13
+
14
+ test("blocks execShell unless explicit shell mode is enabled", () => {
15
+ expect(resolveLocalToolPolicy({
16
+ env: { NOLO_LOCAL_ALLOWED_TOOLS: "execShell" },
17
+ agentToolNames: ["execShell"],
18
+ toolName: "execShell",
19
+ })).toMatchObject({ allowed: false });
20
+ });
21
+
22
+ test("requires both env allowlist and agent declaration", () => {
23
+ expect(resolveLocalToolPolicy({
24
+ env: { NOLO_LOCAL_ALLOWED_TOOLS: "readFile" },
25
+ agentToolNames: ["readFile"],
26
+ toolName: "readFile",
27
+ })).toEqual({ allowed: true, toolName: "readFile" });
28
+
29
+ expect(resolveLocalToolPolicy({
30
+ env: { NOLO_LOCAL_ALLOWED_TOOLS: "readFile" },
31
+ agentToolNames: [],
32
+ toolName: "readFile",
33
+ })).toMatchObject({ allowed: false });
34
+ });
35
+
36
+ test("executes only registered tools after policy allows them", async () => {
37
+ const result = await executeLocalToolWithPolicy({
38
+ env: { NOLO_LOCAL_ALLOWED_TOOLS: "readFile" },
39
+ agentToolNames: ["readFile"],
40
+ call: { id: "call-1", name: "readFile", arguments: "{\"path\":\"README.md\"}" },
41
+ executors: {
42
+ readFile: async (call) => ({ content: `read:${call.arguments}` }),
43
+ },
44
+ });
45
+
46
+ expect(result.content).toContain("README.md");
47
+ });
48
+ });
@@ -0,0 +1,85 @@
1
+ import type { AgentRuntimeToolCallInput, AgentRuntimeToolResult } from "../../agent-runtime";
2
+
3
+ type EnvLike = Record<string, string | undefined>;
4
+
5
+ export type LocalToolPolicyDecision =
6
+ | { allowed: true; toolName: string }
7
+ | { allowed: false; toolName: string; reason: string };
8
+
9
+ const NEVER_LOCAL_TOOLS = new Set([
10
+ "deleteSpaces",
11
+ "updateAgent",
12
+ "updateSelf",
13
+ "createAgent",
14
+ ]);
15
+
16
+ function parseCsv(value: string | undefined) {
17
+ return (value ?? "")
18
+ .split(",")
19
+ .map((item) => item.trim())
20
+ .filter(Boolean);
21
+ }
22
+
23
+ export function resolveLocalToolPolicy(args: {
24
+ env: EnvLike;
25
+ agentToolNames?: string[];
26
+ toolName: string;
27
+ }): LocalToolPolicyDecision {
28
+ if (args.toolName === "execShell") {
29
+ const shellMode = args.env.NOLO_LOCAL_SHELL_MODE || "";
30
+ const agentTools = new Set(args.agentToolNames ?? []);
31
+ if (
32
+ agentTools.has("execShell") &&
33
+ ["worktree", "dangerous", "1", "true"].includes(shellMode)
34
+ ) {
35
+ return { allowed: true, toolName: args.toolName };
36
+ }
37
+ return {
38
+ allowed: false,
39
+ toolName: args.toolName,
40
+ reason: "execShell requires NOLO_LOCAL_SHELL_MODE=worktree and an agent that declares execShell.",
41
+ };
42
+ }
43
+
44
+ if (NEVER_LOCAL_TOOLS.has(args.toolName)) {
45
+ return {
46
+ allowed: false,
47
+ toolName: args.toolName,
48
+ reason: `${args.toolName} is blocked by the local CLI safety policy.`,
49
+ };
50
+ }
51
+
52
+ const allowedByEnv = new Set(parseCsv(args.env.NOLO_LOCAL_ALLOWED_TOOLS));
53
+ const agentTools = new Set(args.agentToolNames ?? []);
54
+ if (allowedByEnv.has(args.toolName) && agentTools.has(args.toolName)) {
55
+ return { allowed: true, toolName: args.toolName };
56
+ }
57
+
58
+ return {
59
+ allowed: false,
60
+ toolName: args.toolName,
61
+ reason:
62
+ `${args.toolName} is not enabled for local CLI runs. ` +
63
+ "Set NOLO_LOCAL_ALLOWED_TOOLS and make sure the agent declares the tool before using it locally.",
64
+ };
65
+ }
66
+
67
+ export async function executeLocalToolWithPolicy(args: {
68
+ env: EnvLike;
69
+ agentToolNames?: string[];
70
+ call: AgentRuntimeToolCallInput;
71
+ executors?: Record<string, (call: AgentRuntimeToolCallInput) => Promise<AgentRuntimeToolResult>>;
72
+ }): Promise<AgentRuntimeToolResult> {
73
+ const decision = resolveLocalToolPolicy({
74
+ env: args.env,
75
+ agentToolNames: args.agentToolNames,
76
+ toolName: args.call.name,
77
+ });
78
+ if (!decision.allowed) throw new Error(decision.reason);
79
+
80
+ const executor = args.executors?.[args.call.name];
81
+ if (!executor) {
82
+ throw new Error(`${args.call.name} is allowed by policy but no local executor is registered.`);
83
+ }
84
+ return executor(args.call);
85
+ }
@@ -28,6 +28,7 @@ export const COMMANDS: CommandEntry[] = [
28
28
  { path: ["table", "meta"], script: "upsertTableMeta.ts", description: "Create or update table metadata" },
29
29
 
30
30
  { path: ["agent", "list"], script: "listMyAgents.ts", description: "List owned agents" },
31
+ { path: ["agent", "pull"], script: "", description: "Cache an agent for local runs" },
31
32
  { path: ["agent", "read"], script: "readAgent.ts", description: "Read a single agent" },
32
33
  { path: ["agent", "run"], script: "", description: "Run an agent" },
33
34
  { path: ["agent", "update"], script: "updateAgent.ts", description: "Update agent fields" },
@@ -94,6 +95,7 @@ export function renderHelpText() {
94
95
  ' nolo doc create --title "Trip Notes" --body "hello" --sync local,us --dry-run',
95
96
  ' nolo skill-doc create --title "Agent Query Skill" --description "Inspect recent agent dialogs" --sync local,main,us',
96
97
  " nolo agent list --json",
98
+ " nolo agent pull agent-pub-01APPBUILDER00000001YAII3I",
97
99
  " nolo agent read agent-pub-01APPBUILDER00000001YAII3I",
98
100
  ' nolo agent run frontend-implementer --msg "polish notifications" --local',
99
101
  " nolo agent bind-current agent-user-1-agent-1",
package/index.ts CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  runDoctorRuntimeCommand,
13
13
  } from "./agentRuntimeCommands";
14
14
  import { runAgentRunCommand } from "./agentRunCommand";
15
+ import { runAgentPullCommand } from "./agentPullCommand";
15
16
  import { buildEnvFromProfile, loadProfileConfig } from "./client/profileConfig";
16
17
  import { resolveTuiLaunchMode } from "./runtimeModeArgs";
17
18
  import {
@@ -130,6 +131,10 @@ if (args[0] === "agent" && args[1] === "run") {
130
131
  }));
131
132
  }
132
133
 
134
+ if (args[0] === "agent" && args[1] === "pull") {
135
+ process.exit(await runAgentPullCommand(args.slice(2), { env: runtimeEnv }));
136
+ }
137
+
133
138
  if (args[0] === "agent" && args[1] === "runtime-doctor") {
134
139
  process.exit(await runAgentRuntimeDoctorCommand(args.slice(2), { env: runtimeEnv }));
135
140
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nolo-cli",
3
- "version": "0.1.15",
3
+ "version": "0.1.17",
4
4
  "type": "module",
5
5
  "description": "Agent-first terminal workspace for Nolo",
6
6
  "bin": {
@@ -12,6 +12,7 @@
12
12
  "agentRuntimeLocal.ts",
13
13
  "localRuntimeDb.ts",
14
14
  "agentRuntimeCommands.ts",
15
+ "agentPullCommand.ts",
15
16
  "agentRunCommand.ts",
16
17
  "authCommands.ts",
17
18
  "commandRegistry.ts",