@house365/comate 0.5.0 → 0.7.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.
- package/README.md +27 -9
- package/bin/cli.mjs +6 -0
- package/bin/doctor.mjs +86 -2
- package/bin/init-run.mjs +1 -1
- package/bin/init.mjs +2 -2
- package/bin/usage.mjs +252 -0
- package/docs/COMATE_MANUAL.md +710 -669
- package/package.json +3 -2
- package/skills/comate/SKILL.md +17 -20
- package/skills/comate/reference/decision-points.md +3 -3
- package/skills/comate/reference/prerequisites.md +1 -2
- package/skills/comate/scripts/comate-lib.sh +29 -12
- package/skills/comate/scripts/comate.sh +10 -10
- package/skills/comate/tests/comate.bats +31 -26
- package/skills/comate-build/SKILL.md +3 -3
- package/skills/comate-fix/SKILL.md +153 -0
- package/skills/{comate-intake → comate-gate}/SKILL.md +26 -6
- package/skills/{comate-intake → comate-gate}/scaffold/docs/README.md +2 -2
- package/skills/{comate-intake → comate-gate}/scaffold/docs/architecture/README.md +2 -2
- package/skills/{comate-intake → comate-gate}/scaffold/docs/knowledge-base/README.md +8 -8
- package/skills/comate-gate/scaffold/docs/knowledge-base/integrations/README.md +31 -0
- package/skills/{comate-intake → comate-gate}/scaffold/docs/knowledge-base/requirements/_templates/PRD.md +2 -2
- package/skills/comate-grill/SKILL.md +6 -6
- package/skills/comate-kb-integration/SKILL.md +128 -0
- package/skills/comate-kb-integration/scripts/fetch_rendered_doc.py +84 -0
- package/skills/comate-open/SKILL.md +5 -5
- package/skills/comate-hotfix/SKILL.md +0 -115
- package/skills/comate-tweak/SKILL.md +0 -109
- /package/skills/{comate-intake → comate-gate}/scaffold/docs/knowledge-base/integrations/_TEMPLATE/README.md +0 -0
- /package/skills/{comate-intake → comate-gate}/scaffold/docs/knowledge-base/integrations/_TEMPLATE/api-snapshot.md +0 -0
package/README.md
CHANGED
|
@@ -2,30 +2,32 @@
|
|
|
2
2
|
|
|
3
3
|
OpenSpec + Superpowers 规约驱动的 AI 开发编排器。
|
|
4
4
|
|
|
5
|
-
comate 把 **OpenSpec**(WHAT:提案、规约生命周期、归档)和 **Superpowers**(HOW:设计、规划、执行、收尾)整合成统一的五阶段工作流。核心原则:每个变更都要经过深度设计(
|
|
5
|
+
comate 把 **OpenSpec**(WHAT:提案、规约生命周期、归档)和 **Superpowers**(HOW:设计、规划、执行、收尾)整合成统一的五阶段工作流。核心原则:每个变更都要经过深度设计(fix 预设除外)。
|
|
6
6
|
|
|
7
7
|
## 五阶段
|
|
8
8
|
|
|
9
9
|
```
|
|
10
|
-
[原始 PRD] ─→ /comate-
|
|
10
|
+
[原始 PRD] ─→ /comate-gate ─┐ (阶段 0:门禁+拆分)
|
|
11
11
|
▼
|
|
12
12
|
/comate ──自动探测阶段──────→ open → design → build → verify → archive
|
|
13
13
|
```
|
|
14
14
|
|
|
15
15
|
| 阶段 | skill | 职责 |
|
|
16
16
|
|------|-------|------|
|
|
17
|
-
|
|
|
17
|
+
| gate | `/comate-gate` | (阶段 0,可选)对原始 PRD 做质量门禁 + 拆分成 N 个 change |
|
|
18
18
|
| open | `/comate-open` | 探索想法,创建 change 结构并初始化 `.comate.yaml` |
|
|
19
19
|
| design | `/comate-design` | 技术设计与方案 brainstorming |
|
|
20
20
|
| build | `/comate-build` | 按任务实现并提交 |
|
|
21
21
|
| verify | `/comate-verify` | 验证、出报告、处理分支 |
|
|
22
22
|
| archive | `/comate-archive` | 同步增量规约并归档 |
|
|
23
23
|
|
|
24
|
-
预设路径:`/comate-
|
|
24
|
+
预设路径:`/comate-fix`(快速小改动,覆盖 bug 修复与文案/配置小调整两种模式)。
|
|
25
25
|
|
|
26
|
-
工具 skill
|
|
26
|
+
工具 skill:
|
|
27
|
+
- `/comate-grill`(决策点穷追式澄清)——对计划/设计/PRD 的模糊点逐个深挖,每问附推荐答案、一次一问,直到歧义清零。comate-gate 在阻断问题多、有依赖链时会自动提供「深度澄清」选项调用它;也可单独 `/comate-grill <话题>` 挑战自己的计划草稿。
|
|
28
|
+
- `/comate-kb-integration`(第三方对接档案生成)——从官方文档 URL 抓取并按 `docs/knowledge-base/integrations/_TEMPLATE/` 规范产出对接档案(`<service-id>/README.md` + `api-snapshot.md`),自动追加根索引一行。是知识库 integrations scaffold 的配套建档工具;遇 JS 动态渲染文档站自动降级 Playwright 无头抓取。
|
|
27
29
|
|
|
28
|
-
**阶段 0(PRD 门禁与拆分)**:需求若来自一份**原始 PRD**,先过 `/comate-
|
|
30
|
+
**阶段 0(PRD 门禁与拆分)**:需求若来自一份**原始 PRD**,先过 `/comate-gate`——它审阅 PRD 质量、百分制评分、建阻断台账,门禁通过前禁止建任何 change;通过后按依赖/独立性判定把 PRD 拆成 1 或 N 个 change(粒度由用户定),逐个交给 `/comate-open`。门禁状态钉在 PRD 同级 `<PRD>.gate.yaml`,PRD 通过后被改会自动失效需重跑。需求来自对话时无需此阶段,直接从 open 起步。
|
|
29
31
|
|
|
30
32
|
## 安装
|
|
31
33
|
|
|
@@ -40,7 +42,7 @@ cd your-project
|
|
|
40
42
|
comate init
|
|
41
43
|
```
|
|
42
44
|
|
|
43
|
-
`init` 会装好 comate 及其依赖(OpenSpec CLI、Superpowers),创建 `openspec/` 项目结构,并铺出 `docs/` 知识库骨架(`docs/knowledge-base/requirements/` 放 PRD + PRD 模板、`docs/architecture/` 架构规范占位、integrations 对接模板)——`/comate-
|
|
45
|
+
`init` 会装好 comate 及其依赖(OpenSpec CLI、Superpowers),创建 `openspec/` 项目结构,并铺出 `docs/` 知识库骨架(`docs/knowledge-base/requirements/` 放 PRD + PRD 模板、`docs/architecture/` 架构规范占位、integrations 对接模板)——`/comate-gate` 从这里读 PRD。支持 Claude Code、Codex、Cursor 等 70+ agent。可重复执行,已装的自动跳过,已有的 docs 文件不覆盖。
|
|
44
46
|
|
|
45
47
|
skills 装到用户级(`~/.agents/skills`),装一次所有项目共用;`openspec/` 是项目自己的规约,建在当前目录,所以 `init` 需要在项目目录下运行。
|
|
46
48
|
|
|
@@ -59,6 +61,21 @@ cd your-project && comate init
|
|
|
59
61
|
|
|
60
62
|
输入 `/comate` 自动探测当前阶段并分派,或直接调用某个阶段 skill。状态记录在每个 change 的 `openspec/changes/<name>/.comate.yaml` 中,支持上下文压缩后断点恢复。
|
|
61
63
|
|
|
64
|
+
## Token 用量:`comate usage`
|
|
65
|
+
|
|
66
|
+
查看**当前项目**的 token 消耗与估算成本(封装 [ccusage](https://github.com/ryoppippi/ccusage),随 comate 一起装):
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
comate usage # 本项目按会话(session)统计
|
|
70
|
+
comate usage daily # 本项目按天
|
|
71
|
+
comate usage --global # 全部项目(不做项目隔离)
|
|
72
|
+
comate usage --json -s 2026-08-01 # 其余参数原样透传给 ccusage
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
默认只统计当前工作目录这一个项目:Claude Code 数据按 `~/.claude/projects/<项目>/` 隔离,Codex 数据按会话记录里的 `cwd` 过滤,两者都不会混入其它项目的用量。加 `--global` 看全量,或用 `comate usage codex ...` 单看某个 agent。
|
|
76
|
+
|
|
77
|
+
> 粒度到项目/会话级;不做 open/design/build 阶段级归因(那需要在状态机打时间戳,属后续增强)。
|
|
78
|
+
|
|
62
79
|
## 目录结构
|
|
63
80
|
|
|
64
81
|
```
|
|
@@ -68,9 +85,10 @@ skills/
|
|
|
68
85
|
reference/ 决策点、脏工作树协议、前置条件
|
|
69
86
|
tests/ bats 测试
|
|
70
87
|
comate-open/ design/ build/ verify/ archive/ 各阶段 skill
|
|
71
|
-
comate-
|
|
72
|
-
comate-
|
|
88
|
+
comate-gate/ 阶段 0:PRD 门禁与拆分
|
|
89
|
+
comate-fix/ 预设路径 skill(bug + 小调整)
|
|
73
90
|
comate-grill/ 决策点穷追式澄清工具
|
|
91
|
+
comate-kb-integration/ 第三方对接档案生成工具(知识库配套)
|
|
74
92
|
docs/
|
|
75
93
|
COMATE_MANUAL.md 完整操作手册
|
|
76
94
|
```
|
package/bin/cli.mjs
CHANGED
|
@@ -21,12 +21,18 @@ if (args[0] === 'doctor') {
|
|
|
21
21
|
process.exit(doctor(args.slice(1)));
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
if (args[0] === 'usage') {
|
|
25
|
+
const { usage } = await import('./usage.mjs');
|
|
26
|
+
process.exit(await usage(args.slice(1), { pkgRoot }));
|
|
27
|
+
}
|
|
28
|
+
|
|
24
29
|
if (args[0] === '--help' || args[0] === '-h') {
|
|
25
30
|
console.log(`comate — OpenSpec + Superpowers 规约驱动的开发编排器
|
|
26
31
|
|
|
27
32
|
用法:
|
|
28
33
|
comate init 安装 comate 和依赖到用户级,并在当前项目创建 openspec/
|
|
29
34
|
comate doctor 健康检查
|
|
35
|
+
comate usage 查看本项目 token 用量(--global 看全部;参数透传给 ccusage)
|
|
30
36
|
|
|
31
37
|
装好后在 agent 中输入 /comate 开始使用。`);
|
|
32
38
|
process.exit(0);
|
package/bin/doctor.mjs
CHANGED
|
@@ -3,8 +3,12 @@
|
|
|
3
3
|
// ponytail: comate.sh already implements every check. This only locates the
|
|
4
4
|
// installed copy so the user doesn't have to remember the path.
|
|
5
5
|
import { spawnSync } from 'node:child_process';
|
|
6
|
-
import { existsSync } from 'node:fs';
|
|
7
|
-
import {
|
|
6
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
7
|
+
import { createRequire } from 'node:module';
|
|
8
|
+
import { delimiter, dirname, join, resolve } from 'node:path';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
|
|
11
|
+
import { atLeast, capture, npmInvocation } from './init.mjs';
|
|
8
12
|
|
|
9
13
|
// Where the skills CLI puts things. `init` installs user-level, but a project
|
|
10
14
|
// copy shadows it for the agent, so check cwd first and report on the same one
|
|
@@ -67,5 +71,85 @@ export function doctor(argv) {
|
|
|
67
71
|
console.error(`无法执行 bash (${bash}) — comate 的脚本需要 bash。${error.message}`);
|
|
68
72
|
return 1;
|
|
69
73
|
}
|
|
74
|
+
|
|
75
|
+
// ccusage is a node dependency (for `comate usage`), not a bash-side tool, so
|
|
76
|
+
// it lives here rather than in comate.sh's doctor. A miss is a warning, never
|
|
77
|
+
// a failure: usage falls back to `npx ccusage@latest`, so it still works.
|
|
78
|
+
reportCcusage();
|
|
79
|
+
|
|
80
|
+
// Version check is the only place a user learns a newer comate exists: the
|
|
81
|
+
// npm package doesn't self-update and `~/.agents/skills` keeps the copied
|
|
82
|
+
// skills until `comate init` is re-run. Never blocks or fails doctor — a
|
|
83
|
+
// network miss / offline just skips the line (see checkForUpdate).
|
|
84
|
+
reportUpdate();
|
|
85
|
+
|
|
70
86
|
return status ?? 1;
|
|
71
87
|
}
|
|
88
|
+
|
|
89
|
+
// resolvable = shipped as comate's pinned dependency; the common, happy case
|
|
90
|
+
// after `npm i -g @house365/comate`. Otherwise point at the two ways out.
|
|
91
|
+
export function ccusageResolvable(pkgRoot = defaultPkgRoot()) {
|
|
92
|
+
try {
|
|
93
|
+
createRequire(join(pkgRoot, 'package.json')).resolve('ccusage/package.json');
|
|
94
|
+
return true;
|
|
95
|
+
} catch {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function reportCcusage() {
|
|
101
|
+
if (ccusageResolvable()) {
|
|
102
|
+
console.log(' ✓ ccusage present (comate usage 可用)');
|
|
103
|
+
} else {
|
|
104
|
+
console.log(
|
|
105
|
+
' ⚠ ccusage not found — reinstall with npm i -g @house365/comate ' +
|
|
106
|
+
'(comate usage 会回退到 npx ccusage@latest)',
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Read {name, version} from the installed package.json. Both come from the same
|
|
112
|
+
// file so the check keys off whatever package this binary actually belongs to —
|
|
113
|
+
// no hardcoded name/version to drift when the package is renamed or bumped.
|
|
114
|
+
export function localPackage(pkgRoot = defaultPkgRoot()) {
|
|
115
|
+
try {
|
|
116
|
+
const { name, version } = JSON.parse(readFileSync(join(pkgRoot, 'package.json'), 'utf8'));
|
|
117
|
+
return name && version ? { name, version } : null;
|
|
118
|
+
} catch {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Compare the installed version against npm's latest. Returns null (silent) on
|
|
124
|
+
// anything uncertain — no package.json, no npm, offline, timeout, unparseable
|
|
125
|
+
// output — because a *diagnostic* command must never hang or error on a network
|
|
126
|
+
// miss. `fetchLatest` is injectable so this is unit-testable without the network.
|
|
127
|
+
export function checkForUpdate({
|
|
128
|
+
pkg = localPackage(),
|
|
129
|
+
fetchLatest = defaultFetchLatest,
|
|
130
|
+
} = {}) {
|
|
131
|
+
if (!pkg) return null;
|
|
132
|
+
const latest = fetchLatest(pkg.name);
|
|
133
|
+
if (!latest || !/^\d+\.\d+\.\d+/.test(latest)) return null;
|
|
134
|
+
// outdated when current is NOT at least latest (atLeast is >=).
|
|
135
|
+
return { name: pkg.name, current: pkg.version, latest, outdated: !atLeast(pkg.version, latest) };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// `npm view <pkg> version` — capture() already caps at 15s and returns null on
|
|
139
|
+
// any non-zero exit, so offline/timeout falls through to a skipped check.
|
|
140
|
+
function defaultFetchLatest(name) {
|
|
141
|
+
const { command, args, shell } = npmInvocation(['view', name, 'version']);
|
|
142
|
+
return capture(command, args, { shell: Boolean(shell) });
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function reportUpdate(info = checkForUpdate()) {
|
|
146
|
+
if (!info || !info.outdated) return; // up to date or couldn't tell → say nothing
|
|
147
|
+
console.log(
|
|
148
|
+
` ⬆ 有新版 comate:${info.current} → ${info.latest}。升级:` +
|
|
149
|
+
`npm i -g ${info.name}@latest 然后重跑 comate init`,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function defaultPkgRoot() {
|
|
154
|
+
return resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
155
|
+
}
|
package/bin/init-run.mjs
CHANGED
|
@@ -156,7 +156,7 @@ export async function init(pkgRoot) {
|
|
|
156
156
|
results.push(['openspec/', r2.ok ? 'installed' : 'failed', r2.ok ? '' : r2.reason, r2.output]);
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
-
// 5. docs/ knowledge-base scaffold. /comate-
|
|
159
|
+
// 5. docs/ knowledge-base scaffold. /comate-gate reads PRDs from
|
|
160
160
|
// docs/knowledge-base/requirements/ (comate_kb_root is hardcoded there), so a
|
|
161
161
|
// fresh project has nowhere to put a PRD. Idempotent — existing files are left
|
|
162
162
|
// alone, only missing pieces are filled in, so re-running never clobbers a
|
package/bin/init.mjs
CHANGED
|
@@ -176,7 +176,7 @@ function superpowersInstalled(scope) {
|
|
|
176
176
|
// (repo checkout) and after `skills add` copies the skill to ~/.agents/skills.
|
|
177
177
|
// Locating it relative to pkgRoot means init reads the same tree it packed.
|
|
178
178
|
function scaffoldSourceDir(pkgRoot) {
|
|
179
|
-
return join(pkgRoot, 'skills', 'comate-
|
|
179
|
+
return join(pkgRoot, 'skills', 'comate-gate', 'scaffold', 'docs');
|
|
180
180
|
}
|
|
181
181
|
|
|
182
182
|
// copyTreeSkipExisting — recursively copy src→dst, never overwriting a file that
|
|
@@ -201,7 +201,7 @@ function copyTreeSkipExisting(src, dst, acc = { created: 0, skipped: 0 }) {
|
|
|
201
201
|
}
|
|
202
202
|
|
|
203
203
|
// scaffoldDocs — lay down docs/ (knowledge-base + architecture placeholder + PRD
|
|
204
|
-
// template) so /comate-
|
|
204
|
+
// template) so /comate-gate has somewhere to read PRDs from. comate_kb_root in
|
|
205
205
|
// comate-lib.sh is hardcoded to docs/knowledge-base, so this path is not
|
|
206
206
|
// configurable — it must match. Idempotent: existing files are never clobbered.
|
|
207
207
|
function scaffoldDocs(pkgRoot, cwd = process.cwd()) {
|
package/bin/usage.mjs
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
// `comate usage` — show this project's Claude Code / Codex token usage by
|
|
2
|
+
// wrapping ccusage.
|
|
3
|
+
//
|
|
4
|
+
// Why a wrapper and not just `npx ccusage`: ccusage aggregates every project
|
|
5
|
+
// globally and there is no "just this repo" flag. Scoping has to be done per
|
|
6
|
+
// agent, because the two store data differently:
|
|
7
|
+
//
|
|
8
|
+
// Claude Code — one dir per project: ~/.claude/projects/<encoded-cwd>/*.jsonl.
|
|
9
|
+
// Honours CLAUDE_CONFIG_DIR. So: a throwaway root whose
|
|
10
|
+
// projects/ holds a single symlink to this project's dir.
|
|
11
|
+
// Codex — flat by date: ~/.codex/sessions/YYYY/MM/DD/*.jsonl, project
|
|
12
|
+
// identified only by a `cwd` inside each file's session_meta.
|
|
13
|
+
// Honours CODEX_HOME. So: filter files by cwd, symlink the
|
|
14
|
+
// matches into a throwaway sessions/ dir.
|
|
15
|
+
//
|
|
16
|
+
// Both env vars are set to the scoped roots even when a root is empty — an empty
|
|
17
|
+
// scoped root reports zero for that agent, which is correct, whereas leaving the
|
|
18
|
+
// var unset would let ccusage fall back to the global dir and leak every other
|
|
19
|
+
// project's usage in. `--global` skips all scoping and forwards straight through.
|
|
20
|
+
import { spawnSync } from 'node:child_process';
|
|
21
|
+
import {
|
|
22
|
+
existsSync,
|
|
23
|
+
mkdtempSync,
|
|
24
|
+
mkdirSync,
|
|
25
|
+
openSync,
|
|
26
|
+
readSync,
|
|
27
|
+
closeSync,
|
|
28
|
+
readdirSync,
|
|
29
|
+
rmSync,
|
|
30
|
+
linkSync,
|
|
31
|
+
copyFileSync,
|
|
32
|
+
} from 'node:fs';
|
|
33
|
+
import { createRequire } from 'node:module';
|
|
34
|
+
import { tmpdir } from 'node:os';
|
|
35
|
+
import { basename, dirname, join, relative } from 'node:path';
|
|
36
|
+
|
|
37
|
+
// Claude Code encodes a project's cwd into a directory name by replacing every
|
|
38
|
+
// path separator and the drive colon with a dash: D:\a\b -> D--a-b, /a/b -> -a-b.
|
|
39
|
+
// Verified against a real ~/.claude/projects entry on Windows.
|
|
40
|
+
export function encodeProjectDir(cwd) {
|
|
41
|
+
return cwd.replace(/[:\\/]/g, '-');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function home() {
|
|
45
|
+
return process.env.HOME ?? process.env.USERPROFILE ?? '';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Where Claude Code keeps this project's transcripts. CLAUDE_CONFIG_DIR wins if
|
|
49
|
+
// the user has relocated the config dir, matching ccusage's own resolution.
|
|
50
|
+
export function claudeDataDir(cwd = process.cwd(), env = process.env) {
|
|
51
|
+
const configRoot = env.CLAUDE_CONFIG_DIR || join(home(), '.claude');
|
|
52
|
+
return join(configRoot, 'projects', encodeProjectDir(cwd));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Codex's sessions root. CODEX_HOME wins, else ~/.codex.
|
|
56
|
+
export function codexSessionsDir(env = process.env) {
|
|
57
|
+
const root = env.CODEX_HOME || join(home(), '.codex');
|
|
58
|
+
return join(root, 'sessions');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Read just the head of a file — enough to catch the session_meta record, which
|
|
62
|
+
// Codex writes first. Avoids slurping multi-MB transcripts to read one cwd.
|
|
63
|
+
function readHead(file, bytes = 8192) {
|
|
64
|
+
const fd = openSync(file, 'r');
|
|
65
|
+
try {
|
|
66
|
+
const buf = Buffer.alloc(bytes);
|
|
67
|
+
const n = readSync(fd, buf, 0, bytes, 0);
|
|
68
|
+
return buf.toString('utf8', 0, n);
|
|
69
|
+
} finally {
|
|
70
|
+
closeSync(fd);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// The cwd recorded in a Codex session file, or null. session_meta is the first
|
|
75
|
+
// record; its payload.cwd (older: top-level cwd) is the project path.
|
|
76
|
+
export function codexFileCwd(file, read = readHead) {
|
|
77
|
+
const head = read(file);
|
|
78
|
+
const line = head.split('\n').find((l) => l.includes('session_meta'));
|
|
79
|
+
if (!line) return null;
|
|
80
|
+
try {
|
|
81
|
+
const o = JSON.parse(line);
|
|
82
|
+
return o.payload?.cwd ?? o.cwd ?? null;
|
|
83
|
+
} catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Every *.jsonl under a Codex sessions dir whose recorded cwd matches this
|
|
89
|
+
// project. Bounded head-reads only; returns [] if the sessions dir is absent.
|
|
90
|
+
export function codexFilesForCwd(sessionsDir, cwd, { read = readHead } = {}) {
|
|
91
|
+
if (!existsSync(sessionsDir)) return [];
|
|
92
|
+
const out = [];
|
|
93
|
+
const walk = (dir) => {
|
|
94
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
95
|
+
const p = join(dir, e.name);
|
|
96
|
+
if (e.isDirectory()) walk(p);
|
|
97
|
+
else if (e.name.endsWith('.jsonl') && codexFileCwd(p, read) === cwd) out.push(p);
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
walk(sessionsDir);
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Prefer the pinned ccusage dependency (offline, version-locked); fall back to
|
|
105
|
+
// npx when resolution fails (e.g. a hoisting layout we can't see). Mirrors
|
|
106
|
+
// init.mjs's skillsInvocation so the two behave the same across install shapes.
|
|
107
|
+
export function ccusageInvocation(pkgRoot, args) {
|
|
108
|
+
try {
|
|
109
|
+
const require = createRequire(join(pkgRoot, 'package.json'));
|
|
110
|
+
const manifest = require('ccusage/package.json');
|
|
111
|
+
const rel = typeof manifest.bin === 'string' ? manifest.bin : manifest.bin.ccusage;
|
|
112
|
+
const entry = join(dirnameOf(require.resolve('ccusage/package.json')), rel);
|
|
113
|
+
return { command: process.execPath, args: [entry, ...args] };
|
|
114
|
+
} catch {
|
|
115
|
+
return {
|
|
116
|
+
command: process.platform === 'win32' ? 'npx.cmd' : 'npx',
|
|
117
|
+
args: ['--yes', 'ccusage@latest', ...args],
|
|
118
|
+
shell: process.platform === 'win32',
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function dirnameOf(file) {
|
|
124
|
+
return file.slice(0, Math.max(file.lastIndexOf('/'), file.lastIndexOf('\\')));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Materialise one source file into the scoped tree. Hardlink first — it is
|
|
128
|
+
// instant, uses no extra disk, and (unlike a symlink or junction) is a real
|
|
129
|
+
// directory entry that ccusage's globber traverses on Windows, where symlinked
|
|
130
|
+
// and junctioned dirs are silently skipped. Copy is the cross-volume fallback
|
|
131
|
+
// (hardlinks can't span drives). Creates parent dirs as needed.
|
|
132
|
+
function linkFile(src, dst) {
|
|
133
|
+
mkdirSync(dirname(dst), { recursive: true });
|
|
134
|
+
try {
|
|
135
|
+
linkSync(src, dst);
|
|
136
|
+
} catch {
|
|
137
|
+
copyFileSync(src, dst);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Every *.jsonl under a dir, as [absolutePath, relativePath] pairs so the scoped
|
|
142
|
+
// copy can preserve Claude's per-project subdir layout.
|
|
143
|
+
function jsonlFiles(dir, base = dir, out = []) {
|
|
144
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
145
|
+
const p = join(dir, e.name);
|
|
146
|
+
if (e.isDirectory()) jsonlFiles(p, base, out);
|
|
147
|
+
else if (e.name.endsWith('.jsonl')) out.push([p, relative(base, p)]);
|
|
148
|
+
}
|
|
149
|
+
return out;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Build a throwaway config root scoped to this project, for both agents. Always
|
|
153
|
+
// returns { root, env, cleanup }: env carries CLAUDE_CONFIG_DIR + CODEX_HOME
|
|
154
|
+
// pointing at the scoped roots, so unset agents report zero rather than leaking
|
|
155
|
+
// global data. `found` says whether any agent had data for this project.
|
|
156
|
+
//
|
|
157
|
+
// Files are hardlinked (see linkFile) rather than the dir symlinked, because
|
|
158
|
+
// ccusage does not traverse symlinked/junctioned directories on Windows.
|
|
159
|
+
export function buildScopedEnv(
|
|
160
|
+
{ claudeDir, codexSessionsDir: codexDir, cwd },
|
|
161
|
+
{ mkdtemp = defaultMkdtemp, linkFn = linkFile, codexFiles = codexFilesForCwd } = {},
|
|
162
|
+
) {
|
|
163
|
+
const root = mkdtemp();
|
|
164
|
+
|
|
165
|
+
// Claude: hardlink this project's transcripts under projects/proj/, keeping
|
|
166
|
+
// any subdir structure so ccusage sees them exactly as it would in place.
|
|
167
|
+
const claudeConfig = join(root, 'claude');
|
|
168
|
+
const claudeProj = join(claudeConfig, 'projects', 'proj');
|
|
169
|
+
mkdirSync(claudeProj, { recursive: true });
|
|
170
|
+
let found = false;
|
|
171
|
+
if (claudeDir && existsSync(claudeDir)) {
|
|
172
|
+
for (const [abs, rel] of jsonlFiles(claudeDir)) {
|
|
173
|
+
linkFn(abs, join(claudeProj, rel));
|
|
174
|
+
found = true;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Codex: hardlink only the session files whose cwd matches this project,
|
|
179
|
+
// flattened under sessions/ (ccusage globs recursively, so layout is moot).
|
|
180
|
+
const codexHome = join(root, 'codex');
|
|
181
|
+
const codexSessions = join(codexHome, 'sessions');
|
|
182
|
+
mkdirSync(codexSessions, { recursive: true });
|
|
183
|
+
const matches = codexDir ? codexFiles(codexDir, cwd) : [];
|
|
184
|
+
matches.forEach((f, i) => linkFn(f, join(codexSessions, `${i}-${basename(f)}`)));
|
|
185
|
+
if (matches.length > 0) found = true;
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
root,
|
|
189
|
+
found,
|
|
190
|
+
env: { ...process.env, CLAUDE_CONFIG_DIR: claudeConfig, CODEX_HOME: codexHome },
|
|
191
|
+
cleanup: () => rmSync(root, { recursive: true, force: true }),
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function defaultMkdtemp() {
|
|
196
|
+
return mkdtempSync(join(tmpdir(), 'comate-usage-'));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Split comate's own flags (--global) from everything else, which is forwarded
|
|
200
|
+
// to ccusage untouched. Default subcommand is `session` — the per-conversation
|
|
201
|
+
// view is what "how much did this project cost" usually means.
|
|
202
|
+
export function parseArgs(argv) {
|
|
203
|
+
const global = argv.includes('--global');
|
|
204
|
+
const rest = argv.filter((a) => a !== '--global');
|
|
205
|
+
const hasCommand = rest.length > 0 && !rest[0].startsWith('-');
|
|
206
|
+
const forwarded = hasCommand ? rest : ['session', ...rest];
|
|
207
|
+
return { global, forwarded };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export async function usage(argv, { pkgRoot = defaultPkgRoot() } = {}) {
|
|
211
|
+
const { global, forwarded } = parseArgs(argv);
|
|
212
|
+
|
|
213
|
+
let scoped = null;
|
|
214
|
+
let env = process.env;
|
|
215
|
+
if (!global) {
|
|
216
|
+
const cwd = process.cwd();
|
|
217
|
+
scoped = buildScopedEnv({
|
|
218
|
+
claudeDir: claudeDataDir(cwd),
|
|
219
|
+
codexSessionsDir: codexSessionsDir(),
|
|
220
|
+
cwd,
|
|
221
|
+
});
|
|
222
|
+
if (!scoped.found) {
|
|
223
|
+
scoped.cleanup();
|
|
224
|
+
console.error(
|
|
225
|
+
'本项目暂无 Claude Code / Codex 会话记录。\n' +
|
|
226
|
+
'用 comate usage --global 查看全部项目的用量。',
|
|
227
|
+
);
|
|
228
|
+
return 1;
|
|
229
|
+
}
|
|
230
|
+
env = scoped.env;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
try {
|
|
234
|
+
const { command, args, shell } = ccusageInvocation(pkgRoot, forwarded);
|
|
235
|
+
const { status, error } = spawnSync(command, args, {
|
|
236
|
+
stdio: 'inherit',
|
|
237
|
+
env,
|
|
238
|
+
...(shell ? { shell: true } : {}),
|
|
239
|
+
});
|
|
240
|
+
if (error) {
|
|
241
|
+
console.error(`无法运行 ccusage:${error.message}\n试试 npx ccusage@latest`);
|
|
242
|
+
return 1;
|
|
243
|
+
}
|
|
244
|
+
return status ?? 1;
|
|
245
|
+
} finally {
|
|
246
|
+
scoped?.cleanup();
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function defaultPkgRoot() {
|
|
251
|
+
return dirnameOf(dirnameOf(import.meta.url.replace(/^file:\/\/\/?/, '')));
|
|
252
|
+
}
|