nolo-cli 0.1.11 → 0.1.13

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.
@@ -0,0 +1,10 @@
1
+ /**
2
+ * 构建包含 system prompt 的完整 prompt。
3
+ *
4
+ * CLI agent 与普通 model 共用 prompt / model / 最近文本上下文这些能力面,
5
+ * 但 CLI 不走本地 tool-calls 协议,因此这里只做文本级结构化拼接。
6
+ */
7
+ export function buildCliPrompt(systemPrompt: string | undefined, taskPrompt: string): string {
8
+ if (!systemPrompt?.trim()) return taskPrompt;
9
+ return `[角色设定]\n${systemPrompt.trim()}\n\n[当前任务]\n${taskPrompt.trim()}`;
10
+ }
@@ -0,0 +1,95 @@
1
+ export type MachineRunPermissionMode = "read_only" | "full_access";
2
+
3
+ export type MachineRunPermissionPolicy = {
4
+ mode: MachineRunPermissionMode;
5
+ allowFilesystemRead: boolean;
6
+ allowFilesystemWrite: boolean;
7
+ allowShell: boolean;
8
+ writableRoots: string[];
9
+ };
10
+
11
+ const DEFAULT_POLICY: MachineRunPermissionPolicy = {
12
+ mode: "read_only",
13
+ allowFilesystemRead: true,
14
+ allowFilesystemWrite: false,
15
+ allowShell: false,
16
+ writableRoots: [],
17
+ };
18
+
19
+ const WRITE_DENIED =
20
+ "Machine permission denied: this bound machine agent is read-only for filesystem writes. Enable machine write permission for this agent before asking it to modify files.";
21
+
22
+ const SHELL_DENIED =
23
+ "Machine permission denied: this bound machine agent cannot run arbitrary shell commands. Enable machine shell permission for this agent before asking it to execute commands.";
24
+
25
+ function asObject(value: unknown): Record<string, any> {
26
+ return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, any> : {};
27
+ }
28
+
29
+ function normalizeWritableRoots(value: unknown): string[] {
30
+ if (!Array.isArray(value)) return [];
31
+ return value
32
+ .filter((item): item is string => typeof item === "string")
33
+ .map((item) => item.trim())
34
+ .filter(Boolean);
35
+ }
36
+
37
+ export function resolveMachineRunPermissionPolicy(agentConfig: any): MachineRunPermissionPolicy {
38
+ const runtimeBinding = asObject(agentConfig?.runtimeBinding);
39
+ const raw = asObject(
40
+ agentConfig?.machinePermissions ??
41
+ runtimeBinding.permissions ??
42
+ runtimeBinding.machinePermissions ??
43
+ agentConfig?.boundRuntimeMachine?.permissions
44
+ );
45
+
46
+ const mode: MachineRunPermissionMode =
47
+ raw.mode === "full_access" || raw.allowFilesystemWrite === true || raw.allowShell === true
48
+ ? "full_access"
49
+ : "read_only";
50
+
51
+ if (mode === "full_access") {
52
+ return {
53
+ mode,
54
+ allowFilesystemRead: raw.allowFilesystemRead !== false,
55
+ allowFilesystemWrite: true,
56
+ allowShell: raw.allowShell !== false,
57
+ writableRoots: normalizeWritableRoots(raw.writableRoots),
58
+ };
59
+ }
60
+
61
+ return {
62
+ ...DEFAULT_POLICY,
63
+ allowFilesystemRead: raw.allowFilesystemRead !== false,
64
+ };
65
+ }
66
+
67
+ const WRITE_INTENT_RE =
68
+ /\b(write|overwrite|edit|modify|delete|remove|rename|move|create|save|patch|apply patch|mkdir|touch|rm|rmdir|del|erase|copy-item|move-item|remove-item|set-content|add-content|new-item)\b|[\u5199\u5220\u6539\u4FEE\u4FDD\u5B58\u521B\u5EFA\u79FB\u52A8]/i;
69
+
70
+ const SHELL_INTENT_RE =
71
+ /\b(run|execute|exec|shell|terminal|powershell|pwsh|bash|zsh|cmd\.exe|npm|bun|node|python|git|curl|wget|ssh|scp|chmod|chown|sudo)\b|[\u8FD0\u884C\u6267\u884C\u547D\u4EE4\u7EC8\u7AEF]/i;
72
+
73
+ export function assertMachineRunAllowed(userInput: string, policy: MachineRunPermissionPolicy) {
74
+ const task = String(userInput || "");
75
+ if (!policy.allowFilesystemWrite && WRITE_INTENT_RE.test(task)) {
76
+ throw new Error(WRITE_DENIED);
77
+ }
78
+ if (!policy.allowShell && SHELL_INTENT_RE.test(task)) {
79
+ throw new Error(SHELL_DENIED);
80
+ }
81
+ }
82
+
83
+ export function buildMachinePermissionPromptBlock(policy: MachineRunPermissionPolicy) {
84
+ return [
85
+ "--- Machine permission policy ---",
86
+ `Mode: ${policy.mode}`,
87
+ `Filesystem reads are ${policy.allowFilesystemRead ? "allowed" : "not allowed"}.`,
88
+ `File writes are ${policy.allowFilesystemWrite ? "allowed" : "not allowed"}.`,
89
+ `Arbitrary shell commands are ${policy.allowShell ? "allowed" : "not allowed"}.`,
90
+ policy.writableRoots.length
91
+ ? `Writable roots: ${policy.writableRoots.join(", ")}`
92
+ : "Writable roots: none.",
93
+ "If the user asks for an operation outside this policy, refuse instead of attempting it.",
94
+ ].join("\n");
95
+ }
package/ai/agent.ts ADDED
@@ -0,0 +1,2 @@
1
+ import { ulid } from "ulid";
2
+ export const createAgent = () => ({ id: ulid() });
package/ai/index.ts ADDED
@@ -0,0 +1 @@
1
+ export const test = 1;
package/authCommands.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { createInterface } from "node:readline/promises";
2
2
  import { stdin as input, stdout as output } from "node:process";
