@love-moon/conductor-cli 0.2.21 → 0.2.23

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.
@@ -12,9 +12,10 @@ import { RUNTIME_SUPPORTED_BACKENDS } from "../src/runtime-backends.js";
12
12
 
13
13
  const CONFIG_DIR = path.join(os.homedir(), ".conductor");
14
14
  const CONFIG_FILE = path.join(CONFIG_DIR, "config.yaml");
15
+ const packageJson = JSON.parse(
16
+ fs.readFileSync(new URL("../package.json", import.meta.url), "utf-8"),
17
+ );
15
18
 
16
- // 市面上主流 Coding CLI 配置
17
- // 格式: { name: { command: string, description: string, execArgs?: string } }
18
19
  const DEFAULT_CLIs = {
19
20
  claude: {
20
21
  command: "claude",
@@ -26,21 +27,11 @@ const DEFAULT_CLIs = {
26
27
  execArgs: "--dangerously-bypass-approvals-and-sandbox --ask-for-approval never",
27
28
  description: "OpenAI Codex CLI"
28
29
  },
29
- // gemini: {
30
- // command: "gemini",
31
- // execArgs: "",
32
- // description: "Google Gemini CLI"
33
- // },
34
30
  opencode: {
35
31
  command: "opencode",
36
32
  execArgs: "",
37
33
  description: "OpenCode CLI (Conductor runs opencode serve with permission=allow)"
38
34
  },
39
- // kimi: {
40
- // command: "kimi",
41
- // execArgs: "--yolo --print --prompt",
42
- // description: "Kimi CLI"
43
- // },
44
35
  };
45
36
 
46
37
  const backendUrl =
@@ -48,8 +39,8 @@ const backendUrl =
48
39
  process.env.BACKEND_URL ||
49
40
  "https://conductor-ai.top";
50
41
  const defaultDaemonName = os.hostname() || "my-daemon";
42
+ const cliVersion = packageJson.version || "unknown";
51
43
 
52
- // ANSI 颜色代码
53
44
  const COLORS = {
54
45
  yellow: "\x1b[33m",
55
46
  green: "\x1b[32m",
@@ -58,6 +49,8 @@ const COLORS = {
58
49
  bold: "\x1b[1m"
59
50
  };
60
51
 
52
+ let lastDeviceAuthConfig = null;
53
+
61
54
  function colorize(text, color) {
62
55
  return `${COLORS[color] || ""}${text}${COLORS.reset}`;
63
56
  }
@@ -79,11 +72,15 @@ function buildConfigEntryLines(cli, info, { commented = false } = {}) {
79
72
  }
80
73
 
81
74
  async function main() {
82
- // 解析命令行参数
83
75
  const argv = yargs(hideBin(process.argv))
84
76
  .option("token", {
85
77
  type: "string",
86
- description: "Conductor token (optional, will prompt if not provided)"
78
+ description: "Conductor token"
79
+ })
80
+ .option("manual", {
81
+ type: "boolean",
82
+ default: false,
83
+ description: "Enter token manually instead of browser device authorization"
87
84
  })
88
85
  .option("force", {
89
86
  type: "boolean",
@@ -96,13 +93,13 @@ async function main() {
96
93
  description: "Show help"
97
94
  })
98
95
  .usage("Usage: conductor config [options]")
99
- .example("conductor config", "Interactive configuration")
100
- .example("conductor config --token <your-token>", "Configure with token")
96
+ .example("conductor config", "Authorize this device in the browser and write config")
97
+ .example("conductor config --manual", "Enter token manually")
98
+ .example("conductor config --token <token>", "Configure with token")
101
99
  .example("conductor config --token <token> --force", "Force overwrite existing config")
102
100
  .help()
103
101
  .argv;
104
102
 
105
- // 检查配置文件是否存在
106
103
  if (fs.existsSync(CONFIG_FILE) && !argv.force) {
107
104
  process.stderr.write(
108
105
  colorize(`Config already exists at ${CONFIG_FILE}. Use --force to overwrite.\n`, "yellow")
@@ -110,66 +107,68 @@ async function main() {
110
107
  process.exit(1);
111
108
  }
112
109
 
113
- // 获取 token
114
- let token = argv.token;
115
- if (!token) {
116
- token = await promptForToken();
117
- if (!token) {
118
- process.stderr.write(colorize("No token provided. Aborting.\n", "yellow"));
119
- process.exit(1);
120
- }
121
- }
122
-
123
- // 检测已安装的 CLI
110
+ let resolvedBackendUrl = backendUrl;
111
+ let resolvedWebsocketUrl = null;
124
112
  const detectedCLIs = detectInstalledCLIs();
125
113
 
126
- // 如果没有检测到任何 CLI,显示警告
127
114
  if (detectedCLIs.length === 0) {
128
115
  console.log("");
129
116
  console.log(colorize("=".repeat(70), "yellow"));
130
117
  console.log(colorize("⚠️ WARNING: No coding CLI detected!", "yellow"));
131
118
  console.log(colorize("=".repeat(70), "yellow"));
132
- console.log(colorize("", "yellow"));
119
+ console.log("");
133
120
  console.log(colorize("Conductor requires at least one coding CLI to work properly.", "yellow"));
134
- console.log(colorize("", "yellow"));
121
+ console.log("");
135
122
  console.log(colorize("Please install one of the following CLIs first:", "yellow"));
136
123
  console.log("");
137
-
138
- Object.entries(DEFAULT_CLIs).forEach(([key, info]) => {
124
+
125
+ Object.entries(DEFAULT_CLIs).forEach(([, info]) => {
139
126
  console.log(` • ${colorize(info.command, "cyan")} - ${info.description}`);
140
127
  });
141
-
128
+
142
129
  console.log("");
143
130
  console.log(colorize("After installing a CLI, run 'conductor config' again.", "yellow"));
144
131
  console.log(colorize("=".repeat(70), "yellow"));
145
132
  console.log("");
146
-
147
- // 询问是否继续创建配置
133
+
148
134
  const shouldContinue = await promptYesNo(
149
135
  "Do you want to continue creating the config anyway? (y/N): "
150
136
  );
151
-
137
+
152
138
  if (!shouldContinue) {
153
139
  process.exit(1);
154
140
  }
155
141
  } else {
156
- // 显示检测到的 CLI
157
142
  console.log("");
158
143
  console.log(colorize("✓ Detected the following coding CLIs:", "green"));
159
- detectedCLIs.forEach(cli => {
144
+ detectedCLIs.forEach((cli) => {
160
145
  const info = DEFAULT_CLIs[cli];
161
146
  console.log(` • ${colorize(info.command, "cyan")} - ${info.description}`);
162
147
  });
163
148
  console.log("");
164
149
  }
165
150
 
166
- // 创建配置目录
151
+ let token = argv.token;
152
+ if (!token) {
153
+ if (argv.manual) {
154
+ token = await promptForToken();
155
+ if (!token) {
156
+ process.stderr.write(colorize("No token provided. Aborting.\n", "yellow"));
157
+ process.exit(1);
158
+ }
159
+ } else {
160
+ const authResult = await authorizeDeviceAndGetToken();
161
+ token = authResult.agentToken;
162
+ resolvedBackendUrl = authResult.backendUrl || resolvedBackendUrl;
163
+ resolvedWebsocketUrl = authResult.websocketUrl || null;
164
+ }
165
+ }
166
+
167
167
  fs.mkdirSync(CONFIG_DIR, { recursive: true });
168
168
 
169
- // 构建配置内容
170
169
  const lines = [
171
170
  `agent_token: ${yamlQuote(token)}`,
172
- `backend_url: ${yamlQuote(backendUrl)}`,
171
+ `backend_url: ${yamlQuote(resolvedBackendUrl)}`,
173
172
  `daemon_name: ${yamlQuote(defaultDaemonName)}`,
174
173
  "log_level: debug",
175
174
  "workspace: '~/ws/fires'",
@@ -178,14 +177,16 @@ async function main() {
178
177
  "allow_cli_list:"
179
178
  ];
180
179
 
181
- // 添加检测到的 CLI 到配置
180
+ if (resolvedWebsocketUrl) {
181
+ lines.splice(2, 0, `websocket_url: ${yamlQuote(resolvedWebsocketUrl)}`);
182
+ }
183
+
182
184
  if (detectedCLIs.length > 0) {
183
- detectedCLIs.forEach(cli => {
185
+ detectedCLIs.forEach((cli) => {
184
186
  const info = DEFAULT_CLIs[cli];
185
187
  lines.push(...buildConfigEntryLines(cli, info));
186
188
  });
187
189
  } else {
188
- // 如果没有检测到任何 CLI,添加示例注释
189
190
  lines.push(" # No CLI detected. Add your installed CLI here:");
190
191
  Object.entries(DEFAULT_CLIs).forEach(([key, info]) => {
191
192
  lines.push(...buildConfigEntryLines(key, info, { commented: true }));
@@ -202,22 +203,18 @@ async function main() {
202
203
  );
203
204
 
204
205
  fs.writeFileSync(CONFIG_FILE, lines.join("\n"), "utf-8");
205
-
206
+
206
207
  console.log(colorize(`✓ Wrote Conductor config to ${CONFIG_FILE}`, "green"));
207
-
208
+
208
209
  if (detectedCLIs.length === 0) {
209
210
  console.log("");
210
211
  console.log(colorize("⚠️ Remember to install a coding CLI before using Conductor!", "yellow"));
211
212
  }
212
213
  }
213
214
 
214
- /**
215
- * 检测系统中已安装的 CLI
216
- * @returns {string[]} 已安装的 CLI key 列表
217
- */
218
215
  function detectInstalledCLIs() {
219
216
  const detected = [];
220
-
217
+
221
218
  for (const [key, info] of Object.entries(DEFAULT_CLIs)) {
222
219
  if (!RUNTIME_SUPPORTED_BACKENDS.includes(key)) {
223
220
  continue;
@@ -226,52 +223,29 @@ function detectInstalledCLIs() {
226
223
  detected.push(key);
227
224
  }
228
225
  }
229
-
226
+
230
227
  return detected;
231
228
  }
232
229
 
233
- /**
234
- * 检查命令是否在系统 PATH 中可用
235
- * @param {string} command - 命令名称
236
- * @returns {boolean}
237
- */
238
230
  function isCommandAvailable(command) {
239
231
  try {
240
232
  const platform = os.platform();
241
- let checkCmd;
242
-
243
- if (platform === "win32") {
244
- // Windows: 使用 where 命令
245
- checkCmd = `where ${command}`;
246
- } else {
247
- // Unix/Linux/macOS: 使用 which 或 command -v
248
- checkCmd = `command -v ${command}`;
249
- }
250
-
251
- execSync(checkCmd, {
233
+ const checkCmd = platform === "win32" ? `where ${command}` : `command -v ${command}`;
234
+ execSync(checkCmd, {
252
235
  stdio: "pipe",
253
- timeout: 5000
236
+ timeout: 5000
254
237
  });
255
238
  return true;
256
- } catch (error) {
257
- // 对于某些 CLI,可能有特定的检测方式
258
- // 例如检查特定的配置文件或目录
239
+ } catch {
259
240
  return checkAlternativeInstallations(command);
260
241
  }
261
242
  }
262
243
 
263
- /**
264
- * 检查 CLI 的替代安装方式
265
- * @param {string} command - 命令名称
266
- * @returns {boolean}
267
- */
268
244
  function checkAlternativeInstallations(command) {
269
- // 检查常见的全局安装路径
270
245
  const homeDir = os.homedir();
271
246
  const platform = os.platform();
272
-
273
247
  const commonPaths = [];
274
-
248
+
275
249
  if (platform === "win32") {
276
250
  commonPaths.push(
277
251
  path.join(homeDir, "AppData", "Roaming", "npm", `${command}.cmd`),
@@ -290,15 +264,89 @@ function checkAlternativeInstallations(command) {
290
264
  `/opt/${command}/bin/${command}`
291
265
  );
292
266
  }
293
-
294
- // 检查文件是否存在
295
- for (const checkPath of commonPaths) {
296
- if (fs.existsSync(checkPath)) {
297
- return true;
267
+
268
+ return commonPaths.some((checkPath) => fs.existsSync(checkPath));
269
+ }
270
+
271
+ async function authorizeDeviceAndGetToken() {
272
+ const startResponse = await fetch(new URL("/api/auth/device/start", backendUrl), {
273
+ method: "POST",
274
+ headers: { "Content-Type": "application/json" },
275
+ body: JSON.stringify({
276
+ cli_version: cliVersion,
277
+ hostname: defaultDaemonName,
278
+ platform: os.platform(),
279
+ backend_url: backendUrl,
280
+ }),
281
+ });
282
+ const startData = await parseJsonResponse(startResponse);
283
+ if (!startResponse.ok) {
284
+ throw new Error(startData.error || `Failed to start device authorization (${startResponse.status})`);
285
+ }
286
+
287
+ console.log("");
288
+ console.log(colorize("Open this link in your browser to authorize this device:", "cyan"));
289
+ console.log("");
290
+ console.log(`Device code: ${colorize(startData.user_code, "bold")}`);
291
+ console.log(`Direct link: ${startData.verification_uri_complete}`);
292
+ console.log("");
293
+ console.log("Only approve the request if the web page shows the same device code.");
294
+ console.log("");
295
+ console.log("Waiting for authorization...");
296
+
297
+ const intervalSeconds =
298
+ typeof startData.interval === "number" && Number.isFinite(startData.interval)
299
+ ? Math.max(1, startData.interval)
300
+ : 3;
301
+ const expiresInSeconds =
302
+ typeof startData.expires_in === "number" && Number.isFinite(startData.expires_in)
303
+ ? Math.max(intervalSeconds, startData.expires_in)
304
+ : 600;
305
+ const deadlineAt = Date.now() + expiresInSeconds * 1000;
306
+
307
+ while (Date.now() <= deadlineAt) {
308
+ await sleep(intervalSeconds * 1000);
309
+
310
+ const pollResponse = await fetch(new URL("/api/auth/device/poll", backendUrl), {
311
+ method: "POST",
312
+ headers: { "Content-Type": "application/json" },
313
+ body: JSON.stringify({ device_code: startData.device_code }),
314
+ });
315
+ const pollData = await parseJsonResponse(pollResponse);
316
+ if (!pollResponse.ok) {
317
+ throw new Error(pollData.error || `Failed to poll device authorization (${pollResponse.status})`);
298
318
  }
319
+
320
+ if (pollData.status === "pending") {
321
+ continue;
322
+ }
323
+ if (pollData.status === "approved") {
324
+ const result = {
325
+ agentToken: pollData.agent_token,
326
+ backendUrl: pollData.backend_url || backendUrl,
327
+ websocketUrl: pollData.websocket_url || null,
328
+ };
329
+ lastDeviceAuthConfig = result;
330
+ console.log(colorize("✓ Device authorized", "green"));
331
+ return result;
332
+ }
333
+ if (pollData.status === "denied" || pollData.status === "expired" || pollData.status === "consumed") {
334
+ throw new Error(pollData.message || `Device authorization ${pollData.status}`);
335
+ }
336
+ if (pollData.error) {
337
+ throw new Error(pollData.error);
338
+ }
339
+ }
340
+
341
+ throw new Error("Device authorization timed out");
342
+ }
343
+
344
+ async function parseJsonResponse(response) {
345
+ try {
346
+ return await response.json();
347
+ } catch {
348
+ return {};
299
349
  }
300
-
301
- return false;
302
350
  }
303
351
 
304
352
  async function promptForToken() {
@@ -330,6 +378,12 @@ async function promptYesNo(question) {
330
378
  }
331
379
  }
332
380
 
381
+ function sleep(ms) {
382
+ return new Promise((resolve) => {
383
+ setTimeout(resolve, ms);
384
+ });
385
+ }
386
+
333
387
  function yamlQuote(value) {
334
388
  return JSON.stringify(value);
335
389
  }
@@ -1227,6 +1227,7 @@ export async function applyWorkingDirectory(targetPath) {
1227
1227
  }
1228
1228
  try {
1229
1229
  process.chdir(normalizedPath);
1230
+ process.env.PWD = process.cwd();
1230
1231
  } catch (error) {
1231
1232
  throw new Error(`Cannot switch working directory to ${normalizedPath}: ${error?.message || error}`);
1232
1233
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@love-moon/conductor-cli",
3
- "version": "0.2.21",
4
- "gitCommitId": "fa11085",
3
+ "version": "0.2.23",
4
+ "gitCommitId": "e1d19e3",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "conductor": "bin/conductor.js"
@@ -17,8 +17,8 @@
17
17
  "test": "node --test test/*.test.js"
18
18
  },
19
19
  "dependencies": {
20
- "@love-moon/ai-sdk": "0.2.21",
21
- "@love-moon/conductor-sdk": "0.2.21",
20
+ "@love-moon/ai-sdk": "0.2.23",
21
+ "@love-moon/conductor-sdk": "0.2.23",
22
22
  "dotenv": "^16.4.5",
23
23
  "enquirer": "^2.4.1",
24
24
  "js-yaml": "^4.1.1",
package/src/daemon.js CHANGED
@@ -284,6 +284,15 @@ export function startDaemon(config = {}, deps = {}) {
284
284
  let requestShutdown = async () => {};
285
285
  let shutdownSignalHandled = false;
286
286
  let forcedSignalExitHandled = false;
287
+ let processHandlersAttached = false;
288
+
289
+ const removeProcessListener = (eventName, handler) => {
290
+ if (typeof process.off === "function") {
291
+ process.off(eventName, handler);
292
+ return;
293
+ }
294
+ process.removeListener(eventName, handler);
295
+ };
287
296
 
288
297
  const exitAndReturn = (code) => {
289
298
  exitFn(code);
@@ -597,7 +606,6 @@ export function startDaemon(config = {}, deps = {}) {
597
606
  );
598
607
  };
599
608
 
600
- process.on("exit", cleanupLock);
601
609
  const signalExitCode = (signal) => (signal === "SIGINT" ? 130 : 143);
602
610
  const handleSignal = (signal) => {
603
611
  if (shutdownSignalHandled) {
@@ -621,17 +629,33 @@ export function startDaemon(config = {}, deps = {}) {
621
629
  }
622
630
  })();
623
631
  };
624
- process.on("SIGINT", () => {
632
+ const onSigInt = () => {
625
633
  handleSignal("SIGINT");
626
- });
627
- process.on("SIGTERM", () => {
634
+ };
635
+ const onSigTerm = () => {
628
636
  handleSignal("SIGTERM");
629
- });
630
- process.on("uncaughtException", (err) => {
637
+ };
638
+ const onUncaughtException = (err) => {
631
639
  logError(`Uncaught exception: ${err}`);
632
640
  cleanupLock();
633
641
  exitFn(1);
634
- });
642
+ };
643
+ const detachProcessHandlers = () => {
644
+ if (!processHandlersAttached) {
645
+ return;
646
+ }
647
+ processHandlersAttached = false;
648
+ removeProcessListener("exit", cleanupLock);
649
+ removeProcessListener("SIGINT", onSigInt);
650
+ removeProcessListener("SIGTERM", onSigTerm);
651
+ removeProcessListener("uncaughtException", onUncaughtException);
652
+ };
653
+
654
+ process.on("exit", cleanupLock);
655
+ process.on("SIGINT", onSigInt);
656
+ process.on("SIGTERM", onSigTerm);
657
+ process.on("uncaughtException", onUncaughtException);
658
+ processHandlersAttached = true;
635
659
 
636
660
  if (config.CLEAN_ALL) {
637
661
  cleanAllAgents(BACKEND_HTTP, AGENT_TOKEN, fetchFn)
@@ -641,8 +665,11 @@ export function startDaemon(config = {}, deps = {}) {
641
665
  .catch((err) => {
642
666
  log(`Failed to clean daemons: ${err.message}`);
643
667
  })
644
- .finally(() => exitFn(0));
645
- return { close: () => {} };
668
+ .finally(() => {
669
+ detachProcessHandlers();
670
+ exitFn(0);
671
+ });
672
+ return { close: detachProcessHandlers };
646
673
  }
647
674
 
648
675
  log("Daemon starting...");
@@ -2946,6 +2973,7 @@ export function startDaemon(config = {}, deps = {}) {
2946
2973
 
2947
2974
  return {
2948
2975
  close: () => {
2976
+ detachProcessHandlers();
2949
2977
  void shutdownDaemon();
2950
2978
  },
2951
2979
  };