nolo-cli 0.1.6 → 0.1.8

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
@@ -37,10 +37,16 @@ nolo
37
37
  nolo doctor
38
38
  nolo --version
39
39
 
40
- # update later, from inside nolo itself
40
+ # update later from your shell
41
41
  nolo update
42
42
  ```
43
43
 
44
+ Inside the TUI, run:
45
+
46
+ ```text
47
+ /update
48
+ ```
49
+
44
50
  The npm package expects Bun to be available because the executable is a Bun
45
51
  TypeScript script. For agent calls outside this repository, provide a token:
46
52
 
@@ -58,19 +64,81 @@ nolo
58
64
 
59
65
  Local repo development can still use the script bridge without `AUTH_TOKEN`.
60
66
 
67
+ Inside the TUI, `/update` is the shortcut for the same global `nolo update`
68
+ command.
69
+
61
70
  ```bash
62
71
  nolo --help
63
72
  nolo doctor
64
73
  nolo update
65
74
  nolo
66
75
  nolo chat
76
+ nolo connect
77
+ nolo connect --watch
78
+ nolo connect --daemon
79
+ nolo daemon --server-url https://api.nolo.chat --api-key sk_machine_xxx
80
+ nolo machine status
67
81
  nolo run "summarize my latest agent dialogs"
68
82
  nolo doc create --title "Trip Notes" --body "hello"
69
83
  nolo skill-doc create --title "Agent Query Skill" --description "Inspect recent agent dialogs"
70
84
  nolo agent list --json
85
+ nolo agent bind-current agent-user-1-agent-1
86
+ nolo agent runtime-doctor agent-user-1-agent-1
87
+ nolo agent smoke-current agent-user-1-agent-1 --msg "ping"
71
88
  nolo chat --agent agent-pub-01APPBUILDER00000001YAII3I --msg "你好"
72
89
  ```
73
90
 
91
+ Experimental machine connector commands:
92
+
93
+ ```bash
94
+ nolo connect # send one machine heartbeat
95
+ nolo connect --watch # keep this terminal process online with periodic heartbeats
96
+ nolo connect --ws # keep a live connector websocket for bound agent runs
97
+ nolo connect --daemon # start connect --ws silently in the background
98
+ nolo daemon --server-url https://api.nolo.chat --api-key sk_machine_xxx
99
+ nolo machine status # list machines registered to the current profile
100
+ ```
101
+
102
+ These commands only register machine presence and runtime capabilities today.
103
+ They do not expose shell, file-write, or raw local LLM endpoints.
104
+
105
+ Run a Slock-style connector command:
106
+
107
+ ```bash
108
+ nolo daemon --server-url https://api.nolo.chat --api-key sk_machine_xxx
109
+ ```
110
+
111
+ Future published package shape:
112
+
113
+ ```bash
114
+ npx @nolo/daemon@latest --server-url https://api.nolo.chat --api-key sk_machine_xxx
115
+ ```
116
+
117
+ The daemon registers this computer, reports local CLI capabilities, keeps a live
118
+ websocket open, and executes bound CLI agents on this computer. Agents bind to a
119
+ machine, not to a workspace or project folder.
120
+
121
+ Bind an agent to the current machine:
122
+
123
+ ```bash
124
+ nolo agent bind-current <agentKey>
125
+ ```
126
+
127
+ The agent keeps its own CLI settings. The binding only records which connected
128
+ machine must be online when that agent runs.
129
+
130
+ Run a one-command connector smoke test:
131
+
132
+ ```bash
133
+ nolo agent runtime-doctor <agentKey>
134
+ nolo agent smoke-current <agentKey> --msg "ping"
135
+ ```
136
+
137
+ `runtime-doctor` checks whether the agent is a CLI agent and whether the current
138
+ machine has the required CLI capability. `smoke-current` heartbeats the current
139
+ machine, binds the agent to it, opens a temporary connector websocket, calls
140
+ `/api/agent/run`, and prints the returned dialog id/content.
141
+
74
142
  ## Product Shape
75
143
 
76
144
  The preferred command model is Agent-first:
@@ -86,13 +154,16 @@ nolo doc list --agent <agent>
86
154
  nolo table query <table> --agent <agent>
87
155
  ```