3
3
  import { rmSync } from "node:fs";
4
+ import { spawn } from "node:child_process";
4
5
 
5
6
  import {
6
7
  getCurrentProfile,
@@ -8,40 +9,203 @@ import {
8
9
  loadProfileConfig,
9
10
  saveDefaultProfile,
10
11
  } from "./client/profileConfig";
12
+ import { DEFAULT_NOLO_SERVER_URL } from "./defaultServer";
11
13
 
12
14
  function getArg(args: string[], flag: string) {
13
15
  const index = args.indexOf(flag);
14
16
  return index >= 0 ? args[index + 1] : undefined;
15
17
  }
16
18
 
17
- export async function runLoginCommand(args: string[]) {
18
- const configPath = getDefaultProfileConfigPath();
19
- const serverArg = getArg(args, "--server");
20
- const tokenArg = getArg(args, "--token");
21
- const rl = createInterface({ input, output });
19
+ type LoginCommandDeps = {
20
+ configPath?: string;
21
+ fetchImpl?: typeof fetch;
22
+ openBrowser?: (url: string) => Promise<boolean> | boolean;
23
+ sleep?: (ms: number) => Promise<void>;
24
+ now?: () => number;
25
+ question?: (prompt: string) => Promise<string>;
26
+ output?: Pick<Console, "log">;
27
+ error?: Pick<Console, "error">;
28
+ };
29
+
30
+ const postJson = async (
31
+ fetchImpl: typeof fetch,
32
+ url: string,
33
+ body: Record<string, unknown>
34
+ ) =>
35
+ fetchImpl(url, {
36
+ method: "POST",
37
+ headers: { "Content-Type": "application/json" },
38
+ body: JSON.stringify(body),
39
+ });
40
+
41
+ const defaultSleep = (ms: number) =>
42
+ new Promise<void>((resolve) => setTimeout(resolve, ms));
43
+
44
+ const defaultOpenBrowser = async (url: string) => {
45
+ const command =
46
+ process.platform === "darwin"
47
+ ? "open"
48
+ : process.platform === "win32"
49
+ ? "cmd"
50
+ : "xdg-open";
51
+ const args =
52
+ process.platform === "win32" ? ["/c", "start", "", url] : [url];
22
53
 
23
54
  try {
24
- const serverUrl = (
25
- serverArg ||
26
- (await rl.question("server [https://nolo.chat]: ")) ||
27
- "https://nolo.chat"
28
- ).replace(/\/+$/, "");
29
-
30
- const authToken = tokenArg || (await rl.question("paste auth token: "));
31
- if (!authToken.trim()) {
32
- console.error("No auth token provided.");
33
- return 1;
55
+ const child = spawn(command, args, {
56
+ detached: true,
57
+ stdio: "ignore",
58
+ });
59
+ child.unref();
60
+ return true;
61
+ } catch {
62
+ return false;
63
+ }
64
+ };
65
+
66
+ async function saveTokenLogin(args: {
67
+ configPath: string;
68
+ serverUrl: string;
69
+ authToken: string;
70
+ output: Pick<Console, "log">;
71
+ error: Pick<Console, "error">;
72
+ }) {
73
+ if (!args.authToken.trim()) {
74
+ args.error.error("No auth token provided.");
75
+ return 1;
76
+ }
77
+
78
+ saveDefaultProfile(args.configPath, {
79
+ serverUrl: args.serverUrl,
80
+ authToken: args.authToken.trim(),
81
+ });
82
+ args.output.log(`Saved profile default -> ${args.serverUrl}`);
83
+ return 0;
84
+ }
85
+
86
+ async function runWebLogin(args: {
87
+ configPath: string;
88
+ serverUrl: string;
89
+ fetchImpl: typeof fetch;
90
+ openBrowser: (url: string) => Promise<boolean> | boolean;
91
+ sleep: (ms: number) => Promise<void>;
92
+ now: () => number;
93
+ output: Pick<Console, "log">;
94
+ error: Pick<Console, "error">;
95
+ noBrowser: boolean;
96
+ }) {
97
+ const startResponse = await postJson(
98
+ args.fetchImpl,
99
+ `${args.serverUrl}/api/v1/users/cli-login/start`,
100
+ { clientName: "nolo-cli" }
101
+ );
102
+ const start = await startResponse.json().catch(() => ({} as any));
103
+ if (!startResponse.ok || !start?.deviceCode || !start?.verificationUriComplete) {
104
+ args.error.error(
105
+ `Failed to start web login (${startResponse.status}). Use --token to paste a token manually.`
106
+ );
107
+ return 1;
108
+ }
109
+
110
+ args.output.log("Open this URL to authorize nolo-cli:");
111
+ args.output.log(start.verificationUriComplete);
112
+ args.output.log(`Code: ${start.userCode}`);
113
+
114
+ if (!args.noBrowser) {
115
+ const opened = await args.openBrowser(start.verificationUriComplete);
116
+ if (!opened) {
117
+ args.output.log("Could not open a browser automatically. Paste the URL above.");
34
118
  }
119
+ }
120
+
121
+ const intervalMs = Math.max(1, Number(start.interval) || 2) * 1000;
122
+ const timeoutMs = Math.max(1, Number(start.expiresIn) || 600) * 1000;
123
+ const deadline = args.now() + timeoutMs;
124
+
125
+ while (args.now() <= deadline) {
126
+ const pollResponse = await postJson(
127
+ args.fetchImpl,
128
+ `${args.serverUrl}/api/v1/users/cli-login/poll`,
129
+ { deviceCode: start.deviceCode }
130
+ );
131
+ const poll = await pollResponse.json().catch(() => ({} as any));
132
+
133
+ if (pollResponse.status === 202) {
134
+ await args.sleep(intervalMs);
135
+ continue;
136
+ }
137
+
138
+ if (pollResponse.ok && poll?.token) {
139
+ const approvedServer =
140
+ typeof poll.serverUrl === "string" && poll.serverUrl.trim()
141
+ ? poll.serverUrl.trim().replace(/\/+$/, "")
142
+ : args.serverUrl;
143
+ return saveTokenLogin({
144
+ configPath: args.configPath,
145
+ serverUrl: approvedServer,
146
+ authToken: poll.token,
147
+ output: args.output,
148
+ error: args.error,
149
+ });
150
+ }
151
+
152
+ args.error.error(
153
+ `Web login failed: ${poll?.error || `HTTP ${pollResponse.status}`}. Use --token to paste a token manually.`
154
+ );
155
+ return 1;
156
+ }
35
157
 
36
- saveDefaultProfile(configPath, {
158
+ args.error.error("Web login timed out. Run `nolo login` again or use --token.");
159
+ return 1;
160
+ }
161
+
162
+ export async function runLoginCommand(args: string[], deps: LoginCommandDeps = {}) {
163
+ const configPath = deps.configPath ?? getDefaultProfileConfigPath();
164
+ const serverArg = getArg(args, "--server");
165
+ const tokenArg = getArg(args, "--token");
166
+ const noBrowser = args.includes("--no-browser");
167
+ const outputTarget = deps.output ?? console;
168
+ const errorTarget = deps.error ?? console;
169
+ const serverUrl = (serverArg || DEFAULT_NOLO_SERVER_URL).replace(/\/+$/, "");
170
+
171
+ if (tokenArg) {
172
+ return saveTokenLogin({
173
+ configPath,
37
174
  serverUrl,
38
- authToken: authToken.trim(),
175
+ authToken: tokenArg,
176
+ output: outputTarget,
177
+ error: errorTarget,
39
178
  });
40
- console.log(`Saved profile default -> ${serverUrl}`);
41
- return 0;
42
- } finally {
43
- rl.close();
44
179
  }
180
+
181
+ if (args.includes("--manual")) {
182
+ const rl = createInterface({ input, output });
183
+ const question = deps.question ?? ((prompt: string) => rl.question(prompt));
184
+ try {
185
+ const authToken = await question("paste auth token: ");
186
+ return saveTokenLogin({
187
+ configPath,
188
+ serverUrl,
189
+ authToken,
190
+ output: outputTarget,
191
+ error: errorTarget,
192
+ });
193
+ } finally {
194
+ rl.close();
195
+ }
196
+ }
197
+
198
+ return runWebLogin({
199
+ configPath,
200
+ serverUrl,
201
+ fetchImpl: deps.fetchImpl ?? fetch,
202
+ openBrowser: deps.openBrowser ?? defaultOpenBrowser,
203
+ sleep: deps.sleep ?? defaultSleep,
204
+ now: deps.now ?? Date.now,
205
+ output: outputTarget,
206
+ error: errorTarget,
207
+ noBrowser,
208
+ });
45
209
  }
46
210
 
47
211
  export function runWhoamiCommand() {
@@ -0,0 +1,222 @@
1
+ // packages/cli/client/compactDialog.ts
2
+ // HTTP-only compact helper for CLI TUI (no Redux store available).
3
+
4
+ import { ulid } from "ulid";
5
+
6
+ const DB_PATH = "/api/v1/db";
7
+
8
+ /**
9
+ * Extract userId from a JWT-style auth token without verifying the signature.
10
+ * Mirrors the logic of `parseToken` in `auth/token.ts` without the crypto imports.
11
+ * @internal - exported for testing only
12
+ */
13
+ export function parseTokenUserId(token: string): string | null {
14
+ try {
15
+ const parts = token.split(".");
16
+ if (parts.length < 2) return null;
17
+ const payloadBase64 = parts[1];
18
+ const payload = JSON.parse(
19
+ Buffer.from(payloadBase64, "base64").toString("utf8")
20
+ );
21
+ return typeof payload?.userId === "string" ? payload.userId : null;
22
+ } catch {
23
+ return null;
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Extract the custom ID (ULID) from a dialog key like `dialog-{userId}-{id}`.
29
+ * Mirrors `extractCustomId` from `core/prefix` without importing it.
30
+ */
31
+ function extractCustomId(key: string): string {
32
+ const parts = key.split("-");
33
+ return parts.slice(2).join("-");
34
+ }
35
+
36
+ async function readDialogRecord(
37
+ fetchImpl: typeof fetch,
38
+ serverUrl: string,
39
+ authToken: string,
40
+ dialogKey: string
41
+ ): Promise<Record<string, unknown>> {
42
+ const res = await fetchImpl(`${serverUrl}${DB_PATH}/read/${dialogKey}`, {
43
+ headers: { Authorization: `Bearer ${authToken}` },
44
+ });
45
+ if (!res.ok) {
46
+ throw new Error(`Failed to read dialog "${dialogKey}": HTTP ${res.status}`);
47
+ }
48
+ const data = await res.json();
49
+ return data as Record<string, unknown>;
50
+ }
51
+
52
+ /**
53
+ * Fields carried forward from the source dialog into the fork.
54
+ * Conversation summary/compression state is intentionally excluded so the new
55
+ * dialog starts clean: summary, summarizedBeforeId, proactiveSummary,
56
+ * proactiveSummaryBeforeId, compressionCount, summaryPending must NOT be
57
+ * inherited — they describe the old conversation, not the fork.
58
+ */
59
+ const FORKED_CARRY_FIELDS = [
60
+ "cybots",
61
+ "type",
62
+ "title",
63
+ "spaceId",
64
+ "category",
65
+ "referenceKeys",
66
+ "triggerType",
67
+ "schedule",
68
+ "taskPrompt",
69
+ "executionMode",
70
+ ] as const;
71
+
72
+ function buildForkedDialogRecord(
73
+ current: Record<string, unknown>,
74
+ userId: string
75
+ ): Record<string, unknown> & { dbKey: string; id: string } {
76
+ const newId = ulid();
77
+ const dbKey = `dialog-${userId}-${newId}`;
78
+ const now = new Date().toISOString();
79
+
80
+ // Explicitly pick only the allowed fields — never spread `current` wholesale.
81
+ const carried: Record<string, unknown> = {};
82
+ for (const field of FORKED_CARRY_FIELDS) {
83
+ if (current[field] !== undefined) {
84
+ carried[field] = current[field];
85
+ }
86
+ }
87
+
88
+ return {
89
+ ...carried,
90
+ id: newId,
91
+ dbKey,
92
+ inheritedFromDialogKey: current.dbKey,
93
+ inheritedFromDialogTitle: current.title,
94
+ createdAt: now,
95
+ updatedAt: now,
96
+ // reset per-dialog stats
97
+ inputTokens: 0,
98
+ outputTokens: 0,
99
+ totalCost: 0,
100
+ };
101
+ }
102
+
103
+ async function writeDialogRecord(
104
+ fetchImpl: typeof fetch,
105
+ serverUrl: string,
106
+ authToken: string,
107
+ record: Record<string, unknown> & { dbKey: string }
108
+ ): Promise<void> {
109
+ const res = await fetchImpl(`${serverUrl}${DB_PATH}/write/`, {
110
+ method: "POST",
111
+ headers: {
112
+ "Content-Type": "application/json",
113
+ Authorization: `Bearer ${authToken}`,
114
+ },
115
+ body: JSON.stringify({ data: record, customKey: record.dbKey }),
116
+ });
117
+ if (!res.ok) {
118
+ throw new Error(
119
+ `Failed to write forked dialog "${record.dbKey}": HTTP ${res.status}`
120
+ );
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Best-effort: register the forked dialog in the space sidebar.
126
+ * Failure here is non-fatal since the dialog is already stored.
127
+ */
128
+ async function addDialogToSpaceIfNeeded(
129
+ fetchImpl: typeof fetch,
130
+ serverUrl: string,
131
+ authToken: string,
132
+ record: Record<string, unknown> & { dbKey: string }
133
+ ): Promise<void> {
134
+ const rawSpaceId = record.spaceId;
135
+ if (!rawSpaceId || typeof rawSpaceId !== "string") return;
136
+
137
+ const normalizedSpaceId = rawSpaceId.startsWith("space-")
138
+ ? rawSpaceId.slice("space-".length)
139
+ : rawSpaceId;
140
+ const spaceKey = `space-${normalizedSpaceId}`;
141
+ const now = Date.now();
142
+
143
+ const contentEntry = {
144
+ title: typeof record.title === "string" ? record.title : record.id,
145
+ type: "dialog",
146
+ contentKey: record.dbKey,
147
+ pinned: false,
148
+ createdAt: now,
149
+ updatedAt: now,
150
+ };
151
+
152
+ try {
153
+ const res = await fetchImpl(`${serverUrl}${DB_PATH}/patch/${spaceKey}`, {
154
+ method: "PATCH",
155
+ headers: {
156
+ "Content-Type": "application/json",
157
+ Authorization: `Bearer ${authToken}`,
158
+ },
159
+ body: JSON.stringify({ contents: { [record.dbKey]: contentEntry } }),
160
+ });
161
+ if (!res.ok) {
162
+ console.warn(
163
+ `[nolo] compact: addDialogToSpace failed for ${spaceKey}: HTTP ${res.status}`
164
+ );
165
+ }
166
+ } catch (error) {
167
+ console.warn(`[nolo] compact: addDialogToSpace error: ${error}`);
168
+ }
169
+ }
170
+
171
+ export type CompactDialogResult = {
172
+ dialogId: string;
173
+ dialogKey: string;
174
+ spaceId?: string;
175
+ };
176
+
177
+ /**
178
+ * Compact the current dialog by forking it:
179
+ * 1. Read the current dialog config from the server.
180
+ * 2. Build a new dialog record that inherits from the old one.
181
+ * 3. Write the new record to the server.
182
+ * 4. Register the new dialog in the space sidebar (best-effort).
183
+ *
184
+ * Returns the new dialog's ID so the TUI can switch to it.
185
+ */
186
+ export async function compactDialog(options: {
187
+ serverUrl: string;
188
+ authToken: string;
189
+ dialogId: string;
190
+ fetchImpl?: typeof fetch;
191
+ }): Promise<CompactDialogResult> {
192
+ const fetchImpl = options.fetchImpl ?? fetch;
193
+
194
+ const userId = parseTokenUserId(options.authToken);
195
+ if (!userId) {
196
+ throw new Error(
197
+ "[nolo] compact: cannot compact — invalid or missing auth token"
198
+ );
199
+ }
200
+
201
+ const dialogKey = `dialog-${userId}-${options.dialogId}`;
202
+ const current = await readDialogRecord(
203
+ fetchImpl,
204
+ options.serverUrl,
205
+ options.authToken,
206
+ dialogKey
207
+ );
208
+ const next = buildForkedDialogRecord(current, userId);
209
+ await writeDialogRecord(fetchImpl, options.serverUrl, options.authToken, next);
210
+ await addDialogToSpaceIfNeeded(
211
+ fetchImpl,
212
+ options.serverUrl,
213
+ options.authToken,
214
+ next
215
+ );
216
+
217
+ return {
218
+ dialogId: extractCustomId(next.dbKey),
219
+ dialogKey: next.dbKey,
220
+ spaceId: typeof next.spaceId === "string" ? next.spaceId : undefined,
221
+ };
222
+ }
@@ -73,6 +73,8 @@ export function renderHelpText() {
73
73
  " nolo",
74
74
  " nolo chat",
75
75
  " nolo login",
76
+ " nolo login --no-browser",
77
+ " nolo login --token <auth-token>",
76
78
  " nolo whoami",
77
79
  " nolo connect",
78
80
  " nolo connect --watch",
@@ -0,0 +1,73 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { resolveLaunchableCodexCommand } from "./codexBinary";
5
+
6
+ type EnvLike = Record<string, string | undefined>;
7
+
8
+ type DetectRuntimeCapabilitiesOptions = {
9
+ commandExists?: (command: string) => boolean;
10
+ commandLaunchable?: (command: string, args: string[]) => boolean;
11
+ env?: EnvLike;
12
+ probeLaunchable?: boolean;
13
+ };
14
+
15
+ type CommandProbe = {
16
+ command: string;
17
+ args: string[];
18
+ };
19
+
20
+ const COMMAND_CAPABILITIES: Array<[probes: CommandProbe[], capability: string]> = [
21
+ [[{ command: "codex", args: ["--version"] }], "codex-cli"],
22
+ [[{ command: "claude", args: ["--version"] }], "claude-code"],
23
+ [
24
+ [
25
+ { command: "gh", args: ["--version"] },
26
+ { command: "gh", args: ["copilot", "--", "--help"] },
27
+ ],
28
+ "copilot-cli",
29
+ ],
30
+ [[{ command: "gemini", args: ["--version"] }], "gemini-cli"],
31
+ [[{ command: "kimi", args: ["--version"] }], "kimi-cli"],
32
+ ];
33
+
34
+ function defaultCommandExists(command: string) {
35
+ const pathEntries = (process.env.PATH ?? "").split(process.platform === "win32" ? ";" : ":");
36
+ const extensions = process.platform === "win32" ? [".exe", ".cmd", ".bat", ""] : [""];
37
+ return pathEntries.some((entry) =>
38
+ extensions.some((extension) => existsSync(join(entry, `${command}${extension}`)))
39
+ );
40
+ }
41
+
42
+ function defaultCommandLaunchable(command: string, args: string[]) {
43
+ const executable = command === "codex" ? resolveLaunchableCodexCommand() : command;
44
+ const result = spawnSync(executable, args, {
45
+ stdio: "pipe",
46
+ timeout: 3_000,
47
+ windowsHide: true,
48
+ });
49
+ return !result.error && result.status === 0;
50
+ }
51
+
52
+ export function detectRuntimeCapabilities(
53
+ options: DetectRuntimeCapabilitiesOptions = {}
54
+ ): string[] {
55
+ const commandExists = options.commandExists ?? defaultCommandExists;
56
+ const commandLaunchable = options.commandLaunchable ?? defaultCommandLaunchable;
57
+ const env = options.env ?? process.env;
58
+ const capabilities: string[] = [];
59
+
60
+ for (const [probes, capability] of COMMAND_CAPABILITIES) {
61
+ if (!probes.every((probe) => commandExists(probe.command))) continue;
62
+ if (options.probeLaunchable && !probes.every((probe) => commandLaunchable(probe.command, probe.args))) {
63
+ continue;
64
+ }
65
+ capabilities.push(capability);
66
+ }
67
+
68
+ if (env.NOLO_LOCAL_LLM_ENDPOINT || env.LLAMA_SERVER_URL) {
69
+ capabilities.push("local-llm");
70
+ }
71
+
72
+ return capabilities;
73
+ }
@@ -0,0 +1,41 @@
1
+ import { copyFileSync, existsSync, mkdirSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+
5
+ type EnvLike = Record<string, string | undefined>;
6
+
7
+ const WINDOWS_APPS_CODEX_PATTERN = /\\WindowsApps\\OpenAI\.Codex_[^\\]+\\app\\resources$/i;
8
+
9
+ function pathEntries(env: EnvLike) {
10
+ return (env.PATH ?? process.env.PATH ?? "").split(process.platform === "win32" ? ";" : ":");
11
+ }
12
+
13
+ function findWindowsAppsCodex(env: EnvLike) {
14
+ if (process.platform !== "win32") return "";
15
+ for (const entry of pathEntries(env)) {
16
+ if (!WINDOWS_APPS_CODEX_PATTERN.test(entry)) continue;
17
+ const candidate = join(entry, "codex.exe");
18
+ if (existsSync(candidate)) return candidate;
19
+ }
20
+ return "";
21
+ }
22
+
23
+ function copyCodexToUserBin(source: string, env: EnvLike) {
24
+ const target =
25
+ env.NOLO_CODEX_SHIM_PATH ??
26
+ join(homedir(), ".nolo", "bin", "codex.exe");
27
+ if (existsSync(target)) return target;
28
+ mkdirSync(dirname(target), { recursive: true });
29
+ copyFileSync(source, target);
30
+ return target;
31
+ }
32
+
33
+ export function resolveLaunchableCodexCommand(env: EnvLike = process.env) {
34
+ const explicit = env.NOLO_CODEX_BIN?.trim();
35
+ if (explicit) return explicit;
36
+
37
+ const windowsAppsCodex = findWindowsAppsCodex(env);
38
+ if (windowsAppsCodex) return copyCodexToUserBin(windowsAppsCodex, env);
39
+
40
+ return "codex";
41
+ }