@house365/comate 0.10.2 → 1.0.2-next.0

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.
Files changed (74) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +104 -64
  3. package/bin/cli.mjs +197 -190
  4. package/bin/doctor.mjs +29 -41
  5. package/bin/init-run.mjs +310 -368
  6. package/bin/init.mjs +53 -71
  7. package/bin/postinstall.mjs +56 -56
  8. package/docs/CHANGELOG-fix-simplification.md +145 -0
  9. package/docs/COMATE_BETA.md +90 -0
  10. package/docs/COMATE_MANUAL.md +239 -665
  11. package/docs/superpowers/plans/2026-10-14-review-fixes.md +48 -0
  12. package/docs/superpowers/plans/2026-10-14-workflow-quality.md +48 -0
  13. package/package.json +54 -55
  14. package/skills/comate/SKILL.md +42 -325
  15. package/skills/comate/reference/archive.md +50 -0
  16. package/skills/comate/reference/build.md +67 -0
  17. package/skills/comate/reference/fix.md +150 -0
  18. package/skills/comate/reference/open.md +162 -0
  19. package/skills/comate/reference/verify.md +123 -0
  20. package/skills/comate/scaffold/comate/README.md +18 -0
  21. package/skills/{comate-gate/scaffold/docs → comate/scaffold/comate}/architecture/README.md +5 -6
  22. package/skills/comate/scaffold/comate/comate/config.json +10 -0
  23. package/skills/comate/scaffold/comate/knowledge-base/README.md +42 -0
  24. package/skills/{comate-gate/scaffold/docs → comate/scaffold/comate}/knowledge-base/integrations/_TEMPLATE/README.md +2 -2
  25. package/skills/{comate-gate/scaffold/docs → comate/scaffold/comate}/knowledge-base/integrations/_TEMPLATE/api-snapshot.md +47 -47
  26. package/skills/{comate-gate/scaffold/docs → comate/scaffold/comate}/knowledge-base/requirements/_templates/PRD.md +4 -4
  27. package/skills/comate/scripts/comate.mjs +147 -0
  28. package/skills/comate/scripts/lib/acceptance.mjs +112 -0
  29. package/skills/comate/scripts/lib/brief.mjs +50 -0
  30. package/skills/comate/scripts/lib/checks.mjs +178 -0
  31. package/skills/comate/scripts/lib/cmd-archive.mjs +152 -0
  32. package/skills/comate/scripts/lib/cmd-decide.mjs +116 -0
  33. package/skills/comate/scripts/lib/cmd-init.mjs +75 -0
  34. package/skills/comate/scripts/lib/cmd-loop.mjs +273 -0
  35. package/skills/comate/scripts/lib/cmd-open.mjs +161 -0
  36. package/skills/comate/scripts/lib/cmd-status.mjs +121 -0
  37. package/skills/comate/scripts/lib/common.mjs +85 -0
  38. package/skills/comate/scripts/lib/docs.mjs +45 -0
  39. package/skills/comate/scripts/lib/finish.mjs +108 -0
  40. package/skills/comate/scripts/lib/git.mjs +112 -0
  41. package/skills/comate/scripts/lib/md.mjs +85 -0
  42. package/skills/comate/scripts/lib/next.mjs +217 -0
  43. package/skills/comate/scripts/lib/paths.mjs +96 -0
  44. package/skills/comate/scripts/lib/render.mjs +46 -0
  45. package/skills/comate/scripts/lib/spec.mjs +106 -0
  46. package/skills/comate/scripts/lib/state.mjs +128 -0
  47. package/skills/comate/scripts/lib/tasks.mjs +53 -0
  48. package/skills/comate/scripts/lib/templates.mjs +58 -0
  49. package/skills/comate/scripts/lib/util.mjs +108 -0
  50. package/skills/comate-kb-integration/SKILL.md +128 -128
  51. package/skills/comate-kb-integration/scripts/fetch_rendered_doc.py +84 -84
  52. package/bin/cleanup-duplicate-superpowers.mjs +0 -119
  53. package/docs/CLEANUP_SUPERPOWERS.md +0 -128
  54. package/docs/COMATE_BEGINNER_GUIDE.md +0 -188
  55. package/docs/COMATE_INIT_DIRECTORY_GUIDE.md +0 -201
  56. package/docs/SUPERPOWERS_FIX_SUMMARY.md +0 -114
  57. package/skills/comate/reference/decision-points.md +0 -103
  58. package/skills/comate/reference/dirty-worktree.md +0 -58
  59. package/skills/comate/reference/prerequisites.md +0 -89
  60. package/skills/comate/scripts/comate-lib.sh +0 -559
  61. package/skills/comate/scripts/comate.sh +0 -2147
  62. package/skills/comate/tests/comate.bats +0 -1019
  63. package/skills/comate/tests/run.sh +0 -101
  64. package/skills/comate-archive/SKILL.md +0 -63
  65. package/skills/comate-build/SKILL.md +0 -278
  66. package/skills/comate-design/SKILL.md +0 -178
  67. package/skills/comate-fix/SKILL.md +0 -195
  68. package/skills/comate-gate/SKILL.md +0 -316
  69. package/skills/comate-gate/scaffold/docs/README.md +0 -36
  70. package/skills/comate-gate/scaffold/docs/knowledge-base/README.md +0 -47
  71. package/skills/comate-grill/SKILL.md +0 -185
  72. package/skills/comate-open/SKILL.md +0 -230
  73. package/skills/comate-verify/SKILL.md +0 -212
  74. /package/skills/{comate-gate/scaffold/docs → comate/scaffold/comate}/knowledge-base/integrations/README.md +0 -0
