@mison/ling 1.1.0 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/utils.js CHANGED
@@ -1,9 +1,17 @@
1
- const { execSync } = require("child_process");
1
+ const { execSync, spawnSync } = require("child_process");
2
2
  const fs = require("fs");
3
3
  const path = require("path");
4
4
  const os = require("os");
5
5
 
6
- const REPO_URL = "https://github.com/MisonL/Ling.git";
6
+ const DEFAULT_REPO_URL = "https://github.com/MisonL/Ling.git";
7
+
8
+ function resolveRepoUrl() {
9
+ const override = process.env.LING_REPO_URL;
10
+ if (typeof override === "string" && override.trim()) {
11
+ return override.trim();
12
+ }
13
+ return DEFAULT_REPO_URL;
14
+ }
7
15
 
8
16
  function parseJsonSafe(raw) {
9
17
  try {
@@ -51,14 +59,15 @@ function cloneBranchAgentDir(branch, options) {
51
59
 
52
60
  const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ling-"));
53
61
  const logFn = options && options.logger ? options.logger : console.log;
62
+ const repoUrl = resolveRepoUrl();
54
63
 
55
- if (!options.quiet) logFn(`[download] 正在从 ${REPO_URL} 拉取分支 ${safeBranch} ...`);
64
+ if (!options.quiet) logFn(`[download] 正在从 ${repoUrl} 拉取分支 ${safeBranch} ...`);
56
65
 
57
- try {
58
- execSync(`git clone --depth 1 --branch ${safeBranch} ${REPO_URL} "${tempDir}"`, {
59
- stdio: options.quiet ? "ignore" : "pipe",
60
- });
61
- } catch (err) {
66
+ const cloneResult = spawnSync("git", ["clone", "--depth", "1", "--branch", safeBranch, repoUrl, tempDir], {
67
+ encoding: "utf8",
68
+ stdio: options.quiet ? "ignore" : "pipe",
69
+ });
70
+ if (cloneResult.status !== 0) {
62
71
  fs.rmSync(tempDir, { recursive: true, force: true });
63
72
  throw new Error(`无法拉取分支 ${safeBranch},请确认分支存在且网络可用`);
64
73
  }
@@ -82,8 +91,42 @@ function cloneBranchAgentDir(branch, options) {
82
91
  };
83
92
  }
84
93
 
94
+ function cloneBranchSpecDir(branch, options) {
95
+ const safeBranch = branch.trim();
96
+ if (!/^[A-Za-z0-9._/-]+$/.test(safeBranch)) {
97
+ throw new Error(`非法分支名: ${branch}`);
98
+ }
99
+
100
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ling-"));
101
+ const logFn = options && options.logger ? options.logger : console.log;
102
+ const repoUrl = resolveRepoUrl();
103
+
104
+ if (!options.quiet) logFn(`[download] 正在从 ${repoUrl} 拉取分支 ${safeBranch} (spec) ...`);
105
+
106
+ const cloneResult = spawnSync("git", ["clone", "--depth", "1", "--branch", safeBranch, repoUrl, tempDir], {
107
+ encoding: "utf8",
108
+ stdio: options.quiet ? "ignore" : "pipe",
109
+ });
110
+ if (cloneResult.status !== 0) {
111
+ fs.rmSync(tempDir, { recursive: true, force: true });
112
+ throw new Error(`无法拉取分支 ${safeBranch},请确认分支存在且网络可用`);
113
+ }
114
+
115
+ const specDir = path.join(tempDir, ".spec");
116
+ if (!fs.existsSync(specDir)) {
117
+ fs.rmSync(tempDir, { recursive: true, force: true });
118
+ throw new Error(`分支 ${safeBranch} 中未找到 .spec 目录`);
119
+ }
120
+
121
+ return {
122
+ specDir,
123
+ cleanup: () => fs.rmSync(tempDir, { recursive: true, force: true }),
124
+ };
125
+ }
126
+
85
127
  module.exports = {
86
128
  parseJsonSafe,
87
129
  readGlobalNpmDependencies,
88
- cloneBranchAgentDir
130
+ cloneBranchAgentDir,
131
+ cloneBranchSpecDir
89
132
  };
package/docs/TECH.md CHANGED
@@ -65,19 +65,27 @@ cd web && npm install && npm run lint
65
65
  ### 测试隔离
66
66
  - `LING_GLOBAL_ROOT`:替代 `$HOME`(用于测试与 CI,避免污染真实用户目录)
67
67
 
68
- ## Spec Profile:`ling spec enable/disable/status`
68
+ ## Spec Profile:`ling spec ...`
69
69
  ### 当前范围
70
- - 当前只实现全局层:
70
+ - 全局 Spec Profile(机器级):
71
71
  - `ling spec enable [--target codex|gemini] [--dry-run] [--quiet]`
72
72
  - `ling spec disable [--target codex|gemini] [--dry-run] [--quiet]`
73
73
  - `ling spec status [--quiet]`
74
- - 默认目标:未指定 `--target/--targets` 时启用 `codex + gemini`
75
- - 当前 Spec 源目录:`.spec/`
74
+ - 默认目标:未指定 `--target/--targets` 时启用 `codex + gemini`
75
+ - 项目级 Spec 资产(工作区级):
76
+ - `ling spec init [--path <dir>] [--target codex|gemini|--targets codex,gemini] [--branch <name>] [--dry-run] [--quiet]`
77
+ - 未指定 `--path` 时,默认初始化 Spec 工作区目录:`$HOME/.ling/spec-workspace`
78
+ - 指定 `--targets` 时,会同时执行对应目标的 `ling init` 安装(`.agent/.agents`)
79
+ - 指定 `--branch` 时,Spec 资产将从该分支的 `.spec/` 拉取(用于验证或灰度 Spec 模板)
80
+ - `ling spec doctor [--path <dir>] [--quiet]`
81
+ - 会校验 `issues.csv` 表头与状态枚举,并检查 `.ling/spec/` 目录是否完整
82
+ - Spec 源目录:`.spec/`
76
83
 
77
84
  ### 落盘与状态
78
85
  - Spec 状态文件:`$HOME/.ling/spec/state.json`
79
86
  - Spec templates:`$HOME/.ling/spec/templates/`
80
87
  - Spec references:`$HOME/.ling/spec/references/`
88
+ - Spec profiles:`$HOME/.ling/spec/profiles/`
81
89
  - Spec 备份目录:`$HOME/.ling/backups/spec/<timestamp>/before/...`
82
90
 
83
91
  ### 当前安装内容
@@ -94,6 +102,10 @@ cd web && npm install && npm run lint
94
102
  - `harness-engineering-digest.md`
95
103
  - `gda-framework.md`
96
104
  - 相关 quickstart / README
105
+ - Profiles:
106
+ - `codex/AGENTS.spec.md`
107
+ - `codex/ling.spec.rules.md`
108
+ - `gemini/GEMINI.spec.md`
97
109
 
98
110
  ### 状态契约
99
111
  - `ling spec status --quiet` 输出:
@@ -108,11 +120,13 @@ cd web && npm install && npm run lint
108
120
  ### 回退语义
109
121
  - `spec enable`:
110
122
  - 若目标位置已存在同名 Skill,会先备份再覆盖
111
- - 若 `templates/` 或 `references/` 已存在,也会先备份
123
+ - 若 `templates/`、`references/` 或 `profiles/` 已存在,也会先备份
112
124
  - `spec disable`:
113
125
  - 若存在备份,恢复启用前快照
114
126
  - 若启用前不存在资源,则删除由 Spec 安装的目录
115
- - 当前尚未实现项目级 `spec init / remove / doctor`
127
+ - `spec init`:
128
+ - 会在工作区内写入 `.ling/spec/`(templates/references/profiles)与 `issues.csv`,并创建 `docs/reviews`、`docs/handoff`
129
+ - 当检测到冲突资产时,支持保留 / 备份后覆盖 / 直接覆盖
116
130
 
117
131
  ## 状态契约(自动化)
118
132
  - `ling status --quiet` / `ling global status --quiet` 只输出三态:
@@ -168,6 +182,7 @@ cp -a "$HOME/.ling/backups/global/$ts/antigravity/$skill" "$HOME/.gemini/antigra
168
182
  - `LING_INDEX_PATH`:工作区索引文件路径(默认 `~/.ling/workspaces.json`)
169
183
  - `LING_GLOBAL_ROOT`:全局目录根(替代 `$HOME`)
170
184
  - `LING_SKIP_UPSTREAM_CHECK`:跳过上游同名包安装提示(测试用)
185
+ - `LING_REPO_URL`:覆盖默认仓库地址(主要用于测试与私有镜像)
171
186
 
172
187
  ## 安装提示机制
173
188
  - npm 全局安装:`postinstall` 会尽力检测并提示上游英文版 `@vudovn/ag-kit` 冲突。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mison/ling",
3
- "version": "1.1.0",
3
+ "version": "1.1.1",
4
4
  "description": "面向 Gemini CLI、Antigravity 与 Codex 的中文 AI Agent 模板工具包",
5
5
  "repository": {
6
6
  "type": "git",
@@ -0,0 +1,175 @@
1
+ const { test, describe, beforeEach, afterEach } = require("node:test");
2
+ const assert = require("node:assert");
3
+ const fs = require("fs");
4
+ const os = require("os");
5
+ const path = require("path");
6
+ const { spawnSync } = require("node:child_process");
7
+
8
+ const REPO_ROOT = path.resolve(__dirname, "..");
9
+ const CLI_PATH = path.join(REPO_ROOT, "bin", "ling.js");
10
+
11
+ function runCli(args, options = {}) {
12
+ const env = {
13
+ ...process.env,
14
+ LING_SKIP_UPSTREAM_CHECK: "1",
15
+ ...options.env,
16
+ };
17
+
18
+ return spawnSync(process.execPath, [CLI_PATH, ...args], {
19
+ cwd: options.cwd || REPO_ROOT,
20
+ env,
21
+ encoding: "utf8",
22
+ });
23
+ }
24
+
25
+ describe("Spec init/doctor", () => {
26
+ let tempRoot;
27
+ let workspaceRoot;
28
+
29
+ beforeEach(() => {
30
+ tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ling-spec-init-"));
31
+ workspaceRoot = path.join(tempRoot, "workspace");
32
+ fs.mkdirSync(workspaceRoot, { recursive: true });
33
+ });
34
+
35
+ afterEach(() => {
36
+ fs.rmSync(tempRoot, { recursive: true, force: true });
37
+ });
38
+
39
+ test("spec init should create workspace assets and doctor should report installed", () => {
40
+ const env = {
41
+ LING_GLOBAL_ROOT: tempRoot,
42
+ LING_INDEX_PATH: path.join(tempRoot, "workspaces.json"),
43
+ };
44
+
45
+ const initResult = runCli(["spec", "init", "--path", workspaceRoot, "--quiet"], { env });
46
+ assert.strictEqual(initResult.status, 0, initResult.stderr || initResult.stdout);
47
+
48
+ assert.ok(fs.existsSync(path.join(workspaceRoot, "issues.csv")), "issues.csv should be created");
49
+ assert.ok(fs.existsSync(path.join(workspaceRoot, ".ling", "spec", "templates", "driver-prompt.md")), "spec templates should be created");
50
+ assert.ok(fs.existsSync(path.join(workspaceRoot, ".ling", "spec", "references", "gda-framework.md")), "spec references should be created");
51
+ assert.ok(fs.existsSync(path.join(workspaceRoot, ".ling", "spec", "profiles", "codex", "AGENTS.spec.md")), "spec profiles should be created");
52
+ assert.ok(
53
+ fs.existsSync(path.join(workspaceRoot, ".ling", "spec", "profiles", "codex", "ling.spec.rules.md")),
54
+ "spec profile rules should be created",
55
+ );
56
+ assert.ok(fs.existsSync(path.join(workspaceRoot, ".ling", "spec", "profiles", "gemini", "GEMINI.spec.md")), "spec profiles should be created");
57
+ assert.ok(fs.existsSync(path.join(workspaceRoot, "docs", "reviews")), "docs/reviews should exist");
58
+ assert.ok(fs.existsSync(path.join(workspaceRoot, "docs", "handoff")), "docs/handoff should exist");
59
+
60
+ const doctorResult = runCli(["spec", "doctor", "--path", workspaceRoot, "--quiet"], { env });
61
+ assert.strictEqual(doctorResult.status, 0, doctorResult.stderr || doctorResult.stdout);
62
+ assert.strictEqual((doctorResult.stdout || "").trim(), "installed");
63
+ });
64
+
65
+ test("spec doctor should report broken when multiple tasks are in 进行中", () => {
66
+ const env = {
67
+ LING_GLOBAL_ROOT: tempRoot,
68
+ LING_INDEX_PATH: path.join(tempRoot, "workspaces.json"),
69
+ };
70
+
71
+ const initResult = runCli(["spec", "init", "--path", workspaceRoot, "--quiet"], { env });
72
+ assert.strictEqual(initResult.status, 0, initResult.stderr || initResult.stdout);
73
+
74
+ const issuesPath = path.join(workspaceRoot, "issues.csv");
75
+ fs.writeFileSync(
76
+ issuesPath,
77
+ [
78
+ "ID,标题,内容,验收标准,审查要求,状态,标签",
79
+ "A,任务A,内容A,验收A,审查A,进行中,高优先级",
80
+ "B,任务B,内容B,验收B,审查B,进行中,高优先级",
81
+ "",
82
+ ].join("\n"),
83
+ "utf8",
84
+ );
85
+
86
+ const doctorResult = runCli(["spec", "doctor", "--path", workspaceRoot, "--quiet"], { env });
87
+ assert.strictEqual(doctorResult.status, 1, doctorResult.stderr || doctorResult.stdout);
88
+ assert.strictEqual((doctorResult.stdout || "").trim(), "broken");
89
+ });
90
+
91
+ test("spec doctor should report broken when issues.csv is missing but spec directory exists", () => {
92
+ const env = {
93
+ LING_GLOBAL_ROOT: tempRoot,
94
+ LING_INDEX_PATH: path.join(tempRoot, "workspaces.json"),
95
+ };
96
+
97
+ const initResult = runCli(["spec", "init", "--path", workspaceRoot, "--quiet"], { env });
98
+ assert.strictEqual(initResult.status, 0, initResult.stderr || initResult.stdout);
99
+
100
+ fs.rmSync(path.join(workspaceRoot, "issues.csv"), { force: true });
101
+
102
+ const doctorResult = runCli(["spec", "doctor", "--path", workspaceRoot, "--quiet"], { env });
103
+ assert.strictEqual(doctorResult.status, 1, doctorResult.stderr || doctorResult.stdout);
104
+ assert.strictEqual((doctorResult.stdout || "").trim(), "broken");
105
+ });
106
+
107
+ test("spec init should support --branch for spec assets", () => {
108
+ const gitCheck = spawnSync("git", ["--version"], { encoding: "utf8" });
109
+ if (gitCheck.status !== 0) {
110
+ return;
111
+ }
112
+
113
+ const sourceRepo = path.join(tempRoot, "spec-source-repo");
114
+ fs.mkdirSync(sourceRepo, { recursive: true });
115
+
116
+ const runGit = (args) =>
117
+ spawnSync("git", args, {
118
+ cwd: sourceRepo,
119
+ encoding: "utf8",
120
+ });
121
+
122
+ const initRes = runGit(["init", "--initial-branch", "main"]);
123
+ if (initRes.status !== 0) {
124
+ assert.strictEqual(runGit(["init"]).status, 0);
125
+ assert.strictEqual(runGit(["checkout", "-b", "main"]).status, 0);
126
+ }
127
+
128
+ const specRoot = path.join(sourceRepo, ".spec");
129
+ const templatesDir = path.join(specRoot, "templates");
130
+ const referencesDir = path.join(specRoot, "references");
131
+ const profilesCodexDir = path.join(specRoot, "profiles", "codex");
132
+ const profilesGeminiDir = path.join(specRoot, "profiles", "gemini");
133
+ fs.mkdirSync(templatesDir, { recursive: true });
134
+ fs.mkdirSync(referencesDir, { recursive: true });
135
+ fs.mkdirSync(profilesCodexDir, { recursive: true });
136
+ fs.mkdirSync(profilesGeminiDir, { recursive: true });
137
+
138
+ fs.writeFileSync(path.join(templatesDir, "issues.template.csv"), "ID,状态\nX,未开始\n", "utf8");
139
+ fs.writeFileSync(path.join(templatesDir, "driver-prompt.md"), "branch driver prompt", "utf8");
140
+ fs.writeFileSync(path.join(templatesDir, "review-report.md"), "branch review report", "utf8");
141
+ fs.writeFileSync(path.join(templatesDir, "phase-acceptance.md"), "branch acceptance", "utf8");
142
+ fs.writeFileSync(path.join(templatesDir, "handoff.md"), "branch handoff", "utf8");
143
+
144
+ fs.writeFileSync(path.join(referencesDir, "README.md"), "branch readme", "utf8");
145
+ fs.writeFileSync(path.join(referencesDir, "harness-engineering-digest.md"), "branch digest", "utf8");
146
+ fs.writeFileSync(path.join(referencesDir, "gda-framework.md"), "branch gda", "utf8");
147
+ fs.writeFileSync(path.join(referencesDir, "cse-quickstart.md"), "branch quickstart", "utf8");
148
+
149
+ fs.writeFileSync(path.join(profilesCodexDir, "AGENTS.spec.md"), "branch codex agents", "utf8");
150
+ fs.writeFileSync(path.join(profilesCodexDir, "ling.spec.rules.md"), "branch codex rules", "utf8");
151
+ fs.writeFileSync(path.join(profilesGeminiDir, "GEMINI.spec.md"), "branch gemini profile", "utf8");
152
+
153
+ assert.strictEqual(runGit(["add", "."]).status, 0);
154
+ assert.strictEqual(
155
+ runGit(["-c", "user.name=ling-test", "-c", "user.email=ling-test@example.com", "commit", "-m", "spec assets"]).status,
156
+ 0,
157
+ );
158
+
159
+ const env = {
160
+ LING_GLOBAL_ROOT: tempRoot,
161
+ LING_REPO_URL: sourceRepo,
162
+ LING_INDEX_PATH: path.join(tempRoot, "workspaces.json"),
163
+ };
164
+
165
+ const initResult = runCli(["spec", "init", "--path", workspaceRoot, "--branch", "main", "--quiet"], { env });
166
+ assert.strictEqual(initResult.status, 0, initResult.stderr || initResult.stdout);
167
+
168
+ const installedPrompt = path.join(workspaceRoot, ".ling", "spec", "templates", "driver-prompt.md");
169
+ assert.strictEqual(fs.readFileSync(installedPrompt, "utf8"), "branch driver prompt");
170
+
171
+ const doctorResult = runCli(["spec", "doctor", "--path", workspaceRoot, "--quiet"], { env });
172
+ assert.strictEqual(doctorResult.status, 0, doctorResult.stderr || doctorResult.stdout);
173
+ assert.strictEqual((doctorResult.stdout || "").trim(), "installed");
174
+ });
175
+ });
@@ -51,11 +51,14 @@ describe("Spec Profile", () => {
51
51
  const stateFile = path.join(tempRoot, ".ling", "spec", "state.json");
52
52
  const templatesDir = path.join(tempRoot, ".ling", "spec", "templates");
53
53
  const referencesDir = path.join(tempRoot, ".ling", "spec", "references");
54
+ const profilesDir = path.join(tempRoot, ".ling", "spec", "profiles");
54
55
 
55
56
  assert.ok(fs.existsSync(codexSkill), "missing installed codex spec skill");
56
57
  assert.ok(fs.existsSync(stateFile), "missing spec state");
57
58
  assert.ok(fs.existsSync(path.join(templatesDir, "issues.template.csv")), "missing spec template");
58
59
  assert.ok(fs.existsSync(path.join(referencesDir, "harness-engineering-digest.md")), "missing spec reference");
60
+ assert.ok(fs.existsSync(path.join(profilesDir, "codex", "AGENTS.spec.md")), "missing spec profile");
61
+ assert.ok(fs.existsSync(path.join(profilesDir, "codex", "ling.spec.rules.md")), "missing spec profile rules");
59
62
 
60
63
  const statusResult = runCli(["spec", "status", "--quiet"], { env });
61
64
  assert.strictEqual(statusResult.status, 0);
@@ -67,6 +70,7 @@ describe("Spec Profile", () => {
67
70
  assert.ok(!fs.existsSync(stateFile), "spec state should be removed after final disable");
68
71
  assert.ok(!fs.existsSync(templatesDir), "spec templates should be removed after final disable");
69
72
  assert.ok(!fs.existsSync(referencesDir), "spec references should be removed after final disable");
73
+ assert.ok(!fs.existsSync(profilesDir), "spec profiles should be removed after final disable");
70
74
  });
71
75
 
72
76
  test("spec disable should restore pre-existing skill backup", () => {
@@ -83,4 +87,68 @@ describe("Spec Profile", () => {
83
87
  assert.strictEqual(disableResult.status, 0, disableResult.stderr || disableResult.stdout);
84
88
  assert.strictEqual(fs.readFileSync(path.join(skillDir, "SKILL.md"), "utf8"), "legacy skill");
85
89
  });
90
+
91
+ test("spec enable should repair missing assets and skills when state exists", () => {
92
+ const env = { LING_GLOBAL_ROOT: tempRoot };
93
+
94
+ const enableResult = runCli(["spec", "enable", "--target", "codex", "--quiet"], { env });
95
+ assert.strictEqual(enableResult.status, 0, enableResult.stderr || enableResult.stdout);
96
+
97
+ const templatesDir = path.join(tempRoot, ".ling", "spec", "templates");
98
+ const codexSkillDir = path.join(tempRoot, ".codex", "skills", "harness-engineering");
99
+ fs.rmSync(templatesDir, { recursive: true, force: true });
100
+ fs.rmSync(codexSkillDir, { recursive: true, force: true });
101
+
102
+ const brokenResult = runCli(["spec", "status", "--quiet"], { env });
103
+ assert.strictEqual(brokenResult.status, 1);
104
+ assert.strictEqual((brokenResult.stdout || "").trim(), "broken");
105
+
106
+ const repairResult = runCli(["spec", "enable", "--target", "codex", "--quiet"], { env });
107
+ assert.strictEqual(repairResult.status, 0, repairResult.stderr || repairResult.stdout);
108
+
109
+ const repairedStatus = runCli(["spec", "status", "--quiet"], { env });
110
+ assert.strictEqual(repairedStatus.status, 0);
111
+ assert.strictEqual((repairedStatus.stdout || "").trim(), "installed");
112
+
113
+ assert.ok(fs.existsSync(path.join(templatesDir, "issues.template.csv")), "templates should be repaired");
114
+ assert.ok(fs.existsSync(path.join(codexSkillDir, "SKILL.md")), "spec skill should be repaired");
115
+ });
116
+
117
+ test("spec status should report broken when an asset file is missing", () => {
118
+ const env = { LING_GLOBAL_ROOT: tempRoot };
119
+
120
+ const enableResult = runCli(["spec", "enable", "--target", "codex", "--quiet"], { env });
121
+ assert.strictEqual(enableResult.status, 0, enableResult.stderr || enableResult.stdout);
122
+
123
+ const driverPrompt = path.join(tempRoot, ".ling", "spec", "templates", "driver-prompt.md");
124
+ assert.ok(fs.existsSync(driverPrompt), "driver-prompt.md should exist after enable");
125
+ fs.rmSync(driverPrompt, { force: true });
126
+
127
+ const statusResult = runCli(["spec", "status", "--quiet"], { env });
128
+ assert.strictEqual(statusResult.status, 1);
129
+ assert.strictEqual((statusResult.stdout || "").trim(), "broken");
130
+
131
+ const repairResult = runCli(["spec", "enable", "--target", "codex", "--quiet"], { env });
132
+ assert.strictEqual(repairResult.status, 0, repairResult.stderr || repairResult.stdout);
133
+ assert.ok(fs.existsSync(driverPrompt), "driver-prompt.md should be repaired");
134
+ });
135
+
136
+ test("spec status should report broken when state.json is missing but assets exist", () => {
137
+ const env = { LING_GLOBAL_ROOT: tempRoot };
138
+
139
+ const templatesDir = path.join(tempRoot, ".ling", "spec", "templates");
140
+ fs.mkdirSync(templatesDir, { recursive: true });
141
+ fs.writeFileSync(path.join(templatesDir, "issues.template.csv"), "sentinel", "utf8");
142
+
143
+ const statusResult = runCli(["spec", "status", "--quiet"], { env });
144
+ assert.strictEqual(statusResult.status, 1);
145
+ assert.strictEqual((statusResult.stdout || "").trim(), "broken");
146
+
147
+ const repairResult = runCli(["spec", "enable", "--target", "codex", "--quiet"], { env });
148
+ assert.strictEqual(repairResult.status, 0, repairResult.stderr || repairResult.stdout);
149
+
150
+ const repairedStatus = runCli(["spec", "status", "--quiet"], { env });
151
+ assert.strictEqual(repairedStatus.status, 0);
152
+ assert.strictEqual((repairedStatus.stdout || "").trim(), "installed");
153
+ });
86
154
  });