ronds_ai 0.1.6 → 0.1.8
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 +289 -0
- package/bin/ronds_ai.js +114 -0
- package/lib/check_record.js +106 -0
- package/lib/skills_install.js +226 -0
- package/lib/skills_prompt.js +87 -0
- package/lib/skills_targets.js +81 -0
- package/package.json +4 -1
package/README.md
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
# ronds_ai
|
|
2
|
+
|
|
3
|
+
`ronds_ai` 是一个命令行工具,主要用于两类事情:
|
|
4
|
+
|
|
5
|
+
- 接收 Claude / Cursor 的 hook 事件,整理成统一的代码变更事件并上报
|
|
6
|
+
- 帮助项目写入对应的 hook 配置,以及安装 Skills 到 Claude / Codex / Cursor
|
|
7
|
+
|
|
8
|
+
## Requirements
|
|
9
|
+
|
|
10
|
+
- Node.js `>=16`
|
|
11
|
+
- `git`
|
|
12
|
+
- `unzip`
|
|
13
|
+
|
|
14
|
+
`git` 主要用于读取仓库信息和用户邮箱;`unzip` 用于 `skills install` 解压技能包。
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
按一次性执行使用:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npx ronds_ai@latest <command>
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
或全局安装:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
npm install -g ronds_ai
|
|
28
|
+
ronds_ai <command>
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Commands
|
|
32
|
+
|
|
33
|
+
### `check record`
|
|
34
|
+
|
|
35
|
+
检查 `record` 命令运行所需的基础环境。
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
ronds_ai check record
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
示例:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
npx ronds_ai@latest check record
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
当前会检查并输出:
|
|
48
|
+
|
|
49
|
+
- 当前 Node 版本
|
|
50
|
+
- Node 是否满足 `>=16`
|
|
51
|
+
- 当前目录下读取到的 `git user.email`
|
|
52
|
+
- 当前 `worker_id` 配置来源和取值
|
|
53
|
+
- `~/.profile` 路径
|
|
54
|
+
|
|
55
|
+
输出是一个 JSON,例如:
|
|
56
|
+
|
|
57
|
+
```json
|
|
58
|
+
{
|
|
59
|
+
"ok": true,
|
|
60
|
+
"targetDir": "/path/to/project",
|
|
61
|
+
"node": {
|
|
62
|
+
"version": "v20.19.0",
|
|
63
|
+
"requirement": ">=16",
|
|
64
|
+
"satisfied": true
|
|
65
|
+
},
|
|
66
|
+
"gitUserEmail": "name@example.com",
|
|
67
|
+
"workerId": {
|
|
68
|
+
"name": "MCP_TRACKER_WORKER_ID",
|
|
69
|
+
"value": "worker-123",
|
|
70
|
+
"source": "env"
|
|
71
|
+
},
|
|
72
|
+
"profileFile": "/Users/you/.profile"
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
如果 Node 版本不满足要求,命令会返回非 0 退出码。
|
|
77
|
+
|
|
78
|
+
### `record`
|
|
79
|
+
|
|
80
|
+
从标准输入读取 Claude 或 Cursor 的 hook payload,转换为统一事件后发送到服务端。
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
ronds_ai record <tool>
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
支持的 `tool`:
|
|
87
|
+
|
|
88
|
+
- `claude`
|
|
89
|
+
- `cursor`
|
|
90
|
+
|
|
91
|
+
示例:
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
npx ronds_ai@latest record claude
|
|
95
|
+
npx ronds_ai@latest record cursor
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
这个命令通常不是手工执行,而是被 Claude / Cursor 的 hook 配置调用。
|
|
99
|
+
|
|
100
|
+
行为说明:
|
|
101
|
+
|
|
102
|
+
- 从 `stdin` 读取一段 JSON
|
|
103
|
+
- 根据来源提取文件路径、变更内容、增删行数、仓库信息、worker_id
|
|
104
|
+
- 组装事件后通过 HTTP POST 上报
|
|
105
|
+
- 成功时输出一行 JSON
|
|
106
|
+
- 失败时会把事件写入本地失败日志,并输出保存位置
|
|
107
|
+
|
|
108
|
+
成功输出示例:
|
|
109
|
+
|
|
110
|
+
```json
|
|
111
|
+
{"status":"sent","event_id":"...","response_status":200}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
`dry-run` 输出示例:
|
|
115
|
+
|
|
116
|
+
```json
|
|
117
|
+
{"status":"dry-run","event_id":"...","response_status":0}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
失败保存输出示例:
|
|
121
|
+
|
|
122
|
+
```json
|
|
123
|
+
{"status":"saved","event_id":"...","saved_path":"/Users/you/.ronds_ai/failed-events/20260409-claude-error.jsonl"}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
目前支持的 hook 事件:
|
|
127
|
+
|
|
128
|
+
- Claude: `PostToolUse`
|
|
129
|
+
- Cursor: `afterFileEdit`
|
|
130
|
+
|
|
131
|
+
其中 Claude 仅处理这些工具产生的事件:
|
|
132
|
+
|
|
133
|
+
- `Write`
|
|
134
|
+
- `Edit`
|
|
135
|
+
- `MultiEdit`
|
|
136
|
+
|
|
137
|
+
### `doctor`
|
|
138
|
+
|
|
139
|
+
检查当前目录下的 hook 配置和最近错误日志,方便排查接入问题。
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
ronds_ai doctor <tool>
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
支持的 `tool`:
|
|
146
|
+
|
|
147
|
+
- `claude`
|
|
148
|
+
- `cursor`
|
|
149
|
+
|
|
150
|
+
示例:
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
npx ronds_ai@latest doctor claude
|
|
154
|
+
npx ronds_ai@latest doctor cursor
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
输出内容包括:
|
|
158
|
+
|
|
159
|
+
- 当前检查的工具类型
|
|
160
|
+
- 当前目标目录
|
|
161
|
+
- 当前目录下的 `git user.email`
|
|
162
|
+
- 关键配置文件是否存在
|
|
163
|
+
- 今天的最近错误日志内容
|
|
164
|
+
|
|
165
|
+
Claude 会检查:
|
|
166
|
+
|
|
167
|
+
- `.claude/settings.json`
|
|
168
|
+
- `.claude/settings.local.json`
|
|
169
|
+
|
|
170
|
+
Cursor 会检查:
|
|
171
|
+
|
|
172
|
+
- `.cursor/hooks.json`
|
|
173
|
+
|
|
174
|
+
### `hooks deploy`
|
|
175
|
+
|
|
176
|
+
在当前项目目录生成或更新 Claude / Cursor 的 hook 配置。
|
|
177
|
+
|
|
178
|
+
```bash
|
|
179
|
+
ronds_ai hooks deploy
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
示例:
|
|
183
|
+
|
|
184
|
+
```bash
|
|
185
|
+
npx ronds_ai@latest hooks deploy
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
这个命令会做的事情:
|
|
189
|
+
|
|
190
|
+
- 在 `.cursor/hooks.json` 中确保存在 `npx ronds_ai@latest record cursor`
|
|
191
|
+
- 在 `.claude/settings.json` 中确保存在 `npx ronds_ai@latest record claude`
|
|
192
|
+
- 清理旧版 hook 脚本文件
|
|
193
|
+
- 如果存在 `.claude/settings.local.json`,会移除其中由本工具管理的旧 hook,避免重复触发
|
|
194
|
+
|
|
195
|
+
输出是一个 JSON,对应本次操作中:
|
|
196
|
+
|
|
197
|
+
- `createdFiles`
|
|
198
|
+
- `updatedFiles`
|
|
199
|
+
- `removedFiles`
|
|
200
|
+
|
|
201
|
+
### `skills install`
|
|
202
|
+
|
|
203
|
+
下载一个技能包并安装到 Claude / Codex / Cursor 对应的技能目录。
|
|
204
|
+
|
|
205
|
+
```bash
|
|
206
|
+
ronds_ai skills install <name> [--tool claude,codex,cursor] [--scope project|global] [--project-dir <path>] [--force]
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
示例:
|
|
210
|
+
|
|
211
|
+
```bash
|
|
212
|
+
npx ronds_ai@latest skills install demo-skill
|
|
213
|
+
npx ronds_ai@latest skills install demo-skill --tool claude --scope global
|
|
214
|
+
npx ronds_ai@latest skills install demo-skill --tool codex,cursor --scope project
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
参数说明:
|
|
218
|
+
|
|
219
|
+
- `name`: 要安装的 skill 名称
|
|
220
|
+
- `--tool`: 目标工具,可传 `claude`、`codex`、`cursor`,多个值用逗号分隔
|
|
221
|
+
- `--scope`: 安装范围,可选 `project` 或 `global`
|
|
222
|
+
- `--project-dir`: 当 `scope=project` 时指定项目目录;默认是当前目录
|
|
223
|
+
- `--force`: 目标目录已存在时覆盖
|
|
224
|
+
|
|
225
|
+
如果没有传 `--tool` 或 `--scope`,CLI 会进入交互式提示。
|
|
226
|
+
|
|
227
|
+
安装目录规则:
|
|
228
|
+
|
|
229
|
+
- `claude` + `project`: `<project>/.claude/skills/<name>`
|
|
230
|
+
- `claude` + `global`: `~/.claude/skills/<name>`
|
|
231
|
+
- `codex` + `project`: `<project>/.agents/skills/<name>`
|
|
232
|
+
- `codex` + `global`: `~/.agents/skills/<name>`
|
|
233
|
+
- `cursor` + `project`: `<project>/.agents/skills/<name>`
|
|
234
|
+
- `cursor` + `global`: `~/.agents/skills/<name>`
|
|
235
|
+
|
|
236
|
+
输出是一个 JSON,包含:
|
|
237
|
+
|
|
238
|
+
- 安装的 skill 名称
|
|
239
|
+
- 安装范围
|
|
240
|
+
- 项目目录
|
|
241
|
+
- 下载地址
|
|
242
|
+
- 实际安装到的目标路径列表
|
|
243
|
+
|
|
244
|
+
## Environment Variables
|
|
245
|
+
|
|
246
|
+
### `record` 相关
|
|
247
|
+
|
|
248
|
+
- `HOOK_REPORT_URL`: 上报地址
|
|
249
|
+
- `CHANGE_REPORT_URL`: 上报地址,作为 `HOOK_REPORT_URL` 的备用读取项
|
|
250
|
+
- `HOOK_REPORT_TIMEOUT_MS`: 请求超时时间,默认 `10000`
|
|
251
|
+
- `HOOK_REPORT_TOKEN`: 如果设置,会以 `Authorization: Bearer <token>` 发送
|
|
252
|
+
- `HOOK_REPORT_HEADERS`: 额外请求头,要求是 JSON 字符串
|
|
253
|
+
- `HOOK_REQUEST_DRY_RUN=1`: 不发请求,只把事件打印到标准输出
|
|
254
|
+
|
|
255
|
+
默认上报地址:
|
|
256
|
+
|
|
257
|
+
```text
|
|
258
|
+
https://aihub.ronds.com/api/api/v1/ai-code-events
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
### `worker_id` 解析顺序
|
|
262
|
+
|
|
263
|
+
`record` 在构建事件时,会按下面顺序寻找 `worker_id`:
|
|
264
|
+
|
|
265
|
+
1. `process.cwd()/.ai_config/config.json` 中的 `worker_id`
|
|
266
|
+
2. Git 仓库根目录下 `.ai_config/config.json` 中的 `worker_id`
|
|
267
|
+
3. 环境变量 `MCP_TRACKER_WORKER_ID`
|
|
268
|
+
4. 环境变量 `WORKER_ID`
|
|
269
|
+
5. 当前系统用户名
|
|
270
|
+
|
|
271
|
+
## Error Logs
|
|
272
|
+
|
|
273
|
+
命令运行失败或上报失败时,会把信息保存到:
|
|
274
|
+
|
|
275
|
+
```text
|
|
276
|
+
~/.ronds_ai/failed-events
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
常见文件名格式:
|
|
280
|
+
|
|
281
|
+
- `YYYYMMDD-claude-error.jsonl`
|
|
282
|
+
- `YYYYMMDD-cursor-error.jsonl`
|
|
283
|
+
- `YYYYMMDD-cli-error.jsonl`
|
|
284
|
+
|
|
285
|
+
## Notes
|
|
286
|
+
|
|
287
|
+
- `record` 和 `doctor` 目前只支持 `claude` 与 `cursor`
|
|
288
|
+
- `skills install` 支持 `claude`、`codex`、`cursor`
|
|
289
|
+
- 不支持的命令或参数会直接报错,并把错误写入 CLI 错误日志
|
package/bin/ronds_ai.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
const { runCodeRecord, saveCliError } = require('../lib/code_record');
|
|
4
|
+
const { runCheckRecord } = require('../lib/check_record');
|
|
4
5
|
const { runDoctor } = require('../lib/doctor');
|
|
5
6
|
const { deployHooks } = require('../lib/hooks_deploy');
|
|
7
|
+
const { runSkillsInstall } = require('../lib/skills_install');
|
|
6
8
|
|
|
7
9
|
const SUPPORTED_SOURCES = new Set(['claude', 'cursor']);
|
|
8
10
|
|
|
@@ -10,19 +12,26 @@ function printUsage() {
|
|
|
10
12
|
process.stderr.write([
|
|
11
13
|
'Usage:',
|
|
12
14
|
' ronds_ai record <tool>',
|
|
15
|
+
' ronds_ai check record',
|
|
13
16
|
' ronds_ai doctor <tool>',
|
|
14
17
|
' ronds_ai hooks deploy',
|
|
18
|
+
' ronds_ai skills install <name> [--tool claude,codex,cursor] [--scope project|global] [--project-dir <path>] [--force]',
|
|
15
19
|
'',
|
|
16
20
|
'Supported tools:',
|
|
17
21
|
' claude',
|
|
18
22
|
' cursor',
|
|
23
|
+
' codex',
|
|
19
24
|
'',
|
|
20
25
|
'Examples:',
|
|
21
26
|
' npx ronds_ai@latest record claude',
|
|
22
27
|
' npx ronds_ai@latest record cursor',
|
|
28
|
+
' npx ronds_ai@latest check record',
|
|
23
29
|
' npx ronds_ai@latest doctor claude',
|
|
24
30
|
' npx ronds_ai@latest doctor cursor',
|
|
25
31
|
' npx ronds_ai@latest hooks deploy',
|
|
32
|
+
' npx ronds_ai@latest skills install demo-skill',
|
|
33
|
+
' npx ronds_ai@latest skills install demo-skill --tool claude --scope global',
|
|
34
|
+
' npx ronds_ai@latest skills install demo-skill --tool codex,cursor --scope project',
|
|
26
35
|
].join('\n'));
|
|
27
36
|
process.stderr.write('\n');
|
|
28
37
|
}
|
|
@@ -38,6 +47,101 @@ async function runHooksCommand(args) {
|
|
|
38
47
|
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
39
48
|
}
|
|
40
49
|
|
|
50
|
+
async function runCheckCommand(args) {
|
|
51
|
+
const [target] = args;
|
|
52
|
+
|
|
53
|
+
if (target !== 'record') {
|
|
54
|
+
throw new Error('Unsupported check command');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const result = runCheckRecord(process.cwd());
|
|
58
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
59
|
+
|
|
60
|
+
if (!result.ok) {
|
|
61
|
+
process.exitCode = 1;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function parseSkillsInstallArgs(args) {
|
|
66
|
+
const options = {
|
|
67
|
+
tool: '',
|
|
68
|
+
scope: '',
|
|
69
|
+
projectDir: '',
|
|
70
|
+
force: false,
|
|
71
|
+
};
|
|
72
|
+
const positional = [];
|
|
73
|
+
|
|
74
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
75
|
+
const value = args[index];
|
|
76
|
+
|
|
77
|
+
if (value === '--force') {
|
|
78
|
+
options.force = true;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (value === '--tool') {
|
|
83
|
+
index += 1;
|
|
84
|
+
options.tool = args[index] || '';
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (value.startsWith('--tool=')) {
|
|
89
|
+
options.tool = value.slice('--tool='.length);
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (value === '--scope') {
|
|
94
|
+
index += 1;
|
|
95
|
+
options.scope = args[index] || '';
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (value.startsWith('--scope=')) {
|
|
100
|
+
options.scope = value.slice('--scope='.length);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (value === '--project-dir') {
|
|
105
|
+
index += 1;
|
|
106
|
+
options.projectDir = args[index] || '';
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (value.startsWith('--project-dir=')) {
|
|
111
|
+
options.projectDir = value.slice('--project-dir='.length);
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (value.startsWith('--')) {
|
|
116
|
+
throw new Error(`Unsupported option: ${value}`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
positional.push(value);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const [name] = positional;
|
|
123
|
+
if (!name) {
|
|
124
|
+
throw new Error('Missing skill name');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
name,
|
|
129
|
+
options,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function runSkillsCommand(args) {
|
|
134
|
+
const [action, ...rest] = args;
|
|
135
|
+
|
|
136
|
+
if (action !== 'install') {
|
|
137
|
+
throw new Error('Unsupported skills command');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const { name, options } = parseSkillsInstallArgs(rest);
|
|
141
|
+
const result = await runSkillsInstall(name, options);
|
|
142
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
143
|
+
}
|
|
144
|
+
|
|
41
145
|
async function run() {
|
|
42
146
|
const [, , command, ...args] = process.argv;
|
|
43
147
|
|
|
@@ -66,11 +170,21 @@ async function run() {
|
|
|
66
170
|
return;
|
|
67
171
|
}
|
|
68
172
|
|
|
173
|
+
if (command === 'check') {
|
|
174
|
+
await runCheckCommand(args);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
69
178
|
if (command === 'hooks') {
|
|
70
179
|
await runHooksCommand(args);
|
|
71
180
|
return;
|
|
72
181
|
}
|
|
73
182
|
|
|
183
|
+
if (command === 'skills') {
|
|
184
|
+
await runSkillsCommand(args);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
74
188
|
throw new Error(`Unsupported command: ${command || ''}`);
|
|
75
189
|
}
|
|
76
190
|
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const os = require('os');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const { execFileSync } = require('child_process');
|
|
5
|
+
|
|
6
|
+
const ENVIRONMENT_VARIABLE_NAME = process.env.ENVIRONMENT_VARIABLE_NAME || 'MCP_TRACKER_WORKER_ID';
|
|
7
|
+
const MINIMUM_NODE_MAJOR = 16;
|
|
8
|
+
|
|
9
|
+
function readGitUserEmail(baseDir) {
|
|
10
|
+
try {
|
|
11
|
+
return execFileSync('git', ['config', 'user.email'], {
|
|
12
|
+
cwd: baseDir,
|
|
13
|
+
encoding: 'utf8',
|
|
14
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
15
|
+
})
|
|
16
|
+
.trim()
|
|
17
|
+
.replace(/^["'“”]+/, '')
|
|
18
|
+
.replace(/["'“”]+$/, '');
|
|
19
|
+
} catch {
|
|
20
|
+
return '';
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function getProfileFilePath() {
|
|
25
|
+
return path.join(os.homedir(), '.profile');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function getCurrentWorkerId() {
|
|
29
|
+
const envValue = String(process.env[ENVIRONMENT_VARIABLE_NAME] || '').trim();
|
|
30
|
+
if (envValue) {
|
|
31
|
+
return {
|
|
32
|
+
name: ENVIRONMENT_VARIABLE_NAME,
|
|
33
|
+
value: envValue,
|
|
34
|
+
source: 'env',
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const profileFile = getProfileFilePath();
|
|
39
|
+
if (!fs.existsSync(profileFile)) {
|
|
40
|
+
return {
|
|
41
|
+
name: ENVIRONMENT_VARIABLE_NAME,
|
|
42
|
+
value: '',
|
|
43
|
+
source: '',
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const lines = fs.readFileSync(profileFile, 'utf8').split('\n');
|
|
48
|
+
const prefix = `export ${ENVIRONMENT_VARIABLE_NAME}=`;
|
|
49
|
+
|
|
50
|
+
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
51
|
+
const line = lines[index].trim();
|
|
52
|
+
if (!line.startsWith(prefix)) {
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const value = line
|
|
57
|
+
.slice(prefix.length)
|
|
58
|
+
.trim()
|
|
59
|
+
.replace(/^"/, '')
|
|
60
|
+
.replace(/"$/, '');
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
name: ENVIRONMENT_VARIABLE_NAME,
|
|
64
|
+
value,
|
|
65
|
+
source: 'profile',
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
name: ENVIRONMENT_VARIABLE_NAME,
|
|
71
|
+
value: '',
|
|
72
|
+
source: '',
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function parseNodeMajor(version) {
|
|
77
|
+
const normalized = String(version || '').trim();
|
|
78
|
+
const match = normalized.match(/^v?(\d+)/);
|
|
79
|
+
return match ? Number(match[1]) : 0;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function runCheckRecord(targetDir = process.cwd()) {
|
|
83
|
+
const baseDir = path.resolve(targetDir);
|
|
84
|
+
const nodeVersion = process.version;
|
|
85
|
+
const nodeMajor = parseNodeMajor(nodeVersion);
|
|
86
|
+
const gitUserEmail = readGitUserEmail(baseDir);
|
|
87
|
+
const worker = getCurrentWorkerId();
|
|
88
|
+
const nodeSatisfied = nodeMajor >= MINIMUM_NODE_MAJOR;
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
ok: nodeSatisfied,
|
|
92
|
+
targetDir: baseDir,
|
|
93
|
+
node: {
|
|
94
|
+
version: nodeVersion,
|
|
95
|
+
requirement: `>=${MINIMUM_NODE_MAJOR}`,
|
|
96
|
+
satisfied: nodeSatisfied,
|
|
97
|
+
},
|
|
98
|
+
gitUserEmail,
|
|
99
|
+
workerId: worker,
|
|
100
|
+
profileFile: getProfileFilePath(),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
module.exports = {
|
|
105
|
+
runCheckRecord,
|
|
106
|
+
};
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const os = require('os');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const http = require('http');
|
|
5
|
+
const https = require('https');
|
|
6
|
+
const { execFileSync } = require('child_process');
|
|
7
|
+
const { buildInstallTargets, normalizeScope, normalizeTools } = require('./skills_targets');
|
|
8
|
+
const { promptForScope, promptForTools } = require('./skills_prompt');
|
|
9
|
+
|
|
10
|
+
const SKILL_DOWNLOAD_BASE_URL = 'https://aihub.ronds.com/api/api/v1/skills/download';
|
|
11
|
+
const MAX_REDIRECTS = 5;
|
|
12
|
+
|
|
13
|
+
function parseJson(raw, label) {
|
|
14
|
+
try {
|
|
15
|
+
return JSON.parse(raw);
|
|
16
|
+
} catch (error) {
|
|
17
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
18
|
+
throw new Error(`${label} is not valid JSON: ${detail}`);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function loadDownloadConfig() {
|
|
23
|
+
const token = String(process.env.SKILL_DOWNLOAD_TOKEN || '').trim();
|
|
24
|
+
const extraHeaders = process.env.SKILL_DOWNLOAD_HEADERS
|
|
25
|
+
? parseJson(process.env.SKILL_DOWNLOAD_HEADERS, 'SKILL_DOWNLOAD_HEADERS')
|
|
26
|
+
: {};
|
|
27
|
+
|
|
28
|
+
const headers = {
|
|
29
|
+
...extraHeaders,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
if (token) {
|
|
33
|
+
headers.authorization = `Bearer ${token}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return { headers };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function copyDirectoryRecursive(sourceDir, destinationDir) {
|
|
40
|
+
const entries = fs.readdirSync(sourceDir, { withFileTypes: true });
|
|
41
|
+
fs.mkdirSync(destinationDir, { recursive: true });
|
|
42
|
+
|
|
43
|
+
for (const entry of entries) {
|
|
44
|
+
const sourcePath = path.join(sourceDir, entry.name);
|
|
45
|
+
const destinationPath = path.join(destinationDir, entry.name);
|
|
46
|
+
|
|
47
|
+
if (entry.isDirectory()) {
|
|
48
|
+
copyDirectoryRecursive(sourcePath, destinationPath);
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (entry.isSymbolicLink()) {
|
|
53
|
+
const targetPath = fs.readlinkSync(sourcePath);
|
|
54
|
+
fs.symlinkSync(targetPath, destinationPath);
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
fs.copyFileSync(sourcePath, destinationPath);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function copyDirectory(sourceDir, destinationDir) {
|
|
63
|
+
if (typeof fs.cpSync === 'function') {
|
|
64
|
+
fs.cpSync(sourceDir, destinationDir, { recursive: true });
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
copyDirectoryRecursive(sourceDir, destinationDir);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function streamToFile(url, destinationPath, config, redirectCount = 0) {
|
|
72
|
+
return new Promise((resolve, reject) => {
|
|
73
|
+
const transport = String(url).startsWith('http://') ? http : https;
|
|
74
|
+
const request = transport.get(url, { headers: config.headers }, (response) => {
|
|
75
|
+
if (response.statusCode && response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) {
|
|
76
|
+
response.resume();
|
|
77
|
+
if (redirectCount >= MAX_REDIRECTS) {
|
|
78
|
+
reject(new Error('Too many redirects while downloading skill archive'));
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const nextUrl = new URL(response.headers.location, url).toString();
|
|
83
|
+
streamToFile(nextUrl, destinationPath, config, redirectCount + 1).then(resolve, reject);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (response.statusCode !== 200) {
|
|
88
|
+
response.resume();
|
|
89
|
+
reject(new Error(`Skill download failed with status ${response.statusCode || 0}`));
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const fileStream = fs.createWriteStream(destinationPath);
|
|
94
|
+
response.pipe(fileStream);
|
|
95
|
+
|
|
96
|
+
fileStream.on('finish', () => {
|
|
97
|
+
fileStream.close(() => resolve(destinationPath));
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
fileStream.on('error', (error) => {
|
|
101
|
+
fileStream.close(() => reject(error));
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
request.on('error', reject);
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function downloadSkillArchive(name, workingDir) {
|
|
110
|
+
const archivePath = path.join(workingDir, `${name}.zip`);
|
|
111
|
+
const url = `${SKILL_DOWNLOAD_BASE_URL}/${encodeURIComponent(name)}`;
|
|
112
|
+
await streamToFile(url, archivePath, loadDownloadConfig());
|
|
113
|
+
return {
|
|
114
|
+
url,
|
|
115
|
+
archivePath,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function extractArchive(archivePath, outputDir) {
|
|
120
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
121
|
+
execFileSync('unzip', ['-q', archivePath, '-d', outputDir], {
|
|
122
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function findSkillRoot(extractDir) {
|
|
127
|
+
const directSkillMd = path.join(extractDir, 'SKILL.md');
|
|
128
|
+
if (fs.existsSync(directSkillMd)) {
|
|
129
|
+
return extractDir;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const entries = fs.readdirSync(extractDir, { withFileTypes: true });
|
|
133
|
+
const candidateDirs = entries
|
|
134
|
+
.filter((entry) => entry.isDirectory())
|
|
135
|
+
.map((entry) => path.join(extractDir, entry.name))
|
|
136
|
+
.filter((entryPath) => fs.existsSync(path.join(entryPath, 'SKILL.md')));
|
|
137
|
+
|
|
138
|
+
if (candidateDirs.length === 1) {
|
|
139
|
+
return candidateDirs[0];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (candidateDirs.length > 1) {
|
|
143
|
+
throw new Error('Downloaded archive contains multiple skill roots');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
throw new Error('Downloaded archive does not contain SKILL.md');
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function copySkill(sourceDir, destinationDir, force) {
|
|
150
|
+
if (fs.existsSync(destinationDir)) {
|
|
151
|
+
if (!force) {
|
|
152
|
+
throw new Error(`Skill already exists at ${destinationDir}. Use --force to overwrite.`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
fs.rmSync(destinationDir, { recursive: true, force: true });
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
fs.mkdirSync(path.dirname(destinationDir), { recursive: true });
|
|
159
|
+
copyDirectory(sourceDir, destinationDir);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function resolveInstallOptions(options) {
|
|
163
|
+
let tools = normalizeTools(options.tool);
|
|
164
|
+
if (tools.length === 0) {
|
|
165
|
+
tools = normalizeTools(await promptForTools());
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (tools.length === 0) {
|
|
169
|
+
throw new Error('No skills tool selected');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
let scope = normalizeScope(options.scope);
|
|
173
|
+
if (!scope) {
|
|
174
|
+
scope = normalizeScope(await promptForScope());
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (!scope) {
|
|
178
|
+
throw new Error('No skills scope selected');
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
tools,
|
|
183
|
+
scope,
|
|
184
|
+
projectDir: options.projectDir ? path.resolve(options.projectDir) : process.cwd(),
|
|
185
|
+
force: Boolean(options.force),
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function runSkillsInstall(name, rawOptions = {}) {
|
|
190
|
+
const skillName = String(name || '').trim();
|
|
191
|
+
if (!skillName) {
|
|
192
|
+
throw new Error('Missing skill name');
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const options = await resolveInstallOptions(rawOptions);
|
|
196
|
+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ronds-ai-skill-'));
|
|
197
|
+
|
|
198
|
+
try {
|
|
199
|
+
const download = await downloadSkillArchive(skillName, tempRoot);
|
|
200
|
+
const extractDir = path.join(tempRoot, 'extracted');
|
|
201
|
+
extractArchive(download.archivePath, extractDir);
|
|
202
|
+
const skillRoot = findSkillRoot(extractDir);
|
|
203
|
+
const targets = buildInstallTargets(options.tools, options.scope, options.projectDir, skillName);
|
|
204
|
+
|
|
205
|
+
for (const target of targets) {
|
|
206
|
+
copySkill(skillRoot, target.destination, options.force);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return {
|
|
210
|
+
name: skillName,
|
|
211
|
+
scope: options.scope,
|
|
212
|
+
projectDir: options.scope === 'project' ? options.projectDir : '',
|
|
213
|
+
archiveUrl: download.url,
|
|
214
|
+
installedTargets: targets.map((target) => ({
|
|
215
|
+
tools: target.tools,
|
|
216
|
+
destination: target.destination,
|
|
217
|
+
})),
|
|
218
|
+
};
|
|
219
|
+
} finally {
|
|
220
|
+
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
module.exports = {
|
|
225
|
+
runSkillsInstall,
|
|
226
|
+
};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
const readline = require('readline/promises');
|
|
2
|
+
|
|
3
|
+
const TOOL_CHOICES = ['claude', 'codex', 'cursor'];
|
|
4
|
+
const SCOPE_CHOICES = ['project', 'global'];
|
|
5
|
+
|
|
6
|
+
function normalizeCsvInput(raw) {
|
|
7
|
+
return String(raw || '')
|
|
8
|
+
.split(',')
|
|
9
|
+
.map((item) => item.trim().toLowerCase())
|
|
10
|
+
.filter(Boolean);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function normalizeChoiceInput(raw) {
|
|
14
|
+
const values = normalizeCsvInput(raw);
|
|
15
|
+
if (values.length === 0) {
|
|
16
|
+
return [];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return values.flatMap((value) => {
|
|
20
|
+
if (/^\d+$/.test(value)) {
|
|
21
|
+
const index = Number(value) - 1;
|
|
22
|
+
return TOOL_CHOICES[index] ? [TOOL_CHOICES[index]] : [];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return [value];
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function promptForTools() {
|
|
30
|
+
const rl = readline.createInterface({
|
|
31
|
+
input: process.stdin,
|
|
32
|
+
output: process.stdout,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
process.stderr.write([
|
|
37
|
+
'Select tools to install:',
|
|
38
|
+
' 1. claude',
|
|
39
|
+
' 2. codex',
|
|
40
|
+
' 3. cursor',
|
|
41
|
+
'Enter comma-separated names or numbers:',
|
|
42
|
+
].join('\n'));
|
|
43
|
+
process.stderr.write('\n');
|
|
44
|
+
|
|
45
|
+
const answer = await rl.question('> ');
|
|
46
|
+
return normalizeChoiceInput(answer);
|
|
47
|
+
} finally {
|
|
48
|
+
rl.close();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function promptForScope() {
|
|
53
|
+
const rl = readline.createInterface({
|
|
54
|
+
input: process.stdin,
|
|
55
|
+
output: process.stdout,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
process.stderr.write([
|
|
60
|
+
'Select install scope:',
|
|
61
|
+
' 1. project',
|
|
62
|
+
' 2. global',
|
|
63
|
+
'Enter project or global:',
|
|
64
|
+
].join('\n'));
|
|
65
|
+
process.stderr.write('\n');
|
|
66
|
+
|
|
67
|
+
const answer = String(await rl.question('> ')).trim().toLowerCase();
|
|
68
|
+
if (answer === '1') {
|
|
69
|
+
return 'project';
|
|
70
|
+
}
|
|
71
|
+
if (answer === '2') {
|
|
72
|
+
return 'global';
|
|
73
|
+
}
|
|
74
|
+
return answer;
|
|
75
|
+
} finally {
|
|
76
|
+
rl.close();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
module.exports = {
|
|
81
|
+
SCOPE_CHOICES,
|
|
82
|
+
TOOL_CHOICES,
|
|
83
|
+
normalizeChoiceInput,
|
|
84
|
+
normalizeCsvInput,
|
|
85
|
+
promptForScope,
|
|
86
|
+
promptForTools,
|
|
87
|
+
};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
const os = require('os');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { SCOPE_CHOICES, TOOL_CHOICES, normalizeCsvInput } = require('./skills_prompt');
|
|
4
|
+
|
|
5
|
+
function normalizeTools(rawTools) {
|
|
6
|
+
const values = Array.isArray(rawTools)
|
|
7
|
+
? rawTools.flatMap((item) => normalizeCsvInput(item))
|
|
8
|
+
: normalizeCsvInput(rawTools);
|
|
9
|
+
const unique = [];
|
|
10
|
+
|
|
11
|
+
for (const tool of values) {
|
|
12
|
+
if (!TOOL_CHOICES.includes(tool)) {
|
|
13
|
+
throw new Error(`Unsupported skills tool: ${tool}`);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
if (!unique.includes(tool)) {
|
|
17
|
+
unique.push(tool);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return unique;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function normalizeScope(rawScope) {
|
|
25
|
+
const scope = String(rawScope || '').trim().toLowerCase();
|
|
26
|
+
if (!scope) {
|
|
27
|
+
return '';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (!SCOPE_CHOICES.includes(scope)) {
|
|
31
|
+
throw new Error(`Unsupported skills scope: ${rawScope}`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return scope;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function resolveToolBaseDir(tool, scope, projectDir) {
|
|
38
|
+
const baseDir = scope === 'global' ? os.homedir() : path.resolve(projectDir || process.cwd());
|
|
39
|
+
|
|
40
|
+
if (tool === 'claude') {
|
|
41
|
+
return path.join(baseDir, '.claude', 'skills');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (tool === 'codex' || tool === 'cursor') {
|
|
45
|
+
return path.join(baseDir, '.agents', 'skills');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
throw new Error(`Unsupported skills tool: ${tool}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function buildInstallTargets(tools, scope, projectDir, skillName) {
|
|
52
|
+
const deduped = new Map();
|
|
53
|
+
|
|
54
|
+
for (const tool of tools) {
|
|
55
|
+
const baseDir = resolveToolBaseDir(tool, scope, projectDir);
|
|
56
|
+
const destination = path.join(baseDir, skillName);
|
|
57
|
+
const existing = deduped.get(destination);
|
|
58
|
+
|
|
59
|
+
if (existing) {
|
|
60
|
+
existing.tools.push(tool);
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
deduped.set(destination, {
|
|
65
|
+
tool,
|
|
66
|
+
tools: [tool],
|
|
67
|
+
scope,
|
|
68
|
+
baseDir,
|
|
69
|
+
destination,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return Array.from(deduped.values());
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
module.exports = {
|
|
77
|
+
buildInstallTargets,
|
|
78
|
+
normalizeScope,
|
|
79
|
+
normalizeTools,
|
|
80
|
+
resolveToolBaseDir,
|
|
81
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ronds_ai",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.8",
|
|
4
4
|
"description": "CLI for reporting AI code edit events.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"ronds_ai": "bin/ronds_ai.js"
|
|
@@ -9,5 +9,8 @@
|
|
|
9
9
|
"bin",
|
|
10
10
|
"lib"
|
|
11
11
|
],
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=16"
|
|
14
|
+
},
|
|
12
15
|
"license": "MIT"
|
|
13
16
|
}
|