package/bin/init-run.mjs CHANGED
@@ -1,368 +1,310 @@
1
- // The `init` flow: install whatever the current project is missing.
2
- import { spawn } from 'node:child_process';
3
- import { existsSync, readFileSync } from 'node:fs';
4
- import { join, resolve } from 'node:path';
5
- import { createInterface } from 'node:readline';
6
-
7
- import { findBash } from './doctor.mjs';
8
- import {
9
- NPM_TIMEOUT_MS,
10
- capture,
11
- SKILLS_TIMEOUT_MS,
12
- npmInvocation,
13
- openSpecState,
14
- pruneDeprecatedSkills,
15
- run,
16
- scaffoldDocs,
17
- skillsInvocation,
18
- superpowersInstalled,
19
- userSkillsRoot,
20
- } from './init.mjs';
21
-
22
- // `comate init` — no flags. Skills go user-level (~/.agents/skills), matching how
23
- // the CLI itself is installed (npm i -g): one copy, every project sees it.
24
- //
25
- // openspec/ is still per-project — it holds the project's own specs — so cwd
26
- // must be a project. Refuse the two cases that are almost certainly a mistake:
27
- // the home directory, and a directory with no project marker at all.
28
- const PROJECT_MARKERS = [
29
- '.git',
30
- 'package.json',
31
- 'pyproject.toml',
32
- 'Cargo.toml',
33
- 'go.mod',
34
- 'pom.xml',
35
- 'build.gradle',
36
- 'composer.json',
37
- 'Gemfile',
38
- 'openspec',
39
- '.agents',
40
- ];
41
-
42
- export function inspectTarget(dir, home) {
43
- const normalise = (p) => resolve(p).replace(/[\\/]+$/, '').toLowerCase();
44
- if (home && normalise(dir) === normalise(home)) return { kind: 'home' };
45
- const marker = PROJECT_MARKERS.find((m) => existsSync(join(dir, m)));
46
- return marker ? { kind: 'project', marker } : { kind: 'bare' };
47
- }
48
-
49
- export async function init(pkgRoot) {
50
- const results = [];
51
- const home = process.env.HOME ?? process.env.USERPROFILE ?? '';
52
- const cwd = process.cwd();
53
-
54
- const target = inspectTarget(cwd, home);
55
- if (target.kind !== 'project') {
56
- // Home directory is always refused
57
- if (target.kind === 'home') {
58
- console.error('\n这是主目录,请在项目目录下运行 comate init。');
59
- return 1;
60
- }
61
-
62
- // Bare directory: ask user if they want to create a project here
63
- console.log('\n当前目录不像项目(无 .git / package.json 等)。');
64
- const confirmed = await askConfirm('要在这里初始化新项目吗?');
65
- if (!confirmed) {
66
- console.log('已取消。');
67
- return 1;
68
- }
69
- }
70
-
71
- // comate's scripts intentionally support macOS's system Bash 3.2. Fail only
72
- // when Bash is older than the syntax the state machine supports.
73
- const bash = bashVersion();
74
- if (bash !== null && bash < 3) {
75
- console.error(
76
- `\ncomate 的脚本需要 bash >= 3.2,当前 ${bash === 0 ? '未知版本' : `bash ${bash}.x`}。\n` +
77
- '请安装 Bash 3.2 或更高版本后重跑 comate init。',
78
- );
79
- return 3;
80
- }
81
-
82
- const stopSpinner = startSpinner('Initializing…');
83
-
84
- // 1. OpenSpec CLI — a PATH command, so always global.
85
- const os = openSpecState();
86
- if (os.status === 'ready') {
87
- results.push(['OpenSpec CLI', 'skipped', `已安装 ${os.version}`]);
88
- } else {
89
- const r = run(
90
- npmInvocation(['install', '-g', '@fission-ai/openspec@latest']),
91
- NPM_TIMEOUT_MS,
92
- { quiet: true },
93
- );
94
- if (!r.ok) {
95
- // An outdated-but-usable CLI is better than a hard failure.
96
- const usable = os.status === 'outdated';
97
- const note = usable ? `升级失败,沿用 ${os.version}` : r.reason;
98
- results.push(['OpenSpec CLI', usable ? 'skipped' : 'failed', note, usable ? '' : r.output]);
99
- } else if (openSpecState().status === 'missing') {
100
- results.push(['OpenSpec CLI', 'failed', 'npm 安装完成但 PATH 中仍找不到 openspec,请重启终端']);
101
- } else {
102
- results.push(['OpenSpec CLI', 'installed', '']);
103
- }
104
- }
105
-
106
- // 2. Superpowers — the skills CLI detects the agent itself. A pre-existing
107
- // project-level copy also counts: reinstalling globally would just shadow it.
108
- if (superpowersInstalled('global') || superpowersInstalled('project')) {
109
- results.push(['Superpowers', 'skipped', '已安装']);
110
- } else {
111
- const r = run(
112
- skillsInvocation(pkgRoot, ['add', 'obra/superpowers', '-g', '-y']),
113
- SKILLS_TIMEOUT_MS,
114
- { quiet: true },
115
- );
116
- // It clones from GitHub, so a bare exit code hides the usual cause: no
117
- // network. Say so rather than leaving the user with "exit code 1".
118
- const note = r.ok
119
- ? ''
120
- : `${r.reason} — 需要访问 github.com,检查网络/代理后重试`;
121
- results.push(['Superpowers', r.ok ? 'installed' : 'failed', note, r.output]);
122
- }
123
-
124
- // 3. comate itself. The skills CLI reports per-agent failures without ever
125
- // setting a non-zero exit code, and we swallow its output — so a zero status
126
- // is not evidence the skill landed. Check the file.
127
- const comateArgs = ['add', pkgRoot, '--skill', '*', '-g', '-y'];
128
- const r = run(skillsInvocation(pkgRoot, comateArgs), SKILLS_TIMEOUT_MS, { quiet: true });
129
- const landed = existsSync(join(home, '.agents', 'skills', 'comate', 'SKILL.md'));
130
- if (r.ok && !landed) {
131
- results.push(['comate', 'failed', `未装到 ${join(home, '.agents', 'skills')}`]);
132
- } else {
133
- results.push(['comate', r.ok ? 'installed' : 'failed', r.ok ? '' : r.reason, r.output]);
134
- }
135
-
136
- // Prune comate skill dirs renamed/dropped in past releases. `skills add` above
137
- // only adds/overwrites, so /comate-hotfix etc. would otherwise linger as dead
138
- // slash commands. The postinstall hook does this too, but --ignore-scripts
139
- // users never run it — init is their only path, so it must clean up here.
140
- const pruned = pruneDeprecatedSkills(userSkillsRoot(home));
141
- if (pruned.length) results.push(['清理废弃 skill', 'installed', pruned.join('、')]);
142
-
143
- // 4. openspec/ project structure. Not optional: comate's open and archive
144
- // phases read it, so an install without it cannot actually run. An existing
145
- // one is left alone, which covers projects that already keep it elsewhere.
146
- if (existsSync('openspec')) {
147
- results.push(['openspec/', 'skipped', '已存在']);
148
- } else {
149
- // --tools is not optional in practice: openspec derives the tool list by
150
- // sniffing existing agent config dirs, so a fresh project (no .claude etc.)
151
- // aborts with "No tools detected and no --tools flag provided". comate's
152
- // skills are agent-agnostic and only openspec/ itself matters here, so ask
153
- // for none rather than guessing which agents the user wants wired up.
154
- const r2 = run(
155
- {
156
- command: 'openspec',
157
- args: ['init', '.', '--tools', 'none'],
158
- shell: process.platform === 'win32',
159
- },
160
- NPM_TIMEOUT_MS,
161
- { quiet: true },
162
- );
163
- results.push(['openspec/', r2.ok ? 'installed' : 'failed', r2.ok ? '' : r2.reason, r2.output]);
164
- }
165
-
166
- // 5. docs/ knowledge-base scaffold. /comate-gate reads PRDs from
167
- // docs/knowledge-base/requirements/ (comate_kb_root is hardcoded there), so a
168
- // fresh project has nowhere to put a PRD. Idempotent — existing files are left
169
- // alone, only missing pieces are filled in, so re-running never clobbers a
170
- // project's real docs. A failure here is non-fatal to the rest of the install.
171
- const sc = scaffoldDocs(pkgRoot);
172
- if (!sc.ok) {
173
- results.push(['docs/', 'failed', sc.reason]);
174
- } else if (sc.created === 0) {
175
- results.push(['docs/', 'skipped', '已存在']);
176
- } else {
177
- const note = sc.skipped > 0 ? `新建 ${sc.created} 个文件,保留 ${sc.skipped} 个已有` : '';
178
- results.push(['docs/', 'installed', note]);
179
- }
180
-
181
- stopSpinner();
182
- return report(results);
183
- }
184
-
185
- // A yes/no prompt. Defaults to "no" so a piped/non-interactive run (CI, an
186
- // unattended npx) never silently scaffolds a project into whatever directory it
187
- // happened to land in — those callers should point at a real project instead.
188
- // [y/N]: empty input, EOF (Ctrl-D / closed stdin), or anything but y/yes = no.
189
- export function askConfirm(question, { input = process.stdin, output = process.stdout } = {}) {
190
- if (!input.isTTY) return Promise.resolve(false);
191
- const rl = createInterface({ input, output });
192
- return new Promise((done) => {
193
- rl.question(`${question} [y/N] `, (answer) => {
194
- rl.close();
195
- done(/^\s*y(es)?\s*$/i.test(answer));
196
- });
197
- });
198
- }
199
-
200
- // The spinner runs in its own process because every step above is a synchronous
201
- // spawnSync — an interval on this thread would never fire. In a pipe \r does not
202
- // overwrite, so animating there would leave every frame on screen; print one
203
- // static line instead.
204
- function startSpinner(label) {
205
- if (!process.stdout.isTTY) {
206
- console.log(`\n${label}`);
207
- return () => {};
208
- }
209
-
210
- const child = spawn(process.execPath, [join(import.meta.dirname, 'spinner.mjs'), label], {
211
- stdio: ['ignore', 'inherit', 'ignore'],
212
- });
213
- let stopped = false;
214
- return () => {
215
- if (stopped) return;
216
- stopped = true;
217
- child.kill();
218
- // The parent erases the line, not the child: on Windows SIGTERM is not a
219
- // real signal — Node terminates the process outright, so a handler in the
220
- // child never runs and its last frame would sit under the logo.
221
- process.stdout.write(`\r${' '.repeat(label.length + 6)}\r`);
222
- };
223
- }
224
-
225
- // Success is one screen: the wordmark and where things landed. The per-item
226
- // breakdown only earns its space when something failed, and then it carries the
227
- // reason — a bare "失败" would leave the user with nowhere to go.
228
- function report(results) {
229
- const failed = results.filter(([, s]) => s === 'failed');
230
- if (failed.length > 0) {
231
- console.error('\n安装失败\n');
232
- for (const [name, status, note, output] of results) {
233
- const mark = { installed: '✓', skipped: '·', failed: '✗' }[status];
234
- console.error(` ${mark} ${name.padEnd(14)}${note}`);
235
- // The child's own output, indented under the item it belongs to, so a
236
- // knock-on failure doesn't look like a second unrelated problem.
237
- if (status === 'failed' && output) {
238
- for (const line of output.split('\n')) console.error(` ${line.trim()}`);
239
- }
240
- }
241
- console.error('\n修好后重跑 comate init。');
242
- return 1;
243
- }
244
-
245
- console.log(banner(pkgVersion()));
246
- console.log(' Type /comate in your agent to start, or run comate doctor\n');
247
- return 0;
248
- }
249
-
250
- // ponytail: read package.json rather than hardcoding — npm version bumps the
251
- // file, and a stale literal in the banner is worse than no version at all.
252
- export function pkgVersion(root = resolve(import.meta.dirname, '..')) {
253
- try {
254
- return JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).version;
255
- } catch {
256
- return '';
257
- }
258
- }
259
-
260
- // Only CI gates the wordmark. Two earlier guards were both wrong and are worth
261
- // naming so they don't come back:
262
- //
263
- // isTTY — npm's Windows global install resolves `comate` to a comate.ps1
264
- // shim, and PowerShell calling node through a .ps1 wrapper takes over stdout,
265
- // so isTTY is false in a real terminal — exactly where the logo belongs.
266
- //
267
- // chcp — reports the codepage the child process inherited, which the shim
268
- // does not propagate (65001 outside, 936 inside). It also answers the wrong
269
- // question: writing to a real Windows console goes through WriteConsoleW,
270
- // which converts to UTF-16 and bypasses the codepage entirely.
271
- export function banner(version, { ci = isCI() } = {}) {
272
- const tag = `comate${version ? ` v${version}` : ''}`;
273
- const plain = `\n ${tag} - AI coding almost there!\n`;
274
- if (ci) return plain;
275
-
276
- const art = [
277
- ' ██████╗ ██████╗ ███╗ ███╗ █████╗ ████████╗███████╗',
278
- '██╔════╝██╔═══██╗████╗ ████║██╔══██╗╚══██╔══╝██╔════╝',
279
- '██║ ██║ ██║██╔████╔██║███████║ ██║ █████╗ ',
280
- '██║ ██║ ██║██║╚██╔╝██║██╔══██║ ██║ ██╔══╝ ',
281
- '╚██████╗╚██████╔╝██║ ╚═╝ ██║██║ ██║ ██║ ███████╗',
282
- ' ╚═════╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚══════╝',
283
- ];
284
- // Trailing padding keeps the source rows aligned; strip it so a colour run
285
- // doesn't sit behind spaces the terminal may highlight on select.
286
- const trim = (line) => line.replace(/\s+$/, '');
287
- // Centred under the art, not under the terminal: the wordmark is fixed-width,
288
- // so centring against art[0] holds at any window size. Computed rather than
289
- // hardcoded because the version string changes length on every release.
290
- const tagline = `${tag} - AI coding almost there!`;
291
- const indent = ' '.repeat(2 + Math.max(0, Math.round((art[0].length - tagline.length) / 2)));
292
- if (process.env.NO_COLOR) {
293
- return ['', ...art.map((l) => ` ${trim(l)}`), '', `${indent}${tagline}`, ''].join('\n');
294
- }
295
-
296
- // 24-bit SGR. Truecolor is not universal, but the two terminals that matter
297
- // here (Windows Terminal, VS Code) both support it, and one that doesn't
298
- // degrades to its nearest palette entry rather than breaking.
299
- const sgr = ([r, g, b]) => `\x1B[38;2;${r};${g};${b}m`;
300
- const RESET = '\x1B[0m';
301
-
302
- // Polished chrome under a cyan→magenta rim light: [face, edge] per row. The
303
- // ramp shifts hue rather than dropping brightness — every row stays high
304
- // luminance, which is what keeps it reading as lit metal instead of fading
305
- // out. An earlier darkening ramp went muddy across the bottom half.
306
- const ramp = [
307
- [[255, 255, 255], [150, 240, 255]],
308
- [[214, 248, 255], [ 56, 214, 255]],
309
- [[168, 214, 255], [ 92, 148, 255]],
310
- [[186, 160, 255], [148, 100, 255]],
311
- [[233, 150, 255], [200, 76, 255]],
312
- [[255, 150, 224], [255, 72, 178]],
313
- ];
314
-
315
- // Blocks are the lit face, box-drawing pieces are the bevelled edge. Two
316
- // shades per row is the whole trick: it gives every glyph a rim, which is
317
- // what separates brushed metal from a flat decal.
318
- const emboss = (line, [face, edge]) => {
319
- let out = '';
320
- let cur = null;
321
- for (const ch of line) {
322
- const want = ch === ' ' ? null : ch === '█' ? face : edge;
323
- if (want !== cur) {
324
- out += want ? sgr(want) : RESET;
325
- cur = want;
326
- }
327
- out += ch;
328
- }
329
- return out + RESET;
330
- };
331
-
332
- // The tagline picks up the ramp instead of going dim grey: the name in the
333
- // white highlight so it stays the thing you read first, the strapline in the
334
- // magenta rim so the line closes the gradient the art opened. Dropping \x1B[2m
335
- // matters — dim on a saturated colour is what made this read as washed out.
336
- const nameLit = `${sgr(ramp[0][0])}${tag}${RESET}`;
337
- const strap = `${sgr(ramp[5][1])} - AI coding almost there!${RESET}`;
338
-
339
- return [
340
- '',
341
- ...art.map((line, i) => ` ${emboss(trim(line), ramp[i])}`),
342
- '',
343
- `${indent}${nameLit}${strap}`,
344
- '',
345
- ].join('\n');
346
- }
347
-
348
- // Major version of the bash that comate's scripts will actually run under, or
349
- // null when there is no bash at all — that is doctor's diagnosis to deliver, not
350
- // a reason to block the install. Windows resolves through findBash so a Git Bash
351
- // install is not mistaken for the WSL launcher.
352
- // Parsed from --version rather than `-c 'echo $BASH_VERSINFO'`: capture() runs
353
- // through a shell on Windows, which swallows the variable before bash sees it.
354
- export function bashVersion(probe = (bin) => capture(bin, ['--version'])) {
355
- const bin = process.platform === 'win32' ? findBash() : 'bash';
356
- if (!bin) return null;
357
- const out = probe(bin);
358
- if (out === null) return null;
359
- // "GNU bash, version 5.2.26(1)-release ..." / macOS: "... version 3.2.57(1)"
360
- const major = out.match(/version (\d+)\./)?.[1];
361
- return major === undefined ? 0 : Number(major);
362
- }
363
-
364
- // Every major CI sets CI=true; GITHUB_ACTIONS covers the Actions runner even in
365
- // the odd config that doesn't.
366
- function isCI() {
367
- return Boolean(process.env.CI || process.env.GITHUB_ACTIONS);
368
- }
1
+ // The `init` flow: install whatever the current project is missing.
2
+ import { spawn, spawnSync } from 'node:child_process';
3
+ import { existsSync, readFileSync } from 'node:fs';
4
+ import { join, resolve } from 'node:path';
5
+ import { createInterface } from 'node:readline';
6
+
7
+ import {
8
+ LAYOUT_MOVES,
9
+ SKILLS_TIMEOUT_MS,
10
+ capture,
11
+ migrateLayout,
12
+ pruneDeprecatedSkills,
13
+ run,
14
+ scaffoldComate,
15
+ skillsInvocation,
16
+ userSkillsRoot,
17
+ } from './init.mjs';
18
+
19
+ // `comate init [--migrate [--dry-run]]`. Skills go user-level (~/.agents/skills),
20
+ // matching how the CLI itself is installed (npm i -g): one copy, every project
21
+ // sees it.
22
+ //
23
+ // comate/ is per-project — it holds the project's own specs and changes — so
24
+ // cwd must be a project. Refuse the two cases that are almost certainly a mistake:
25
+ // the home directory, and a directory with no project marker at all.
26
+ const PROJECT_MARKERS = [
27
+ '.git',
28
+ 'package.json',
29
+ 'pyproject.toml',
30
+ 'Cargo.toml',
31
+ 'go.mod',
32
+ 'pom.xml',
33
+ 'build.gradle',
34
+ 'composer.json',
35
+ 'Gemfile',
36
+ 'openspec',
37
+ '.agents',
38
+ ];
39
+
40
+ export function inspectTarget(dir, home) {
41
+ const normalise = (p) => resolve(p).replace(/[\\/]+$/, '').toLowerCase();
42
+ if (home && normalise(dir) === normalise(home)) return { kind: 'home' };
43
+ const marker = PROJECT_MARKERS.find((m) => existsSync(join(dir, m)));
44
+ return marker ? { kind: 'project', marker } : { kind: 'bare' };
45
+ }
46
+
47
+ export async function init(pkgRoot, argv = []) {
48
+ const results = [];
49
+ const home = process.env.HOME ?? process.env.USERPROFILE ?? '';
50
+ const cwd = process.cwd();
51
+
52
+ const target = inspectTarget(cwd, home);
53
+ if (target.kind !== 'project') {
54
+ // Home directory is always refused
55
+ if (target.kind === 'home') {
56
+ console.error('\n这是主目录,请在项目目录下运行 comate init。');
57
+ return 1;
58
+ }
59
+
60
+ // Bare directory: ask user if they want to create a project here
61
+ console.log('\n当前目录不像项目(无 .git / package.json 等)。');
62
+ const confirmed = await askConfirm('要在这里初始化新项目吗?');
63
+ if (!confirmed) {
64
+ console.log('已取消。');
65
+ return 1;
66
+ }
67
+ }
68
+
69
+ // --migrate: move 0.x docs/knowledge-base and docs/architecture into comate/.
70
+ // --dry-run lists the moves and stops; a conflict stops before moving anything.
71
+ if (argv.includes('--migrate')) {
72
+ const dryRun = argv.includes('--dry-run');
73
+ const { moves, ok } = migrateLayout(cwd, { dryRun });
74
+ if (!moves.length) console.log('\n没有需要迁移的目录(docs/knowledge-base、docs/architecture 都不存在)。');
75
+ for (const m of moves) {
76
+ const mark = { move: '→', moved: '✓', conflict: '✗' }[m.status];
77
+ console.log(` ${mark} ${m.from} → ${m.to}${m.status === 'conflict' ? '(目标已存在,未移动)' : ''}`);
78
+ }
79
+ if (!ok) {
80
+ console.error('\n目标目录已存在,没有移动任何文件。手动合并后重跑 comate init --migrate。');
81
+ return 1;
82
+ }
83
+ if (dryRun) {
84
+ console.log('\n以上为预览(--dry-run)。确认后运行 comate init --migrate 执行(使用 git mv 保留历史)。');
85
+ return 0;
86
+ }
87
+ }
88
+
89
+ const stopSpinner = startSpinner('Initializing…');
90
+
91
+ // 1. git — comate 1.0 keeps candidates, archives and finish steps in git.
92
+ const gitVersion = capture('git', ['--version']);
93
+ if (gitVersion === null) {
94
+ results.push(['git', 'failed', '找不到 git:comate 1.0 需要 git']);
95
+ } else if (spawnSync('git', ['rev-parse', '--git-dir'], { cwd, stdio: 'ignore' }).status !== 0) {
96
+ results.push(['git', 'warn', '当前目录还不是 git 仓库:先 git init 并做一次初始提交,再用 /comate']);
97
+ } else {
98
+ results.push(['git', 'skipped', gitVersion]);
99
+ }
100
+
101
+ // 2. comate itself. The skills CLI reports per-agent failures without ever
102
+ // setting a non-zero exit code, and we swallow its output — so a zero status
103
+ // is not evidence the skill landed. Check the file.
104
+ const comateArgs = ['add', pkgRoot, '--skill', '*', '-g', '-y'];
105
+ const r = run(skillsInvocation(pkgRoot, comateArgs), SKILLS_TIMEOUT_MS, { quiet: true });
106
+ const landed = existsSync(join(home, '.agents', 'skills', 'comate', 'SKILL.md'));
107
+ if (r.ok && !landed) {
108
+ results.push(['comate', 'failed', `未装到 ${join(home, '.agents', 'skills')}`]);
109
+ } else {
110
+ results.push(['comate', r.ok ? 'installed' : 'failed', r.ok ? '' : r.reason, r.output]);
111
+ }
112
+
113
+ // Prune comate skill dirs renamed/dropped in past releases. `skills add` above
114
+ // only adds/overwrites, so /comate-spec etc. would otherwise linger as dead
115
+ // slash commands. The postinstall hook does this too, but --ignore-scripts
116
+ // users never run it — init is their only path, so it must clean up here.
117
+ const pruned = pruneDeprecatedSkills(userSkillsRoot(home));
118
+ if (pruned.length) results.push(['清理废弃 skill', 'installed', pruned.join('、')]);
119
+
120
+ // 3. comate/ project scaffold: config.json, knowledge-base (PRD template),
121
+ // architecture placeholder. Idempotent — existing files are left alone.
122
+ const sc = scaffoldComate(pkgRoot);
123
+ if (!sc.ok) {
124
+ results.push(['comate/', 'failed', sc.reason]);
125
+ } else if (sc.created === 0) {
126
+ results.push(['comate/', 'skipped', '已存在']);
127
+ } else {
128
+ const note = sc.skipped > 0 ? `新建 ${sc.created} 个文件,保留 ${sc.skipped} 个已有` : '';
129
+ results.push(['comate/', 'installed', note]);
130
+ }
131
+ if (!argv.includes('--migrate') && LAYOUT_MOVES.some(([from]) => existsSync(join(cwd, from)))) {
132
+ results.push(['docs/', 'warn', '发现 0.x 的 docs/knowledge-base 或 docs/architecture:comate init --migrate --dry-run 查看迁移清单']);
133
+ }
134
+ if (existsSync(join(cwd, 'openspec', 'specs'))) {
135
+ results.push(['openspec/', 'warn', '发现 0.x 主规格:comate import-specs --dry-run 查看迁移清单']);
136
+ }
137
+
138
+ stopSpinner();
139
+ return report(results);
140
+ }
141
+
142
+ // A yes/no prompt. Defaults to "no" so a piped/non-interactive run (CI, an
143
+ // unattended npx) never silently scaffolds a project into whatever directory it
144
+ // happened to land in — those callers should point at a real project instead.
145
+ // [y/N]: empty input, EOF (Ctrl-D / closed stdin), or anything but y/yes = no.
146
+ export function askConfirm(question, { input = process.stdin, output = process.stdout } = {}) {
147
+ if (!input.isTTY) return Promise.resolve(false);
148
+ const rl = createInterface({ input, output });
149
+ return new Promise((done) => {
150
+ rl.question(`${question} [y/N] `, (answer) => {
151
+ rl.close();
152
+ done(/^\s*y(es)?\s*$/i.test(answer));
153
+ });
154
+ });
155
+ }
156
+
157
+ // The spinner runs in its own process because every step above is a synchronous
158
+ // spawnSync — an interval on this thread would never fire. In a pipe \r does not
159
+ // overwrite, so animating there would leave every frame on screen; print one
160
+ // static line instead.
161
+ function startSpinner(label) {
162
+ if (!process.stdout.isTTY) {
163
+ console.log(`\n${label}`);
164
+ return () => {};
165
+ }
166
+
167
+ const child = spawn(process.execPath, [join(import.meta.dirname, 'spinner.mjs'), label], {
168
+ stdio: ['ignore', 'inherit', 'ignore'],
169
+ });
170
+ let stopped = false;
171
+ return () => {
172
+ if (stopped) return;
173
+ stopped = true;
174
+ child.kill();
175
+ // The parent erases the line, not the child: on Windows SIGTERM is not a
176
+ // real signal — Node terminates the process outright, so a handler in the
177
+ // child never runs and its last frame would sit under the logo.
178
+ process.stdout.write(`\r${' '.repeat(label.length + 6)}\r`);
179
+ };
180
+ }
181
+
182
+ // Success is one screen: the wordmark and where things landed. The per-item
183
+ // breakdown only earns its space when something failed, and then it carries the
184
+ // reason — a bare "失败" would leave the user with nowhere to go.
185
+ function report(results) {
186
+ const failed = results.filter(([, s]) => s === 'failed');
187
+ if (failed.length > 0) {
188
+ console.error('\n安装失败\n');
189
+ for (const [name, status, note, output] of results) {
190
+ const mark = { installed: '✓', skipped: '·', warn: '⚠', failed: '✗' }[status];
191
+ console.error(` ${mark} ${name.padEnd(14)}${note}`);
192
+ // The child's own output, indented under the item it belongs to, so a
193
+ // knock-on failure doesn't look like a second unrelated problem.
194
+ if (status === 'failed' && output) {
195
+ for (const line of output.split('\n')) console.error(` ${line.trim()}`);
196
+ }
197
+ }
198
+ console.error('\n修好后重跑 comate init。');
199
+ return 1;
200
+ }
201
+
202
+ console.log(banner(pkgVersion()));
203
+ for (const [name, , note] of results.filter(([, s]) => s === 'warn')) console.log(` ⚠ ${name.padEnd(12)}${note}`);
204
+ console.log(' 在 agent 中输入 /comate 开始;配置检查命令:comate/comate/config.json\n');
205
+ return 0;
206
+ }
207
+
208
+ // ponytail: read package.json rather than hardcoding — npm version bumps the
209
+ // file, and a stale literal in the banner is worse than no version at all.
210
+ export function pkgVersion(root = resolve(import.meta.dirname, '..')) {
211
+ try {
212
+ return JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).version;
213
+ } catch {
214
+ return '';
215
+ }
216
+ }
217
+
218
+ // Only CI gates the wordmark. Two earlier guards were both wrong and are worth
219
+ // naming so they don't come back:
220
+ //
221
+ // isTTY — npm's Windows global install resolves `comate` to a comate.ps1
222
+ // shim, and PowerShell calling node through a .ps1 wrapper takes over stdout,
223
+ // so isTTY is false in a real terminal — exactly where the logo belongs.
224
+ //
225
+ // chcp — reports the codepage the child process inherited, which the shim
226
+ // does not propagate (65001 outside, 936 inside). It also answers the wrong
227
+ // question: writing to a real Windows console goes through WriteConsoleW,
228
+ // which converts to UTF-16 and bypasses the codepage entirely.
229
+ export function banner(version, { ci = isCI() } = {}) {
230
+ const tag = `comate${version ? ` v${version}` : ''}`;
231
+ const plain = `\n ${tag} - AI coding almost there!\n`;
232
+ if (ci) return plain;
233
+
234
+ const art = [
235
+ ' ██████╗ ██████╗ ███╗ ███╗ █████╗ ████████╗███████╗',
236
+ '██╔════╝██╔═══██╗████╗ ████║██╔══██╗╚══██╔══╝██╔════╝',
237
+ '██║ ██║ ██║██╔████╔██║███████║ ██║ █████╗ ',
238
+ '██║ ██║ ██║██║╚██╔╝██║██╔══██║ ██║ ██╔══╝ ',
239
+ '╚██████╗╚██████╔╝██║ ╚═╝ ██║██║ ██║ ██║ ███████╗',
240
+ ' ╚═════╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚══════╝',
241
+ ];
242
+ // Trailing padding keeps the source rows aligned; strip it so a colour run
243
+ // doesn't sit behind spaces the terminal may highlight on select.
244
+ const trim = (line) => line.replace(/\s+$/, '');
245
+ // Centred under the art, not under the terminal: the wordmark is fixed-width,
246
+ // so centring against art[0] holds at any window size. Computed rather than
247
+ // hardcoded because the version string changes length on every release.
248
+ const tagline = `${tag} - AI coding almost there!`;
249
+ const indent = ' '.repeat(2 + Math.max(0, Math.round((art[0].length - tagline.length) / 2)));
250
+ if (process.env.NO_COLOR) {
251
+ return ['', ...art.map((l) => ` ${trim(l)}`), '', `${indent}${tagline}`, ''].join('\n');
252
+ }
253
+
254
+ // 24-bit SGR. Truecolor is not universal, but the two terminals that matter
255
+ // here (Windows Terminal, VS Code) both support it, and one that doesn't
256
+ // degrades to its nearest palette entry rather than breaking.
257
+ const sgr = ([r, g, b]) => `\x1B[38;2;${r};${g};${b}m`;
258
+ const RESET = '\x1B[0m';
259
+
260
+ // Polished chrome under a cyan→magenta rim light: [face, edge] per row. The
261
+ // ramp shifts hue rather than dropping brightness — every row stays high
262
+ // luminance, which is what keeps it reading as lit metal instead of fading
263
+ // out. An earlier darkening ramp went muddy across the bottom half.
264
+ const ramp = [
265
+ [[255, 255, 255], [150, 240, 255]],
266
+ [[214, 248, 255], [ 56, 214, 255]],
267
+ [[168, 214, 255], [ 92, 148, 255]],
268
+ [[186, 160, 255], [148, 100, 255]],
269
+ [[233, 150, 255], [200, 76, 255]],
270
+ [[255, 150, 224], [255, 72, 178]],
271
+ ];
272
+
273
+ // Blocks are the lit face, box-drawing pieces are the bevelled edge. Two
274
+ // shades per row is the whole trick: it gives every glyph a rim, which is
275
+ // what separates brushed metal from a flat decal.
276
+ const emboss = (line, [face, edge]) => {
277
+ let out = '';
278
+ let cur = null;
279
+ for (const ch of line) {
280
+ const want = ch === ' ' ? null : ch === '█' ? face : edge;
281
+ if (want !== cur) {
282
+ out += want ? sgr(want) : RESET;
283
+ cur = want;
284
+ }
285
+ out += ch;
286
+ }
287
+ return out + RESET;
288
+ };
289
+
290
+ // The tagline picks up the ramp instead of going dim grey: the name in the
291
+ // white highlight so it stays the thing you read first, the strapline in the
292
+ // magenta rim so the line closes the gradient the art opened. Dropping \x1B[2m
293
+ // matters — dim on a saturated colour is what made this read as washed out.
294
+ const nameLit = `${sgr(ramp[0][0])}${tag}${RESET}`;
295
+ const strap = `${sgr(ramp[5][1])} - AI coding almost there!${RESET}`;
296
+
297
+ return [
298
+ '',
299
+ ...art.map((line, i) => ` ${emboss(trim(line), ramp[i])}`),
300
+ '',
301
+ `${indent}${nameLit}${strap}`,
302
+ '',
303
+ ].join('\n');
304
+ }
305
+
306
+ // Every major CI sets CI=true; GITHUB_ACTIONS covers the Actions runner even in
307
+ // the odd config that doesn't.
308
+ function isCI() {
309
+ return Boolean(process.env.CI || process.env.GITHUB_ACTIONS);
310
+ }