88
156
 
89
- Inside the future TUI, the same actions should be available as slash commands:
157
+ Inside the current TUI, examples of supported slash commands include:
158
+ `/agent`, `/agents`, `/switch`, `/context` (alias `/ctx`), `/dialog`, `/doc`,
159
+ `/help`, `/new`, `/customize`, `/login`, `/profile`, `/version`, `/quit`
160
+ (alias `/exit`), and `/update` (which maps to `nolo update`).
161
+
162
+ Future product-direction examples for the broader TUI command model:
90
163
 
91
164
  ```text
92
165
  /agent list
93
166
  /agent switch ops
94
- /agents
95
- /switch 2
96
167
  /dialog open latest
97
168
  /doc list
98
169
  /table query builtin-dialog-probe-runs
@@ -0,0 +1,464 @@
1
+ import type { MachineHeartbeat } from "connector-experimental/protocol";
2
+ import { detectMachineInfo } from "connector-experimental/machineInfo";
3
+ import {
4
+ assertMachineRunAllowed,
5
+ buildMachinePermissionPromptBlock,
6
+ resolveMachineRunPermissionPolicy,
7
+ } from "../ai/agent/machineRunPermissions";
8
+ import { resolveConnectorWebSocketTarget } from "./connectorWebSocketTarget";
9
+ import { DEFAULT_NOLO_SERVER_URL } from "./defaultServer";
10
+
11
+ type EnvLike = Record<string, string | undefined>;
12
+ type OutputLike = { write(chunk: string): unknown };
13
+ type SmokeWebSocketOptions = {
14
+ headers: Record<string, string>;
15
+ onMessage: (message: string) => void | Promise<void>;
16
+ onOpen: () => void | Promise<void>;
17
+ sentMessages: string[];
18
+ };
19
+ type LocalCliExecutor = (
20
+ provider: string,
21
+ prompt: string,
22
+ options: { model?: string; yolo?: boolean }
23
+ ) => Promise<{ text: string; raw?: string; elapsed?: number }>;
24
+
25
+ type AgentRuntimeCommandDeps = {
26
+ env?: EnvLike;
27
+ output?: OutputLike;
28
+ fetchImpl?: typeof fetch;
29
+ machineInfo?: () => MachineHeartbeat;
30
+ connectWebSocket?: (url: string, options: SmokeWebSocketOptions) => Promise<void>;
31
+ executeCli?: LocalCliExecutor;
32
+ };
33
+
34
+ function resolveServerUrl(env: EnvLike) {
35
+ return (env.NOLO_SERVER || env.BASE_URL || DEFAULT_NOLO_SERVER_URL).replace(/\/+$/, "");
36
+ }
37
+
38
+ function resolveAuthToken(env: EnvLike) {
39
+ return env.AUTH_TOKEN || env.AUTH || "";
40
+ }
41
+
42
+ function parseUserIdFromAuthToken(token: string) {
43
+ const payload = token.trim().split(".")[0];
44
+ if (!payload) return "";
45
+ try {
46
+ const parsed = JSON.parse(Buffer.from(payload, "base64").toString("utf8"));
47
+ return typeof parsed?.userId === "string" ? parsed.userId : "";
48
+ } catch {
49
+ return "";
50
+ }
51
+ }
52
+
53
+ function readOption(args: string[], flag: string) {
54
+ const index = args.indexOf(flag);
55
+ return index >= 0 ? args[index + 1] : undefined;
56
+ }
57
+
58
+ async function defaultExecuteCli(provider: string, prompt: string, options: { model?: string; yolo?: boolean }) {
59
+ const { executeCli } = await import("ai/agent/cliExecutor");
60
+ return executeCli(provider as any, prompt, options);
61
+ }
62
+
63
+ function detectLaunchableMachineInfo() {
64
+ return detectMachineInfo({ probeLaunchable: true });
65
+ }
66
+
67
+ function buildConnectorCliPrompt(agentConfig: any, userInput: string) {
68
+ const policy = resolveMachineRunPermissionPolicy(agentConfig);
69
+ return [
70
+ typeof agentConfig?.prompt === "string" ? agentConfig.prompt.trim() : "",
71
+ buildMachinePermissionPromptBlock(policy),
72
+ `--- User task ---\n${userInput}`,
73
+ ].filter(Boolean).join("\n\n");
74
+ }
75
+
76
+ function requiredCapabilityForAgent(agent: any) {
77
+ if (agent?.apiSource !== "cli") return "";
78
+ const cliProvider = String(agent?.cliProvider || "").trim();
79
+ const capabilityByProvider: Record<string, string> = {
80
+ codex: "codex-cli",
81
+ claude: "claude-code",
82
+ copilot: "copilot-cli",
83
+ gemini: "gemini-cli",
84
+ kimi: "kimi-cli",
85
+ };
86
+ return capabilityByProvider[cliProvider] ?? "";
87
+ }
88
+
89
+ function assertSmokeCompatible(agent: any, machine: MachineHeartbeat) {
90
+ if (agent?.apiSource !== "cli") {
91
+ throw new Error(`Agent ${agent?.name ?? agent?.dbKey ?? "unknown"} is not a CLI agent.`);
92
+ }
93
+ const requiredCapability = requiredCapabilityForAgent(agent);
94
+ if (!requiredCapability) {
95
+ throw new Error(`Agent ${agent?.name ?? agent?.dbKey ?? "unknown"} has unsupported cliProvider: ${agent?.cliProvider ?? "missing"}.`);
96
+ }
97
+ if (!machine.capabilities.includes(requiredCapability)) {
98
+ const currentCapabilities = machine.capabilities.length
99
+ ? machine.capabilities.join(", ")
100
+ : "none";
101
+ throw new Error(
102
+ `Agent ${agent?.name ?? agent?.dbKey ?? "unknown"} requires ${requiredCapability}; current machine capabilities: ${currentCapabilities}.`
103
+ );
104
+ }
105
+ }
106
+
107
+ async function defaultConnectWebSocket(url: string, options: SmokeWebSocketOptions) {
108
+ const WebSocketCtor = globalThis.WebSocket;
109
+ if (!WebSocketCtor) {
110
+ throw new Error("WebSocket is not available in this runtime");
111
+ }
112
+ await new Promise<void>((resolve, reject) => {
113
+ const ws = new WebSocketCtor(url, { headers: options.headers } as any);
114
+ ws.addEventListener("open", () => {
115
+ Promise.resolve(options.onOpen())
116
+ .then(() => ws.close())
117
+ .catch((error) => {
118
+ ws.close();
119
+ reject(error);
120
+ });
121
+ }, { once: true });
122
+ ws.addEventListener("error", () => reject(new Error("connector websocket failed")));
123
+ ws.addEventListener("close", () => resolve(), { once: true });
124
+ ws.addEventListener("message", (event) => {
125
+ const startIndex = options.sentMessages.length;
126
+ Promise.resolve(options.onMessage(String(event.data))).then(() => {
127
+ for (const message of options.sentMessages.slice(startIndex)) {
128
+ ws.send(message);
129
+ }
130
+ }).catch(reject);
131
+ });
132
+ });
133
+ }
134
+
135
+ async function handleSmokeConnectorMessage(
136
+ message: string,
137
+ send: (message: string) => void,
138
+ executeCli: LocalCliExecutor
139
+ ) {
140
+ let parsed: any;
141
+ try {
142
+ parsed = JSON.parse(message);
143
+ } catch {
144
+ return;
145
+ }
146
+ if (parsed?.type !== "agent.run" || typeof parsed.requestId !== "string") return;
147
+ const agentConfig = parsed.payload?.agentConfig ?? {};
148
+ try {
149
+ if (agentConfig.apiSource !== "cli") {
150
+ throw new Error("Connector can only execute CLI agents. Set the agent apiSource to cli and choose a cliProvider.");
151
+ }
152
+ const provider = String(agentConfig.cliProvider || "copilot");
153
+ const policy = resolveMachineRunPermissionPolicy(agentConfig);
154
+ const userInput = String(parsed.payload?.userInput ?? "");
155
+ assertMachineRunAllowed(userInput, policy);
156
+ const result = await executeCli(
157
+ provider,
158
+ buildConnectorCliPrompt(agentConfig, userInput),
159
+ {
160
+ model: agentConfig.model || undefined,
161
+ yolo: true,
162
+ }
163
+ );
164
+ send(JSON.stringify({
165
+ type: "agent.run.result",
166
+ requestId: parsed.requestId,
167
+ result: {
168
+ content: result.text,
169
+ model: agentConfig.model ?? provider,
170
+ trace: [{ role: "assistant", content: result.text }],
171
+ },
172
+ }));
173
+ } catch (error) {
174
+ send(JSON.stringify({
175
+ type: "agent.run.result",
176
+ requestId: parsed.requestId,
177
+ error: error instanceof Error ? error.message : String(error),
178
+ }));
179
+ }
180
+ }
181
+
182
+ async function readAgentRecord(args: {
183
+ agentKey: string;
184
+ authToken: string;
185
+ fetchImpl: typeof fetch;
186
+ serverUrl: string;
187
+ }) {
188
+ const res = await args.fetchImpl(
189
+ `${args.serverUrl}/api/v1/db/read/${encodeURIComponent(args.agentKey)}`,
190
+ {
191
+ method: "GET",
192
+ headers: { Authorization: `Bearer ${args.authToken}` },
193
+ }
194
+ );
195
+ const data = await res.json().catch(() => ({}));
196
+ if (!res.ok) {
197
+ throw new Error(`read failed: HTTP ${res.status} ${JSON.stringify(data)}`);
198
+ }
199
+ return data?.data ?? data;
200
+ }
201
+
202
+ async function writeAgentRecord(args: {
203
+ agentKey: string;
204
+ authToken: string;
205
+ fetchImpl: typeof fetch;
206
+ serverUrl: string;
207
+ userId: string;
208
+ record: Record<string, any>;
209
+ }) {
210
+ const res = await args.fetchImpl(`${args.serverUrl}/api/v1/db/write/`, {
211
+ method: "POST",
212
+ headers: {
213
+ Authorization: `Bearer ${args.authToken}`,
214
+ "Content-Type": "application/json",
215
+ },
216
+ body: JSON.stringify({
217
+ customKey: args.agentKey,
218
+ userId: args.userId,
219
+ data: {
220
+ ...args.record,
221
+ dbKey: args.agentKey,
222
+ },
223
+ }),
224
+ });
225
+ const data = await res.json().catch(() => ({}));
226
+ if (!res.ok) {
227
+ throw new Error(`write failed: HTTP ${res.status} ${JSON.stringify(data)}`);
228
+ }
229
+ }
230
+
231
+ async function heartbeatCurrentMachine(args: {
232
+ authToken: string;
233
+ fetchImpl: typeof fetch;
234
+ machine: MachineHeartbeat;
235
+ serverUrl: string;
236
+ }) {
237
+ const res = await args.fetchImpl(`${args.serverUrl}/api/machines/heartbeat`, {
238
+ method: "POST",
239
+ headers: {
240
+ Authorization: `Bearer ${args.authToken}`,
241
+ "Content-Type": "application/json",
242
+ },
243
+ body: JSON.stringify(args.machine),
244
+ });
245
+ const text = await res.text().catch(() => "");
246
+ if (!res.ok) {
247
+ throw new Error(`heartbeat failed: HTTP ${res.status} ${text}`);
248
+ }
249
+ }
250
+
251
+ export async function runAgentBindCurrentCommand(
252
+ args: string[],
253
+ deps: AgentRuntimeCommandDeps = {}
254
+ ) {
255
+ const env = deps.env ?? process.env;
256
+ const output = deps.output ?? process.stdout;
257
+ const agentKey = args[0]?.trim();
258
+ if (!agentKey || agentKey === "--help" || agentKey === "-h") {
259
+ output.write("Usage: nolo agent bind-current <agentKey>\n");
260
+ return agentKey ? 0 : 1;
261
+ }
262
+
263
+ const authToken = resolveAuthToken(env);
264
+ if (!authToken) {
265
+ output.write("[nolo] agent bind-current requires an auth token. Run `nolo login` or set AUTH_TOKEN.\n");
266
+ return 1;
267
+ }
268
+
269
+ const userId = parseUserIdFromAuthToken(authToken);
270
+ if (!userId) {
271
+ output.write("[nolo] agent bind-current could not read userId from AUTH_TOKEN.\n");
272
+ return 1;
273
+ }
274
+
275
+ const serverUrl = resolveServerUrl(env);
276
+ const fetchImpl = deps.fetchImpl ?? fetch;
277
+ const machine = (deps.machineInfo ?? detectLaunchableMachineInfo)();
278
+
279
+ try {
280
+ await heartbeatCurrentMachine({ authToken, fetchImpl, machine, serverUrl });
281
+ const existing = await readAgentRecord({ agentKey, authToken, fetchImpl, serverUrl });
282
+ const updated = {
283
+ ...existing,
284
+ runtimeBinding: {
285
+ ...(existing?.runtimeBinding && typeof existing.runtimeBinding === "object"
286
+ ? existing.runtimeBinding
287
+ : {}),
288
+ machineId: machine.machineId,
289
+ },
290
+ updatedAt: Date.now(),
291
+ };
292
+ await writeAgentRecord({
293
+ agentKey,
294
+ authToken,
295
+ fetchImpl,
296
+ serverUrl,
297
+ userId,
298
+ record: updated,
299
+ });
300
+ } catch (error) {
301
+ output.write(
302
+ `[nolo] agent bind-current failed: ${
303
+ error instanceof Error ? error.message : String(error)
304
+ }\n`
305
+ );
306
+ return 1;
307
+ }
308
+
309
+ output.write(`Bound agent ${agentKey} to this machine: ${machine.name} (${machine.machineId})\n`);
310
+ return 0;
311
+ }
312
+
313
+ export async function runAgentSmokeCurrentCommand(
314
+ args: string[],
315
+ deps: AgentRuntimeCommandDeps = {}
316
+ ) {
317
+ const env = deps.env ?? process.env;
318
+ const output = deps.output ?? process.stdout;
319
+ const agentKey = args[0]?.trim();
320
+ if (!agentKey || agentKey === "--help" || agentKey === "-h") {
321
+ output.write("Usage: nolo agent smoke-current <agentKey> --msg \"hello\"\n");
322
+ return agentKey ? 0 : 1;
323
+ }
324
+
325
+ const authToken = resolveAuthToken(env);
326
+ if (!authToken) {
327
+ output.write("[nolo] agent smoke-current requires an auth token. Run `nolo login` or set AUTH_TOKEN.\n");
328
+ return 1;
329
+ }
330
+ const userId = parseUserIdFromAuthToken(authToken);
331
+ if (!userId) {
332
+ output.write("[nolo] agent smoke-current could not read userId from AUTH_TOKEN.\n");
333
+ return 1;
334
+ }
335
+
336
+ const userInput = readOption(args, "--msg") ?? "Smoke test from nolo connector.";
337
+ const serverUrl = resolveServerUrl(env);
338
+ const fetchImpl = deps.fetchImpl ?? fetch;
339
+ const machine = (deps.machineInfo ?? detectLaunchableMachineInfo)();
340
+ const sentMessages: string[] = [];
341
+
342
+ try {
343
+ await heartbeatCurrentMachine({ authToken, fetchImpl, machine, serverUrl });
344
+ const existing = await readAgentRecord({ agentKey, authToken, fetchImpl, serverUrl });
345
+ assertSmokeCompatible(existing, machine);
346
+ await writeAgentRecord({
347
+ agentKey,
348
+ authToken,
349
+ fetchImpl,
350
+ serverUrl,
351
+ userId,
352
+ record: {
353
+ ...existing,
354
+ runtimeBinding: {
355
+ ...(existing?.runtimeBinding && typeof existing.runtimeBinding === "object"
356
+ ? existing.runtimeBinding
357
+ : {}),
358
+ machineId: machine.machineId,
359
+ },
360
+ updatedAt: Date.now(),
361
+ },
362
+ });
363
+
364
+ let runResponse: any = null;
365
+ const wsTarget = await resolveConnectorWebSocketTarget({
366
+ serverUrl,
367
+ machineId: machine.machineId,
368
+ headers: { Authorization: `Bearer ${authToken}` },
369
+ fetchImpl,
370
+ });
371
+ await (deps.connectWebSocket ?? defaultConnectWebSocket)(
372
+ wsTarget,
373
+ {
374
+ headers: { Authorization: `Bearer ${authToken}` },
375
+ sentMessages,
376
+ onMessage: (message) => handleSmokeConnectorMessage(
377
+ message,
378
+ (response) => sentMessages.push(response),
379
+ deps.executeCli ?? defaultExecuteCli
380
+ ),
381
+ onOpen: async () => {
382
+ const res = await fetchImpl(`${serverUrl}/api/agent/run`, {
383
+ method: "POST",
384
+ headers: {
385
+ Authorization: `Bearer ${authToken}`,
386
+ "Content-Type": "application/json",
387
+ },
388
+ body: JSON.stringify({
389
+ agentKey,
390
+ userInput,
391
+ stream: false,
392
+ }),
393
+ });
394
+ runResponse = await res.json().catch(() => ({}));
395
+ if (!res.ok) {
396
+ throw new Error(`agent run failed: HTTP ${res.status} ${JSON.stringify(runResponse)}`);
397
+ }
398
+ },
399
+ }
400
+ );
401
+ output.write(`Smoke OK: ${runResponse?.dialogId ?? "no-dialog"}\n`);
402
+ if (runResponse?.content) output.write(`${runResponse.content}\n`);
403
+ return 0;
404
+ } catch (error) {
405
+ output.write(
406
+ `[nolo] agent smoke-current failed: ${
407
+ error instanceof Error ? error.message : String(error)
408
+ }\n`
409
+ );
410
+ return 1;
411
+ }
412
+ }
413
+
414
+ export async function runAgentRuntimeDoctorCommand(
415
+ args: string[],
416
+ deps: AgentRuntimeCommandDeps = {}
417
+ ) {
418
+ const env = deps.env ?? process.env;
419
+ const output = deps.output ?? process.stdout;
420
+ const agentKey = args[0]?.trim();
421
+ if (!agentKey || agentKey === "--help" || agentKey === "-h") {
422
+ output.write("Usage: nolo agent runtime-doctor <agentKey>\n");
423
+ return agentKey ? 0 : 1;
424
+ }
425
+
426
+ const authToken = resolveAuthToken(env);
427
+ if (!authToken) {
428
+ output.write("[nolo] agent runtime-doctor requires an auth token. Run `nolo login` or set AUTH_TOKEN.\n");
429
+ return 1;
430
+ }
431
+
432
+ const serverUrl = resolveServerUrl(env);
433
+ const fetchImpl = deps.fetchImpl ?? fetch;
434
+ const machine = (deps.machineInfo ?? detectLaunchableMachineInfo)();
435
+
436
+ try {
437
+ const agent = await readAgentRecord({ agentKey, authToken, fetchImpl, serverUrl });
438
+ const requiredCapability = requiredCapabilityForAgent(agent);
439
+ const isBoundToCurrent = agent?.runtimeBinding?.machineId === machine.machineId;
440
+ const hasCapability = requiredCapability
441
+ ? machine.capabilities.includes(requiredCapability)
442
+ : false;
443
+ output.write(`Agent runtime doctor: ${agent?.name ?? agentKey}\n`);
444
+ output.write(`Agent key: ${agentKey}\n`);
445
+ output.write(`API source: ${agent?.apiSource ?? "unknown"}\n`);
446
+ output.write(`CLI provider: ${agent?.cliProvider ?? "none"}\n`);
447
+ output.write(`Required capability: ${requiredCapability || "none"}\n`);
448
+ output.write(`Current machine: ${machine.name} (${machine.machineId})\n`);
449
+ output.write(`Current machine capabilities: ${machine.capabilities.length ? machine.capabilities.join(", ") : "none"}\n`);
450
+ output.write(`Current machine binding: ${isBoundToCurrent ? "yes" : "no"}\n`);
451
+ output.write(`Current machine has required capability: ${hasCapability ? "yes" : "no"}\n`);
452
+
453
+ if (agent?.apiSource !== "cli") return 1;
454
+ if (!requiredCapability || !hasCapability) return 1;
455
+ return 0;
456
+ } catch (error) {
457
+ output.write(
458
+ `[nolo] agent runtime-doctor failed: ${
459
+ error instanceof Error ? error.message : String(error)
460
+ }\n`
461
+ );
462
+ return 1;
463
+ }
464
+ }