@openspecui/core 1.0.0 → 1.0.2

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/dist/index.mjs CHANGED
@@ -1077,6 +1077,15 @@ async function reactiveReadFile(filepath) {
1077
1077
  return state.get();
1078
1078
  }
1079
1079
  /**
1080
+ * 主动更新响应式文件缓存(用于写入后立即推送订阅)
1081
+ *
1082
+ * 仅当该文件已有缓存状态时生效;不会创建新的监听器。
1083
+ */
1084
+ function updateReactiveFileCache(filepath, content) {
1085
+ const key = `file:${resolve(filepath)}`;
1086
+ stateCache$1.get(key)?.set(content);
1087
+ }
1088
+ /**
1080
1089
  * 响应式读取目录内容
1081
1090
  *
1082
1091
  * 特性:
@@ -1870,20 +1879,124 @@ function createFileChangeObservable(watcher) {
1870
1879
  //#endregion
1871
1880
  //#region src/config.ts
1872
1881
  const execAsync = promisify(exec);
1873
- /** 默认的 fallback CLI 命令(数组形式) */
1874
- const FALLBACK_CLI_COMMAND = ["npx", "@fission-ai/openspec"];
1875
- /** 全局 openspec 命令(数组形式) */
1876
- const GLOBAL_CLI_COMMAND = ["openspec"];
1877
- /** 缓存检测到的 CLI 命令 */
1878
- let detectedCliCommand = null;
1882
+ const CLI_PROBE_TIMEOUT_MS = 2e4;
1883
+ const THEME_VALUES = [
1884
+ "light",
1885
+ "dark",
1886
+ "system"
1887
+ ];
1888
+ const CURSOR_STYLE_VALUES = [
1889
+ "block",
1890
+ "underline",
1891
+ "bar"
1892
+ ];
1893
+ const BASE_PACKAGE_MANAGER_RUNNERS = [
1894
+ {
1895
+ id: "npx",
1896
+ source: "npx",
1897
+ commandParts: [
1898
+ "npx",
1899
+ "-y",
1900
+ "@fission-ai/openspec"
1901
+ ]
1902
+ },
1903
+ {
1904
+ id: "bunx",
1905
+ source: "bunx",
1906
+ commandParts: ["bunx", "@fission-ai/openspec"]
1907
+ },
1908
+ {
1909
+ id: "deno",
1910
+ source: "deno",
1911
+ commandParts: [
1912
+ "deno",
1913
+ "run",
1914
+ "-A",
1915
+ "npm:@fission-ai/openspec"
1916
+ ]
1917
+ },
1918
+ {
1919
+ id: "pnpm",
1920
+ source: "pnpm",
1921
+ commandParts: [
1922
+ "pnpm",
1923
+ "dlx",
1924
+ "@fission-ai/openspec"
1925
+ ]
1926
+ },
1927
+ {
1928
+ id: "yarn",
1929
+ source: "yarn",
1930
+ commandParts: [
1931
+ "yarn",
1932
+ "dlx",
1933
+ "@fission-ai/openspec"
1934
+ ]
1935
+ }
1936
+ ];
1937
+ function tokenizeCliCommand(input) {
1938
+ const tokens = [];
1939
+ let current = "";
1940
+ let quote = null;
1941
+ let tokenStarted = false;
1942
+ for (let index = 0; index < input.length; index += 1) {
1943
+ const char = input[index];
1944
+ if (quote) {
1945
+ if (char === quote) {
1946
+ quote = null;
1947
+ tokenStarted = true;
1948
+ continue;
1949
+ }
1950
+ if (char === "\\") {
1951
+ const next = input[index + 1];
1952
+ if (next && (next === quote || next === "\\")) {
1953
+ current += next;
1954
+ tokenStarted = true;
1955
+ index += 1;
1956
+ continue;
1957
+ }
1958
+ }
1959
+ current += char;
1960
+ tokenStarted = true;
1961
+ continue;
1962
+ }
1963
+ if (char === "\"" || char === "'") {
1964
+ quote = char;
1965
+ tokenStarted = true;
1966
+ continue;
1967
+ }
1968
+ if (char === "\\") {
1969
+ const next = input[index + 1];
1970
+ if (next && /[\s"'\\]/.test(next)) {
1971
+ current += next;
1972
+ tokenStarted = true;
1973
+ index += 1;
1974
+ continue;
1975
+ }
1976
+ current += char;
1977
+ tokenStarted = true;
1978
+ continue;
1979
+ }
1980
+ if (/\s/.test(char)) {
1981
+ if (tokenStarted) {
1982
+ tokens.push(current);
1983
+ current = "";
1984
+ tokenStarted = false;
1985
+ }
1986
+ continue;
1987
+ }
1988
+ current += char;
1989
+ tokenStarted = true;
1990
+ }
1991
+ if (tokenStarted) tokens.push(current);
1992
+ return tokens;
1993
+ }
1879
1994
  /**
1880
1995
  * 解析 CLI 命令字符串为数组
1881
1996
  *
1882
1997
  * 支持两种格式:
1883
1998
  * 1. JSON 数组:以 `[` 开头,如 `["npx", "@fission-ai/openspec"]`
1884
- * 2. 简单字符串:用空格分割,如 `npx @fission-ai/openspec`
1885
- *
1886
- * 注意:简单字符串解析不支持带引号的参数,如需复杂命令请使用 JSON 数组格式
1999
+ * 2. shell-like 字符串:支持引号与基础转义
1887
2000
  */
1888
2001
  function parseCliCommand(command) {
1889
2002
  const trimmed = command.trim();
@@ -1894,7 +2007,144 @@ function parseCliCommand(command) {
1894
2007
  } catch (err) {
1895
2008
  throw new Error(`Failed to parse CLI command as JSON array: ${err instanceof Error ? err.message : err}`);
1896
2009
  }
1897
- return trimmed.split(/\s+/).filter(Boolean);
2010
+ const tokens = tokenizeCliCommand(trimmed);
2011
+ if (tokens.length !== 1) return tokens;
2012
+ const firstChar = trimmed[0];
2013
+ const lastChar = trimmed[trimmed.length - 1];
2014
+ if (firstChar !== "\"" && firstChar !== "'" || firstChar !== lastChar) return tokens;
2015
+ const inner = trimmed.slice(1, -1).trim();
2016
+ if (!inner) return tokens;
2017
+ const innerTokens = tokenizeCliCommand(inner.replace(/\\(["'])/g, "$1"));
2018
+ if (innerTokens.length > 1 && innerTokens.slice(1).some((token) => token.startsWith("-"))) return innerTokens;
2019
+ return tokens;
2020
+ }
2021
+ function commandToString(commandParts) {
2022
+ const formatToken = (token) => {
2023
+ if (!token) return "\"\"";
2024
+ if (!/[\s"'\\]/.test(token)) return token;
2025
+ return JSON.stringify(token);
2026
+ };
2027
+ return commandParts.map(formatToken).join(" ").trim();
2028
+ }
2029
+ function getRunnerPriorityFromUserAgent(userAgent) {
2030
+ if (!userAgent) return null;
2031
+ if (userAgent.startsWith("bun")) return "bunx";
2032
+ if (userAgent.startsWith("npm")) return "npx";
2033
+ if (userAgent.startsWith("deno")) return "deno";
2034
+ if (userAgent.startsWith("pnpm")) return "pnpm";
2035
+ if (userAgent.startsWith("yarn")) return "yarn";
2036
+ return null;
2037
+ }
2038
+ function buildCliRunnerCandidates(options) {
2039
+ const candidates = [];
2040
+ const configuredCommandParts = options.configuredCommandParts?.filter(Boolean) ?? [];
2041
+ if (configuredCommandParts.length > 0) candidates.push({
2042
+ id: "configured",
2043
+ source: "config.cli.command",
2044
+ commandParts: configuredCommandParts
2045
+ });
2046
+ candidates.push({
2047
+ id: "openspec",
2048
+ source: "openspec",
2049
+ commandParts: ["openspec"]
2050
+ });
2051
+ const packageRunners = [...BASE_PACKAGE_MANAGER_RUNNERS];
2052
+ const preferred = getRunnerPriorityFromUserAgent(options.userAgent);
2053
+ if (preferred) {
2054
+ const index = packageRunners.findIndex((item) => item.id === preferred);
2055
+ if (index > 0) {
2056
+ const [runner] = packageRunners.splice(index, 1);
2057
+ packageRunners.unshift(runner);
2058
+ }
2059
+ }
2060
+ return [...candidates, ...packageRunners];
2061
+ }
2062
+ function createCleanCliEnv(baseEnv = process.env) {
2063
+ const env = { ...baseEnv };
2064
+ for (const key of Object.keys(env)) if (key.startsWith("npm_config_") || key.startsWith("npm_package_") || key === "npm_execpath" || key === "npm_lifecycle_event" || key === "npm_lifecycle_script") delete env[key];
2065
+ return env;
2066
+ }
2067
+ async function probeCliRunner(candidate, cwd, env) {
2068
+ const [cmd, ...cmdArgs] = candidate.commandParts;
2069
+ return new Promise((resolve$1) => {
2070
+ let stdout = "";
2071
+ let stderr = "";
2072
+ let timedOut = false;
2073
+ const timer = setTimeout(() => {
2074
+ timedOut = true;
2075
+ child.kill();
2076
+ }, CLI_PROBE_TIMEOUT_MS);
2077
+ const child = spawn(cmd, [...cmdArgs, "--version"], {
2078
+ cwd,
2079
+ shell: false,
2080
+ env
2081
+ });
2082
+ child.stdout?.on("data", (data) => {
2083
+ stdout += data.toString();
2084
+ });
2085
+ child.stderr?.on("data", (data) => {
2086
+ stderr += data.toString();
2087
+ });
2088
+ child.on("error", (err) => {
2089
+ clearTimeout(timer);
2090
+ const code = err.code;
2091
+ const suffix = code ? ` (${code})` : "";
2092
+ resolve$1({
2093
+ source: candidate.source,
2094
+ command: commandToString(candidate.commandParts),
2095
+ success: false,
2096
+ error: `${err.message}${suffix}`,
2097
+ exitCode: null
2098
+ });
2099
+ });
2100
+ child.on("close", (exitCode) => {
2101
+ clearTimeout(timer);
2102
+ if (timedOut) {
2103
+ resolve$1({
2104
+ source: candidate.source,
2105
+ command: commandToString(candidate.commandParts),
2106
+ success: false,
2107
+ error: "CLI probe timed out",
2108
+ exitCode
2109
+ });
2110
+ return;
2111
+ }
2112
+ if (exitCode === 0) {
2113
+ const version = stdout.trim().split("\n")[0] || void 0;
2114
+ resolve$1({
2115
+ source: candidate.source,
2116
+ command: commandToString(candidate.commandParts),
2117
+ success: true,
2118
+ version,
2119
+ exitCode
2120
+ });
2121
+ return;
2122
+ }
2123
+ resolve$1({
2124
+ source: candidate.source,
2125
+ command: commandToString(candidate.commandParts),
2126
+ success: false,
2127
+ error: stderr.trim() || `Exit code ${exitCode ?? "null"}`,
2128
+ exitCode
2129
+ });
2130
+ });
2131
+ });
2132
+ }
2133
+ async function resolveCliRunner(candidates, cwd, env) {
2134
+ const attempts = [];
2135
+ for (const candidate of candidates) {
2136
+ const attempt = await probeCliRunner(candidate, cwd, env);
2137
+ attempts.push(attempt);
2138
+ if (attempt.success) return {
2139
+ source: attempt.source,
2140
+ command: attempt.command,
2141
+ commandParts: candidate.commandParts,
2142
+ version: attempt.version,
2143
+ attempts
2144
+ };
2145
+ }
2146
+ const details = attempts.map((attempt) => `- ${attempt.command}: ${attempt.error ?? "failed"}`).join("\n");
2147
+ throw new Error(`No available OpenSpec CLI runner.\n${details}`);
1898
2148
  }
1899
2149
  /**
1900
2150
  * 比较两个语义化版本号
@@ -1921,7 +2171,7 @@ function compareVersions(a, b) {
1921
2171
  */
1922
2172
  async function fetchLatestVersion() {
1923
2173
  try {
1924
- const { stdout } = await execAsync("npx @fission-ai/openspec --version", { timeout: 6e4 });
2174
+ const { stdout } = await execAsync("npx -y @fission-ai/openspec --version", { timeout: 6e4 });
1925
2175
  return stdout.trim();
1926
2176
  } catch {
1927
2177
  return;
@@ -1951,7 +2201,6 @@ async function sniffGlobalCli() {
1951
2201
  };
1952
2202
  }
1953
2203
  const version = localResult.stdout.trim();
1954
- detectedCliCommand = GLOBAL_CLI_COMMAND;
1955
2204
  return {
1956
2205
  hasGlobal: true,
1957
2206
  version,
@@ -1960,53 +2209,44 @@ async function sniffGlobalCli() {
1960
2209
  };
1961
2210
  }
1962
2211
  /**
1963
- * 检测全局安装的 openspec 命令
1964
- * 优先使用全局命令,fallback 到 npx
1965
- *
1966
- * @returns CLI 命令数组
1967
- */
1968
- async function detectCliCommand() {
1969
- if (detectedCliCommand !== null) return detectedCliCommand;
1970
- try {
1971
- await execAsync(`${process.platform === "win32" ? "where" : "which"} openspec`);
1972
- detectedCliCommand = GLOBAL_CLI_COMMAND;
1973
- return detectedCliCommand;
1974
- } catch {
1975
- detectedCliCommand = FALLBACK_CLI_COMMAND;
1976
- return detectedCliCommand;
1977
- }
1978
- }
1979
- /**
1980
2212
  * 获取默认 CLI 命令(异步,带检测)
1981
2213
  *
1982
2214
  * @returns CLI 命令数组,如 `['openspec']` 或 `['npx', '@fission-ai/openspec']`
1983
2215
  */
1984
2216
  async function getDefaultCliCommand() {
1985
- return detectCliCommand();
2217
+ return (await resolveCliRunner(buildCliRunnerCandidates({ userAgent: process.env.npm_config_user_agent }).filter((candidate) => candidate.id !== "configured"), process.cwd(), createCleanCliEnv())).commandParts;
1986
2218
  }
1987
2219
  /**
1988
2220
  * 获取默认 CLI 命令的字符串形式(用于 UI 显示)
1989
2221
  */
1990
2222
  async function getDefaultCliCommandString() {
1991
- return (await detectCliCommand()).join(" ");
2223
+ return commandToString(await getDefaultCliCommand());
1992
2224
  }
2225
+ const TerminalConfigSchema = z.object({
2226
+ fontSize: z.number().min(8).max(32).default(13),
2227
+ fontFamily: z.string().default(""),
2228
+ cursorBlink: z.boolean().default(true),
2229
+ cursorStyle: z.enum(CURSOR_STYLE_VALUES).default("block"),
2230
+ scrollback: z.number().min(0).max(1e5).default(1e3)
2231
+ });
1993
2232
  /**
1994
2233
  * OpenSpecUI 配置 Schema
1995
2234
  *
1996
2235
  * 存储在 openspec/.openspecui.json 中,利用文件监听实现响应式更新
1997
2236
  */
1998
2237
  const OpenSpecUIConfigSchema = z.object({
1999
- cli: z.object({ command: z.string().optional() }).default({}),
2000
- ui: z.object({ theme: z.enum([
2001
- "light",
2002
- "dark",
2003
- "system"
2004
- ]).default("system") }).default({})
2238
+ cli: z.object({
2239
+ command: z.string().optional(),
2240
+ args: z.array(z.string()).optional()
2241
+ }).default({}),
2242
+ theme: z.enum(THEME_VALUES).default("system"),
2243
+ terminal: TerminalConfigSchema.default(TerminalConfigSchema.parse({}))
2005
2244
  });
2006
2245
  /** 默认配置(静态,用于测试和类型) */
2007
2246
  const DEFAULT_CONFIG = {
2008
2247
  cli: {},
2009
- ui: { theme: "system" }
2248
+ theme: "system",
2249
+ terminal: TerminalConfigSchema.parse({})
2010
2250
  };
2011
2251
  /**
2012
2252
  * 配置管理器
@@ -2016,7 +2256,11 @@ const DEFAULT_CONFIG = {
2016
2256
  */
2017
2257
  var ConfigManager = class {
2018
2258
  configPath;
2259
+ projectDir;
2260
+ resolvedRunner = null;
2261
+ resolvingRunnerPromise = null;
2019
2262
  constructor(projectDir) {
2263
+ this.projectDir = projectDir;
2020
2264
  this.configPath = join(projectDir, "openspec", ".openspecui.json");
2021
2265
  }
2022
2266
  /**
@@ -2046,43 +2290,140 @@ var ConfigManager = class {
2046
2290
  */
2047
2291
  async writeConfig(config) {
2048
2292
  const current = await this.readConfig();
2293
+ const nextCli = { ...current.cli };
2294
+ if (config.cli && Object.prototype.hasOwnProperty.call(config.cli, "command")) {
2295
+ const trimmed = config.cli.command?.trim();
2296
+ if (trimmed) nextCli.command = trimmed;
2297
+ else {
2298
+ delete nextCli.command;
2299
+ delete nextCli.args;
2300
+ }
2301
+ }
2302
+ if (config.cli && Object.prototype.hasOwnProperty.call(config.cli, "args")) {
2303
+ const args = (config.cli.args ?? []).map((arg) => arg.trim()).filter(Boolean);
2304
+ if (args.length > 0) nextCli.args = args;
2305
+ else delete nextCli.args;
2306
+ }
2307
+ if (!nextCli.command) delete nextCli.args;
2049
2308
  const merged = {
2050
2309
  ...current,
2051
- ...config,
2052
- cli: {
2053
- ...current.cli,
2054
- ...config.cli
2055
- },
2056
- ui: {
2057
- ...current.ui,
2058
- ...config.ui
2310
+ cli: nextCli,
2311
+ theme: config.theme ?? current.theme,
2312
+ terminal: {
2313
+ ...current.terminal,
2314
+ ...config.terminal
2059
2315
  }
2060
2316
  };
2061
- await writeFile(this.configPath, JSON.stringify(merged, null, 2), "utf-8");
2317
+ const serialized = JSON.stringify(merged, null, 2);
2318
+ await writeFile(this.configPath, serialized, "utf-8");
2319
+ updateReactiveFileCache(this.configPath, serialized);
2320
+ this.invalidateResolvedCliRunner();
2321
+ }
2322
+ /**
2323
+ * 解析并缓存可用 CLI runner。
2324
+ */
2325
+ async resolveCliRunner() {
2326
+ if (this.resolvedRunner) return this.resolvedRunner;
2327
+ if (this.resolvingRunnerPromise) return this.resolvingRunnerPromise;
2328
+ this.resolvingRunnerPromise = this.resolveCliRunnerUncached().then((runner) => {
2329
+ this.resolvedRunner = runner;
2330
+ return runner;
2331
+ }).finally(() => {
2332
+ this.resolvingRunnerPromise = null;
2333
+ });
2334
+ return this.resolvingRunnerPromise;
2335
+ }
2336
+ async resolveCliRunnerUncached() {
2337
+ const config = await this.readConfig();
2338
+ const configuredCommandParts = this.getConfiguredCommandParts(config.cli);
2339
+ const hasConfiguredCommand = configuredCommandParts.length > 0;
2340
+ const resolved = await resolveCliRunner(hasConfiguredCommand ? [{
2341
+ id: "configured",
2342
+ source: "config.cli.command",
2343
+ commandParts: configuredCommandParts
2344
+ }] : buildCliRunnerCandidates({
2345
+ configuredCommandParts,
2346
+ userAgent: process.env.npm_config_user_agent
2347
+ }), this.projectDir, createCleanCliEnv());
2348
+ if (!hasConfiguredCommand) {
2349
+ const [resolvedCommand, ...resolvedArgs] = resolved.commandParts;
2350
+ const currentCommand = config.cli.command?.trim();
2351
+ const currentArgs = config.cli.args ?? [];
2352
+ if (currentCommand !== resolvedCommand || currentArgs.length !== resolvedArgs.length || currentArgs.some((arg, index) => arg !== resolvedArgs[index])) await this.writeConfig({ cli: {
2353
+ command: resolvedCommand,
2354
+ args: resolvedArgs
2355
+ } });
2356
+ }
2357
+ return resolved;
2062
2358
  }
2063
2359
  /**
2064
2360
  * 获取 CLI 命令(数组形式)
2065
- *
2066
- * 优先级:配置文件 > 全局 openspec 命令 > npx fallback
2067
- *
2068
- * @returns CLI 命令数组,如 `['openspec']` 或 `['npx', '@fission-ai/openspec']`
2069
2361
  */
2070
2362
  async getCliCommand() {
2071
- const config = await this.readConfig();
2072
- if (config.cli.command) return parseCliCommand(config.cli.command);
2073
- return getDefaultCliCommand();
2363
+ return (await this.resolveCliRunner()).commandParts;
2074
2364
  }
2075
2365
  /**
2076
2366
  * 获取 CLI 命令的字符串形式(用于 UI 显示)
2077
2367
  */
2078
2368
  async getCliCommandString() {
2079
- return (await this.getCliCommand()).join(" ");
2369
+ return (await this.resolveCliRunner()).command;
2370
+ }
2371
+ /**
2372
+ * 获取 CLI 解析结果(用于诊断)
2373
+ */
2374
+ async getResolvedCliRunner() {
2375
+ return this.resolveCliRunner();
2376
+ }
2377
+ /**
2378
+ * 清理 CLI 解析缓存(用于 ENOENT 自愈)
2379
+ */
2380
+ invalidateResolvedCliRunner() {
2381
+ this.resolvedRunner = null;
2382
+ this.resolvingRunnerPromise = null;
2080
2383
  }
2081
2384
  /**
2082
2385
  * 设置 CLI 命令
2083
2386
  */
2084
2387
  async setCliCommand(command) {
2085
- await this.writeConfig({ cli: { command } });
2388
+ const trimmed = command.trim();
2389
+ if (!trimmed) {
2390
+ await this.writeConfig({ cli: {
2391
+ command: null,
2392
+ args: null
2393
+ } });
2394
+ return;
2395
+ }
2396
+ const commandParts = parseCliCommand(trimmed);
2397
+ if (commandParts.length === 0) {
2398
+ await this.writeConfig({ cli: {
2399
+ command: null,
2400
+ args: null
2401
+ } });
2402
+ return;
2403
+ }
2404
+ const [resolvedCommand, ...resolvedArgs] = commandParts;
2405
+ await this.writeConfig({ cli: {
2406
+ command: resolvedCommand,
2407
+ args: resolvedArgs
2408
+ } });
2409
+ }
2410
+ getConfiguredCommandParts(cli) {
2411
+ const command = cli.command?.trim();
2412
+ if (!command) return [];
2413
+ if (Array.isArray(cli.args) && cli.args.length > 0) return [command, ...cli.args];
2414
+ return parseCliCommand(command);
2415
+ }
2416
+ /**
2417
+ * 设置主题
2418
+ */
2419
+ async setTheme(theme) {
2420
+ await this.writeConfig({ theme });
2421
+ }
2422
+ /**
2423
+ * 设置终端配置(部分更新)
2424
+ */
2425
+ async setTerminalConfig(terminal) {
2426
+ await this.writeConfig({ terminal });
2086
2427
  }
2087
2428
  };
2088
2429
 
@@ -2091,50 +2432,24 @@ var ConfigManager = class {
2091
2432
  /**
2092
2433
  * CLI 执行器
2093
2434
  *
2094
- * 负责调用外部 openspec CLI 命令。
2095
- * 命令前缀从 ConfigManager 获取,支持:
2096
- * - ['npx', '@fission-ai/openspec'] (默认)
2097
- * - ['openspec'] (全局安装)
2098
- * - 自定义数组或字符串
2099
- *
2100
- * 注意:所有命令都使用 shell: false 执行,避免 shell 注入风险
2435
+ * 负责调用外部 openspec CLI 命令,统一通过 ConfigManager 的 runner 解析结果执行。
2436
+ * 所有命令都使用 shell: false,避免 shell 注入风险。
2101
2437
  */
2102
2438
  var CliExecutor = class {
2103
2439
  constructor(configManager, projectDir) {
2104
2440
  this.configManager = configManager;
2105
2441
  this.projectDir = projectDir;
2106
2442
  }
2107
- /**
2108
- * 创建干净的环境变量,移除 pnpm 特有的配置
2109
- * 避免 pnpm 环境变量污染 npx/npm 执行
2110
- */
2111
- getCleanEnv() {
2112
- const env = { ...process.env };
2113
- for (const key of Object.keys(env)) if (key.startsWith("npm_config_") || key.startsWith("npm_package_") || key === "npm_execpath" || key === "npm_lifecycle_event" || key === "npm_lifecycle_script") delete env[key];
2114
- return env;
2115
- }
2116
- /**
2117
- * 构建完整命令数组
2118
- *
2119
- * @param args CLI 参数,如 ['init'] 或 ['archive', 'change-id']
2120
- * @returns [command, ...commandArgs, ...args]
2121
- */
2122
2443
  async buildCommandArray(args) {
2123
2444
  return [...await this.configManager.getCliCommand(), ...args];
2124
2445
  }
2125
- /**
2126
- * 执行 CLI 命令
2127
- *
2128
- * @param args CLI 参数,如 ['init'] 或 ['archive', 'change-id']
2129
- * @returns 执行结果
2130
- */
2131
- async execute(args) {
2132
- const [cmd, ...cmdArgs] = await this.buildCommandArray(args);
2446
+ runCommandOnce(fullCommand) {
2447
+ const [cmd, ...cmdArgs] = fullCommand;
2133
2448
  return new Promise((resolve$1) => {
2134
2449
  const child = spawn(cmd, cmdArgs, {
2135
2450
  cwd: this.projectDir,
2136
2451
  shell: false,
2137
- env: this.getCleanEnv()
2452
+ env: createCleanCliEnv()
2138
2453
  });
2139
2454
  let stdout = "";
2140
2455
  let stderr = "";
@@ -2153,19 +2468,50 @@ var CliExecutor = class {
2153
2468
  });
2154
2469
  });
2155
2470
  child.on("error", (err) => {
2471
+ const errorCode = err.code;
2472
+ const errorMessage = err.message + (errorCode ? ` (${errorCode})` : "");
2156
2473
  resolve$1({
2157
2474
  success: false,
2158
2475
  stdout,
2159
- stderr: stderr + "\n" + err.message,
2160
- exitCode: null
2476
+ stderr: stderr ? `${stderr}\n${errorMessage}` : errorMessage,
2477
+ exitCode: null,
2478
+ errorCode
2161
2479
  });
2162
2480
  });
2163
2481
  });
2164
2482
  }
2483
+ async executeInternal(args, allowRetry) {
2484
+ let fullCommand;
2485
+ try {
2486
+ fullCommand = await this.buildCommandArray(args);
2487
+ } catch (err) {
2488
+ return {
2489
+ success: false,
2490
+ stdout: "",
2491
+ stderr: err instanceof Error ? err.message : String(err),
2492
+ exitCode: null
2493
+ };
2494
+ }
2495
+ const result = await this.runCommandOnce(fullCommand);
2496
+ if (allowRetry && result.errorCode === "ENOENT") {
2497
+ this.configManager.invalidateResolvedCliRunner();
2498
+ return this.executeInternal(args, false);
2499
+ }
2500
+ return {
2501
+ success: result.success,
2502
+ stdout: result.stdout,
2503
+ stderr: result.stderr,
2504
+ exitCode: result.exitCode
2505
+ };
2506
+ }
2507
+ /**
2508
+ * 执行 CLI 命令
2509
+ */
2510
+ async execute(args) {
2511
+ return this.executeInternal(args, true);
2512
+ }
2165
2513
  /**
2166
2514
  * 执行 openspec init(非交互式)
2167
- *
2168
- * @param tools 工具列表,如 ['claude', 'cursor'] 或 'all' 或 'none'
2169
2515
  */
2170
2516
  async init(tools = "all") {
2171
2517
  const toolsArg = Array.isArray(tools) ? tools.join(",") : tools;
@@ -2177,9 +2523,6 @@ var CliExecutor = class {
2177
2523
  }
2178
2524
  /**
2179
2525
  * 执行 openspec archive <changeId>(非交互式)
2180
- *
2181
- * @param changeId 要归档的 change ID
2182
- * @param options 选项
2183
2526
  */
2184
2527
  async archive(changeId, options = {}) {
2185
2528
  const args = [
@@ -2236,75 +2579,108 @@ var CliExecutor = class {
2236
2579
  }
2237
2580
  /**
2238
2581
  * 检查 CLI 是否可用
2239
- * @param timeout 超时时间(毫秒),默认 10 秒
2240
2582
  */
2241
2583
  async checkAvailability(timeout = 1e4) {
2242
2584
  try {
2243
- const result = await Promise.race([this.execute(["--version"]), new Promise((_, reject) => setTimeout(() => reject(/* @__PURE__ */ new Error("CLI check timed out")), timeout))]);
2244
- if (result.success) return {
2585
+ const resolved = await Promise.race([this.configManager.getResolvedCliRunner(), new Promise((_, reject) => setTimeout(() => reject(/* @__PURE__ */ new Error("CLI runner resolve timed out")), timeout))]);
2586
+ const versionResult = await Promise.race([this.runCommandOnce([...resolved.commandParts, "--version"]), new Promise((_, reject) => setTimeout(() => reject(/* @__PURE__ */ new Error("CLI check timed out")), timeout))]);
2587
+ if (versionResult.success) return {
2245
2588
  available: true,
2246
- version: result.stdout.trim()
2589
+ version: versionResult.stdout.trim() || resolved.version,
2590
+ effectiveCommand: resolved.command,
2591
+ tried: resolved.attempts.map((attempt) => attempt.command)
2247
2592
  };
2248
2593
  return {
2249
2594
  available: false,
2250
- error: result.stderr || "Unknown error"
2595
+ error: versionResult.stderr || "Unknown error",
2596
+ effectiveCommand: resolved.command,
2597
+ tried: resolved.attempts.map((attempt) => attempt.command)
2251
2598
  };
2252
2599
  } catch (err) {
2253
2600
  return {
2254
2601
  available: false,
2255
- error: err instanceof Error ? err.message : "Unknown error"
2602
+ error: err instanceof Error ? err.message : String(err)
2256
2603
  };
2257
2604
  }
2258
2605
  }
2259
2606
  /**
2260
2607
  * 流式执行 CLI 命令
2261
- *
2262
- * @param args CLI 参数
2263
- * @param onEvent 事件回调
2264
- * @returns 取消函数
2265
2608
  */
2266
2609
  async executeStream(args, onEvent) {
2267
- const fullCommand = await this.buildCommandArray(args);
2268
- const [cmd, ...cmdArgs] = fullCommand;
2269
- onEvent({
2270
- type: "command",
2271
- data: fullCommand.join(" ")
2272
- });
2273
- const child = spawn(cmd, cmdArgs, {
2274
- cwd: this.projectDir,
2275
- shell: false,
2276
- env: this.getCleanEnv()
2277
- });
2278
- child.stdout?.on("data", (data) => {
2610
+ let cancelled = false;
2611
+ let activeChild = null;
2612
+ const start = async (allowRetry) => {
2613
+ if (cancelled) return;
2614
+ let fullCommand;
2615
+ try {
2616
+ fullCommand = await this.buildCommandArray(args);
2617
+ } catch (err) {
2618
+ onEvent({
2619
+ type: "stderr",
2620
+ data: err instanceof Error ? err.message : String(err)
2621
+ });
2622
+ onEvent({
2623
+ type: "exit",
2624
+ exitCode: null
2625
+ });
2626
+ return;
2627
+ }
2279
2628
  onEvent({
2280
- type: "stdout",
2281
- data: data.toString()
2629
+ type: "command",
2630
+ data: fullCommand.join(" ")
2282
2631
  });
2283
- });
2284
- child.stderr?.on("data", (data) => {
2285
- onEvent({
2286
- type: "stderr",
2287
- data: data.toString()
2632
+ const [cmd, ...cmdArgs] = fullCommand;
2633
+ const child = spawn(cmd, cmdArgs, {
2634
+ cwd: this.projectDir,
2635
+ shell: false,
2636
+ env: createCleanCliEnv()
2288
2637
  });
2289
- });
2290
- child.on("close", (exitCode) => {
2291
- onEvent({
2292
- type: "exit",
2293
- exitCode
2638
+ activeChild = child;
2639
+ child.stdout?.on("data", (data) => {
2640
+ onEvent({
2641
+ type: "stdout",
2642
+ data: data.toString()
2643
+ });
2294
2644
  });
2295
- });
2296
- child.on("error", (err) => {
2297
- onEvent({
2298
- type: "stderr",
2299
- data: err.message
2645
+ child.stderr?.on("data", (data) => {
2646
+ onEvent({
2647
+ type: "stderr",
2648
+ data: data.toString()
2649
+ });
2300
2650
  });
2301
- onEvent({
2302
- type: "exit",
2303
- exitCode: null
2651
+ child.on("close", (exitCode) => {
2652
+ if (activeChild !== child) return;
2653
+ activeChild = null;
2654
+ onEvent({
2655
+ type: "exit",
2656
+ exitCode
2657
+ });
2304
2658
  });
2305
- });
2659
+ child.on("error", (err) => {
2660
+ if (activeChild !== child) return;
2661
+ activeChild = null;
2662
+ const code = err.code;
2663
+ const message = err.message + (code ? ` (${code})` : "");
2664
+ if (allowRetry && code === "ENOENT" && !cancelled) {
2665
+ this.configManager.invalidateResolvedCliRunner();
2666
+ start(false);
2667
+ return;
2668
+ }
2669
+ onEvent({
2670
+ type: "stderr",
2671
+ data: message
2672
+ });
2673
+ onEvent({
2674
+ type: "exit",
2675
+ exitCode: null
2676
+ });
2677
+ });
2678
+ };
2679
+ await start(true);
2306
2680
  return () => {
2307
- child.kill();
2681
+ cancelled = true;
2682
+ activeChild?.kill();
2683
+ activeChild = null;
2308
2684
  };
2309
2685
  }
2310
2686
  /**
@@ -2333,13 +2709,6 @@ var CliExecutor = class {
2333
2709
  }
2334
2710
  /**
2335
2711
  * 流式执行任意命令(数组形式)
2336
- *
2337
- * 用于执行不需要 openspec CLI 前缀的命令,如 npm install。
2338
- * 使用 shell: false 避免 shell 注入风险。
2339
- *
2340
- * @param command 命令数组,如 ['npm', 'install', '-g', '@fission-ai/openspec']
2341
- * @param onEvent 事件回调
2342
- * @returns 取消函数
2343
2712
  */
2344
2713
  executeCommandStream(command, onEvent) {
2345
2714
  const [cmd, ...cmdArgs] = command;
@@ -2350,7 +2719,7 @@ var CliExecutor = class {
2350
2719
  const child = spawn(cmd, cmdArgs, {
2351
2720
  cwd: this.projectDir,
2352
2721
  shell: false,
2353
- env: this.getCleanEnv()
2722
+ env: createCleanCliEnv()
2354
2723
  });
2355
2724
  child.stdout?.on("data", (data) => {
2356
2725
  onEvent({
@@ -2371,9 +2740,10 @@ var CliExecutor = class {
2371
2740
  });
2372
2741
  });
2373
2742
  child.on("error", (err) => {
2743
+ const code = err.code;
2374
2744
  onEvent({
2375
2745
  type: "stderr",
2376
- data: err.message
2746
+ data: err.message + (code ? ` (${code})` : "")
2377
2747
  });
2378
2748
  onEvent({
2379
2749
  type: "exit",
@@ -3478,4 +3848,4 @@ const PtyServerMessageSchema = z.discriminatedUnion("type", [
3478
3848
  ]);
3479
3849
 
3480
3850
  //#endregion
3481
- export { AI_TOOLS, ApplyInstructionsSchema, ApplyTaskSchema, ArtifactInstructionsSchema, ArtifactStatusSchema, ChangeFileSchema, ChangeSchema, ChangeStatusSchema, CliExecutor, ConfigManager, DEFAULT_CONFIG, DeltaOperationType, DeltaSchema, DeltaSpecSchema, DependencyInfoSchema, MarkdownParser, OpenSpecAdapter, OpenSpecUIConfigSchema, OpenSpecWatcher, OpsxKernel, ProjectWatcher, PtyAttachMessageSchema, PtyBufferResponseSchema, PtyClientMessageSchema, PtyCloseMessageSchema, PtyCreateMessageSchema, PtyCreatedResponseSchema, PtyErrorCodeSchema, PtyErrorResponseSchema, PtyExitResponseSchema, PtyInputMessageSchema, PtyListMessageSchema, PtyListResponseSchema, PtyOutputResponseSchema, PtyPlatformSchema, PtyResizeMessageSchema, PtyServerMessageSchema, PtyTitleResponseSchema, ReactiveContext, ReactiveState, RequirementSchema, SchemaArtifactSchema, SchemaDetailSchema, SchemaInfoSchema, SchemaResolutionSchema, SpecSchema, TaskSchema, TemplatesSchema, Validator, acquireWatcher, clearCache, closeAllProjectWatchers, closeAllWatchers, contextStorage, createFileChangeObservable, getActiveWatcherCount, getAllToolIds, getAllTools, getAvailableToolIds, getAvailableTools, getCacheSize, getConfiguredTools, getDefaultCliCommand, getDefaultCliCommandString, getProjectWatcher, getToolById, getWatchedProjectDir, initWatcherPool, isGlobPattern, isToolConfigured, isWatcherPoolInitialized, parseCliCommand, reactiveExists, reactiveReadDir, reactiveReadFile, reactiveStat, sniffGlobalCli };
3851
+ export { AI_TOOLS, ApplyInstructionsSchema, ApplyTaskSchema, ArtifactInstructionsSchema, ArtifactStatusSchema, ChangeFileSchema, ChangeSchema, ChangeStatusSchema, CliExecutor, ConfigManager, DEFAULT_CONFIG, DeltaOperationType, DeltaSchema, DeltaSpecSchema, DependencyInfoSchema, MarkdownParser, OpenSpecAdapter, OpenSpecUIConfigSchema, OpenSpecWatcher, OpsxKernel, ProjectWatcher, PtyAttachMessageSchema, PtyBufferResponseSchema, PtyClientMessageSchema, PtyCloseMessageSchema, PtyCreateMessageSchema, PtyCreatedResponseSchema, PtyErrorCodeSchema, PtyErrorResponseSchema, PtyExitResponseSchema, PtyInputMessageSchema, PtyListMessageSchema, PtyListResponseSchema, PtyOutputResponseSchema, PtyPlatformSchema, PtyResizeMessageSchema, PtyServerMessageSchema, PtyTitleResponseSchema, ReactiveContext, ReactiveState, RequirementSchema, SchemaArtifactSchema, SchemaDetailSchema, SchemaInfoSchema, SchemaResolutionSchema, SpecSchema, TaskSchema, TemplatesSchema, TerminalConfigSchema, Validator, acquireWatcher, buildCliRunnerCandidates, clearCache, closeAllProjectWatchers, closeAllWatchers, contextStorage, createCleanCliEnv, createFileChangeObservable, getActiveWatcherCount, getAllToolIds, getAllTools, getAvailableToolIds, getAvailableTools, getCacheSize, getConfiguredTools, getDefaultCliCommand, getDefaultCliCommandString, getProjectWatcher, getToolById, getWatchedProjectDir, initWatcherPool, isGlobPattern, isToolConfigured, isWatcherPoolInitialized, parseCliCommand, reactiveExists, reactiveReadDir, reactiveReadFile, reactiveStat, sniffGlobalCli };