@openspecui/core 0.9.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.d.mts +1090 -108
- package/dist/index.mjs +1414 -269
- package/package.json +3 -1
package/dist/index.mjs
CHANGED
|
@@ -2,14 +2,14 @@ import { mkdir, readFile, rename, writeFile } from "fs/promises";
|
|
|
2
2
|
import { join } from "path";
|
|
3
3
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
4
4
|
import { readFile as readFile$1, readdir, stat } from "node:fs/promises";
|
|
5
|
-
import { dirname, join as join$1, resolve } from "node:path";
|
|
5
|
+
import { dirname, join as join$1, matchesGlob, relative, resolve, sep } from "node:path";
|
|
6
6
|
import { existsSync, realpathSync, utimesSync } from "node:fs";
|
|
7
7
|
import { z } from "zod";
|
|
8
8
|
import { watch } from "fs";
|
|
9
9
|
import { EventEmitter } from "events";
|
|
10
10
|
import { exec, spawn } from "child_process";
|
|
11
11
|
import { promisify } from "util";
|
|
12
|
-
import {
|
|
12
|
+
import { parse } from "yaml";
|
|
13
13
|
|
|
14
14
|
//#region src/parser.ts
|
|
15
15
|
/**
|
|
@@ -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
|
-
|
|
1874
|
-
const
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
|
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({
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
...
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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:
|
|
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
|
|
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 = [
|
|
@@ -2201,6 +2544,31 @@ var CliExecutor = class {
|
|
|
2201
2544
|
return this.execute(args);
|
|
2202
2545
|
}
|
|
2203
2546
|
/**
|
|
2547
|
+
* 执行 openspec schemas --json
|
|
2548
|
+
*/
|
|
2549
|
+
async schemas() {
|
|
2550
|
+
return this.execute(["schemas", "--json"]);
|
|
2551
|
+
}
|
|
2552
|
+
/**
|
|
2553
|
+
* 执行 openspec schema which <name> --json
|
|
2554
|
+
*/
|
|
2555
|
+
async schemaWhich(name) {
|
|
2556
|
+
return this.execute([
|
|
2557
|
+
"schema",
|
|
2558
|
+
"which",
|
|
2559
|
+
name,
|
|
2560
|
+
"--json"
|
|
2561
|
+
]);
|
|
2562
|
+
}
|
|
2563
|
+
/**
|
|
2564
|
+
* 执行 openspec templates --json [--schema <name>]
|
|
2565
|
+
*/
|
|
2566
|
+
async templates(schema) {
|
|
2567
|
+
const args = ["templates", "--json"];
|
|
2568
|
+
if (schema) args.push("--schema", schema);
|
|
2569
|
+
return this.execute(args);
|
|
2570
|
+
}
|
|
2571
|
+
/**
|
|
2204
2572
|
* 流式执行 openspec validate
|
|
2205
2573
|
*/
|
|
2206
2574
|
validateStream(type, id, onEvent) {
|
|
@@ -2211,75 +2579,108 @@ var CliExecutor = class {
|
|
|
2211
2579
|
}
|
|
2212
2580
|
/**
|
|
2213
2581
|
* 检查 CLI 是否可用
|
|
2214
|
-
* @param timeout 超时时间(毫秒),默认 10 秒
|
|
2215
2582
|
*/
|
|
2216
2583
|
async checkAvailability(timeout = 1e4) {
|
|
2217
2584
|
try {
|
|
2218
|
-
const
|
|
2219
|
-
|
|
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 {
|
|
2220
2588
|
available: true,
|
|
2221
|
-
version:
|
|
2589
|
+
version: versionResult.stdout.trim() || resolved.version,
|
|
2590
|
+
effectiveCommand: resolved.command,
|
|
2591
|
+
tried: resolved.attempts.map((attempt) => attempt.command)
|
|
2222
2592
|
};
|
|
2223
2593
|
return {
|
|
2224
2594
|
available: false,
|
|
2225
|
-
error:
|
|
2595
|
+
error: versionResult.stderr || "Unknown error",
|
|
2596
|
+
effectiveCommand: resolved.command,
|
|
2597
|
+
tried: resolved.attempts.map((attempt) => attempt.command)
|
|
2226
2598
|
};
|
|
2227
2599
|
} catch (err) {
|
|
2228
2600
|
return {
|
|
2229
2601
|
available: false,
|
|
2230
|
-
error: err instanceof Error ? err.message :
|
|
2602
|
+
error: err instanceof Error ? err.message : String(err)
|
|
2231
2603
|
};
|
|
2232
2604
|
}
|
|
2233
2605
|
}
|
|
2234
2606
|
/**
|
|
2235
2607
|
* 流式执行 CLI 命令
|
|
2236
|
-
*
|
|
2237
|
-
* @param args CLI 参数
|
|
2238
|
-
* @param onEvent 事件回调
|
|
2239
|
-
* @returns 取消函数
|
|
2240
2608
|
*/
|
|
2241
2609
|
async executeStream(args, onEvent) {
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
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
|
+
}
|
|
2254
2628
|
onEvent({
|
|
2255
|
-
type: "
|
|
2256
|
-
data:
|
|
2629
|
+
type: "command",
|
|
2630
|
+
data: fullCommand.join(" ")
|
|
2257
2631
|
});
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2632
|
+
const [cmd, ...cmdArgs] = fullCommand;
|
|
2633
|
+
const child = spawn(cmd, cmdArgs, {
|
|
2634
|
+
cwd: this.projectDir,
|
|
2635
|
+
shell: false,
|
|
2636
|
+
env: createCleanCliEnv()
|
|
2263
2637
|
});
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2638
|
+
activeChild = child;
|
|
2639
|
+
child.stdout?.on("data", (data) => {
|
|
2640
|
+
onEvent({
|
|
2641
|
+
type: "stdout",
|
|
2642
|
+
data: data.toString()
|
|
2643
|
+
});
|
|
2269
2644
|
});
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2645
|
+
child.stderr?.on("data", (data) => {
|
|
2646
|
+
onEvent({
|
|
2647
|
+
type: "stderr",
|
|
2648
|
+
data: data.toString()
|
|
2649
|
+
});
|
|
2275
2650
|
});
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
|
|
2651
|
+
child.on("close", (exitCode) => {
|
|
2652
|
+
if (activeChild !== child) return;
|
|
2653
|
+
activeChild = null;
|
|
2654
|
+
onEvent({
|
|
2655
|
+
type: "exit",
|
|
2656
|
+
exitCode
|
|
2657
|
+
});
|
|
2279
2658
|
});
|
|
2280
|
-
|
|
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);
|
|
2281
2680
|
return () => {
|
|
2282
|
-
|
|
2681
|
+
cancelled = true;
|
|
2682
|
+
activeChild?.kill();
|
|
2683
|
+
activeChild = null;
|
|
2283
2684
|
};
|
|
2284
2685
|
}
|
|
2285
2686
|
/**
|
|
@@ -2308,13 +2709,6 @@ var CliExecutor = class {
|
|
|
2308
2709
|
}
|
|
2309
2710
|
/**
|
|
2310
2711
|
* 流式执行任意命令(数组形式)
|
|
2311
|
-
*
|
|
2312
|
-
* 用于执行不需要 openspec CLI 前缀的命令,如 npm install。
|
|
2313
|
-
* 使用 shell: false 避免 shell 注入风险。
|
|
2314
|
-
*
|
|
2315
|
-
* @param command 命令数组,如 ['npm', 'install', '-g', '@fission-ai/openspec']
|
|
2316
|
-
* @param onEvent 事件回调
|
|
2317
|
-
* @returns 取消函数
|
|
2318
2712
|
*/
|
|
2319
2713
|
executeCommandStream(command, onEvent) {
|
|
2320
2714
|
const [cmd, ...cmdArgs] = command;
|
|
@@ -2325,7 +2719,7 @@ var CliExecutor = class {
|
|
|
2325
2719
|
const child = spawn(cmd, cmdArgs, {
|
|
2326
2720
|
cwd: this.projectDir,
|
|
2327
2721
|
shell: false,
|
|
2328
|
-
env:
|
|
2722
|
+
env: createCleanCliEnv()
|
|
2329
2723
|
});
|
|
2330
2724
|
child.stdout?.on("data", (data) => {
|
|
2331
2725
|
onEvent({
|
|
@@ -2346,9 +2740,10 @@ var CliExecutor = class {
|
|
|
2346
2740
|
});
|
|
2347
2741
|
});
|
|
2348
2742
|
child.on("error", (err) => {
|
|
2743
|
+
const code = err.code;
|
|
2349
2744
|
onEvent({
|
|
2350
2745
|
type: "stderr",
|
|
2351
|
-
data: err.message
|
|
2746
|
+
data: err.message + (code ? ` (${code})` : "")
|
|
2352
2747
|
});
|
|
2353
2748
|
onEvent({
|
|
2354
2749
|
type: "exit",
|
|
@@ -2366,32 +2761,27 @@ var CliExecutor = class {
|
|
|
2366
2761
|
/**
|
|
2367
2762
|
* 工具配置检测模块
|
|
2368
2763
|
*
|
|
2369
|
-
*
|
|
2370
|
-
*
|
|
2371
|
-
*
|
|
2372
|
-
* 重要:使用响应式文件系统实现,监听配置目录,
|
|
2373
|
-
* 当配置文件变化时会自动触发更新。
|
|
2374
|
-
*
|
|
2375
|
-
* @see references/openspec/src/core/config.ts (AI_TOOLS)
|
|
2376
|
-
* @see references/openspec/src/core/configurators/slash/
|
|
2377
|
-
* @see references/openspec/src/core/init.ts (isToolConfigured)
|
|
2764
|
+
* 对齐 @fission-ai/openspec 的 skills 体系,
|
|
2765
|
+
* 通过 `skills/<skill>/SKILL.md` 判断工具是否已配置。
|
|
2378
2766
|
*/
|
|
2379
2767
|
/**
|
|
2380
|
-
*
|
|
2381
|
-
*
|
|
2382
|
-
* @see references/openspec/src/core/configurators/slash/codex.ts
|
|
2768
|
+
* OpenSpec 初始化生成的技能目录名称
|
|
2769
|
+
* @see references/openspec/src/core/shared/tool-detection.ts
|
|
2383
2770
|
*/
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2771
|
+
const SKILL_NAMES = [
|
|
2772
|
+
"openspec-explore",
|
|
2773
|
+
"openspec-new-change",
|
|
2774
|
+
"openspec-continue-change",
|
|
2775
|
+
"openspec-apply-change",
|
|
2776
|
+
"openspec-ff-change",
|
|
2777
|
+
"openspec-sync-specs",
|
|
2778
|
+
"openspec-archive-change",
|
|
2779
|
+
"openspec-bulk-archive-change",
|
|
2780
|
+
"openspec-verify-change"
|
|
2781
|
+
];
|
|
2387
2782
|
/**
|
|
2388
2783
|
* 所有支持的 AI 工具配置
|
|
2389
|
-
*
|
|
2390
2784
|
* 完全对齐官方 OpenSpec CLI 的 AI_TOOLS
|
|
2391
|
-
* 按字母顺序排序(与官方一致)
|
|
2392
|
-
*
|
|
2393
|
-
* @see references/openspec/src/core/config.ts
|
|
2394
|
-
* @see references/openspec/src/core/configurators/slash/registry.ts
|
|
2395
2785
|
*/
|
|
2396
2786
|
const AI_TOOLS = [
|
|
2397
2787
|
{
|
|
@@ -2399,168 +2789,160 @@ const AI_TOOLS = [
|
|
|
2399
2789
|
value: "amazon-q",
|
|
2400
2790
|
available: true,
|
|
2401
2791
|
successLabel: "Amazon Q Developer",
|
|
2402
|
-
|
|
2403
|
-
detectionPath: ".amazonq/prompts/openspec-proposal.md"
|
|
2792
|
+
skillsDir: ".amazonq"
|
|
2404
2793
|
},
|
|
2405
2794
|
{
|
|
2406
2795
|
name: "Antigravity",
|
|
2407
2796
|
value: "antigravity",
|
|
2408
2797
|
available: true,
|
|
2409
2798
|
successLabel: "Antigravity",
|
|
2410
|
-
|
|
2411
|
-
detectionPath: ".agent/workflows/openspec-proposal.md"
|
|
2799
|
+
skillsDir: ".agent"
|
|
2412
2800
|
},
|
|
2413
2801
|
{
|
|
2414
2802
|
name: "Auggie (Augment CLI)",
|
|
2415
2803
|
value: "auggie",
|
|
2416
2804
|
available: true,
|
|
2417
2805
|
successLabel: "Auggie",
|
|
2418
|
-
|
|
2419
|
-
detectionPath: ".augment/commands/openspec-proposal.md"
|
|
2806
|
+
skillsDir: ".augment"
|
|
2420
2807
|
},
|
|
2421
2808
|
{
|
|
2422
2809
|
name: "Claude Code",
|
|
2423
2810
|
value: "claude",
|
|
2424
2811
|
available: true,
|
|
2425
2812
|
successLabel: "Claude Code",
|
|
2426
|
-
|
|
2427
|
-
detectionPath: ".claude/commands/openspec/proposal.md"
|
|
2813
|
+
skillsDir: ".claude"
|
|
2428
2814
|
},
|
|
2429
2815
|
{
|
|
2430
2816
|
name: "Cline",
|
|
2431
2817
|
value: "cline",
|
|
2432
2818
|
available: true,
|
|
2433
2819
|
successLabel: "Cline",
|
|
2434
|
-
|
|
2435
|
-
detectionPath: ".clinerules/workflows/openspec-proposal.md"
|
|
2820
|
+
skillsDir: ".cline"
|
|
2436
2821
|
},
|
|
2437
2822
|
{
|
|
2438
2823
|
name: "Codex",
|
|
2439
2824
|
value: "codex",
|
|
2440
2825
|
available: true,
|
|
2441
2826
|
successLabel: "Codex",
|
|
2442
|
-
|
|
2443
|
-
detectionPath: () => join$1(getCodexGlobalPromptsDir(), "openspec-proposal.md")
|
|
2827
|
+
skillsDir: ".codex"
|
|
2444
2828
|
},
|
|
2445
2829
|
{
|
|
2446
2830
|
name: "CodeBuddy Code (CLI)",
|
|
2447
2831
|
value: "codebuddy",
|
|
2448
2832
|
available: true,
|
|
2449
2833
|
successLabel: "CodeBuddy Code",
|
|
2450
|
-
|
|
2451
|
-
|
|
2834
|
+
skillsDir: ".codebuddy"
|
|
2835
|
+
},
|
|
2836
|
+
{
|
|
2837
|
+
name: "Continue",
|
|
2838
|
+
value: "continue",
|
|
2839
|
+
available: true,
|
|
2840
|
+
successLabel: "Continue (VS Code / JetBrains / Cli)",
|
|
2841
|
+
skillsDir: ".continue"
|
|
2452
2842
|
},
|
|
2453
2843
|
{
|
|
2454
2844
|
name: "CoStrict",
|
|
2455
2845
|
value: "costrict",
|
|
2456
2846
|
available: true,
|
|
2457
2847
|
successLabel: "CoStrict",
|
|
2458
|
-
|
|
2459
|
-
detectionPath: ".cospec/openspec/commands/openspec-proposal.md"
|
|
2848
|
+
skillsDir: ".cospec"
|
|
2460
2849
|
},
|
|
2461
2850
|
{
|
|
2462
2851
|
name: "Crush",
|
|
2463
2852
|
value: "crush",
|
|
2464
2853
|
available: true,
|
|
2465
2854
|
successLabel: "Crush",
|
|
2466
|
-
|
|
2467
|
-
detectionPath: ".crush/commands/openspec/proposal.md"
|
|
2855
|
+
skillsDir: ".crush"
|
|
2468
2856
|
},
|
|
2469
2857
|
{
|
|
2470
2858
|
name: "Cursor",
|
|
2471
2859
|
value: "cursor",
|
|
2472
2860
|
available: true,
|
|
2473
2861
|
successLabel: "Cursor",
|
|
2474
|
-
|
|
2475
|
-
detectionPath: ".cursor/commands/openspec-proposal.md"
|
|
2862
|
+
skillsDir: ".cursor"
|
|
2476
2863
|
},
|
|
2477
2864
|
{
|
|
2478
2865
|
name: "Factory Droid",
|
|
2479
2866
|
value: "factory",
|
|
2480
2867
|
available: true,
|
|
2481
2868
|
successLabel: "Factory Droid",
|
|
2482
|
-
|
|
2483
|
-
detectionPath: ".factory/commands/openspec-proposal.md"
|
|
2869
|
+
skillsDir: ".factory"
|
|
2484
2870
|
},
|
|
2485
2871
|
{
|
|
2486
2872
|
name: "Gemini CLI",
|
|
2487
2873
|
value: "gemini",
|
|
2488
2874
|
available: true,
|
|
2489
2875
|
successLabel: "Gemini CLI",
|
|
2490
|
-
|
|
2491
|
-
detectionPath: ".gemini/commands/openspec/proposal.toml"
|
|
2876
|
+
skillsDir: ".gemini"
|
|
2492
2877
|
},
|
|
2493
2878
|
{
|
|
2494
2879
|
name: "GitHub Copilot",
|
|
2495
2880
|
value: "github-copilot",
|
|
2496
2881
|
available: true,
|
|
2497
2882
|
successLabel: "GitHub Copilot",
|
|
2498
|
-
|
|
2499
|
-
detectionPath: ".github/prompts/openspec-proposal.prompt.md"
|
|
2883
|
+
skillsDir: ".github"
|
|
2500
2884
|
},
|
|
2501
2885
|
{
|
|
2502
2886
|
name: "iFlow",
|
|
2503
2887
|
value: "iflow",
|
|
2504
2888
|
available: true,
|
|
2505
2889
|
successLabel: "iFlow",
|
|
2506
|
-
|
|
2507
|
-
detectionPath: ".iflow/commands/openspec-proposal.md"
|
|
2890
|
+
skillsDir: ".iflow"
|
|
2508
2891
|
},
|
|
2509
2892
|
{
|
|
2510
2893
|
name: "Kilo Code",
|
|
2511
2894
|
value: "kilocode",
|
|
2512
2895
|
available: true,
|
|
2513
2896
|
successLabel: "Kilo Code",
|
|
2514
|
-
|
|
2515
|
-
detectionPath: ".kilocode/workflows/openspec-proposal.md"
|
|
2897
|
+
skillsDir: ".kilocode"
|
|
2516
2898
|
},
|
|
2517
2899
|
{
|
|
2518
2900
|
name: "OpenCode",
|
|
2519
2901
|
value: "opencode",
|
|
2520
2902
|
available: true,
|
|
2521
2903
|
successLabel: "OpenCode",
|
|
2522
|
-
|
|
2523
|
-
detectionPath: ".opencode/command/openspec-proposal.md"
|
|
2904
|
+
skillsDir: ".opencode"
|
|
2524
2905
|
},
|
|
2525
2906
|
{
|
|
2526
|
-
name: "Qoder
|
|
2907
|
+
name: "Qoder",
|
|
2527
2908
|
value: "qoder",
|
|
2528
2909
|
available: true,
|
|
2529
2910
|
successLabel: "Qoder",
|
|
2530
|
-
|
|
2531
|
-
detectionPath: ".qoder/commands/openspec/proposal.md"
|
|
2911
|
+
skillsDir: ".qoder"
|
|
2532
2912
|
},
|
|
2533
2913
|
{
|
|
2534
2914
|
name: "Qwen Code",
|
|
2535
2915
|
value: "qwen",
|
|
2536
2916
|
available: true,
|
|
2537
2917
|
successLabel: "Qwen Code",
|
|
2538
|
-
|
|
2539
|
-
detectionPath: ".qwen/commands/openspec-proposal.toml"
|
|
2918
|
+
skillsDir: ".qwen"
|
|
2540
2919
|
},
|
|
2541
2920
|
{
|
|
2542
2921
|
name: "RooCode",
|
|
2543
2922
|
value: "roocode",
|
|
2544
2923
|
available: true,
|
|
2545
2924
|
successLabel: "RooCode",
|
|
2546
|
-
|
|
2547
|
-
|
|
2925
|
+
skillsDir: ".roo"
|
|
2926
|
+
},
|
|
2927
|
+
{
|
|
2928
|
+
name: "Trae",
|
|
2929
|
+
value: "trae",
|
|
2930
|
+
available: true,
|
|
2931
|
+
successLabel: "Trae",
|
|
2932
|
+
skillsDir: ".trae"
|
|
2548
2933
|
},
|
|
2549
2934
|
{
|
|
2550
2935
|
name: "Windsurf",
|
|
2551
2936
|
value: "windsurf",
|
|
2552
2937
|
available: true,
|
|
2553
2938
|
successLabel: "Windsurf",
|
|
2554
|
-
|
|
2555
|
-
detectionPath: ".windsurf/workflows/openspec-proposal.md"
|
|
2939
|
+
skillsDir: ".windsurf"
|
|
2556
2940
|
},
|
|
2557
2941
|
{
|
|
2558
2942
|
name: "AGENTS.md (works with Amp, VS Code, …)",
|
|
2559
2943
|
value: "agents",
|
|
2560
2944
|
available: false,
|
|
2561
|
-
successLabel: "your AGENTS.md-compatible assistant"
|
|
2562
|
-
scope: "project",
|
|
2563
|
-
detectionPath: "AGENTS.md"
|
|
2945
|
+
successLabel: "your AGENTS.md-compatible assistant"
|
|
2564
2946
|
}
|
|
2565
2947
|
];
|
|
2566
2948
|
/**
|
|
@@ -2597,71 +2979,39 @@ function getToolById(toolId) {
|
|
|
2597
2979
|
const stateCache = /* @__PURE__ */ new Map();
|
|
2598
2980
|
/** 监听器释放函数缓存 */
|
|
2599
2981
|
const releaseCache = /* @__PURE__ */ new Map();
|
|
2600
|
-
|
|
2601
|
-
|
|
2602
|
-
|
|
2603
|
-
async function fileExists(filePath) {
|
|
2604
|
-
try {
|
|
2605
|
-
await stat(filePath);
|
|
2606
|
-
return true;
|
|
2607
|
-
} catch {
|
|
2608
|
-
return false;
|
|
2609
|
-
}
|
|
2982
|
+
function getSkillsDir(projectDir, tool) {
|
|
2983
|
+
if (!tool.skillsDir) return null;
|
|
2984
|
+
return join$1(projectDir, tool.skillsDir, "skills");
|
|
2610
2985
|
}
|
|
2611
|
-
|
|
2612
|
-
|
|
2613
|
-
|
|
2614
|
-
|
|
2615
|
-
|
|
2616
|
-
|
|
2617
|
-
function resolveDetectionPath(config, projectDir) {
|
|
2618
|
-
if (config.scope === "none" || !config.detectionPath) return;
|
|
2619
|
-
if (config.scope === "global") return config.detectionPath();
|
|
2620
|
-
return join$1(projectDir, config.detectionPath);
|
|
2986
|
+
async function getSkillCount(projectDir, tool) {
|
|
2987
|
+
const skillsDir = getSkillsDir(projectDir, tool);
|
|
2988
|
+
if (!skillsDir) return 0;
|
|
2989
|
+
let count = 0;
|
|
2990
|
+
for (const skillName of SKILL_NAMES) if (await reactiveExists(join$1(skillsDir, skillName, "SKILL.md"))) count++;
|
|
2991
|
+
return count;
|
|
2621
2992
|
}
|
|
2622
2993
|
/**
|
|
2623
2994
|
* 扫描已配置的工具(并行检查)
|
|
2624
2995
|
*/
|
|
2625
2996
|
async function scanConfiguredTools(projectDir) {
|
|
2626
2997
|
return (await Promise.all(AI_TOOLS.map(async (config) => {
|
|
2627
|
-
|
|
2628
|
-
|
|
2629
|
-
return await fileExists(filePath) ? config.value : null;
|
|
2998
|
+
if (!config.skillsDir) return null;
|
|
2999
|
+
return await getSkillCount(projectDir, config) > 0 ? config.value : null;
|
|
2630
3000
|
}))).filter((id) => id !== null);
|
|
2631
3001
|
}
|
|
2632
3002
|
/**
|
|
2633
3003
|
* 获取需要监听的项目级目录列表
|
|
2634
|
-
* 只监听包含工具配置的一级隐藏目录
|
|
2635
3004
|
*/
|
|
2636
3005
|
function getProjectWatchDirs(projectDir) {
|
|
2637
3006
|
const dirs = /* @__PURE__ */ new Set();
|
|
2638
|
-
for (const config of AI_TOOLS)
|
|
2639
|
-
|
|
2640
|
-
|
|
2641
|
-
}
|
|
2642
|
-
return Array.from(dirs);
|
|
2643
|
-
}
|
|
2644
|
-
/**
|
|
2645
|
-
* 获取需要监听的全局目录列表
|
|
2646
|
-
* 如 Codex 的 ~/.codex/prompts/
|
|
2647
|
-
*/
|
|
2648
|
-
function getGlobalWatchDirs() {
|
|
2649
|
-
const dirs = /* @__PURE__ */ new Set();
|
|
2650
|
-
for (const config of AI_TOOLS) if (config.scope === "global" && config.detectionPath) {
|
|
2651
|
-
const filePath = config.detectionPath();
|
|
2652
|
-
dirs.add(dirname(filePath));
|
|
3007
|
+
for (const config of AI_TOOLS) {
|
|
3008
|
+
if (!config.skillsDir) continue;
|
|
3009
|
+
dirs.add(join$1(projectDir, config.skillsDir));
|
|
2653
3010
|
}
|
|
2654
3011
|
return Array.from(dirs);
|
|
2655
3012
|
}
|
|
2656
3013
|
/**
|
|
2657
3014
|
* 检测项目中已配置的工具(响应式)
|
|
2658
|
-
*
|
|
2659
|
-
* 监听两类目录:
|
|
2660
|
-
* 1. 项目级配置目录(如 .claude, .cursor 等)
|
|
2661
|
-
* 2. 全局配置目录(如 ~/.codex/prompts/)
|
|
2662
|
-
*
|
|
2663
|
-
* @param projectDir 项目根目录
|
|
2664
|
-
* @returns 已配置的工具 ID 列表
|
|
2665
3015
|
*/
|
|
2666
3016
|
async function getConfiguredTools(projectDir) {
|
|
2667
3017
|
const normalizedPath = resolve(projectDir);
|
|
@@ -2680,11 +3030,6 @@ async function getConfiguredTools(projectDir) {
|
|
|
2680
3030
|
const release = acquireWatcher(dir, onUpdate, { recursive: true });
|
|
2681
3031
|
releases.push(release);
|
|
2682
3032
|
}
|
|
2683
|
-
const globalWatchDirs = getGlobalWatchDirs();
|
|
2684
|
-
for (const dir of globalWatchDirs) {
|
|
2685
|
-
const release = acquireWatcher(dir, onUpdate, { recursive: false });
|
|
2686
|
-
releases.push(release);
|
|
2687
|
-
}
|
|
2688
3033
|
const rootRelease = acquireWatcher(normalizedPath, onUpdate, { recursive: false });
|
|
2689
3034
|
releases.push(rootRelease);
|
|
2690
3035
|
releaseCache.set(key, () => releases.forEach((r) => r()));
|
|
@@ -2693,14 +3038,814 @@ async function getConfiguredTools(projectDir) {
|
|
|
2693
3038
|
}
|
|
2694
3039
|
/**
|
|
2695
3040
|
* 检查特定工具是否已配置
|
|
2696
|
-
*
|
|
2697
|
-
* @param projectDir 项目根目录
|
|
2698
|
-
* @param toolId 工具 ID
|
|
2699
|
-
* @returns 是否已配置
|
|
2700
3041
|
*/
|
|
2701
3042
|
async function isToolConfigured(projectDir, toolId) {
|
|
2702
3043
|
return (await getConfiguredTools(projectDir)).includes(toolId);
|
|
2703
3044
|
}
|
|
2704
3045
|
|
|
2705
3046
|
//#endregion
|
|
2706
|
-
|
|
3047
|
+
//#region src/opsx-types.ts
|
|
3048
|
+
/** Check if an outputPath contains glob pattern characters */
|
|
3049
|
+
function isGlobPattern(pattern) {
|
|
3050
|
+
return pattern.includes("*") || pattern.includes("?") || pattern.includes("[");
|
|
3051
|
+
}
|
|
3052
|
+
const ArtifactStatusSchema = z.object({
|
|
3053
|
+
id: z.string(),
|
|
3054
|
+
outputPath: z.string(),
|
|
3055
|
+
status: z.enum([
|
|
3056
|
+
"done",
|
|
3057
|
+
"ready",
|
|
3058
|
+
"blocked"
|
|
3059
|
+
]),
|
|
3060
|
+
missingDeps: z.array(z.string()).optional(),
|
|
3061
|
+
relativePath: z.string().optional()
|
|
3062
|
+
});
|
|
3063
|
+
const ChangeStatusSchema = z.object({
|
|
3064
|
+
changeName: z.string(),
|
|
3065
|
+
schemaName: z.string(),
|
|
3066
|
+
isComplete: z.boolean(),
|
|
3067
|
+
applyRequires: z.array(z.string()),
|
|
3068
|
+
artifacts: z.array(ArtifactStatusSchema)
|
|
3069
|
+
});
|
|
3070
|
+
const DependencyInfoSchema = z.object({
|
|
3071
|
+
id: z.string(),
|
|
3072
|
+
done: z.boolean(),
|
|
3073
|
+
path: z.string(),
|
|
3074
|
+
description: z.string()
|
|
3075
|
+
});
|
|
3076
|
+
const ApplyTaskSchema = z.object({
|
|
3077
|
+
id: z.string(),
|
|
3078
|
+
description: z.string(),
|
|
3079
|
+
done: z.boolean()
|
|
3080
|
+
});
|
|
3081
|
+
const ApplyInstructionsSchema = z.object({
|
|
3082
|
+
changeName: z.string(),
|
|
3083
|
+
changeDir: z.string(),
|
|
3084
|
+
schemaName: z.string(),
|
|
3085
|
+
contextFiles: z.record(z.string()),
|
|
3086
|
+
progress: z.object({
|
|
3087
|
+
total: z.number(),
|
|
3088
|
+
complete: z.number(),
|
|
3089
|
+
remaining: z.number()
|
|
3090
|
+
}),
|
|
3091
|
+
tasks: z.array(ApplyTaskSchema),
|
|
3092
|
+
state: z.enum([
|
|
3093
|
+
"blocked",
|
|
3094
|
+
"all_done",
|
|
3095
|
+
"ready"
|
|
3096
|
+
]),
|
|
3097
|
+
missingArtifacts: z.array(z.string()).optional(),
|
|
3098
|
+
instruction: z.string()
|
|
3099
|
+
});
|
|
3100
|
+
const NullableString = z.string().nullable().optional();
|
|
3101
|
+
const ArtifactInstructionsSchema = z.object({
|
|
3102
|
+
changeName: z.string(),
|
|
3103
|
+
artifactId: z.string(),
|
|
3104
|
+
schemaName: z.string(),
|
|
3105
|
+
changeDir: z.string(),
|
|
3106
|
+
outputPath: z.string(),
|
|
3107
|
+
description: z.string(),
|
|
3108
|
+
instruction: NullableString,
|
|
3109
|
+
context: NullableString,
|
|
3110
|
+
rules: z.array(z.string()).optional().nullable(),
|
|
3111
|
+
template: z.string(),
|
|
3112
|
+
dependencies: z.array(DependencyInfoSchema),
|
|
3113
|
+
unlocks: z.array(z.string())
|
|
3114
|
+
});
|
|
3115
|
+
const SchemaInfoSchema = z.object({
|
|
3116
|
+
name: z.string(),
|
|
3117
|
+
description: z.string().optional(),
|
|
3118
|
+
artifacts: z.array(z.string()),
|
|
3119
|
+
source: z.enum([
|
|
3120
|
+
"project",
|
|
3121
|
+
"user",
|
|
3122
|
+
"package"
|
|
3123
|
+
])
|
|
3124
|
+
});
|
|
3125
|
+
const SchemaResolutionSchema = z.object({
|
|
3126
|
+
name: z.string(),
|
|
3127
|
+
source: z.enum([
|
|
3128
|
+
"project",
|
|
3129
|
+
"user",
|
|
3130
|
+
"package"
|
|
3131
|
+
]),
|
|
3132
|
+
path: z.string(),
|
|
3133
|
+
shadows: z.array(z.object({
|
|
3134
|
+
source: z.enum([
|
|
3135
|
+
"project",
|
|
3136
|
+
"user",
|
|
3137
|
+
"package"
|
|
3138
|
+
]),
|
|
3139
|
+
path: z.string()
|
|
3140
|
+
}))
|
|
3141
|
+
});
|
|
3142
|
+
const TemplatesSchema = z.record(z.object({
|
|
3143
|
+
path: z.string(),
|
|
3144
|
+
source: z.enum([
|
|
3145
|
+
"project",
|
|
3146
|
+
"user",
|
|
3147
|
+
"package"
|
|
3148
|
+
])
|
|
3149
|
+
}));
|
|
3150
|
+
const SchemaArtifactSchema = z.object({
|
|
3151
|
+
id: z.string(),
|
|
3152
|
+
outputPath: z.string(),
|
|
3153
|
+
description: z.string().optional(),
|
|
3154
|
+
template: z.string().optional(),
|
|
3155
|
+
instruction: z.string().optional(),
|
|
3156
|
+
requires: z.array(z.string())
|
|
3157
|
+
});
|
|
3158
|
+
const SchemaDetailSchema = z.object({
|
|
3159
|
+
name: z.string(),
|
|
3160
|
+
description: z.string().optional(),
|
|
3161
|
+
version: z.union([z.string(), z.number()]).optional(),
|
|
3162
|
+
artifacts: z.array(SchemaArtifactSchema),
|
|
3163
|
+
applyRequires: z.array(z.string()),
|
|
3164
|
+
applyTracks: z.string().optional(),
|
|
3165
|
+
applyInstruction: z.string().optional()
|
|
3166
|
+
});
|
|
3167
|
+
|
|
3168
|
+
//#endregion
|
|
3169
|
+
//#region src/opsx-kernel.ts
|
|
3170
|
+
function parseCliJson(raw, schema, label) {
|
|
3171
|
+
const trimmed = raw.trim();
|
|
3172
|
+
if (!trimmed) throw new Error(`${label} returned empty output`);
|
|
3173
|
+
let parsed;
|
|
3174
|
+
try {
|
|
3175
|
+
parsed = JSON.parse(trimmed);
|
|
3176
|
+
} catch (err) {
|
|
3177
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
3178
|
+
throw new Error(`${label} returned invalid JSON: ${message}`);
|
|
3179
|
+
}
|
|
3180
|
+
const result = schema.safeParse(parsed);
|
|
3181
|
+
if (!result.success) throw new Error(`${label} returned unexpected JSON: ${result.error.message}`);
|
|
3182
|
+
return result.data;
|
|
3183
|
+
}
|
|
3184
|
+
function toRelativePath(root, absolutePath) {
|
|
3185
|
+
return relative(root, absolutePath).split(sep).join("/");
|
|
3186
|
+
}
|
|
3187
|
+
async function readEntriesUnderRoot(root) {
|
|
3188
|
+
if (!(await reactiveStat(root))?.isDirectory) return [];
|
|
3189
|
+
const collectEntries = async (dir) => {
|
|
3190
|
+
const names = await reactiveReadDir(dir, { includeHidden: false });
|
|
3191
|
+
const entries = [];
|
|
3192
|
+
for (const name of names) {
|
|
3193
|
+
const fullPath = join$1(dir, name);
|
|
3194
|
+
const statInfo = await reactiveStat(fullPath);
|
|
3195
|
+
if (!statInfo) continue;
|
|
3196
|
+
const relativePath = toRelativePath(root, fullPath);
|
|
3197
|
+
if (statInfo.isDirectory) {
|
|
3198
|
+
entries.push({
|
|
3199
|
+
path: relativePath,
|
|
3200
|
+
type: "directory"
|
|
3201
|
+
});
|
|
3202
|
+
entries.push(...await collectEntries(fullPath));
|
|
3203
|
+
} else {
|
|
3204
|
+
const content = await reactiveReadFile(fullPath);
|
|
3205
|
+
const size = content ? Buffer.byteLength(content, "utf-8") : void 0;
|
|
3206
|
+
entries.push({
|
|
3207
|
+
path: relativePath,
|
|
3208
|
+
type: "file",
|
|
3209
|
+
content: content ?? void 0,
|
|
3210
|
+
size
|
|
3211
|
+
});
|
|
3212
|
+
}
|
|
3213
|
+
}
|
|
3214
|
+
return entries;
|
|
3215
|
+
};
|
|
3216
|
+
return collectEntries(root);
|
|
3217
|
+
}
|
|
3218
|
+
async function readGlobArtifactFiles(projectDir, changeId, outputPath) {
|
|
3219
|
+
return (await readEntriesUnderRoot(join$1(projectDir, "openspec", "changes", changeId))).filter((entry) => entry.type === "file" && matchesGlob(entry.path, outputPath)).map((entry) => ({
|
|
3220
|
+
path: entry.path,
|
|
3221
|
+
type: "file",
|
|
3222
|
+
content: entry.content ?? ""
|
|
3223
|
+
}));
|
|
3224
|
+
}
|
|
3225
|
+
async function touchOpsxProjectDeps(projectDir) {
|
|
3226
|
+
const openspecDir = join$1(projectDir, "openspec");
|
|
3227
|
+
await reactiveReadFile(join$1(openspecDir, "config.yaml"));
|
|
3228
|
+
const schemaRoot = join$1(openspecDir, "schemas");
|
|
3229
|
+
const schemaDirs = await reactiveReadDir(schemaRoot, {
|
|
3230
|
+
directoriesOnly: true,
|
|
3231
|
+
includeHidden: true
|
|
3232
|
+
});
|
|
3233
|
+
await Promise.all(schemaDirs.map((name) => reactiveReadFile(join$1(schemaRoot, name, "schema.yaml"))));
|
|
3234
|
+
await reactiveReadDir(join$1(openspecDir, "changes"), {
|
|
3235
|
+
directoriesOnly: true,
|
|
3236
|
+
includeHidden: true,
|
|
3237
|
+
exclude: ["archive"]
|
|
3238
|
+
});
|
|
3239
|
+
}
|
|
3240
|
+
async function touchOpsxChangeDeps(projectDir, changeId) {
|
|
3241
|
+
const changeDir = join$1(projectDir, "openspec", "changes", changeId);
|
|
3242
|
+
await reactiveReadDir(changeDir, { includeHidden: true });
|
|
3243
|
+
await reactiveReadFile(join$1(changeDir, ".openspec.yaml"));
|
|
3244
|
+
}
|
|
3245
|
+
var OpsxKernel = class {
|
|
3246
|
+
projectDir;
|
|
3247
|
+
cliExecutor;
|
|
3248
|
+
controller = new AbortController();
|
|
3249
|
+
_statusList = new ReactiveState([]);
|
|
3250
|
+
_schemas = new ReactiveState([]);
|
|
3251
|
+
_changeIds = new ReactiveState([]);
|
|
3252
|
+
_projectConfig = new ReactiveState(null);
|
|
3253
|
+
_schemaResolutions = /* @__PURE__ */ new Map();
|
|
3254
|
+
_schemaDetails = /* @__PURE__ */ new Map();
|
|
3255
|
+
_schemaFiles = /* @__PURE__ */ new Map();
|
|
3256
|
+
_schemaYamls = /* @__PURE__ */ new Map();
|
|
3257
|
+
_templates = /* @__PURE__ */ new Map();
|
|
3258
|
+
_templateContents = /* @__PURE__ */ new Map();
|
|
3259
|
+
_statuses = /* @__PURE__ */ new Map();
|
|
3260
|
+
_instructions = /* @__PURE__ */ new Map();
|
|
3261
|
+
_applyInstructions = /* @__PURE__ */ new Map();
|
|
3262
|
+
_changeMetadata = /* @__PURE__ */ new Map();
|
|
3263
|
+
_artifactOutputs = /* @__PURE__ */ new Map();
|
|
3264
|
+
_globArtifactFiles = /* @__PURE__ */ new Map();
|
|
3265
|
+
_entityControllers = /* @__PURE__ */ new Map();
|
|
3266
|
+
constructor(projectDir, cliExecutor) {
|
|
3267
|
+
this.projectDir = projectDir;
|
|
3268
|
+
this.cliExecutor = cliExecutor;
|
|
3269
|
+
}
|
|
3270
|
+
async warmup() {
|
|
3271
|
+
const signal = this.controller.signal;
|
|
3272
|
+
await Promise.all([
|
|
3273
|
+
this.startStream(this._schemas, () => this.fetchSchemas(), signal),
|
|
3274
|
+
this.startStream(this._changeIds, () => this.fetchChangeIds(), signal),
|
|
3275
|
+
this.startStream(this._projectConfig, () => this.fetchProjectConfig(), signal)
|
|
3276
|
+
]);
|
|
3277
|
+
const schemas = this._schemas.get();
|
|
3278
|
+
await Promise.all(schemas.map((s) => this.warmupSchema(s.name, signal)));
|
|
3279
|
+
const changeIds = this._changeIds.get();
|
|
3280
|
+
await Promise.all(changeIds.map((id) => this.warmupChange(id, signal)));
|
|
3281
|
+
await this.startStream(this._statusList, () => this.fetchStatusList(), signal);
|
|
3282
|
+
this.watchSchemaChanges(signal);
|
|
3283
|
+
this.watchChangeIdChanges(signal);
|
|
3284
|
+
}
|
|
3285
|
+
dispose() {
|
|
3286
|
+
this.controller.abort();
|
|
3287
|
+
for (const ctrl of this._entityControllers.values()) ctrl.abort();
|
|
3288
|
+
this._entityControllers.clear();
|
|
3289
|
+
}
|
|
3290
|
+
getStatusList() {
|
|
3291
|
+
return this._statusList.get();
|
|
3292
|
+
}
|
|
3293
|
+
getSchemas() {
|
|
3294
|
+
return this._schemas.get();
|
|
3295
|
+
}
|
|
3296
|
+
getChangeIds() {
|
|
3297
|
+
return this._changeIds.get();
|
|
3298
|
+
}
|
|
3299
|
+
getProjectConfig() {
|
|
3300
|
+
return this._projectConfig.get();
|
|
3301
|
+
}
|
|
3302
|
+
getTemplates(schema) {
|
|
3303
|
+
const key = schema ?? "";
|
|
3304
|
+
const state = this._templates.get(key);
|
|
3305
|
+
return state ? state.get() : {};
|
|
3306
|
+
}
|
|
3307
|
+
getTemplateContents(schema) {
|
|
3308
|
+
const key = schema ?? "";
|
|
3309
|
+
const state = this._templateContents.get(key);
|
|
3310
|
+
return state ? state.get() : {};
|
|
3311
|
+
}
|
|
3312
|
+
getStatus(changeId, schema) {
|
|
3313
|
+
const key = `${changeId}:${schema ?? ""}`;
|
|
3314
|
+
const state = this._statuses.get(key);
|
|
3315
|
+
if (!state) throw new Error(`Status not found for change "${changeId}"`);
|
|
3316
|
+
return state.get();
|
|
3317
|
+
}
|
|
3318
|
+
getInstructions(changeId, artifact, schema) {
|
|
3319
|
+
const key = `${changeId}:${artifact}:${schema ?? ""}`;
|
|
3320
|
+
const state = this._instructions.get(key);
|
|
3321
|
+
if (!state) throw new Error(`Instructions not found for change "${changeId}" artifact "${artifact}"`);
|
|
3322
|
+
return state.get();
|
|
3323
|
+
}
|
|
3324
|
+
getApplyInstructions(changeId, schema) {
|
|
3325
|
+
const key = `${changeId}:${schema ?? ""}`;
|
|
3326
|
+
const state = this._applyInstructions.get(key);
|
|
3327
|
+
if (!state) throw new Error(`Apply instructions not found for change "${changeId}"`);
|
|
3328
|
+
return state.get();
|
|
3329
|
+
}
|
|
3330
|
+
getSchemaResolution(name) {
|
|
3331
|
+
const state = this._schemaResolutions.get(name);
|
|
3332
|
+
if (!state) throw new Error(`Schema resolution not found for "${name}"`);
|
|
3333
|
+
return state.get();
|
|
3334
|
+
}
|
|
3335
|
+
getSchemaDetail(name) {
|
|
3336
|
+
const state = this._schemaDetails.get(name);
|
|
3337
|
+
if (!state) throw new Error(`Schema detail not found for "${name}"`);
|
|
3338
|
+
return state.get();
|
|
3339
|
+
}
|
|
3340
|
+
getSchemaFiles(name) {
|
|
3341
|
+
const state = this._schemaFiles.get(name);
|
|
3342
|
+
if (!state) throw new Error(`Schema files not found for "${name}"`);
|
|
3343
|
+
return state.get();
|
|
3344
|
+
}
|
|
3345
|
+
getSchemaYaml(name) {
|
|
3346
|
+
const state = this._schemaYamls.get(name);
|
|
3347
|
+
if (!state) throw new Error(`Schema yaml not found for "${name}"`);
|
|
3348
|
+
return state.get();
|
|
3349
|
+
}
|
|
3350
|
+
getChangeMetadata(changeId) {
|
|
3351
|
+
const state = this._changeMetadata.get(changeId);
|
|
3352
|
+
if (!state) return null;
|
|
3353
|
+
return state.get();
|
|
3354
|
+
}
|
|
3355
|
+
getArtifactOutput(changeId, outputPath) {
|
|
3356
|
+
const key = `${changeId}:${outputPath}`;
|
|
3357
|
+
const state = this._artifactOutputs.get(key);
|
|
3358
|
+
if (!state) return null;
|
|
3359
|
+
return state.get();
|
|
3360
|
+
}
|
|
3361
|
+
getGlobArtifactFiles(changeId, outputPath) {
|
|
3362
|
+
const key = `${changeId}:${outputPath}`;
|
|
3363
|
+
const state = this._globArtifactFiles.get(key);
|
|
3364
|
+
if (!state) return [];
|
|
3365
|
+
return state.get();
|
|
3366
|
+
}
|
|
3367
|
+
startStream(state, task, signal) {
|
|
3368
|
+
return new Promise((resolve$1) => {
|
|
3369
|
+
const context = new ReactiveContext();
|
|
3370
|
+
let first = true;
|
|
3371
|
+
(async () => {
|
|
3372
|
+
try {
|
|
3373
|
+
for await (const data of context.stream(task, signal)) {
|
|
3374
|
+
state.set(data);
|
|
3375
|
+
if (first) {
|
|
3376
|
+
first = false;
|
|
3377
|
+
resolve$1();
|
|
3378
|
+
}
|
|
3379
|
+
}
|
|
3380
|
+
} catch {}
|
|
3381
|
+
if (first) resolve$1();
|
|
3382
|
+
})();
|
|
3383
|
+
});
|
|
3384
|
+
}
|
|
3385
|
+
async warmupSchema(name, parentSignal) {
|
|
3386
|
+
const entityCtrl = new AbortController();
|
|
3387
|
+
this._entityControllers.set(`schema:${name}`, entityCtrl);
|
|
3388
|
+
const signal = this.combineSignals(parentSignal, entityCtrl.signal);
|
|
3389
|
+
if (!this._schemaResolutions.has(name)) this._schemaResolutions.set(name, new ReactiveState(null));
|
|
3390
|
+
if (!this._schemaDetails.has(name)) this._schemaDetails.set(name, new ReactiveState(null));
|
|
3391
|
+
if (!this._schemaFiles.has(name)) this._schemaFiles.set(name, new ReactiveState([]));
|
|
3392
|
+
if (!this._schemaYamls.has(name)) this._schemaYamls.set(name, new ReactiveState(null));
|
|
3393
|
+
if (!this._templates.has(name)) this._templates.set(name, new ReactiveState({}));
|
|
3394
|
+
if (!this._templateContents.has(name)) this._templateContents.set(name, new ReactiveState({}));
|
|
3395
|
+
if (!this._templates.has("")) this._templates.set("", new ReactiveState({}));
|
|
3396
|
+
if (!this._templateContents.has("")) this._templateContents.set("", new ReactiveState({}));
|
|
3397
|
+
await Promise.all([
|
|
3398
|
+
this.startStream(this._schemaResolutions.get(name), () => this.fetchSchemaResolution(name), signal),
|
|
3399
|
+
this.startStream(this._schemaDetails.get(name), () => this.fetchSchemaDetail(name), signal),
|
|
3400
|
+
this.startStream(this._schemaFiles.get(name), () => this.fetchSchemaFiles(name), signal),
|
|
3401
|
+
this.startStream(this._schemaYamls.get(name), () => this.fetchSchemaYaml(name), signal),
|
|
3402
|
+
this.startStream(this._templates.get(name), () => this.fetchTemplates(name), signal),
|
|
3403
|
+
this.startStream(this._templateContents.get(name), () => this.fetchTemplateContents(name), signal),
|
|
3404
|
+
this.startStream(this._templates.get(""), () => this.fetchTemplates(void 0), signal),
|
|
3405
|
+
this.startStream(this._templateContents.get(""), () => this.fetchTemplateContents(void 0), signal)
|
|
3406
|
+
]);
|
|
3407
|
+
}
|
|
3408
|
+
async warmupChange(changeId, parentSignal) {
|
|
3409
|
+
const entityCtrl = new AbortController();
|
|
3410
|
+
this._entityControllers.set(`change:${changeId}`, entityCtrl);
|
|
3411
|
+
const signal = this.combineSignals(parentSignal, entityCtrl.signal);
|
|
3412
|
+
const statusKey = `${changeId}:`;
|
|
3413
|
+
if (!this._statuses.has(statusKey)) this._statuses.set(statusKey, new ReactiveState(null));
|
|
3414
|
+
if (!this._changeMetadata.has(changeId)) this._changeMetadata.set(changeId, new ReactiveState(null));
|
|
3415
|
+
const applyKey = `${changeId}:`;
|
|
3416
|
+
if (!this._applyInstructions.has(applyKey)) this._applyInstructions.set(applyKey, new ReactiveState(null));
|
|
3417
|
+
await Promise.all([
|
|
3418
|
+
this.startStream(this._statuses.get(statusKey), () => this.fetchStatus(changeId, void 0), signal),
|
|
3419
|
+
this.startStream(this._changeMetadata.get(changeId), () => this.fetchChangeMetadata(changeId), signal),
|
|
3420
|
+
this.startStream(this._applyInstructions.get(applyKey), () => this.fetchApplyInstructions(changeId, void 0), signal)
|
|
3421
|
+
]);
|
|
3422
|
+
const status = this._statuses.get(statusKey)?.get();
|
|
3423
|
+
if (status?.artifacts) await Promise.all(status.artifacts.map(async (artifact) => {
|
|
3424
|
+
const instrKey = `${changeId}:${artifact.id}:`;
|
|
3425
|
+
if (!this._instructions.has(instrKey)) this._instructions.set(instrKey, new ReactiveState(null));
|
|
3426
|
+
await this.startStream(this._instructions.get(instrKey), () => this.fetchInstructions(changeId, artifact.id, void 0), signal);
|
|
3427
|
+
const outputKey = `${changeId}:${artifact.outputPath}`;
|
|
3428
|
+
if (!this._artifactOutputs.has(outputKey)) this._artifactOutputs.set(outputKey, new ReactiveState(null));
|
|
3429
|
+
await this.startStream(this._artifactOutputs.get(outputKey), () => this.fetchArtifactOutput(changeId, artifact.outputPath), signal);
|
|
3430
|
+
if (artifact.outputPath.includes("*") || artifact.outputPath.includes("?") || artifact.outputPath.includes("[")) {
|
|
3431
|
+
const globKey = `${changeId}:${artifact.outputPath}`;
|
|
3432
|
+
if (!this._globArtifactFiles.has(globKey)) this._globArtifactFiles.set(globKey, new ReactiveState([]));
|
|
3433
|
+
await this.startStream(this._globArtifactFiles.get(globKey), () => readGlobArtifactFiles(this.projectDir, changeId, artifact.outputPath), signal);
|
|
3434
|
+
}
|
|
3435
|
+
}));
|
|
3436
|
+
}
|
|
3437
|
+
watchSchemaChanges(signal) {
|
|
3438
|
+
const context = new ReactiveContext();
|
|
3439
|
+
(async () => {
|
|
3440
|
+
let prevNames = new Set(this._schemas.get().map((s) => s.name));
|
|
3441
|
+
try {
|
|
3442
|
+
for await (const schemas of context.stream(() => Promise.resolve(this._schemas.get()), signal)) {
|
|
3443
|
+
const newNames = new Set(schemas.map((s) => s.name));
|
|
3444
|
+
for (const name of newNames) if (!prevNames.has(name)) this.warmupSchema(name, signal);
|
|
3445
|
+
for (const name of prevNames) if (!newNames.has(name)) {
|
|
3446
|
+
this.teardownEntity(`schema:${name}`);
|
|
3447
|
+
this._schemaResolutions.delete(name);
|
|
3448
|
+
this._schemaDetails.delete(name);
|
|
3449
|
+
this._schemaFiles.delete(name);
|
|
3450
|
+
this._schemaYamls.delete(name);
|
|
3451
|
+
this._templates.delete(name);
|
|
3452
|
+
this._templateContents.delete(name);
|
|
3453
|
+
}
|
|
3454
|
+
prevNames = newNames;
|
|
3455
|
+
}
|
|
3456
|
+
} catch {}
|
|
3457
|
+
})();
|
|
3458
|
+
}
|
|
3459
|
+
watchChangeIdChanges(signal) {
|
|
3460
|
+
const context = new ReactiveContext();
|
|
3461
|
+
(async () => {
|
|
3462
|
+
let prevIds = new Set(this._changeIds.get());
|
|
3463
|
+
try {
|
|
3464
|
+
for await (const ids of context.stream(() => Promise.resolve(this._changeIds.get()), signal)) {
|
|
3465
|
+
const newIds = new Set(ids);
|
|
3466
|
+
for (const id of newIds) if (!prevIds.has(id)) this.warmupChange(id, signal);
|
|
3467
|
+
for (const id of prevIds) if (!newIds.has(id)) {
|
|
3468
|
+
this.teardownEntity(`change:${id}`);
|
|
3469
|
+
for (const key of this._statuses.keys()) if (key.startsWith(`${id}:`)) this._statuses.delete(key);
|
|
3470
|
+
for (const key of this._instructions.keys()) if (key.startsWith(`${id}:`)) this._instructions.delete(key);
|
|
3471
|
+
for (const key of this._applyInstructions.keys()) if (key.startsWith(`${id}:`)) this._applyInstructions.delete(key);
|
|
3472
|
+
this._changeMetadata.delete(id);
|
|
3473
|
+
for (const key of this._artifactOutputs.keys()) if (key.startsWith(`${id}:`)) this._artifactOutputs.delete(key);
|
|
3474
|
+
for (const key of this._globArtifactFiles.keys()) if (key.startsWith(`${id}:`)) this._globArtifactFiles.delete(key);
|
|
3475
|
+
}
|
|
3476
|
+
prevIds = newIds;
|
|
3477
|
+
}
|
|
3478
|
+
} catch {}
|
|
3479
|
+
})();
|
|
3480
|
+
}
|
|
3481
|
+
teardownEntity(key) {
|
|
3482
|
+
const ctrl = this._entityControllers.get(key);
|
|
3483
|
+
if (ctrl) {
|
|
3484
|
+
ctrl.abort();
|
|
3485
|
+
this._entityControllers.delete(key);
|
|
3486
|
+
}
|
|
3487
|
+
}
|
|
3488
|
+
async fetchSchemas() {
|
|
3489
|
+
await touchOpsxProjectDeps(this.projectDir);
|
|
3490
|
+
const result = await this.cliExecutor.schemas();
|
|
3491
|
+
if (!result.success) throw new Error(result.stderr || `openspec schemas failed (exit ${result.exitCode ?? "null"})`);
|
|
3492
|
+
return parseCliJson(result.stdout, z.array(SchemaInfoSchema), "openspec schemas");
|
|
3493
|
+
}
|
|
3494
|
+
async fetchChangeIds() {
|
|
3495
|
+
return reactiveReadDir(join$1(this.projectDir, "openspec", "changes"), {
|
|
3496
|
+
directoriesOnly: true,
|
|
3497
|
+
includeHidden: false,
|
|
3498
|
+
exclude: ["archive"]
|
|
3499
|
+
});
|
|
3500
|
+
}
|
|
3501
|
+
async fetchProjectConfig() {
|
|
3502
|
+
return reactiveReadFile(join$1(this.projectDir, "openspec", "config.yaml"));
|
|
3503
|
+
}
|
|
3504
|
+
async fetchStatus(changeId, schema) {
|
|
3505
|
+
await touchOpsxProjectDeps(this.projectDir);
|
|
3506
|
+
await touchOpsxChangeDeps(this.projectDir, changeId);
|
|
3507
|
+
const args = [
|
|
3508
|
+
"status",
|
|
3509
|
+
"--json",
|
|
3510
|
+
"--change",
|
|
3511
|
+
changeId
|
|
3512
|
+
];
|
|
3513
|
+
if (schema) args.push("--schema", schema);
|
|
3514
|
+
const result = await this.cliExecutor.execute(args);
|
|
3515
|
+
if (!result.success) throw new Error(result.stderr || `openspec status failed (exit ${result.exitCode ?? "null"})`);
|
|
3516
|
+
const status = parseCliJson(result.stdout, ChangeStatusSchema, "openspec status");
|
|
3517
|
+
const changeRelDir = `openspec/changes/${changeId}`;
|
|
3518
|
+
for (const artifact of status.artifacts) artifact.relativePath = `${changeRelDir}/${artifact.outputPath}`;
|
|
3519
|
+
return status;
|
|
3520
|
+
}
|
|
3521
|
+
async fetchStatusList() {
|
|
3522
|
+
const changeIds = this._changeIds.get();
|
|
3523
|
+
return Promise.all(changeIds.map((id) => {
|
|
3524
|
+
const key = `${id}:`;
|
|
3525
|
+
const state = this._statuses.get(key);
|
|
3526
|
+
if (state) return Promise.resolve(state.get());
|
|
3527
|
+
return this.fetchStatus(id);
|
|
3528
|
+
}));
|
|
3529
|
+
}
|
|
3530
|
+
async fetchInstructions(changeId, artifact, schema) {
|
|
3531
|
+
await touchOpsxProjectDeps(this.projectDir);
|
|
3532
|
+
await touchOpsxChangeDeps(this.projectDir, changeId);
|
|
3533
|
+
const args = [
|
|
3534
|
+
"instructions",
|
|
3535
|
+
artifact,
|
|
3536
|
+
"--json",
|
|
3537
|
+
"--change",
|
|
3538
|
+
changeId
|
|
3539
|
+
];
|
|
3540
|
+
if (schema) args.push("--schema", schema);
|
|
3541
|
+
const result = await this.cliExecutor.execute(args);
|
|
3542
|
+
if (!result.success) throw new Error(result.stderr || `openspec instructions failed (exit ${result.exitCode ?? "null"})`);
|
|
3543
|
+
return parseCliJson(result.stdout, ArtifactInstructionsSchema, "openspec instructions");
|
|
3544
|
+
}
|
|
3545
|
+
async fetchApplyInstructions(changeId, schema) {
|
|
3546
|
+
await touchOpsxProjectDeps(this.projectDir);
|
|
3547
|
+
await touchOpsxChangeDeps(this.projectDir, changeId);
|
|
3548
|
+
const args = [
|
|
3549
|
+
"instructions",
|
|
3550
|
+
"apply",
|
|
3551
|
+
"--json",
|
|
3552
|
+
"--change",
|
|
3553
|
+
changeId
|
|
3554
|
+
];
|
|
3555
|
+
if (schema) args.push("--schema", schema);
|
|
3556
|
+
const result = await this.cliExecutor.execute(args);
|
|
3557
|
+
if (!result.success) throw new Error(result.stderr || `openspec instructions apply failed (exit ${result.exitCode ?? "null"})`);
|
|
3558
|
+
return parseCliJson(result.stdout, ApplyInstructionsSchema, "openspec instructions apply");
|
|
3559
|
+
}
|
|
3560
|
+
async fetchSchemaResolution(name) {
|
|
3561
|
+
await touchOpsxProjectDeps(this.projectDir);
|
|
3562
|
+
const result = await this.cliExecutor.schemaWhich(name);
|
|
3563
|
+
if (!result.success) throw new Error(result.stderr || `openspec schema which failed (exit ${result.exitCode ?? "null"})`);
|
|
3564
|
+
return parseCliJson(result.stdout, SchemaResolutionSchema, "openspec schema which");
|
|
3565
|
+
}
|
|
3566
|
+
async fetchSchemaDetail(name) {
|
|
3567
|
+
await touchOpsxProjectDeps(this.projectDir);
|
|
3568
|
+
const schemaPath = join$1((await this.fetchSchemaResolution(name)).path, "schema.yaml");
|
|
3569
|
+
const content = await reactiveReadFile(schemaPath);
|
|
3570
|
+
if (!content) throw new Error(`schema.yaml not found at ${schemaPath}`);
|
|
3571
|
+
return parseSchemaYamlInline(content);
|
|
3572
|
+
}
|
|
3573
|
+
async fetchSchemaFiles(name) {
|
|
3574
|
+
await touchOpsxProjectDeps(this.projectDir);
|
|
3575
|
+
return readEntriesUnderRoot((await this.fetchSchemaResolution(name)).path);
|
|
3576
|
+
}
|
|
3577
|
+
async fetchSchemaYaml(name) {
|
|
3578
|
+
await touchOpsxProjectDeps(this.projectDir);
|
|
3579
|
+
return reactiveReadFile(join$1((await this.fetchSchemaResolution(name)).path, "schema.yaml"));
|
|
3580
|
+
}
|
|
3581
|
+
async fetchTemplates(schema) {
|
|
3582
|
+
await touchOpsxProjectDeps(this.projectDir);
|
|
3583
|
+
const result = await this.cliExecutor.templates(schema);
|
|
3584
|
+
if (!result.success) throw new Error(result.stderr || `openspec templates failed (exit ${result.exitCode ?? "null"})`);
|
|
3585
|
+
return parseCliJson(result.stdout, TemplatesSchema, "openspec templates");
|
|
3586
|
+
}
|
|
3587
|
+
async fetchTemplateContents(schema) {
|
|
3588
|
+
const templates = await this.fetchTemplates(schema);
|
|
3589
|
+
const entries = await Promise.all(Object.entries(templates).map(async ([artifactId, info]) => {
|
|
3590
|
+
return [artifactId, {
|
|
3591
|
+
content: await reactiveReadFile(info.path),
|
|
3592
|
+
path: info.path,
|
|
3593
|
+
source: info.source
|
|
3594
|
+
}];
|
|
3595
|
+
}));
|
|
3596
|
+
return Object.fromEntries(entries);
|
|
3597
|
+
}
|
|
3598
|
+
async fetchChangeMetadata(changeId) {
|
|
3599
|
+
return reactiveReadFile(join$1(this.projectDir, "openspec", "changes", changeId, ".openspec.yaml"));
|
|
3600
|
+
}
|
|
3601
|
+
async fetchArtifactOutput(changeId, outputPath) {
|
|
3602
|
+
return reactiveReadFile(join$1(this.projectDir, "openspec", "changes", changeId, outputPath));
|
|
3603
|
+
}
|
|
3604
|
+
/**
|
|
3605
|
+
* Ensure a per-change status stream exists. If not yet warmed up,
|
|
3606
|
+
* creates the state and starts a stream lazily.
|
|
3607
|
+
*/
|
|
3608
|
+
ensureStatus(changeId, schema) {
|
|
3609
|
+
const key = `${changeId}:${schema ?? ""}`;
|
|
3610
|
+
if (!this._statuses.has(key)) {
|
|
3611
|
+
this._statuses.set(key, new ReactiveState(null));
|
|
3612
|
+
this.startStream(this._statuses.get(key), () => this.fetchStatus(changeId, schema), this.controller.signal);
|
|
3613
|
+
}
|
|
3614
|
+
}
|
|
3615
|
+
ensureInstructions(changeId, artifact, schema) {
|
|
3616
|
+
const key = `${changeId}:${artifact}:${schema ?? ""}`;
|
|
3617
|
+
if (!this._instructions.has(key)) {
|
|
3618
|
+
this._instructions.set(key, new ReactiveState(null));
|
|
3619
|
+
this.startStream(this._instructions.get(key), () => this.fetchInstructions(changeId, artifact, schema), this.controller.signal);
|
|
3620
|
+
}
|
|
3621
|
+
}
|
|
3622
|
+
ensureApplyInstructions(changeId, schema) {
|
|
3623
|
+
const key = `${changeId}:${schema ?? ""}`;
|
|
3624
|
+
if (!this._applyInstructions.has(key)) {
|
|
3625
|
+
this._applyInstructions.set(key, new ReactiveState(null));
|
|
3626
|
+
this.startStream(this._applyInstructions.get(key), () => this.fetchApplyInstructions(changeId, schema), this.controller.signal);
|
|
3627
|
+
}
|
|
3628
|
+
}
|
|
3629
|
+
ensureArtifactOutput(changeId, outputPath) {
|
|
3630
|
+
const key = `${changeId}:${outputPath}`;
|
|
3631
|
+
if (!this._artifactOutputs.has(key)) {
|
|
3632
|
+
this._artifactOutputs.set(key, new ReactiveState(null));
|
|
3633
|
+
this.startStream(this._artifactOutputs.get(key), () => this.fetchArtifactOutput(changeId, outputPath), this.controller.signal);
|
|
3634
|
+
}
|
|
3635
|
+
}
|
|
3636
|
+
ensureGlobArtifactFiles(changeId, outputPath) {
|
|
3637
|
+
const key = `${changeId}:${outputPath}`;
|
|
3638
|
+
if (!this._globArtifactFiles.has(key)) {
|
|
3639
|
+
this._globArtifactFiles.set(key, new ReactiveState([]));
|
|
3640
|
+
this.startStream(this._globArtifactFiles.get(key), () => readGlobArtifactFiles(this.projectDir, changeId, outputPath), this.controller.signal);
|
|
3641
|
+
}
|
|
3642
|
+
}
|
|
3643
|
+
ensureSchemaResolution(name) {
|
|
3644
|
+
if (!this._schemaResolutions.has(name)) {
|
|
3645
|
+
this._schemaResolutions.set(name, new ReactiveState(null));
|
|
3646
|
+
this.startStream(this._schemaResolutions.get(name), () => this.fetchSchemaResolution(name), this.controller.signal);
|
|
3647
|
+
}
|
|
3648
|
+
}
|
|
3649
|
+
ensureSchemaDetail(name) {
|
|
3650
|
+
if (!this._schemaDetails.has(name)) {
|
|
3651
|
+
this._schemaDetails.set(name, new ReactiveState(null));
|
|
3652
|
+
this.startStream(this._schemaDetails.get(name), () => this.fetchSchemaDetail(name), this.controller.signal);
|
|
3653
|
+
}
|
|
3654
|
+
}
|
|
3655
|
+
ensureSchemaFiles(name) {
|
|
3656
|
+
if (!this._schemaFiles.has(name)) {
|
|
3657
|
+
this._schemaFiles.set(name, new ReactiveState([]));
|
|
3658
|
+
this.startStream(this._schemaFiles.get(name), () => this.fetchSchemaFiles(name), this.controller.signal);
|
|
3659
|
+
}
|
|
3660
|
+
}
|
|
3661
|
+
ensureSchemaYaml(name) {
|
|
3662
|
+
if (!this._schemaYamls.has(name)) {
|
|
3663
|
+
this._schemaYamls.set(name, new ReactiveState(null));
|
|
3664
|
+
this.startStream(this._schemaYamls.get(name), () => this.fetchSchemaYaml(name), this.controller.signal);
|
|
3665
|
+
}
|
|
3666
|
+
}
|
|
3667
|
+
ensureTemplates(schema) {
|
|
3668
|
+
const key = schema ?? "";
|
|
3669
|
+
if (!this._templates.has(key)) {
|
|
3670
|
+
this._templates.set(key, new ReactiveState({}));
|
|
3671
|
+
this.startStream(this._templates.get(key), () => this.fetchTemplates(schema), this.controller.signal);
|
|
3672
|
+
}
|
|
3673
|
+
}
|
|
3674
|
+
ensureTemplateContents(schema) {
|
|
3675
|
+
const key = schema ?? "";
|
|
3676
|
+
if (!this._templateContents.has(key)) {
|
|
3677
|
+
this._templateContents.set(key, new ReactiveState({}));
|
|
3678
|
+
this.startStream(this._templateContents.get(key), () => this.fetchTemplateContents(schema), this.controller.signal);
|
|
3679
|
+
}
|
|
3680
|
+
}
|
|
3681
|
+
ensureChangeMetadata(changeId) {
|
|
3682
|
+
if (!this._changeMetadata.has(changeId)) {
|
|
3683
|
+
this._changeMetadata.set(changeId, new ReactiveState(null));
|
|
3684
|
+
this.startStream(this._changeMetadata.get(changeId), () => this.fetchChangeMetadata(changeId), this.controller.signal);
|
|
3685
|
+
}
|
|
3686
|
+
}
|
|
3687
|
+
combineSignals(a, b) {
|
|
3688
|
+
const ctrl = new AbortController();
|
|
3689
|
+
const abort = () => ctrl.abort();
|
|
3690
|
+
if (a.aborted || b.aborted) {
|
|
3691
|
+
ctrl.abort();
|
|
3692
|
+
return ctrl.signal;
|
|
3693
|
+
}
|
|
3694
|
+
a.addEventListener("abort", abort, { once: true });
|
|
3695
|
+
b.addEventListener("abort", abort, { once: true });
|
|
3696
|
+
return ctrl.signal;
|
|
3697
|
+
}
|
|
3698
|
+
};
|
|
3699
|
+
const SchemaYamlArtifactSchema = z.object({
|
|
3700
|
+
id: z.string(),
|
|
3701
|
+
generates: z.string(),
|
|
3702
|
+
description: z.string().optional(),
|
|
3703
|
+
template: z.string().optional(),
|
|
3704
|
+
instruction: z.string().optional(),
|
|
3705
|
+
requires: z.array(z.string()).optional()
|
|
3706
|
+
});
|
|
3707
|
+
const SchemaYamlSchema = z.object({
|
|
3708
|
+
name: z.string(),
|
|
3709
|
+
version: z.union([z.string(), z.number()]).optional(),
|
|
3710
|
+
description: z.string().optional(),
|
|
3711
|
+
artifacts: z.array(SchemaYamlArtifactSchema),
|
|
3712
|
+
apply: z.object({
|
|
3713
|
+
requires: z.array(z.string()).optional(),
|
|
3714
|
+
tracks: z.string().optional(),
|
|
3715
|
+
instruction: z.string().optional()
|
|
3716
|
+
}).optional()
|
|
3717
|
+
});
|
|
3718
|
+
function parseSchemaYamlInline(content) {
|
|
3719
|
+
const raw = parse(content);
|
|
3720
|
+
const parsed = SchemaYamlSchema.safeParse(raw);
|
|
3721
|
+
if (!parsed.success) throw new Error(`Invalid schema.yaml: ${parsed.error.message}`);
|
|
3722
|
+
const { artifacts, apply, name, description, version } = parsed.data;
|
|
3723
|
+
const detail = {
|
|
3724
|
+
name,
|
|
3725
|
+
description,
|
|
3726
|
+
version,
|
|
3727
|
+
artifacts: artifacts.map((artifact) => ({
|
|
3728
|
+
id: artifact.id,
|
|
3729
|
+
outputPath: artifact.generates,
|
|
3730
|
+
description: artifact.description,
|
|
3731
|
+
template: artifact.template,
|
|
3732
|
+
instruction: artifact.instruction,
|
|
3733
|
+
requires: artifact.requires ?? []
|
|
3734
|
+
})),
|
|
3735
|
+
applyRequires: apply?.requires ?? [],
|
|
3736
|
+
applyTracks: apply?.tracks,
|
|
3737
|
+
applyInstruction: apply?.instruction
|
|
3738
|
+
};
|
|
3739
|
+
const validated = SchemaDetailSchema.safeParse(detail);
|
|
3740
|
+
if (!validated.success) throw new Error(`Invalid schema detail: ${validated.error.message}`);
|
|
3741
|
+
return validated.data;
|
|
3742
|
+
}
|
|
3743
|
+
|
|
3744
|
+
//#endregion
|
|
3745
|
+
//#region src/pty-protocol.ts
|
|
3746
|
+
const PositiveInt = z.number().int().positive();
|
|
3747
|
+
const PtyPlatformSchema = z.enum([
|
|
3748
|
+
"windows",
|
|
3749
|
+
"macos",
|
|
3750
|
+
"common"
|
|
3751
|
+
]);
|
|
3752
|
+
const PtySessionInfoSchema = z.object({
|
|
3753
|
+
id: z.string().min(1),
|
|
3754
|
+
title: z.string(),
|
|
3755
|
+
command: z.string(),
|
|
3756
|
+
args: z.array(z.string()),
|
|
3757
|
+
platform: PtyPlatformSchema,
|
|
3758
|
+
isExited: z.boolean(),
|
|
3759
|
+
exitCode: z.number().int().nullable()
|
|
3760
|
+
});
|
|
3761
|
+
const PtyCreateMessageSchema = z.object({
|
|
3762
|
+
type: z.literal("create"),
|
|
3763
|
+
requestId: z.string().min(1),
|
|
3764
|
+
cols: PositiveInt.optional(),
|
|
3765
|
+
rows: PositiveInt.optional(),
|
|
3766
|
+
command: z.string().min(1).optional(),
|
|
3767
|
+
args: z.array(z.string()).optional()
|
|
3768
|
+
});
|
|
3769
|
+
const PtyInputMessageSchema = z.object({
|
|
3770
|
+
type: z.literal("input"),
|
|
3771
|
+
sessionId: z.string().min(1),
|
|
3772
|
+
data: z.string()
|
|
3773
|
+
});
|
|
3774
|
+
const PtyResizeMessageSchema = z.object({
|
|
3775
|
+
type: z.literal("resize"),
|
|
3776
|
+
sessionId: z.string().min(1),
|
|
3777
|
+
cols: PositiveInt,
|
|
3778
|
+
rows: PositiveInt
|
|
3779
|
+
});
|
|
3780
|
+
const PtyCloseMessageSchema = z.object({
|
|
3781
|
+
type: z.literal("close"),
|
|
3782
|
+
sessionId: z.string().min(1)
|
|
3783
|
+
});
|
|
3784
|
+
const PtyAttachMessageSchema = z.object({
|
|
3785
|
+
type: z.literal("attach"),
|
|
3786
|
+
sessionId: z.string().min(1),
|
|
3787
|
+
cols: PositiveInt.optional(),
|
|
3788
|
+
rows: PositiveInt.optional()
|
|
3789
|
+
});
|
|
3790
|
+
const PtyListMessageSchema = z.object({ type: z.literal("list") });
|
|
3791
|
+
const PtyClientMessageSchema = z.discriminatedUnion("type", [
|
|
3792
|
+
PtyCreateMessageSchema,
|
|
3793
|
+
PtyInputMessageSchema,
|
|
3794
|
+
PtyResizeMessageSchema,
|
|
3795
|
+
PtyCloseMessageSchema,
|
|
3796
|
+
PtyAttachMessageSchema,
|
|
3797
|
+
PtyListMessageSchema
|
|
3798
|
+
]);
|
|
3799
|
+
const PtyCreatedResponseSchema = z.object({
|
|
3800
|
+
type: z.literal("created"),
|
|
3801
|
+
requestId: z.string().min(1),
|
|
3802
|
+
sessionId: z.string().min(1),
|
|
3803
|
+
platform: PtyPlatformSchema
|
|
3804
|
+
});
|
|
3805
|
+
const PtyOutputResponseSchema = z.object({
|
|
3806
|
+
type: z.literal("output"),
|
|
3807
|
+
sessionId: z.string().min(1),
|
|
3808
|
+
data: z.string()
|
|
3809
|
+
});
|
|
3810
|
+
const PtyExitResponseSchema = z.object({
|
|
3811
|
+
type: z.literal("exit"),
|
|
3812
|
+
sessionId: z.string().min(1),
|
|
3813
|
+
exitCode: z.number().int()
|
|
3814
|
+
});
|
|
3815
|
+
const PtyTitleResponseSchema = z.object({
|
|
3816
|
+
type: z.literal("title"),
|
|
3817
|
+
sessionId: z.string().min(1),
|
|
3818
|
+
title: z.string()
|
|
3819
|
+
});
|
|
3820
|
+
const PtyBufferResponseSchema = z.object({
|
|
3821
|
+
type: z.literal("buffer"),
|
|
3822
|
+
sessionId: z.string().min(1),
|
|
3823
|
+
data: z.string()
|
|
3824
|
+
});
|
|
3825
|
+
const PtyListResponseSchema = z.object({
|
|
3826
|
+
type: z.literal("list"),
|
|
3827
|
+
sessions: z.array(PtySessionInfoSchema)
|
|
3828
|
+
});
|
|
3829
|
+
const PtyErrorCodeSchema = z.enum([
|
|
3830
|
+
"INVALID_JSON",
|
|
3831
|
+
"INVALID_MESSAGE",
|
|
3832
|
+
"SESSION_NOT_FOUND"
|
|
3833
|
+
]);
|
|
3834
|
+
const PtyErrorResponseSchema = z.object({
|
|
3835
|
+
type: z.literal("error"),
|
|
3836
|
+
code: PtyErrorCodeSchema,
|
|
3837
|
+
message: z.string().min(1),
|
|
3838
|
+
sessionId: z.string().min(1).optional()
|
|
3839
|
+
});
|
|
3840
|
+
const PtyServerMessageSchema = z.discriminatedUnion("type", [
|
|
3841
|
+
PtyCreatedResponseSchema,
|
|
3842
|
+
PtyOutputResponseSchema,
|
|
3843
|
+
PtyExitResponseSchema,
|
|
3844
|
+
PtyTitleResponseSchema,
|
|
3845
|
+
PtyBufferResponseSchema,
|
|
3846
|
+
PtyListResponseSchema,
|
|
3847
|
+
PtyErrorResponseSchema
|
|
3848
|
+
]);
|
|
3849
|
+
|
|
3850
|
+
//#endregion
|
|
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 };
|