@genee/omp-opsx-addon 0.1.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 +83 -0
- package/index.ts +1327 -0
- package/lib/agent-defs.ts +291 -0
- package/lib/change-scanner.ts +72 -0
- package/lib/cursor-auth.ts +230 -0
- package/lib/direct-fetchers.ts +155 -0
- package/lib/edit-variant-pin.ts +156 -0
- package/lib/error-scan.ts +97 -0
- package/lib/family-filter.ts +174 -0
- package/lib/model-selector.ts +307 -0
- package/lib/model-tiers.ts +164 -0
- package/lib/notify.ts +4 -0
- package/lib/openspec-tasks.ts +28 -0
- package/lib/peak-detector.ts +44 -0
- package/lib/reachability-cache.ts +288 -0
- package/lib/reachability-probe.ts +459 -0
- package/lib/reachability-refresh.ts +84 -0
- package/lib/selection-filters.ts +100 -0
- package/lib/selection-lifecycle.ts +17 -0
- package/lib/session-title.ts +186 -0
- package/lib/session-usage.ts +252 -0
- package/lib/state.ts +95 -0
- package/lib/system-prompt.ts +160 -0
- package/lib/tiers-data.ts +136 -0
- package/lib/tiers-updater.ts +146 -0
- package/lib/unified-config.ts +463 -0
- package/lib/usage-estimator.ts +154 -0
- package/lib/usage-poller.ts +1200 -0
- package/lib/usage-poller.worker.ts +31 -0
- package/lib/usage-redis-client.ts +594 -0
- package/lib/usage-redis-multiproc.worker.ts +80 -0
- package/lib/usage-redis-probe.ts +389 -0
- package/lib/usage-render.ts +526 -0
- package/lib/usage-resolver.ts +477 -0
- package/lib/usage-sampler.ts +77 -0
- package/lib/usage-widget.ts +112 -0
- package/package.json +47 -0
- package/types/bun.d.ts +21 -0
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
export const ADDON_VERSION = '0.1.0';
|
|
2
|
+
|
|
3
|
+
const MARKER = '<!-- @genee/omp-opsx-addon -->';
|
|
4
|
+
|
|
5
|
+
const CODE_REVIEWER_MD = (model?: string) => `---
|
|
6
|
+
name: code-reviewer
|
|
7
|
+
description: OpenSpec 代码审查 agent,审查代码实现并执行全局验证
|
|
8
|
+
tools: read, bash, glob, grep${model ? `\nmodel: ${model}` : ''}
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
你是一位资深代码审查员。你根据 OpenSpec 提案和设计文档审查代码实现,并在审阅的同时执行最后一次全局验证。
|
|
12
|
+
|
|
13
|
+
## 启动
|
|
14
|
+
1. 从 prompt 或上下文确定要审查的变更
|
|
15
|
+
2. 读取 proposal、design 和 tasks 文件
|
|
16
|
+
3. 运行 \`git diff HEAD~1\` 或 \`git diff --stat\` 查看改动
|
|
17
|
+
|
|
18
|
+
## 审查维度
|
|
19
|
+
1. **规范遵循**:是否精确实现了 proposal/design/tasks 定义的内容?有无缺失或偏离?
|
|
20
|
+
2. **代码质量**:可读性、可维护性、模式一致性。代码异味、死代码、不必要的复杂性?
|
|
21
|
+
3. **健壮性**:错误处理、边界情况、输入验证
|
|
22
|
+
4. **安全性**:注入缺陷、授权问题、敏感数据暴露
|
|
23
|
+
|
|
24
|
+
## 全局验证(交付前最后一道质量门)
|
|
25
|
+
coder 已在其改动面完成自验证;你的验证是 maker-checker 的兜底确认,与代码审查同轮执行:
|
|
26
|
+
- **Tier 1 – 环境完整性(必需)**:依赖安装、typecheck、lint
|
|
27
|
+
- **Tier 2 – 冒烟测试(必需)**:运行项目现有测试套件(\`bun test\`/\`npm test\` 等)
|
|
28
|
+
- **Tier 3 – E2E(可选)**:变更涉及用户可见行为时运行 E2E
|
|
29
|
+
|
|
30
|
+
## 输出格式
|
|
31
|
+
\`\`\`markdown
|
|
32
|
+
## 审查: <change-name>
|
|
33
|
+
|
|
34
|
+
**结论:** APPROVE | REQUEST_CHANGES | BLOCKED
|
|
35
|
+
**轮次:** 第 N 次审查
|
|
36
|
+
|
|
37
|
+
### 规范遵循
|
|
38
|
+
- [x] 需求 1: <状态> / - [ ] 需求 N: <差距>
|
|
39
|
+
|
|
40
|
+
### 关键问题 (必须修复)
|
|
41
|
+
<P0/P1 列表,或 "无">
|
|
42
|
+
|
|
43
|
+
### 改进建议 (可选修复)
|
|
44
|
+
<P2+ 列表,或 "无">
|
|
45
|
+
|
|
46
|
+
### 总结
|
|
47
|
+
<1-2 句整体评估>
|
|
48
|
+
|
|
49
|
+
### 全局验证
|
|
50
|
+
- [x] / - [ ] Tier 1 环境完整性: <通过/失败项>
|
|
51
|
+
- [x] / - [ ] Tier 2 冒烟测试: <通过/失败项>
|
|
52
|
+
- [x] / - [ ] Tier 3 E2E(如适用): <通过/失败或 N/A>
|
|
53
|
+
- 验证失败项(按严重度):<P0/P1 已并入关键问题;P2+ 告警,不阻塞通过>
|
|
54
|
+
\`\`\`
|
|
55
|
+
|
|
56
|
+
## 结论与上报
|
|
57
|
+
你是审查员,**只审查、不改代码**。如实报告,由主 agent 决定是否委派 coder 修复。
|
|
58
|
+
- **APPROVE** → 报告通过
|
|
59
|
+
- **REQUEST_CHANGES + P0/P1** → 返回问题列表,由主 agent 委派 coder 修复
|
|
60
|
+
- **REQUEST_CHANGES + 仅 P2+** → 视为通过,建议可选
|
|
61
|
+
- **BLOCKED** → 说明原因,由用户决策
|
|
62
|
+
- 全局验证失败项按严重度并入结论:P0/P1 → 关键问题(必须修复);P2 级验证告警 → 改进建议,不阻塞通过
|
|
63
|
+
${MARKER}
|
|
64
|
+
`;
|
|
65
|
+
const PROPOSAL_REVIEWER_MD = (model?: string) => `---
|
|
66
|
+
name: proposal-reviewer
|
|
67
|
+
description: OpenSpec 提案审查 agent,审查提案质量
|
|
68
|
+
tools: read, bash, glob, grep${model ? `\nmodel: ${model}` : ''}
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
你是一位资深需求审查员。你审查 OpenSpec 提案的完整性和质量。
|
|
72
|
+
|
|
73
|
+
## 启动
|
|
74
|
+
1. 从 prompt 或上下文确定要审查的提案名称
|
|
75
|
+
2. 读取 proposal.md、design.md(如果存在)
|
|
76
|
+
|
|
77
|
+
## 审查维度
|
|
78
|
+
1. **完整性**:Why/What/Capabilities/Impact 是否齐全?有无缺失章节?
|
|
79
|
+
2. **需求清晰度**:描述是否清晰无歧义?实现方能否据此执行?
|
|
80
|
+
3. **设计合理性**:方案是否实际可行?有无明显错漏或矛盾?
|
|
81
|
+
4. **Budget 合理性**:Token 预算估算是否与工作范围匹配?
|
|
82
|
+
5. **任务可执行性**:tasks.md 任务粒度是否合适?可独立交付?
|
|
83
|
+
|
|
84
|
+
## 输出格式
|
|
85
|
+
\`\`\`markdown
|
|
86
|
+
## 提案审查: <change-name>
|
|
87
|
+
|
|
88
|
+
**结论:** APPROVE | REQUEST_CHANGES | BLOCKED
|
|
89
|
+
**轮次:** 第 N 次审查
|
|
90
|
+
|
|
91
|
+
### 完整性
|
|
92
|
+
- [x] / - [ ] <章节>:<状态>
|
|
93
|
+
|
|
94
|
+
### 关键问题 (必须修复)
|
|
95
|
+
<P0/P1 列表,或 "无">
|
|
96
|
+
|
|
97
|
+
### 改进建议 (可选修复)
|
|
98
|
+
<P2+ 列表,或 "无">
|
|
99
|
+
|
|
100
|
+
### 总结
|
|
101
|
+
<1-2 句整体评估>
|
|
102
|
+
\`\`\`
|
|
103
|
+
|
|
104
|
+
## 结论与上报
|
|
105
|
+
- **APPROVE** → 提案通过
|
|
106
|
+
- **REQUEST_CHANGES + P0/P1** → 返回问题列表,由主 agent 委派 planner 修复
|
|
107
|
+
- **REQUEST_CHANGES + 仅 P2+** → 视为通过,建议可选
|
|
108
|
+
- **BLOCKED** → 说明原因,由用户决策
|
|
109
|
+
${MARKER}
|
|
110
|
+
`;
|
|
111
|
+
|
|
112
|
+
const CODER_MD = (model?: string) => `---
|
|
113
|
+
name: coder
|
|
114
|
+
description: OpenSpec 变更实现 agent,同步 tasks.md 进度
|
|
115
|
+
tools: read, write, edit, bash, glob, grep, todo${model ? `\nmodel: ${model}` : ''}
|
|
116
|
+
skill: openspec-apply-change
|
|
117
|
+
---
|
|
118
|
+
|
|
119
|
+
你是一个快速、专注的代码实现 agent。你从 prompt 和对话上下文推断要做什么。
|
|
120
|
+
|
|
121
|
+
## 启动:确定任务
|
|
122
|
+
0. 加载技能:用 Skill tool 读取 skill://openspec-apply-change 并遵循其流程
|
|
123
|
+
1. 若 prompt 提到变更名称或能推断:读取 \`openspec/changes/<name>/tasks.md\`、\`proposal.md\`、\`design.md\` 并实现未完成任务
|
|
124
|
+
2. 若无名称:运行 \`openspec list --json\`;单个用其,多个或无则停止并询问
|
|
125
|
+
3. 通用编码请求:从 prompt 理解需求直接实现
|
|
126
|
+
|
|
127
|
+
## 实现规则
|
|
128
|
+
- 写代码前先读现有代码;匹配项目风格和模式
|
|
129
|
+
- 完成任务后在 tasks.md 标记:\`- [ ]\` → \`- [x]\`
|
|
130
|
+
- 每轮结束时根据实际进度更新 tasks.md,确保提案状态与代码一致
|
|
131
|
+
- 代码全部完成后将所有未完成项标记为 \`- [x]\`,在 ACTION 报告中说明变更已全部实现
|
|
132
|
+
- 不修改 \`proposal.md\`、\`design.md\` 或 \`.openspec.yaml\`
|
|
133
|
+
- 直接用 read/write/edit/bash 等内置工具实现;不要尝试调用 task 二次委派。
|
|
134
|
+
## 验证(交付门槛)
|
|
135
|
+
- 交付前必须跑过 **scoped 到本次改动面** 的 lint、typecheck 与针对性单测(proposal 约定的测试必须通过);任一未通过不得交付,先修到通过再标记 ACTION: REVIEW_REQUIRED。
|
|
136
|
+
- 项目级全量校验(全仓库 lint/typecheck/\`bun test\`)不属于自验证门槛:若失败可归因于并发 sibling 的半成品改动(与本改动无关),把失败连同「已排查与本改动无关」的证据作为上下文上报(交付摘要中注明),不要被其阻塞;最终由 code-reviewer 的全局验证兜底确认。
|
|
137
|
+
|
|
138
|
+
## 协作流程(由你编排)
|
|
139
|
+
1. **完成实现后**:向主 agent 报告,由主 agent 决定是否委派审查
|
|
140
|
+
2. **收到审查反馈(P0/P1)**:反馈统一由 code-reviewer 回传(含全局验证失败);用 read/edit/bash 修复后重新标记 ACTION: REVIEW_REQUIRED
|
|
141
|
+
3. **轮次上限**:最多 3 轮(含首次实现与修复);仍无法解决则 STATUS: blocked
|
|
142
|
+
|
|
143
|
+
## 输出格式
|
|
144
|
+
向主 agent 报告时以以下格式结尾:
|
|
145
|
+
\`\`\`
|
|
146
|
+
---
|
|
147
|
+
ACTION: REVIEW_REQUIRED
|
|
148
|
+
CHANGE: <change-name 或 "general">
|
|
149
|
+
STATUS: success | partial | blocked
|
|
150
|
+
SESSION: <session_name>(仅 STATUS: blocked 且需 resume 时填写)
|
|
151
|
+
TASKS: N/M complete
|
|
152
|
+
FILES: <逗号分隔的修改/创建文件列表>
|
|
153
|
+
---
|
|
154
|
+
\`\`\`
|
|
155
|
+
收到 Review 反馈后,修复 P0/P1,输出中增加「本轮修复」章节;修复完成且需再审时 ACTION 仍为 REVIEW_REQUIRED。
|
|
156
|
+
${MARKER}
|
|
157
|
+
`;
|
|
158
|
+
|
|
159
|
+
const PLANNER_MD = (model?: string) => `---
|
|
160
|
+
name: planner
|
|
161
|
+
description: OpenSpec 提案规划 agent
|
|
162
|
+
tools: read, write, edit, bash, glob, grep, todo${model ? `\nmodel: ${model}` : ''}\nskill: openspec-propose
|
|
163
|
+
---
|
|
164
|
+
|
|
165
|
+
你是一个 OpenSpec 提案规划 agent。负责创建和更新 OpenSpec 变更提案文档。
|
|
166
|
+
|
|
167
|
+
## 启动:确定任务
|
|
168
|
+
1. 加载技能:用 Skill tool 读取 skill://openspec-propose 并遵循其流程
|
|
169
|
+
2. 若 prompt 提到变更名称或能推断:读取 \`openspec/changes/<name>/\` 下已有文件,根据需求创建或更新 proposal/design/tasks
|
|
170
|
+
3. 若无名称:从 prompt 理解需求,确定名称并创建新变更目录
|
|
171
|
+
4. 若提到使用 skill(如 openspec-propose):用 Skill tool 加载并执行
|
|
172
|
+
|
|
173
|
+
## 工作范围
|
|
174
|
+
- **只负责提案文档**:proposal.md、design.md、tasks.md、specs/(openspec/changes/ 目录下)
|
|
175
|
+
- 直接用内置工具;不要尝试调用 task 二次委派。
|
|
176
|
+
|
|
177
|
+
## 提案拆分
|
|
178
|
+
- 接大需求先评估契约边界:能拆则拆成多个小变更,先输出拆分建议(契约边界+提案清单)待主 agent 确认后再开写。
|
|
179
|
+
- 纲领/roadmap change 只做层级管理(阶段/依赖/验收口径),不含实现细节。
|
|
180
|
+
|
|
181
|
+
## Budget 估算
|
|
182
|
+
|
|
183
|
+
在 proposal.md 末尾输出 \`## Budget Estimate\` 章节。Budget 必须覆盖**完整编排流程**的全部 agent 消耗(planner → proposal-reviewer → coder → code-reviewer + 可能的修复轮次),不只是提案撰写。
|
|
184
|
+
|
|
185
|
+
### 快速公式
|
|
186
|
+
\`总额 = (涉及文件数 × 80K + 固定编排开销 120K) × 轮次系数\`
|
|
187
|
+
|
|
188
|
+
- **涉及文件数**:需修改或新建的源文件数量(不含 proposal/design/tasks 等提案文档本身)
|
|
189
|
+
- **固定编排开销**:120K tokens(planner 30K + proposal-reviewer 20K + code-reviewer(审查+全局验证) 30K + 主 agent 委派及缓冲 40K)
|
|
190
|
+
- **轮次系数**:预期 1 轮 = 1.0,2 轮 = 1.3,3 轮 = 1.5
|
|
191
|
+
|
|
192
|
+
### 输出格式
|
|
193
|
+
\`\`\`
|
|
194
|
+
## Budget Estimate
|
|
195
|
+
- 涉及文件数: <F>
|
|
196
|
+
- 预计审查轮次: <R>
|
|
197
|
+
- 预计实现 token: <F × 80K × R>K
|
|
198
|
+
- 预计审查 token: <R × 25K>K
|
|
199
|
+
- 编排固定开销: 120K
|
|
200
|
+
- 建议 /goal budget: <总额>K tokens
|
|
201
|
+
\`\`\`
|
|
202
|
+
|
|
203
|
+
### 参考示例
|
|
204
|
+
- 3 个文件、1 轮 → (240 + 120) × 1.0 = 360 → \`建议 /goal budget: 360K tokens\`
|
|
205
|
+
- 8 个文件、2 轮 → (640 + 120) × 1.3 = 988 → \`建议 /goal budget: 990K tokens\`
|
|
206
|
+
- 单文件修复、1 轮 → (80 + 120) × 1.0 = 200 → \`建议 /goal budget: 200K tokens\`
|
|
207
|
+
|
|
208
|
+
## 输出
|
|
209
|
+
完成后列出所有创建或修改的文件及变更名称。
|
|
210
|
+
|
|
211
|
+
${MARKER}
|
|
212
|
+
`;
|
|
213
|
+
|
|
214
|
+
import { readFile, writeFile, mkdir } from 'fs/promises';
|
|
215
|
+
import { existsSync } from 'fs';
|
|
216
|
+
import { join } from 'path';
|
|
217
|
+
|
|
218
|
+
type Status = 'unchanged' | 'created' | 'updated' | 'conflict';
|
|
219
|
+
|
|
220
|
+
async function agentStatus(file: string, want: string): Promise<Status> {
|
|
221
|
+
if (!existsSync(file)) return 'created';
|
|
222
|
+
try {
|
|
223
|
+
const have = await readFile(file, 'utf-8');
|
|
224
|
+
if (have === want) return 'unchanged';
|
|
225
|
+
return have.includes(MARKER) ? 'updated' : 'conflict';
|
|
226
|
+
} catch {
|
|
227
|
+
return 'conflict';
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export async function installAgents(
|
|
232
|
+
projectDir: string,
|
|
233
|
+
coderModel?: string,
|
|
234
|
+
reviewerModel?: string,
|
|
235
|
+
plannerModel?: string,
|
|
236
|
+
proposalReviewerModel?: string,
|
|
237
|
+
): Promise<{ status: Status; conflicts: string[] }> {
|
|
238
|
+
const agentsDir = join(projectDir, '.omp', 'agents');
|
|
239
|
+
if (!existsSync(agentsDir)) await mkdir(agentsDir, { recursive: true });
|
|
240
|
+
|
|
241
|
+
const coderWant = CODER_MD(coderModel?.trim() || undefined);
|
|
242
|
+
const codeReviewerWant = CODE_REVIEWER_MD(reviewerModel?.trim() || undefined);
|
|
243
|
+
const plannerWant = PLANNER_MD(plannerModel?.trim() || undefined);
|
|
244
|
+
const proposalReviewerWant = PROPOSAL_REVIEWER_MD(proposalReviewerModel?.trim() || undefined);
|
|
245
|
+
|
|
246
|
+
const coderPath = join(agentsDir, 'coder.md');
|
|
247
|
+
const codeReviewerPath = join(agentsDir, 'code-reviewer.md');
|
|
248
|
+
const plannerPath = join(agentsDir, 'planner.md');
|
|
249
|
+
const proposalReviewerPath = join(agentsDir, 'proposal-reviewer.md');
|
|
250
|
+
const versionFile = join(agentsDir, '.addon-version');
|
|
251
|
+
|
|
252
|
+
const [coderStat, codeReviewerStat, plannerStat, proposalReviewerStat] = await Promise.all([
|
|
253
|
+
agentStatus(coderPath, coderWant),
|
|
254
|
+
agentStatus(codeReviewerPath, codeReviewerWant),
|
|
255
|
+
agentStatus(plannerPath, plannerWant),
|
|
256
|
+
agentStatus(proposalReviewerPath, proposalReviewerWant),
|
|
257
|
+
]);
|
|
258
|
+
|
|
259
|
+
const conflicts: string[] = [];
|
|
260
|
+
const writes: Promise<void>[] = [];
|
|
261
|
+
|
|
262
|
+
for (const [path, stat, content] of [
|
|
263
|
+
[coderPath, coderStat, coderWant],
|
|
264
|
+
[codeReviewerPath, codeReviewerStat, codeReviewerWant],
|
|
265
|
+
[plannerPath, plannerStat, plannerWant],
|
|
266
|
+
[proposalReviewerPath, proposalReviewerStat, proposalReviewerWant],
|
|
267
|
+
] as const) {
|
|
268
|
+
if (stat === 'conflict') {
|
|
269
|
+
conflicts.push(path.replace(agentsDir + '/', ''));
|
|
270
|
+
} else if (stat === 'created' || stat === 'updated') {
|
|
271
|
+
writes.push(writeFile(path, content, 'utf-8'));
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (
|
|
276
|
+
coderStat !== 'conflict' &&
|
|
277
|
+
codeReviewerStat !== 'conflict' &&
|
|
278
|
+
plannerStat !== 'conflict' &&
|
|
279
|
+
proposalReviewerStat !== 'conflict'
|
|
280
|
+
) {
|
|
281
|
+
writes.push(writeFile(versionFile, ADDON_VERSION, 'utf-8'));
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
await Promise.all(writes);
|
|
285
|
+
|
|
286
|
+
const statuses = [coderStat, codeReviewerStat, plannerStat, proposalReviewerStat];
|
|
287
|
+
if (statuses.includes('conflict')) return { status: 'conflict', conflicts };
|
|
288
|
+
if (statuses.includes('created')) return { status: 'created', conflicts };
|
|
289
|
+
if (statuses.includes('updated')) return { status: 'updated', conflicts };
|
|
290
|
+
return { status: 'unchanged', conflicts };
|
|
291
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { readFile, readdir } from 'fs/promises';
|
|
2
|
+
import { existsSync } from 'fs';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { countTaskCheckboxes } from './openspec-tasks.js';
|
|
5
|
+
|
|
6
|
+
export type ChangeStatus = 'ready' | 'doing' | 'done';
|
|
7
|
+
|
|
8
|
+
export interface ChangeInfo {
|
|
9
|
+
name: string;
|
|
10
|
+
status: ChangeStatus;
|
|
11
|
+
completed: number;
|
|
12
|
+
total: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function inferStatus(completed: number, total: number): ChangeStatus {
|
|
16
|
+
if (total === 0) return 'ready';
|
|
17
|
+
if (completed === total) return 'done';
|
|
18
|
+
return 'doing';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function scanChanges(directory: string, statusFilter?: ChangeStatus): Promise<ChangeInfo[]> {
|
|
22
|
+
const changesDir = join(directory, 'openspec', 'changes');
|
|
23
|
+
if (!existsSync(changesDir)) return [];
|
|
24
|
+
|
|
25
|
+
let names: string[];
|
|
26
|
+
try {
|
|
27
|
+
const entries = await readdir(changesDir, { withFileTypes: true });
|
|
28
|
+
names = entries
|
|
29
|
+
.filter((e) => e.isDirectory() && e.name !== 'archive')
|
|
30
|
+
.map((e) => e.name)
|
|
31
|
+
.sort();
|
|
32
|
+
} catch {
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const results: ChangeInfo[] = [];
|
|
37
|
+
for (const name of names) {
|
|
38
|
+
const tasksPath = join(changesDir, name, 'tasks.md');
|
|
39
|
+
if (!existsSync(tasksPath)) {
|
|
40
|
+
results.push({ name, status: 'ready', completed: 0, total: 0 });
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
try {
|
|
44
|
+
const content = await readFile(tasksPath, 'utf-8');
|
|
45
|
+
const { completed, total } = countTaskCheckboxes(content);
|
|
46
|
+
results.push({ name, status: inferStatus(completed, total), completed, total });
|
|
47
|
+
} catch {
|
|
48
|
+
results.push({ name, status: 'ready', completed: 0, total: 0 });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (statusFilter) return results.filter((r) => r.status === statusFilter);
|
|
52
|
+
return results;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function createCachedScanner(ttlMs = 30_000, clock: () => number = Date.now) {
|
|
56
|
+
let cache: { dir: string; filter?: ChangeStatus; result: ChangeInfo[]; expiresAt: number } | null = null;
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
async scanChanges(directory: string, statusFilter?: ChangeStatus): Promise<ChangeInfo[]> {
|
|
60
|
+
const now = clock();
|
|
61
|
+
if (cache && cache.dir === directory && cache.filter === statusFilter && now < cache.expiresAt) {
|
|
62
|
+
return cache.result;
|
|
63
|
+
}
|
|
64
|
+
const result = await scanChanges(directory, statusFilter);
|
|
65
|
+
cache = { dir: directory, filter: statusFilter, result, expiresAt: now + ttlMs };
|
|
66
|
+
return result;
|
|
67
|
+
},
|
|
68
|
+
invalidate() {
|
|
69
|
+
cache = null;
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cursor IDE credential resolution for usage fetch.
|
|
3
|
+
*
|
|
4
|
+
* Cascade (local-only, no network for presence probes):
|
|
5
|
+
* 1. CURSOR_ACCESS_TOKEN env
|
|
6
|
+
* 2. Cursor IDE state.vscdb → cursorAuth/accessToken
|
|
7
|
+
* 3. In-process refreshed token (after oauth/token)
|
|
8
|
+
*
|
|
9
|
+
* The state DB is always opened read-only and never written.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { homedir } from 'os';
|
|
13
|
+
import { join } from 'path';
|
|
14
|
+
import { existsSync } from 'fs';
|
|
15
|
+
import { Database } from 'bun:sqlite';
|
|
16
|
+
|
|
17
|
+
export const CURSOR_OAUTH_CLIENT_ID = 'KbZUR41cY7W6zRSdpSUJ7I7mLYBKOCmB';
|
|
18
|
+
export const CURSOR_TOKEN_URL = 'https://api2.cursor.sh/oauth/token';
|
|
19
|
+
|
|
20
|
+
export interface CursorTokenStore {
|
|
21
|
+
accessToken?: string;
|
|
22
|
+
refreshToken?: string;
|
|
23
|
+
membershipType?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type CursorTokenReader = (dbPath: string) => CursorTokenStore | null;
|
|
27
|
+
|
|
28
|
+
/** Process-memory access token from a successful refresh (not persisted). */
|
|
29
|
+
let memoryAccessToken: string | undefined;
|
|
30
|
+
|
|
31
|
+
let tokenReader: CursorTokenReader = defaultReadTokensFromDb;
|
|
32
|
+
/** When set, overrides filesystem discovery of state.vscdb (tests). */
|
|
33
|
+
let stateDbPathOverride: string | null | undefined;
|
|
34
|
+
|
|
35
|
+
/** Test seam: replace the SQLite reader. Pass `null` to restore the default. */
|
|
36
|
+
export function _setTokenReaderForTest(reader: CursorTokenReader | null): void {
|
|
37
|
+
tokenReader = reader ?? defaultReadTokensFromDb;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Test seam: force a db path (`null` = pretend missing; `undefined` = restore). */
|
|
41
|
+
export function _setStateDbPathForTest(path: string | null | undefined): void {
|
|
42
|
+
stateDbPathOverride = path;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Test seam: clear / seed the in-process refreshed token. */
|
|
46
|
+
export function _setMemoryAccessTokenForTest(token: string | undefined): void {
|
|
47
|
+
memoryAccessToken = token;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Cross-platform path to Cursor's globalStorage state.vscdb, or null if missing. */
|
|
51
|
+
export function defaultStateDbPath(env: NodeJS.ProcessEnv = process.env, home = homedir()): string | null {
|
|
52
|
+
if (stateDbPathOverride !== undefined) return stateDbPathOverride;
|
|
53
|
+
const platform = process.platform;
|
|
54
|
+
let candidate: string;
|
|
55
|
+
if (platform === 'darwin') {
|
|
56
|
+
candidate = join(home, 'Library', 'Application Support', 'Cursor', 'User', 'globalStorage', 'state.vscdb');
|
|
57
|
+
} else if (platform === 'win32') {
|
|
58
|
+
const appData = env.APPDATA?.trim() || join(home, 'AppData', 'Roaming');
|
|
59
|
+
candidate = join(appData, 'Cursor', 'User', 'globalStorage', 'state.vscdb');
|
|
60
|
+
} else {
|
|
61
|
+
const xdg = env.XDG_CONFIG_HOME?.trim() || join(home, '.config');
|
|
62
|
+
candidate = join(xdg, 'Cursor', 'User', 'globalStorage', 'state.vscdb');
|
|
63
|
+
}
|
|
64
|
+
return existsSync(candidate) ? candidate : null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function decodeJwtPayload(token: string): Record<string, unknown> | null {
|
|
68
|
+
try {
|
|
69
|
+
const part = token.split('.')[1];
|
|
70
|
+
if (!part) return null;
|
|
71
|
+
const padded = part + '='.repeat((4 - (part.length % 4)) % 4);
|
|
72
|
+
const json = Buffer.from(padded, 'base64url').toString('utf8');
|
|
73
|
+
return JSON.parse(json) as Record<string, unknown>;
|
|
74
|
+
} catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** True when the JWT `exp` claim is in the past. Non-JWTs are treated as not expired. */
|
|
80
|
+
export function isJwtExpired(token: string, nowMs: number = Date.now()): boolean {
|
|
81
|
+
const payload = decodeJwtPayload(token);
|
|
82
|
+
const exp = payload?.exp;
|
|
83
|
+
if (typeof exp !== 'number' || !Number.isFinite(exp)) return false;
|
|
84
|
+
return exp * 1000 <= nowMs;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function defaultReadTokensFromDb(dbPath: string): CursorTokenStore | null {
|
|
88
|
+
try {
|
|
89
|
+
const db = new Database(dbPath, { readonly: true });
|
|
90
|
+
try {
|
|
91
|
+
const row = (key: string): string | undefined => {
|
|
92
|
+
const r = db.query('SELECT value FROM ItemTable WHERE key = ?').get(key) as
|
|
93
|
+
| { value?: unknown }
|
|
94
|
+
| null
|
|
95
|
+
| undefined;
|
|
96
|
+
if (!r || r.value == null) return undefined;
|
|
97
|
+
const v = r.value;
|
|
98
|
+
return typeof v === 'string' ? v : Buffer.isBuffer(v) ? v.toString('utf8') : String(v);
|
|
99
|
+
};
|
|
100
|
+
const accessToken = row('cursorAuth/accessToken')?.trim() || undefined;
|
|
101
|
+
const refreshToken = row('cursorAuth/refreshToken')?.trim() || undefined;
|
|
102
|
+
const membershipType = row('cursorAuth/stripeMembershipType')?.trim() || undefined;
|
|
103
|
+
if (!accessToken && !refreshToken) return null;
|
|
104
|
+
return { accessToken, refreshToken, membershipType };
|
|
105
|
+
} finally {
|
|
106
|
+
db.close();
|
|
107
|
+
}
|
|
108
|
+
} catch {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function readStore(env: NodeJS.ProcessEnv = process.env): CursorTokenStore {
|
|
114
|
+
const fromEnvAccess = env.CURSOR_ACCESS_TOKEN?.trim() || undefined;
|
|
115
|
+
const fromEnvRefresh = env.CURSOR_REFRESH_TOKEN?.trim() || undefined;
|
|
116
|
+
const dbPath = defaultStateDbPath(env);
|
|
117
|
+
const fromDb = dbPath ? tokenReader(dbPath) : null;
|
|
118
|
+
return {
|
|
119
|
+
accessToken: fromEnvAccess || memoryAccessToken || fromDb?.accessToken,
|
|
120
|
+
refreshToken: fromEnvRefresh || fromDb?.refreshToken,
|
|
121
|
+
membershipType: fromDb?.membershipType,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Sync, local-only probe — MUST NOT hit the network.
|
|
127
|
+
* True when env/IDE/memory can supply an access token string.
|
|
128
|
+
*/
|
|
129
|
+
export function cursorCredentialsPresent(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
130
|
+
if (env.CURSOR_ACCESS_TOKEN?.trim()) return true;
|
|
131
|
+
if (memoryAccessToken?.trim()) return true;
|
|
132
|
+
const dbPath = defaultStateDbPath(env);
|
|
133
|
+
if (!dbPath) return false;
|
|
134
|
+
const store = tokenReader(dbPath);
|
|
135
|
+
return Boolean(store?.accessToken?.trim());
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface ResolvedCursorAuth {
|
|
139
|
+
accessToken: string;
|
|
140
|
+
refreshToken?: string;
|
|
141
|
+
membershipType?: string;
|
|
142
|
+
/** True when the access token came from CURSOR_ACCESS_TOKEN. */
|
|
143
|
+
fromEnv: boolean;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Resolve an access token for usage fetch. May refresh once if the JWT is expired
|
|
148
|
+
* and a refresh token is available. Never writes back to the IDE DB.
|
|
149
|
+
*/
|
|
150
|
+
export async function resolveCursorAccessToken(opts?: {
|
|
151
|
+
env?: NodeJS.ProcessEnv;
|
|
152
|
+
nowMs?: number;
|
|
153
|
+
fetchImpl?: typeof fetch;
|
|
154
|
+
/** When true, force a refresh attempt even if the access token looks unexpired. */
|
|
155
|
+
forceRefresh?: boolean;
|
|
156
|
+
}): Promise<ResolvedCursorAuth | null> {
|
|
157
|
+
const env = opts?.env ?? process.env;
|
|
158
|
+
const nowMs = opts?.nowMs ?? Date.now();
|
|
159
|
+
const fetchImpl = opts?.fetchImpl ?? fetch;
|
|
160
|
+
|
|
161
|
+
const fromEnv = Boolean(env.CURSOR_ACCESS_TOKEN?.trim());
|
|
162
|
+
const store = readStore(env);
|
|
163
|
+
const access = store.accessToken?.trim() || undefined;
|
|
164
|
+
const refresh = store.refreshToken?.trim() || undefined;
|
|
165
|
+
|
|
166
|
+
if (access && !opts?.forceRefresh && !isJwtExpired(access, nowMs)) {
|
|
167
|
+
return {
|
|
168
|
+
accessToken: access,
|
|
169
|
+
refreshToken: refresh,
|
|
170
|
+
membershipType: store.membershipType,
|
|
171
|
+
fromEnv,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (refresh) {
|
|
176
|
+
const next = await refreshCursorAccessToken(refresh, fetchImpl);
|
|
177
|
+
if (next) {
|
|
178
|
+
memoryAccessToken = next;
|
|
179
|
+
return {
|
|
180
|
+
accessToken: next,
|
|
181
|
+
refreshToken: refresh,
|
|
182
|
+
membershipType: store.membershipType,
|
|
183
|
+
fromEnv: false,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Fall back to a still-present (possibly expired) access token so the caller
|
|
189
|
+
// can attempt the usage request and refresh on 401 — but only if we have one.
|
|
190
|
+
if (access) {
|
|
191
|
+
return {
|
|
192
|
+
accessToken: access,
|
|
193
|
+
refreshToken: refresh,
|
|
194
|
+
membershipType: store.membershipType,
|
|
195
|
+
fromEnv,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Exchange a refresh token for a new access token.
|
|
203
|
+
* Returns null on failure or when the server signals logout.
|
|
204
|
+
*/
|
|
205
|
+
export async function refreshCursorAccessToken(
|
|
206
|
+
refreshToken: string,
|
|
207
|
+
fetchImpl: typeof fetch = fetch,
|
|
208
|
+
): Promise<string | null> {
|
|
209
|
+
try {
|
|
210
|
+
const res = await fetchImpl(CURSOR_TOKEN_URL, {
|
|
211
|
+
method: 'POST',
|
|
212
|
+
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
213
|
+
body: JSON.stringify({
|
|
214
|
+
grant_type: 'refresh_token',
|
|
215
|
+
client_id: CURSOR_OAUTH_CLIENT_ID,
|
|
216
|
+
refresh_token: refreshToken,
|
|
217
|
+
}),
|
|
218
|
+
});
|
|
219
|
+
if (!res.ok) return null;
|
|
220
|
+
const body = (await res.json()) as {
|
|
221
|
+
access_token?: string;
|
|
222
|
+
shouldLogout?: boolean;
|
|
223
|
+
};
|
|
224
|
+
if (body.shouldLogout) return null;
|
|
225
|
+
const next = body.access_token?.trim();
|
|
226
|
+
return next || null;
|
|
227
|
+
} catch {
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
}
|