@love-moon/conductor-cli 0.1.4 → 0.2.1

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.
@@ -5,47 +5,308 @@ import os from "node:os";
5
5
  import path from "node:path";
6
6
  import process from "node:process";
7
7
  import readline from "node:readline/promises";
8
+ import { execSync } from "node:child_process";
9
+ import yargs from "yargs/yargs";
10
+ import { hideBin } from "yargs/helpers";
8
11
 
9
12
  const CONFIG_DIR = path.join(os.homedir(), ".conductor");
10
13
  const CONFIG_FILE = path.join(CONFIG_DIR, "config.yaml");
11
14
 
15
+ // 市面上主流 Coding CLI 配置
16
+ // 格式: { name: { command: string, description: string, execArgs?: string } }
17
+ const DEFAULT_CLIs = {
18
+ claude: {
19
+ command: "claude",
20
+ execArgs: "--dangerously-skip-permissions",
21
+ description: "Anthropic Claude CLI"
22
+ },
23
+ codex: {
24
+ command: "codex",
25
+ execArgs: "exec --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check",
26
+ description: "OpenAI Codex CLI"
27
+ },
28
+ copilot: {
29
+ command: "copilot",
30
+ execArgs: "--allow-all-paths --allow-all-tools -i",
31
+ description: "GitHub Copilot CLI"
32
+ },
33
+ gemini: {
34
+ command: "gemini",
35
+ execArgs: "",
36
+ description: "Google Gemini CLI"
37
+ },
38
+ opencode: {
39
+ command: "opencode",
40
+ execArgs: "",
41
+ description: "OpenCode CLI"
42
+ },
43
+ kimi: {
44
+ command: "kimi",
45
+ execArgs: "--yolo --print --prompt",
46
+ description: "Kimi CLI"
47
+ },
48
+ aider: {
49
+ command: "aider",
50
+ execArgs: "",
51
+ description: "Aider AI Pair Programming"
52
+ },
53
+ goose: {
54
+ command: "goose",
55
+ execArgs: "",
56
+ description: "Goose AI Agent CLI"
57
+ },
58
+ };
59
+
12
60
  const backendUrl =
13
61
  process.env.CONDUCTOR_BACKEND_URL ||
14
62
  process.env.BACKEND_URL ||
15
63
  "https://conductor-ai.top";
16
64
 
17
- const websocketUrl =
18
- process.env.CONDUCTOR_WS_URL || process.env.PUBLIC_WS_URL || "";
65
+ // ANSI 颜色代码
66
+ const COLORS = {
67
+ yellow: "\x1b[33m",
68
+ green: "\x1b[32m",
69
+ cyan: "\x1b[36m",
70
+ reset: "\x1b[0m",
71
+ bold: "\x1b[1m"
72
+ };
73
+
74
+ function colorize(text, color) {
75
+ return `${COLORS[color] || ""}${text}${COLORS.reset}`;
76
+ }
19
77
 
