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 +75 -4
- package/agentRuntimeCommands.ts +464 -0
- package/client/agentRun.ts +198 -167
- package/commandRegistry.ts +14 -0
- package/connectorWebSocketTarget.ts +29 -0
- package/defaultServer.ts +1 -0
- package/index.ts +158 -104
- package/machineCommands.ts +382 -0
- package/package.json +9 -1
- package/tui/readlineWorkspace.ts +50 -0
- package/tui/session.ts +64 -2
- package/updateCommands.ts +70 -5
package/README.md
CHANGED
|
@@ -37,10 +37,16 @@ nolo
|
|
|
37
37
|
nolo doctor
|
|
38
38
|
nolo --version
|
|
39
39
|
|
|
40
|
-
# update later
|
|
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
|
|
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
|
+
}
|