20
78
  async function main() {
21
- if (fs.existsSync(CONFIG_FILE)) {
22
- process.stderr.write(`Config already exists at ${CONFIG_FILE}. Remove it to recreate.\n`);
79
+ // 解析命令行参数
80
+ const argv = yargs(hideBin(process.argv))
81
+ .option("token", {
82
+ type: "string",
83
+ description: "Conductor token (optional, will prompt if not provided)"
84
+ })
85
+ .option("force", {
86
+ type: "boolean",
87
+ default: false,
88
+ description: "Overwrite existing config file"
89
+ })
90
+ .option("help", {
91
+ type: "boolean",
92
+ alias: "h",
93
+ description: "Show help"
94
+ })
95
+ .usage("Usage: conductor config [options]")
96
+ .example("conductor config", "Interactive configuration")
97
+ .example("conductor config --token <your-token>", "Configure with token")
98
+ .example("conductor config --token <token> --force", "Force overwrite existing config")
99
+ .help()
100
+ .argv;
101
+
102
+ // 检查配置文件是否存在
103
+ if (fs.existsSync(CONFIG_FILE) && !argv.force) {
104
+ process.stderr.write(
105
+ colorize(`Config already exists at ${CONFIG_FILE}. Use --force to overwrite.\n`, "yellow")
106
+ );
23
107
  process.exit(1);
24
108
  }
25
109
 
26
- const token = await promptForToken();
110
+ // 获取 token
111
+ let token = argv.token;
27
112
  if (!token) {
28
- process.stderr.write("No token provided. Aborting.\n");
29
- process.exit(1);
113
+ token = await promptForToken();
114
+ if (!token) {
115
+ process.stderr.write(colorize("No token provided. Aborting.\n", "yellow"));
116
+ process.exit(1);
117
+ }
118
+ }
119
+
120
+ // 检测已安装的 CLI
121
+ const detectedCLIs = detectInstalledCLIs();
122
+
123
+ // 如果没有检测到任何 CLI,显示警告
124
+ if (detectedCLIs.length === 0) {
125
+ console.log("");
126
+ console.log(colorize("=".repeat(70), "yellow"));
127
+ console.log(colorize("⚠️ WARNING: No coding CLI detected!", "yellow"));
128
+ console.log(colorize("=".repeat(70), "yellow"));
129
+ console.log(colorize("", "yellow"));
130
+ console.log(colorize("Conductor requires at least one coding CLI to work properly.", "yellow"));
131
+ console.log(colorize("", "yellow"));
132
+ console.log(colorize("Please install one of the following CLIs first:", "yellow"));
133
+ console.log("");
134
+
135
+ Object.entries(DEFAULT_CLIs).forEach(([key, info]) => {
136
+ console.log(` • ${colorize(info.command, "cyan")} - ${info.description}`);
137
+ });
138
+
139
+ console.log("");
140
+ console.log(colorize("After installing a CLI, run 'conductor config' again.", "yellow"));
141
+ console.log(colorize("=".repeat(70), "yellow"));
142
+ console.log("");
143
+
144
+ // 询问是否继续创建配置
145
+ const shouldContinue = await promptYesNo(
146
+ "Do you want to continue creating the config anyway? (y/N): "
147
+ );
148
+
149
+ if (!shouldContinue) {
150
+ process.exit(1);
151
+ }
152
+ } else {
153
+ // 显示检测到的 CLI
154
+ console.log("");
155
+ console.log(colorize("✓ Detected the following coding CLIs:", "green"));
156
+ detectedCLIs.forEach(cli => {
157
+ const info = DEFAULT_CLIs[cli];
158
+ console.log(` • ${colorize(info.command, "cyan")} - ${info.description}`);
159
+ });
160
+ console.log("");
30
161
  }
31
162
 
163
+ // 创建配置目录
32
164
  fs.mkdirSync(CONFIG_DIR, { recursive: true });
33
165
 
166
+ // 构建配置内容
34
167
  const lines = [
35
168
  `agent_token: ${yamlQuote(token)}`,
36
169
  `backend_url: ${yamlQuote(backendUrl)}`,
170
+ "log_level: debug",
171
+ "",
172
+ "# Allowed coding CLIs",
173
+ "allow_cli_list:"
37
174
  ];
38
- if (websocketUrl) {
39
- lines.push(`websocket_url: ${yamlQuote(websocketUrl)}`);
175
+
176
+ // 添加检测到的 CLI 到配置
177
+ if (detectedCLIs.length > 0) {
178
+ detectedCLIs.forEach(cli => {
179
+ const info = DEFAULT_CLIs[cli];
180
+ const fullCommand = info.execArgs
181
+ ? `${info.command} ${info.execArgs}`
182
+ : info.command;
183
+ lines.push(` ${cli}: ${fullCommand}`);
184
+ });
185
+ } else {
186
+ // 如果没有检测到任何 CLI,添加示例注释
187
+ lines.push(" # No CLI detected. Add your installed CLI here:");
188
+ Object.entries(DEFAULT_CLIs).slice(0, 3).forEach(([key, info]) => {
189
+ const fullCommand = info.execArgs
190
+ ? `${info.command} ${info.execArgs}`
191
+ : info.command;
192
+ lines.push(` # ${key}: ${fullCommand}`);
193
+ });
40
194
  }
41
- lines.push("log_level: debug", "");
42
- lines.push("allow_cli_list:", "");
43
- lines.push("codex: codex exec --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check", "");
44
- lines.push("claude: claude --dangerously-skip-permissions", "");
45
- lines.push("copilot: copilot --allow-all-paths --allow-all-tools -i", "");
195
+
196
+ lines.push(
197
+ "",
198
+ "# Uncomment to use custom envs, such as proxy.",
199
+ "# envs:",
200
+ "# http_proxy: http://127.0.0.1:7890",
201
+ "# https_proxy: http://127.0.0.1:7890",
202
+ "# all_proxy: socks5://127.0.0.1:7890"
203
+ );
46
204
 
47
205
  fs.writeFileSync(CONFIG_FILE, lines.join("\n"), "utf-8");
48
- process.stdout.write(`Wrote Conductor config to ${CONFIG_FILE}\n`);
206
+
207
+ console.log(colorize(`✓ Wrote Conductor config to ${CONFIG_FILE}`, "green"));
208
+
209
+ if (detectedCLIs.length === 0) {
210
+ console.log("");
211
+ console.log(colorize("⚠️ Remember to install a coding CLI before using Conductor!", "yellow"));
212
+ }
213
+ }
214
+
215
+ /**
216
+ * 检测系统中已安装的 CLI
217
+ * @returns {string[]} 已安装的 CLI key 列表
218
+ */
219
+ function detectInstalledCLIs() {
220
+ const detected = [];
221
+
222
+ for (const [key, info] of Object.entries(DEFAULT_CLIs)) {
223
+ if (isCommandAvailable(info.command)) {
224
+ detected.push(key);
225
+ }
226
+ }
227
+
228
+ return detected;
229
+ }
230
+
231
+ /**
232
+ * 检查命令是否在系统 PATH 中可用
233
+ * @param {string} command - 命令名称
234
+ * @returns {boolean}
235
+ */
236
+ function isCommandAvailable(command) {
237
+ try {
238
+ const platform = os.platform();
239
+ let checkCmd;
240
+
241
+ if (platform === "win32") {
242
+ // Windows: 使用 where 命令
243
+ checkCmd = `where ${command}`;
244
+ } else {
245
+ // Unix/Linux/macOS: 使用 which 或 command -v
246
+ checkCmd = `command -v ${command}`;
247
+ }
248
+
249
+ execSync(checkCmd, {
250
+ stdio: "pipe",
251
+ timeout: 5000
252
+ });
253
+ return true;
254
+ } catch (error) {
255
+ // 对于某些 CLI,可能有特定的检测方式
256
+ // 例如检查特定的配置文件或目录
257
+ return checkAlternativeInstallations(command);
258
+ }
259
+ }
260
+
261
+ /**
262
+ * 检查 CLI 的替代安装方式
263
+ * @param {string} command - 命令名称
264
+ * @returns {boolean}
265
+ */
266
+ function checkAlternativeInstallations(command) {
267
+ // 检查常见的全局安装路径
268
+ const homeDir = os.homedir();
269
+ const platform = os.platform();
270
+
271
+ const commonPaths = [];
272
+
273
+ if (platform === "win32") {
274
+ commonPaths.push(
275
+ path.join(homeDir, "AppData", "Roaming", "npm", `${command}.cmd`),
276
+ path.join(homeDir, "AppData", "Local", "Programs", command, `${command}.exe`),
277
+ path.join("C:", "Program Files", command, `${command}.exe`),
278
+ path.join("C:", "Program Files (x86)", command, `${command}.exe`)
279
+ );
280
+ } else {
281
+ commonPaths.push(
282
+ `/usr/local/bin/${command}`,
283
+ `/opt/homebrew/bin/${command}`,
284
+ `/usr/bin/${command}`,
285
+ path.join(homeDir, ".local", "bin", command),
286
+ path.join(homeDir, ".cargo", "bin", command),
287
+ path.join(homeDir, ".npm-global", "bin", command),
288
+ `/opt/${command}/bin/${command}`
289
+ );
290
+ }
291
+
292
+ // 特殊检查:Copilot CLI 可能是 gh copilot 扩展
293
+ if (command === "copilot" || command === "copilot-chat") {
294
+ try {
295
+ execSync("gh copilot --help", { stdio: "pipe", timeout: 5000 });
296
+ return true;
297
+ } catch {
298
+ // gh copilot 未安装
299
+ }
300
+ }
301
+
302
+ // 检查文件是否存在
303
+ for (const checkPath of commonPaths) {
304
+ if (fs.existsSync(checkPath)) {
305
+ return true;
306
+ }
307
+ }
308
+
309
+ return false;
49
310
  }
50
311
 
51
312
  async function promptForToken() {
@@ -64,6 +325,19 @@ async function promptForToken() {
64
325
  }
65
326
  }
66
327
 
328
+ async function promptYesNo(question) {
329
+ const rl = readline.createInterface({
330
+ input: process.stdin,
331
+ output: process.stdout,
332
+ });
333
+ try {
334
+ const answer = (await rl.question(question)).trim().toLowerCase();
335
+ return answer === "y" || answer === "yes";
336
+ } finally {
337
+ rl.close();
338
+ }
339
+ }
340
+
67
341
  function yamlQuote(value) {
68
342
  return JSON.stringify(value);
69
343
  }
@@ -6,6 +6,7 @@ import os from "node:os";
6
6
  import { spawn } from "node:child_process";
7
7
  import yargs from "yargs/yargs";
8
8
  import { hideBin } from "yargs/helpers";
9
+ import yaml from "js-yaml";
9
10
 
10
11
  import { startDaemon } from "../src/daemon.js";
11
12
 
@@ -13,9 +14,85 @@ const argv = hideBin(process.argv);
13
14
 
14
15
  const CLI_NAME = process.env.CONDUCTOR_CLI_NAME || "conductor-daemon";
15
16
 
17
+ function formatBeijingTimestampForFile(date = new Date()) {
18
+ const base = date
19
+ .toLocaleString("sv-SE", { timeZone: "Asia/Shanghai", hour12: false })
20
+ .replace(" ", "T")
21
+ .replace(/:/g, "-");
22
+ const millis = String(date.getMilliseconds()).padStart(3, "0");
23
+ return `${base}-${millis}`;
24
+ }
25
+
26
+ function loadUserConfig(configFilePath) {
27
+ try {
28
+ const home = os.homedir();
29
+ const configPath = configFilePath || path.join(home, ".conductor", "config.yaml");
30
+ if (!fs.existsSync(configPath)) {
31
+ return {};
32
+ }
33
+ const content = fs.readFileSync(configPath, "utf8");
34
+ const parsed = yaml.load(content);
35
+ if (parsed && typeof parsed === "object") {
36
+ return parsed;
37
+ }
38
+ } catch (_err) {
39
+ // Ignore config parse errors here; daemon will surface them later.
40
+ }
41
+ return {};
42
+ }
43
+
44
+ function expandHomePath(inputPath, homeDir) {
45
+ if (typeof inputPath !== "string" || !inputPath) {
46
+ return inputPath;
47
+ }
48
+ if (inputPath === "~") {
49
+ return homeDir;
50
+ }
51
+ if (inputPath.startsWith("~/") || inputPath.startsWith("~\\")) {
52
+ return path.join(homeDir, inputPath.slice(2));
53
+ }
54
+ return inputPath;
55
+ }
56
+
57
+ function resolveWorkspaceRoot(configFilePath) {
58
+ const userConfig = loadUserConfig(configFilePath);
59
+ const home = process.env.HOME || os.homedir() || "/tmp";
60
+ const workspaceRoot = process.env.CONDUCTOR_WS || userConfig.workspace || path.join(home, "ws");
61
+ return expandHomePath(workspaceRoot, home);
62
+ }
63
+
64
+ function findRunningDaemonPid(workspaceRoot) {
65
+ const lockFile = path.join(workspaceRoot, "daemon.pid");
66
+ if (!fs.existsSync(lockFile)) {
67
+ return null;
68
+ }
69
+ try {
70
+ const pid = parseInt(fs.readFileSync(lockFile, "utf8"), 10);
71
+ if (Number.isNaN(pid)) {
72
+ return null;
73
+ }
74
+ process.kill(pid, 0);
75
+ return pid;
76
+ } catch (err) {
77
+ if (err && err.code === "ESRCH") {
78
+ return null;
79
+ }
80
+ return pidFromLockFile(lockFile);
81
+ }
82
+ }
83
+
84
+ function pidFromLockFile(lockFile) {
85
+ try {
86
+ const pid = parseInt(fs.readFileSync(lockFile, "utf8"), 10);
87
+ return Number.isNaN(pid) ? null : pid;
88
+ } catch (_err) {
89
+ return null;
90
+ }
91
+ }
92
+
16
93
  const args = yargs(argv)
17
94
  .scriptName(CLI_NAME)
18
- .usage("Usage: $0 [--name <daemon-name>] [--clean-all] [--config-file <path>] [--nohup]")
95
+ .usage("Usage: $0 [--name <daemon-name>] [--clean-all] [--config-file <path>] [--nohup] [--force]")
19
96
  .option("name", {
20
97
  alias: "n",
21
98
  type: "string",
@@ -27,6 +104,11 @@ const args = yargs(argv)
27
104
  default: false,
28
105
  describe: "Run in background and write logs to ~/.conductor/logs/<timestamp>.log",
29
106
  })
107
+ .option("force", {
108
+ type: "boolean",
109
+ default: false,
110
+ describe: "Force start by stopping an existing daemon process if needed",
111
+ })
30
112
  .option("clean-all", {
31
113
  type: "boolean",
32
114
  default: false,
@@ -41,14 +123,24 @@ const args = yargs(argv)
41
123
  "Use custom config file and daemon name",
42
124
  )
43
125
  .example("$0 --nohup", "Run daemon in background with logfile")
126
+ .example("$0 --nohup --force", "Restart daemon in background by stopping the existing one")
44
127
  .help()
45
128
  .strict()
46
129
  .parse();
47
130
 
48
131
  if (args.nohup) {
132
+ const workspaceRoot = resolveWorkspaceRoot(args.configFile);
133
+ const runningPid = findRunningDaemonPid(workspaceRoot);
134
+ if (runningPid && !args.force) {
135
+ process.stderr.write(
136
+ `${CLI_NAME} detected an existing daemon (PID ${runningPid}). Use --force to restart.\n`,
137
+ );
138
+ process.exit(1);
139
+ }
140
+
49
141
  const logsDir = path.join(os.homedir(), ".conductor", "logs");
50
142
  fs.mkdirSync(logsDir, { recursive: true });
51
- const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
143
+ const timestamp = formatBeijingTimestampForFile(new Date());
52
144
  const logPath = path.join(logsDir, `${timestamp}.log`);
53
145
  const filteredArgv = argv.filter((arg) => !(arg === "--nohup" || arg.startsWith("--nohup=")));
54
146
  const logFd = fs.openSync(logPath, "a");
@@ -66,4 +158,5 @@ startDaemon({
66
158
  NAME: args.name,
67
159
  CLEAN_ALL: args.cleanAll,
68
160
  CONFIG_FILE: args.configFile,
161
+ FORCE: args.force,
69
162
  });