sp-rag 0.3.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 ADDED
@@ -0,0 +1,138 @@
1
+ # `sp-rag`
2
+
3
+ CLI cho setup nhanh SP-RAG theo hướng dev-friendly:
4
+
5
+ - lưu cấu hình mặc định để dev không phải nhớ lại URL, client, alias
6
+ - kiểm tra nhanh health và observability của stack
7
+ - gọi sync codegraph/GitNexus theo branch hoặc `commit_sha`
8
+ - đọc docs đã render
9
+ - cài MCP config cho client
10
+ - cài skill để agent ưu tiên dùng RAG nội bộ
11
+ - chạy evaluation/regression suite từ file JSON
12
+
13
+ ## Yêu cầu
14
+
15
+ - Node.js `>= 20`
16
+
17
+ ## Cài từ source trong monorepo
18
+
19
+ ```bash
20
+ cd apps/sp-rag-cli
21
+ npm install
22
+ npm run build
23
+ node dist/index.js doctor
24
+ ```
25
+
26
+ ## Cài nhanh qua `npx`
27
+
28
+ Nếu package đã được publish, luồng ngắn nhất là:
29
+
30
+ ```bash
31
+ npx sp-rag@latest install --client codex --mcp-token <token>
32
+ npx sp-rag@latest token add --token <token> --client codex
33
+ ```
34
+
35
+ Tương đương bằng `npm`:
36
+
37
+ ```bash
38
+ npm exec --yes sp-rag@latest install -- --client codex --mcp-token <token>
39
+ npm exec --yes sp-rag@latest token add -- --token <token> --client codex
40
+ ```
41
+
42
+ Ghi chú:
43
+
44
+ - `install` là lệnh onboarding một bước: lưu config, cài MCP config, cài skill, và có thể chạy `doctor`
45
+ - `token add` dùng khi dev đã cài xong nhưng chưa gắn bearer token vào MCP client
46
+ - nếu không muốn lưu token literal vào file config client, dùng `sp-rag mcp add --auth-env-var SP_RAG_MCP_TOKEN`
47
+
48
+ ## Luồng khuyên dùng cho dev mới
49
+
50
+ ```bash
51
+ sp-rag install --client codex --mcp-token <token> --doctor
52
+ sp-rag token add --token <token> --client codex
53
+ sp-rag config show
54
+ sp-rag codegraph status
55
+ sp-rag codegraph watch --interval-ms 2000
56
+ sp-rag codegraph runs --limit 5
57
+ sp-rag codegraph metrics
58
+ sp-rag codegraph recover --reason "Ops dọn stale run sau crash"
59
+ sp-rag mcp add codex --mcp-token <token>
60
+ sp-rag skill install
61
+ sp-rag eval run --file ./examples/eval-suite.sample.json
62
+ ```
63
+
64
+ ## Lệnh chính
65
+
66
+ ```bash
67
+ sp-rag install --client codex --mcp-token <token> --doctor
68
+ sp-rag token add --token <token> --client codex
69
+ sp-rag config show
70
+ sp-rag doctor
71
+ sp-rag codegraph status
72
+ sp-rag codegraph watch --interval-ms 2000
73
+ sp-rag codegraph runs --limit 10
74
+ sp-rag codegraph metrics
75
+ sp-rag codegraph recover --reason "Ops dọn stale run sau crash"
76
+ sp-rag codegraph sync --branch master --commit-sha <sha> --webhook-token <token> --gitlab-job-token <ci-job-token>
77
+ sp-rag docs get public --format md
78
+ sp-rag mcp add codex --mcp-token <token>
79
+ sp-rag skill install
80
+ sp-rag eval run --file ./examples/eval-suite.sample.json
81
+ sp-rag update setup --client codex
82
+ ```
83
+
84
+ ## Cấu hình mặc định
85
+
86
+ CLI lưu cấu hình tại:
87
+
88
+ - `~/.sp-rag/config.json`
89
+ - có thể override home dir bằng `SP_RAG_HOME_DIR`
90
+
91
+ CLI có thể lưu thêm:
92
+
93
+ - `mcpToken` cho flow cài nhanh
94
+ - hoặc `authEnvVar` nếu muốn client đọc token từ biến môi trường thay vì lưu literal
95
+
96
+ Giá trị mặc định:
97
+
98
+ - base URL: `https://sp-rag.secomapp.com`
99
+ - MCP URL: `https://sp-rag.secomapp.com/mcp`
100
+ - alias MCP: `sp-rag`
101
+
102
+ Override nhanh bằng env:
103
+
104
+ ```bash
105
+ SP_RAG_BASE_URL=https://sp-rag.secomapp.com
106
+ SP_RAG_MCP_URL=https://sp-rag.secomapp.com/mcp
107
+ SP_RAG_HOME_DIR=D:/Temp/sp-rag-home
108
+ ```
109
+
110
+ ## Evaluation mẫu
111
+
112
+ - file mẫu: [`examples/eval-suite.sample.json`](./examples/eval-suite.sample.json)
113
+
114
+ ## Recovery khi sync bị treo
115
+
116
+ Nếu worker crash giữa chừng, `codegraph-ts` sẽ tự đổi run `running` cũ sang `failed` ở lần truy cập đầu tiên sau khi service lên lại. Khi ops muốn dọn tay hoặc gắn lý do vận hành rõ ràng hơn, dùng:
117
+
118
+ ```bash
119
+ sp-rag codegraph recover --reason "Ops dọn stale run sau crash"
120
+ ```
121
+
122
+ CLI này gọi `POST /codegraph/sync-recover-stale` và trả lại danh sách run vừa được chuyển sang `failed`.
123
+
124
+ ## Theo dõi active
125
+
126
+ Khi một sync đang chạy và ops muốn biết phase hiện tại theo thời gian thực, dùng:
127
+
128
+ ```bash
129
+ sp-rag codegraph watch --interval-ms 2000
130
+ ```
131
+
132
+ CLI sẽ in liên tục `lastStatus`, `activity.currentPhase`, `progressPercentHint`, `elapsed` và `message`.
133
+
134
+ ## Tài liệu thêm
135
+
136
+ - [Hướng dẫn dev sử dụng SP-RAG](../../docs/runbooks/dev-usage-guide.md)
137
+ - [Runbook CLI `sp-rag`](../../docs/runbooks/sp-rag-cli.md)
138
+ - [Runbook MCP Public](../../docs/runbooks/mcp-public-clients.md)
package/dist/cli.js ADDED
@@ -0,0 +1,457 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { loadCliConfig, saveCliConfig, } from './lib/config-store.js';
3
+ import { runEvaluationSuite } from './lib/eval.js';
4
+ import { defaultBaseUrl, defaultMcpServerAlias, defaultMcpUrl, installMcpConfig, } from './lib/mcp-config.js';
5
+ import { fetchJson, fetchText, runDoctor } from './lib/http.js';
6
+ import { installCodexSkill } from './lib/skill.js';
7
+ function parseArgv(argv) {
8
+ const positionals = [];
9
+ const options = {};
10
+ for (let index = 0; index < argv.length; index += 1) {
11
+ const current = argv[index];
12
+ if (!current.startsWith('--')) {
13
+ positionals.push(current);
14
+ continue;
15
+ }
16
+ const eqIndex = current.indexOf('=');
17
+ if (eqIndex >= 0) {
18
+ options[current.slice(2, eqIndex)] = current.slice(eqIndex + 1);
19
+ continue;
20
+ }
21
+ const next = argv[index + 1];
22
+ if (!next || next.startsWith('--')) {
23
+ options[current.slice(2)] = true;
24
+ continue;
25
+ }
26
+ options[current.slice(2)] = next;
27
+ index += 1;
28
+ }
29
+ return { positionals, options };
30
+ }
31
+ function optionString(parsed, key, fallback) {
32
+ const value = parsed.options[key];
33
+ if (typeof value === 'string') {
34
+ return value;
35
+ }
36
+ return fallback;
37
+ }
38
+ function optionFlag(parsed, key) {
39
+ return parsed.options[key] === true;
40
+ }
41
+ function supportedClient(value) {
42
+ if (!value) {
43
+ return undefined;
44
+ }
45
+ if (value === 'codex' || value === 'cursor' || value === 'claude-code') {
46
+ return value;
47
+ }
48
+ throw new Error('Client phải là codex, cursor hoặc claude-code.');
49
+ }
50
+ function supportedScope(value) {
51
+ if (!value) {
52
+ return undefined;
53
+ }
54
+ if (value === 'global' || value === 'project') {
55
+ return value;
56
+ }
57
+ throw new Error('Scope phải là global hoặc project.');
58
+ }
59
+ async function loadRuntimeDefaults(parsed) {
60
+ const homeDir = optionString(parsed, 'home-dir');
61
+ const config = await loadCliConfig(homeDir);
62
+ const baseUrl = optionString(parsed, 'base-url') ??
63
+ process.env['SP_RAG_BASE_URL']?.trim() ??
64
+ config?.baseUrl ??
65
+ defaultBaseUrl();
66
+ const mcpUrl = optionString(parsed, 'mcp-url') ??
67
+ optionString(parsed, 'url') ??
68
+ process.env['SP_RAG_MCP_URL']?.trim() ??
69
+ config?.mcpUrl ??
70
+ defaultMcpUrl();
71
+ const serverAlias = optionString(parsed, 'server-alias') ??
72
+ config?.serverAlias ??
73
+ defaultMcpServerAlias();
74
+ const docsUrl = optionString(parsed, 'docs-url') ??
75
+ config?.docsUrl ??
76
+ `${baseUrl.replace(/\/+$/, '')}/codegraph/docs/public?format=md`;
77
+ const defaultClient = supportedClient(optionString(parsed, 'client') ?? config?.defaultClient);
78
+ const defaultScope = supportedScope(optionString(parsed, 'scope') ?? config?.defaultScope);
79
+ return {
80
+ config,
81
+ homeDir,
82
+ baseUrl,
83
+ mcpUrl,
84
+ serverAlias,
85
+ docsUrl,
86
+ defaultClient,
87
+ defaultScope,
88
+ authEnvVar: optionString(parsed, 'auth-env-var') ?? config?.authEnvVar,
89
+ mcpToken: optionString(parsed, 'mcp-token') ??
90
+ optionString(parsed, 'token') ??
91
+ process.env['SP_RAG_MCP_TOKEN']?.trim() ??
92
+ config?.mcpToken,
93
+ skillTargetDir: optionString(parsed, 'target-dir') ?? config?.skillTargetDir,
94
+ };
95
+ }
96
+ function helpText() {
97
+ return `sp-rag - CLI cho setup, MCP, codegraph, eval và skill của SP-RAG
98
+
99
+ Lệnh chính:
100
+ sp-rag install [--base-url URL] [--mcp-url URL] [--client codex|cursor|claude-code] [--scope global|project] [--mcp-token TOKEN] [--auth-env-var ENV_VAR] [--target-dir PATH]
101
+ sp-rag init [--base-url URL] [--mcp-url URL] [--client codex|cursor|claude-code] [--scope global|project] [--auth-env-var ENV_VAR] [--target-dir PATH]
102
+ sp-rag token add --token TOKEN [--client codex|cursor|claude-code] [--scope global|project] [--cwd PATH]
103
+ sp-rag config show
104
+ sp-rag doctor [--base-url URL]
105
+ sp-rag codegraph status [--base-url URL]
106
+ sp-rag codegraph watch [--base-url URL] [--interval-ms N] [--max-polls N]
107
+ sp-rag codegraph runs [--base-url URL] [--limit N]
108
+ sp-rag codegraph metrics [--base-url URL]
109
+ sp-rag codegraph recover [--base-url URL] [--reason TEXT]
110
+ sp-rag codegraph sync [--base-url URL] [--branch BRANCH] [--commit-sha SHA] [--force] [--webhook-token TOKEN] [--gitlab-job-token TOKEN]
111
+ sp-rag docs get <public|function|dev> [--base-url URL] [--format md|json|html]
112
+ sp-rag mcp add <codex|cursor|claude-code> [--url URL] [--scope global|project] [--auth-env-var ENV_VAR] [--mcp-token TOKEN]
113
+ sp-rag skill install [--target-dir PATH] [--mcp-url URL] [--docs-url URL]
114
+ sp-rag eval run --file eval-suite.json [--base-url URL]
115
+ sp-rag update setup [--client codex|cursor|claude-code] [--scope global|project] [--url URL] [--auth-env-var ENV_VAR] [--target-dir PATH]
116
+ `;
117
+ }
118
+ function buildCliConfig(defaults) {
119
+ return {
120
+ baseUrl: defaults.baseUrl,
121
+ mcpUrl: defaults.mcpUrl,
122
+ serverAlias: defaults.serverAlias,
123
+ defaultClient: defaults.defaultClient,
124
+ defaultScope: defaults.defaultScope,
125
+ authEnvVar: defaults.authEnvVar,
126
+ mcpToken: defaults.mcpToken,
127
+ skillTargetDir: defaults.skillTargetDir,
128
+ docsUrl: defaults.docsUrl,
129
+ };
130
+ }
131
+ async function runCodegraphStatus(baseUrl) {
132
+ const result = await fetchJson(`${baseUrl.replace(/\/+$/, '')}/codegraph/sync-status`);
133
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
134
+ }
135
+ async function runCodegraphRuns(baseUrl, limit) {
136
+ const result = await fetchJson(`${baseUrl.replace(/\/+$/, '')}/codegraph/sync-runs?limit=${limit}`);
137
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
138
+ }
139
+ async function runCodegraphMetrics(baseUrl) {
140
+ const result = await fetchJson(`${baseUrl.replace(/\/+$/, '')}/codegraph/sync-metrics`);
141
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
142
+ }
143
+ function asObject(value) {
144
+ return value && typeof value === 'object' && !Array.isArray(value)
145
+ ? value
146
+ : null;
147
+ }
148
+ function formatDurationMs(value) {
149
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
150
+ return '-';
151
+ }
152
+ if (value < 1000) {
153
+ return `${value}ms`;
154
+ }
155
+ return `${(value / 1000).toFixed(1)}s`;
156
+ }
157
+ function formatWatchLine(payload) {
158
+ const activity = asObject(payload.activity);
159
+ const status = typeof payload.lastStatus === 'string' ? payload.lastStatus : 'unknown';
160
+ const phase = typeof activity?.currentPhase === 'string' ? activity.currentPhase : '-';
161
+ const progress = typeof activity?.progressPercentHint === 'number'
162
+ ? Math.round(activity.progressPercentHint)
163
+ : 0;
164
+ const elapsed = formatDurationMs(activity?.currentPhaseElapsedMs);
165
+ const message = typeof activity?.message === 'string' ? activity.message : '';
166
+ const syncId = typeof payload.lastSyncId === 'string' ? payload.lastSyncId : '-';
167
+ return `[${status}] phase=${phase} progress~${progress}% elapsed=${elapsed} sync=${syncId} ${message}`.trim();
168
+ }
169
+ async function runCodegraphWatch(parsed) {
170
+ const defaults = await loadRuntimeDefaults(parsed);
171
+ const baseUrl = defaults.baseUrl.replace(/\/+$/, '');
172
+ const intervalMsRaw = Number.parseInt(optionString(parsed, 'interval-ms', '5000') ?? '5000', 10);
173
+ const maxPollsRaw = Number.parseInt(optionString(parsed, 'max-polls', '0') ?? '0', 10);
174
+ const intervalMs = Number.isFinite(intervalMsRaw) && intervalMsRaw > 0 ? intervalMsRaw : 5000;
175
+ const maxPolls = Number.isFinite(maxPollsRaw) && maxPollsRaw > 0 ? maxPollsRaw : 0;
176
+ let pollCount = 0;
177
+ while (true) {
178
+ pollCount += 1;
179
+ const result = await fetchJson(`${baseUrl}/codegraph/sync-status`);
180
+ process.stdout.write(`${formatWatchLine(result)}\n`);
181
+ if (result.lastStatus !== 'running') {
182
+ return;
183
+ }
184
+ if (maxPolls > 0 && pollCount >= maxPolls) {
185
+ return;
186
+ }
187
+ await new Promise((resolve) => setTimeout(resolve, intervalMs));
188
+ }
189
+ }
190
+ async function runCodegraphRecover(parsed) {
191
+ const defaults = await loadRuntimeDefaults(parsed);
192
+ const baseUrl = defaults.baseUrl.replace(/\/+$/, '');
193
+ const reason = optionString(parsed, 'reason');
194
+ const result = await fetchJson(`${baseUrl}/codegraph/sync-recover-stale`, {
195
+ method: 'POST',
196
+ headers: {
197
+ 'content-type': 'application/json',
198
+ },
199
+ body: JSON.stringify({
200
+ ...(reason ? { reason } : {}),
201
+ }),
202
+ });
203
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
204
+ }
205
+ async function runCodegraphSync(parsed) {
206
+ const defaults = await loadRuntimeDefaults(parsed);
207
+ const baseUrl = defaults.baseUrl.replace(/\/+$/, '');
208
+ const branch = optionString(parsed, 'branch');
209
+ const commitSha = optionString(parsed, 'commit-sha');
210
+ const force = optionFlag(parsed, 'force');
211
+ const webhookToken = optionString(parsed, 'webhook-token');
212
+ const gitlabJobToken = optionString(parsed, 'gitlab-job-token');
213
+ const body = JSON.stringify({
214
+ ...(branch ? { branch } : {}),
215
+ ...(commitSha ? { commit_sha: commitSha } : {}),
216
+ ...(gitlabJobToken ? { gitlab_job_token: gitlabJobToken } : {}),
217
+ force,
218
+ });
219
+ const url = webhookToken
220
+ ? `${baseUrl}/codegraph/webhook/code-change`
221
+ : `${baseUrl}/codegraph/trigger-sync`;
222
+ const headers = {
223
+ 'content-type': 'application/json',
224
+ };
225
+ if (webhookToken) {
226
+ headers['x-codegraph-webhook-token'] = webhookToken;
227
+ }
228
+ if (gitlabJobToken) {
229
+ headers['x-gitlab-job-token'] = gitlabJobToken;
230
+ }
231
+ const result = await fetchJson(url, {
232
+ method: 'POST',
233
+ headers,
234
+ body,
235
+ });
236
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
237
+ }
238
+ async function runDocsGet(parsed) {
239
+ const docType = parsed.positionals[2];
240
+ if (!docType) {
241
+ throw new Error('Thiếu doc type. Dùng public, function hoặc dev.');
242
+ }
243
+ const defaults = await loadRuntimeDefaults(parsed);
244
+ const format = optionString(parsed, 'format', 'md') ?? 'md';
245
+ const baseUrl = defaults.baseUrl.replace(/\/+$/, '');
246
+ const query = new URLSearchParams({ format });
247
+ const url = `${baseUrl}/codegraph/docs/${encodeURIComponent(docType)}?${query.toString()}`;
248
+ if (format === 'json') {
249
+ const result = await fetchJson(url);
250
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
251
+ return;
252
+ }
253
+ const result = await fetchText(url);
254
+ process.stdout.write(`${result}${result.endsWith('\n') ? '' : '\n'}`);
255
+ }
256
+ async function runMcpAdd(parsed, defaults, explicitClient) {
257
+ const client = supportedClient(explicitClient ?? parsed.positionals[2] ?? defaults.defaultClient);
258
+ if (!client) {
259
+ throw new Error('Thiếu client. Dùng codex, cursor hoặc claude-code.');
260
+ }
261
+ const result = await installMcpConfig({
262
+ client,
263
+ url: optionString(parsed, 'url') ?? defaults.mcpUrl,
264
+ scope: supportedScope(optionString(parsed, 'scope')) ?? defaults.defaultScope,
265
+ authEnvVar: optionString(parsed, 'auth-env-var') ?? defaults.authEnvVar,
266
+ authToken: optionString(parsed, 'mcp-token') ?? defaults.mcpToken,
267
+ cwd: optionString(parsed, 'cwd'),
268
+ serverAlias: optionString(parsed, 'server-alias') ?? defaults.serverAlias,
269
+ });
270
+ process.stdout.write(`Đã cập nhật cấu hình MCP cho ${result.client} tại ${result.path} (${result.scope}).\n`);
271
+ }
272
+ async function runSkillInstall(parsed, defaults) {
273
+ const result = await installCodexSkill({
274
+ targetDir: optionString(parsed, 'target-dir') ?? defaults.skillTargetDir,
275
+ serverAlias: optionString(parsed, 'server-alias') ?? defaults.serverAlias,
276
+ mcpUrl: optionString(parsed, 'mcp-url') ?? defaults.mcpUrl,
277
+ docsUrl: optionString(parsed, 'docs-url') ?? defaults.docsUrl,
278
+ });
279
+ process.stdout.write(`Đã cài Codex skill tại ${result.path}\n`);
280
+ }
281
+ async function runInit(parsed) {
282
+ const defaults = await loadRuntimeDefaults(parsed);
283
+ const config = buildCliConfig(defaults);
284
+ const configPath = await saveCliConfig(config, defaults.homeDir);
285
+ process.stdout.write(`Đã lưu config CLI tại ${configPath}\n`);
286
+ if (!optionFlag(parsed, 'skip-mcp') && defaults.defaultClient) {
287
+ await runMcpAdd(parsed, defaults, defaults.defaultClient);
288
+ }
289
+ if (!optionFlag(parsed, 'skip-skill')) {
290
+ await runSkillInstall(parsed, defaults);
291
+ }
292
+ if (optionFlag(parsed, 'doctor')) {
293
+ const results = await runDoctor({ baseUrl: defaults.baseUrl });
294
+ for (const result of results) {
295
+ process.stdout.write(`${result.ok ? 'OK' : 'FAIL'} ${result.name} ${result.status} ${result.url}\n`);
296
+ }
297
+ }
298
+ }
299
+ async function runInstall(parsed) {
300
+ await runInit(parsed);
301
+ }
302
+ async function runTokenAdd(parsed) {
303
+ const defaults = await loadRuntimeDefaults(parsed);
304
+ const token = optionString(parsed, 'token') ?? optionString(parsed, 'mcp-token') ?? defaults.mcpToken;
305
+ if (!token?.trim()) {
306
+ throw new Error('Thiếu token. Dùng --token <token>.');
307
+ }
308
+ const nextDefaults = {
309
+ ...defaults,
310
+ mcpToken: token.trim(),
311
+ };
312
+ const configPath = await saveCliConfig(buildCliConfig(nextDefaults), defaults.homeDir);
313
+ process.stdout.write(`Đã lưu token MCP tại ${configPath}\n`);
314
+ if (optionFlag(parsed, 'skip-mcp-update')) {
315
+ return;
316
+ }
317
+ const client = supportedClient(optionString(parsed, 'client') ?? defaults.defaultClient);
318
+ if (!client) {
319
+ process.stdout.write('Chưa có client mặc định để cập nhật MCP config. Dùng thêm --client nếu muốn ghi ngay.\n');
320
+ return;
321
+ }
322
+ await runMcpAdd({
323
+ ...parsed,
324
+ positionals: ['mcp', 'add', client],
325
+ options: {
326
+ ...parsed.options,
327
+ 'mcp-token': token.trim(),
328
+ },
329
+ }, nextDefaults, client);
330
+ }
331
+ async function runConfigShow(parsed) {
332
+ const config = await loadCliConfig(optionString(parsed, 'home-dir'));
333
+ process.stdout.write(`${JSON.stringify(config ?? {}, null, 2)}\n`);
334
+ }
335
+ async function runEval(parsed) {
336
+ const filePath = optionString(parsed, 'file');
337
+ if (!filePath) {
338
+ throw new Error('Thiếu --file cho bộ eval.');
339
+ }
340
+ const defaults = await loadRuntimeDefaults(parsed);
341
+ const raw = await readFile(filePath, 'utf8');
342
+ const suite = JSON.parse(raw);
343
+ const result = await runEvaluationSuite({
344
+ baseUrl: suite.baseUrl ?? defaults.baseUrl,
345
+ cases: suite.cases ?? [],
346
+ });
347
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
348
+ return result.failed === 0 ? 0 : 1;
349
+ }
350
+ async function runUpdateSetup(parsed) {
351
+ const defaults = await loadRuntimeDefaults(parsed);
352
+ const client = optionString(parsed, 'client') ?? defaults.defaultClient;
353
+ if (client) {
354
+ await runMcpAdd({
355
+ ...parsed,
356
+ positionals: ['mcp', 'add', client],
357
+ }, defaults, client);
358
+ }
359
+ if (!optionFlag(parsed, 'skip-skill')) {
360
+ await runSkillInstall(parsed, defaults);
361
+ }
362
+ }
363
+ export async function runCli(argv) {
364
+ const parsed = parseArgv(argv);
365
+ const [group, action] = parsed.positionals;
366
+ try {
367
+ if (!group || group === 'help' || group === '--help') {
368
+ process.stdout.write(helpText());
369
+ return 0;
370
+ }
371
+ if (group === 'install') {
372
+ await runInstall(parsed);
373
+ return 0;
374
+ }
375
+ if (group === 'init') {
376
+ await runInit(parsed);
377
+ return 0;
378
+ }
379
+ if (group === 'token' && action === 'add') {
380
+ await runTokenAdd(parsed);
381
+ return 0;
382
+ }
383
+ if (group === 'config' && action === 'show') {
384
+ await runConfigShow(parsed);
385
+ return 0;
386
+ }
387
+ if (group === 'doctor') {
388
+ const defaults = await loadRuntimeDefaults(parsed);
389
+ const results = await runDoctor({
390
+ baseUrl: defaults.baseUrl,
391
+ });
392
+ for (const result of results) {
393
+ process.stdout.write(`${result.ok ? 'OK' : 'FAIL'} ${result.name} ${result.status} ${result.url}\n`);
394
+ }
395
+ return results.every((result) => result.ok) ? 0 : 1;
396
+ }
397
+ if (group === 'codegraph' && action === 'status') {
398
+ const defaults = await loadRuntimeDefaults(parsed);
399
+ await runCodegraphStatus(defaults.baseUrl);
400
+ return 0;
401
+ }
402
+ if (group === 'codegraph' && action === 'runs') {
403
+ const defaults = await loadRuntimeDefaults(parsed);
404
+ const limit = Number.parseInt(optionString(parsed, 'limit', '20') ?? '20', 10);
405
+ await runCodegraphRuns(defaults.baseUrl, Number.isFinite(limit) && limit > 0 ? limit : 20);
406
+ return 0;
407
+ }
408
+ if (group === 'codegraph' && action === 'watch') {
409
+ await runCodegraphWatch(parsed);
410
+ return 0;
411
+ }
412
+ if (group === 'codegraph' && action === 'metrics') {
413
+ const defaults = await loadRuntimeDefaults(parsed);
414
+ await runCodegraphMetrics(defaults.baseUrl);
415
+ return 0;
416
+ }
417
+ if (group === 'codegraph' && action === 'recover') {
418
+ await runCodegraphRecover(parsed);
419
+ return 0;
420
+ }
421
+ if (group === 'codegraph' && action === 'sync') {
422
+ await runCodegraphSync(parsed);
423
+ return 0;
424
+ }
425
+ if (group === 'docs' && action === 'get') {
426
+ await runDocsGet(parsed);
427
+ return 0;
428
+ }
429
+ if (group === 'mcp' && action === 'add') {
430
+ const defaults = await loadRuntimeDefaults(parsed);
431
+ await runMcpAdd(parsed, defaults);
432
+ return 0;
433
+ }
434
+ if (group === 'skill' && action === 'install') {
435
+ const defaults = await loadRuntimeDefaults(parsed);
436
+ await runSkillInstall(parsed, defaults);
437
+ return 0;
438
+ }
439
+ if (group === 'eval' && action === 'run') {
440
+ return runEval(parsed);
441
+ }
442
+ if (group === 'update' && action === 'setup') {
443
+ await runUpdateSetup(parsed);
444
+ return 0;
445
+ }
446
+ if (group === 'version') {
447
+ process.stdout.write('sp-rag 0.3.0\n');
448
+ return 0;
449
+ }
450
+ process.stdout.write(helpText());
451
+ return 1;
452
+ }
453
+ catch (error) {
454
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
455
+ return 1;
456
+ }
457
+ }
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ import { runCli } from './cli.js';
3
+ const exitCode = await runCli(process.argv.slice(2));
4
+ process.exitCode = exitCode;
@@ -0,0 +1,39 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
4
+ function stripUndefined(value) {
5
+ return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
6
+ }
7
+ export function resolveCliHomeDir(homeDir) {
8
+ const override = homeDir?.trim() || process.env['SP_RAG_HOME_DIR']?.trim();
9
+ return path.resolve(override || os.homedir());
10
+ }
11
+ export function resolveCliConfigDir(homeDir) {
12
+ return path.join(resolveCliHomeDir(homeDir), '.sp-rag');
13
+ }
14
+ export function resolveCliConfigPath(homeDir) {
15
+ return path.join(resolveCliConfigDir(homeDir), 'config.json');
16
+ }
17
+ export async function loadCliConfig(homeDir) {
18
+ const filePath = resolveCliConfigPath(homeDir);
19
+ const content = await readFile(filePath, 'utf8').catch((error) => {
20
+ if (error.code === 'ENOENT') {
21
+ return null;
22
+ }
23
+ throw error;
24
+ });
25
+ if (!content) {
26
+ return null;
27
+ }
28
+ return JSON.parse(content);
29
+ }
30
+ export async function saveCliConfig(config, homeDir) {
31
+ const filePath = resolveCliConfigPath(homeDir);
32
+ await mkdir(path.dirname(filePath), { recursive: true });
33
+ const normalized = stripUndefined({
34
+ ...config,
35
+ updatedAt: new Date().toISOString(),
36
+ });
37
+ await writeFile(filePath, `${JSON.stringify(normalized, null, 2)}\n`, 'utf8');
38
+ return filePath;
39
+ }
@@ -0,0 +1,149 @@
1
+ function parseJsonIfPossible(text) {
2
+ const trimmed = text.trim();
3
+ if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) {
4
+ return trimmed;
5
+ }
6
+ try {
7
+ return JSON.parse(trimmed);
8
+ }
9
+ catch {
10
+ return trimmed;
11
+ }
12
+ }
13
+ function extractDocText(payload) {
14
+ if (typeof payload === 'string') {
15
+ return payload;
16
+ }
17
+ if (payload && typeof payload === 'object') {
18
+ const record = payload;
19
+ if (typeof record['markdown'] === 'string') {
20
+ return record['markdown'];
21
+ }
22
+ if (typeof record['html'] === 'string') {
23
+ return record['html'];
24
+ }
25
+ if (typeof record['text'] === 'string') {
26
+ return record['text'];
27
+ }
28
+ }
29
+ return JSON.stringify(payload);
30
+ }
31
+ async function runQueryContextCase(baseUrl, testCase, fetchImpl) {
32
+ const startedAt = Date.now();
33
+ const response = await fetchImpl(`${baseUrl.replace(/\/+$/, '')}/api/v1/query/context`, {
34
+ method: 'POST',
35
+ headers: {
36
+ 'content-type': 'application/json',
37
+ },
38
+ body: JSON.stringify({
39
+ query_text: testCase.queryText,
40
+ }),
41
+ });
42
+ const bodyText = await response.text();
43
+ if (!response.ok) {
44
+ return {
45
+ id: testCase.id,
46
+ type: testCase.type,
47
+ ok: false,
48
+ durationMs: Date.now() - startedAt,
49
+ assertions: [],
50
+ errors: [`Query context thất bại (${response.status}): ${bodyText}`],
51
+ };
52
+ }
53
+ const payload = parseJsonIfPossible(bodyText);
54
+ const entityNames = new Set((payload.entities ?? []).map((entity) => entity.name).filter(Boolean));
55
+ const citationLabels = new Set((payload.citations ?? []).map((citation) => citation.label).filter(Boolean));
56
+ const relationCount = payload.relations?.length ?? 0;
57
+ const assertions = [];
58
+ const errors = [];
59
+ for (const expected of testCase.expectedEntityNames ?? []) {
60
+ if (entityNames.has(expected)) {
61
+ assertions.push(`Có entity ${expected}`);
62
+ }
63
+ else {
64
+ errors.push(`Thiếu entity ${expected}`);
65
+ }
66
+ }
67
+ for (const expected of testCase.expectedCitationLabels ?? []) {
68
+ if (citationLabels.has(expected)) {
69
+ assertions.push(`Có citation ${expected}`);
70
+ }
71
+ else {
72
+ errors.push(`Thiếu citation ${expected}`);
73
+ }
74
+ }
75
+ if (typeof testCase.minRelationCount === 'number') {
76
+ if (relationCount >= testCase.minRelationCount) {
77
+ assertions.push(`Số relation đạt ngưỡng ${testCase.minRelationCount}`);
78
+ }
79
+ else {
80
+ errors.push(`Số relation chỉ có ${relationCount}, thấp hơn ngưỡng ${testCase.minRelationCount}`);
81
+ }
82
+ }
83
+ return {
84
+ id: testCase.id,
85
+ type: testCase.type,
86
+ ok: errors.length === 0,
87
+ durationMs: Date.now() - startedAt,
88
+ assertions,
89
+ errors,
90
+ };
91
+ }
92
+ async function runRenderedDocsCase(baseUrl, testCase, fetchImpl) {
93
+ const startedAt = Date.now();
94
+ const query = new URLSearchParams({
95
+ format: testCase.format ?? 'md',
96
+ });
97
+ const response = await fetchImpl(`${baseUrl.replace(/\/+$/, '')}/codegraph/docs/${encodeURIComponent(testCase.docType)}?${query.toString()}`, { method: 'GET' });
98
+ const bodyText = await response.text();
99
+ if (!response.ok) {
100
+ return {
101
+ id: testCase.id,
102
+ type: testCase.type,
103
+ ok: false,
104
+ durationMs: Date.now() - startedAt,
105
+ assertions: [],
106
+ errors: [`Rendered docs thất bại (${response.status}): ${bodyText}`],
107
+ };
108
+ }
109
+ const payload = parseJsonIfPossible(bodyText);
110
+ const docText = extractDocText(payload);
111
+ const assertions = [];
112
+ const errors = [];
113
+ for (const expected of testCase.mustContainText ?? []) {
114
+ if (docText.includes(expected)) {
115
+ assertions.push(`Docs chứa "${expected}"`);
116
+ }
117
+ else {
118
+ errors.push(`Docs không chứa "${expected}"`);
119
+ }
120
+ }
121
+ return {
122
+ id: testCase.id,
123
+ type: testCase.type,
124
+ ok: errors.length === 0,
125
+ durationMs: Date.now() - startedAt,
126
+ assertions,
127
+ errors,
128
+ };
129
+ }
130
+ export async function runEvaluationSuite(options, fetchImpl = fetch) {
131
+ const startedAt = new Date().toISOString();
132
+ const results = [];
133
+ for (const testCase of options.cases) {
134
+ if (testCase.type === 'query_context') {
135
+ results.push(await runQueryContextCase(options.baseUrl, testCase, fetchImpl));
136
+ continue;
137
+ }
138
+ results.push(await runRenderedDocsCase(options.baseUrl, testCase, fetchImpl));
139
+ }
140
+ const passed = results.filter((entry) => entry.ok).length;
141
+ return {
142
+ total: results.length,
143
+ passed,
144
+ failed: results.length - passed,
145
+ startedAt,
146
+ finishedAt: new Date().toISOString(),
147
+ results,
148
+ };
149
+ }
@@ -0,0 +1,50 @@
1
+ import { defaultBaseUrl } from './mcp-config.js';
2
+ export async function fetchJson(url, init, fetchImpl = fetch) {
3
+ const response = await fetchImpl(url, init);
4
+ const bodyText = await response.text();
5
+ if (!response.ok) {
6
+ throw new Error(`Yêu cầu thất bại (${response.status}) tại ${url}: ${bodyText}`);
7
+ }
8
+ return bodyText.trim() ? JSON.parse(bodyText) : {};
9
+ }
10
+ export async function fetchText(url, init, fetchImpl = fetch) {
11
+ const response = await fetchImpl(url, init);
12
+ const bodyText = await response.text();
13
+ if (!response.ok) {
14
+ throw new Error(`Yêu cầu thất bại (${response.status}) tại ${url}: ${bodyText}`);
15
+ }
16
+ return bodyText;
17
+ }
18
+ export async function runDoctor(options = {}) {
19
+ const baseUrl = (options.baseUrl?.trim() || defaultBaseUrl()).replace(/\/+$/, '');
20
+ const fetchImpl = options.fetchImpl ?? fetch;
21
+ const checks = [
22
+ { name: 'api', url: `${baseUrl}/api/healthz` },
23
+ { name: 'mcp', url: `${baseUrl}/mcp/healthz` },
24
+ { name: 'codegraph', url: `${baseUrl}/codegraph/healthz` },
25
+ { name: 'codegraph-sync-status', url: `${baseUrl}/codegraph/sync-status` },
26
+ ];
27
+ const results = await Promise.all(checks.map(async (check) => {
28
+ try {
29
+ const response = await fetchImpl(check.url, { method: 'GET' });
30
+ const bodyText = await response.text();
31
+ return {
32
+ name: check.name,
33
+ url: check.url,
34
+ ok: response.ok,
35
+ status: response.status,
36
+ summary: bodyText.trim() || '(empty)',
37
+ };
38
+ }
39
+ catch (error) {
40
+ return {
41
+ name: check.name,
42
+ url: check.url,
43
+ ok: false,
44
+ status: 0,
45
+ summary: error instanceof Error ? error.message : String(error),
46
+ };
47
+ }
48
+ }));
49
+ return results;
50
+ }
@@ -0,0 +1,129 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
4
+ function escapeRegex(value) {
5
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
6
+ }
7
+ function quotedTomlKey(value) {
8
+ return `"${value.replace(/"/g, '\\"')}"`;
9
+ }
10
+ export function defaultMcpServerAlias() {
11
+ return 'sp-rag';
12
+ }
13
+ export function defaultMcpUrl() {
14
+ return process.env['SP_RAG_MCP_URL']?.trim() || 'https://sp-rag.secomapp.com/mcp';
15
+ }
16
+ export function defaultBaseUrl() {
17
+ return process.env['SP_RAG_BASE_URL']?.trim() || 'https://sp-rag.secomapp.com';
18
+ }
19
+ export function upsertCodexConfig(existing, options) {
20
+ const alias = options.serverAlias.trim();
21
+ const aliasPattern = escapeRegex(alias);
22
+ const cleaned = existing
23
+ .replace(new RegExp(`(?:^|\\n)\\[mcp_servers\\.(?:"${aliasPattern}"|${aliasPattern})\\.headers\\][\\s\\S]*?(?=(?:\\n\\[[^\\n]+\\])|$)`, 'g'), '')
24
+ .replace(new RegExp(`(?:^|\\n)\\[mcp_servers\\.(?:"${aliasPattern}"|${aliasPattern})\\][\\s\\S]*?(?=(?:\\n\\[[^\\n]+\\])|$)`, 'g'), '')
25
+ .trimEnd();
26
+ const lines = [
27
+ `[mcp_servers.${quotedTomlKey(alias)}]`,
28
+ `url = "${options.url}"`,
29
+ ];
30
+ const authToken = options.authToken?.trim();
31
+ const authEnvVar = options.authEnvVar?.trim();
32
+ if (authToken) {
33
+ lines.push('', `[mcp_servers.${quotedTomlKey(alias)}.headers]`);
34
+ lines.push(`Authorization = "Bearer ${authToken.replace(/"/g, '\\"')}"`);
35
+ }
36
+ else if (authEnvVar) {
37
+ lines.push('', `[mcp_servers.${quotedTomlKey(alias)}.headers]`);
38
+ lines.push(`Authorization = "Bearer \${${authEnvVar}}"`);
39
+ }
40
+ return `${cleaned ? `${cleaned}\n\n` : ''}${lines.join('\n')}\n`;
41
+ }
42
+ export function upsertJsonMcpConfig(existing, options) {
43
+ const base = existing?.trim()
44
+ ? JSON.parse(existing)
45
+ : {};
46
+ const mcpServers = base['mcpServers'] && typeof base['mcpServers'] === 'object'
47
+ ? { ...base['mcpServers'] }
48
+ : {};
49
+ const serverConfig = {
50
+ url: options.url,
51
+ };
52
+ if (options.includeTypeHttp) {
53
+ serverConfig['type'] = 'http';
54
+ }
55
+ const authToken = options.authToken?.trim();
56
+ const authEnvVar = options.authEnvVar?.trim();
57
+ if (authToken) {
58
+ serverConfig['headers'] = {
59
+ Authorization: `Bearer ${authToken}`,
60
+ };
61
+ }
62
+ else if (authEnvVar) {
63
+ serverConfig['headers'] = {
64
+ Authorization: `Bearer \${${authEnvVar}}`,
65
+ };
66
+ }
67
+ mcpServers[options.serverAlias] = serverConfig;
68
+ return `${JSON.stringify({
69
+ ...base,
70
+ mcpServers,
71
+ }, null, 2)}\n`;
72
+ }
73
+ export function resolveMcpConfigPath(options) {
74
+ const scope = options.scope ??
75
+ (options.client === 'codex' ? 'global' : 'project');
76
+ if (options.client === 'codex' && scope !== 'global') {
77
+ throw new Error('Codex hiện chỉ hỗ trợ scope global trong CLI này.');
78
+ }
79
+ if (options.client === 'claude-code' && scope !== 'project') {
80
+ throw new Error('Claude Code hiện chỉ hỗ trợ scope project trong CLI này.');
81
+ }
82
+ const cwd = path.resolve(options.cwd ?? process.cwd());
83
+ const home = os.homedir();
84
+ switch (options.client) {
85
+ case 'codex':
86
+ return {
87
+ client: options.client,
88
+ scope,
89
+ path: path.join(home, '.codex', 'config.toml'),
90
+ };
91
+ case 'cursor':
92
+ return {
93
+ client: options.client,
94
+ scope,
95
+ path: scope === 'global'
96
+ ? path.join(home, '.cursor', 'mcp.json')
97
+ : path.join(cwd, '.cursor', 'mcp.json'),
98
+ };
99
+ case 'claude-code':
100
+ return {
101
+ client: options.client,
102
+ scope,
103
+ path: path.join(cwd, '.mcp.json'),
104
+ };
105
+ }
106
+ }
107
+ export async function installMcpConfig(options) {
108
+ const resolved = resolveMcpConfigPath(options);
109
+ const existing = await readFile(resolved.path, 'utf8').catch(() => '');
110
+ const alias = options.serverAlias?.trim() || defaultMcpServerAlias();
111
+ const url = options.url.trim();
112
+ const content = resolved.client === 'codex'
113
+ ? upsertCodexConfig(existing, {
114
+ serverAlias: alias,
115
+ url,
116
+ authEnvVar: options.authEnvVar,
117
+ authToken: options.authToken,
118
+ })
119
+ : upsertJsonMcpConfig(existing, {
120
+ serverAlias: alias,
121
+ url,
122
+ authEnvVar: options.authEnvVar,
123
+ authToken: options.authToken,
124
+ includeTypeHttp: resolved.client === 'claude-code',
125
+ });
126
+ await mkdir(path.dirname(resolved.path), { recursive: true });
127
+ await writeFile(resolved.path, content, 'utf8');
128
+ return resolved;
129
+ }
@@ -0,0 +1,51 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+ import { mkdir, writeFile } from 'node:fs/promises';
4
+ export function defaultSkillDir() {
5
+ return path.join(os.homedir(), '.codex', 'skills', 'sp-rag');
6
+ }
7
+ export function renderCodexSkill(options) {
8
+ return `---
9
+ name: sp-rag
10
+ description: Dùng RAG nội bộ qua MCP để trả lời câu hỏi về codebase, domain, docs và vận hành trước khi dựa vào trí nhớ.
11
+ ---
12
+
13
+ # SP-RAG
14
+
15
+ MCP server alias mặc định: \`${options.serverAlias}\`
16
+ MCP URL: \`${options.mcpUrl}\`
17
+ Docs URL: \`${options.docsUrl}\`
18
+
19
+ ## Khi nào dùng
20
+
21
+ - Khi câu hỏi liên quan đến codebase nội bộ, domain nghiệp vụ, docs đã render, trạng thái sync hoặc inventory import.
22
+ - Khi cần câu trả lời có evidence thay vì suy đoán theo trí nhớ.
23
+
24
+ ## Luồng dùng khuyến nghị
25
+
26
+ 1. Gọi \`healthz\` nếu nghi server đang lỗi hoặc vừa kết nối lần đầu.
27
+ 2. Dùng \`query_context\` cho câu hỏi về kiến trúc, domain, flow nghiệp vụ, entities, relations.
28
+ 3. Dùng \`get_rendered_docs\` cho public docs, function docs hoặc dev docs đã render sẵn.
29
+ 4. Dùng \`get_sync_status\`, \`get_sync_runs\` hoặc \`get_sync_metrics\` khi cần kiểm tra code graph đang ở commit nào, sync có lỗi không, hay muốn đọc lịch sử và metrics vận hành.
30
+ 5. Chỉ dùng \`trigger_code_graph_sync\` khi user yêu cầu làm mới index hoặc code graph.
31
+
32
+ ## Nguyên tắc trả lời
33
+
34
+ - Ưu tiên câu trả lời grounded từ MCP/RAG trước.
35
+ - Nếu evidence có dấu hiệu stale, nói rõ dữ liệu có thể chưa được sync mới nhất.
36
+ - Không tự kích hoạt sync/index nếu user chưa yêu cầu hoặc chưa thật sự cần.
37
+ - Khi docs đã đủ, ưu tiên trích từ docs đã render thay vì tự diễn giải dài dòng.
38
+ `;
39
+ }
40
+ export async function installCodexSkill(options = {}) {
41
+ const targetDir = path.resolve(options.targetDir ?? defaultSkillDir());
42
+ const filePath = path.join(targetDir, 'SKILL.md');
43
+ const content = renderCodexSkill({
44
+ serverAlias: options.serverAlias?.trim() || 'sp-rag',
45
+ mcpUrl: options.mcpUrl?.trim() || 'https://sp-rag.secomapp.com/mcp',
46
+ docsUrl: options.docsUrl?.trim() || 'https://sp-rag.secomapp.com/codegraph/docs/public?format=md',
47
+ });
48
+ await mkdir(targetDir, { recursive: true });
49
+ await writeFile(filePath, content, 'utf8');
50
+ return { path: filePath };
51
+ }
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "sp-rag",
3
+ "version": "0.3.0",
4
+ "description": "CLI cho setup MCP, codegraph GitNexus và skill của SP-RAG",
5
+ "type": "module",
6
+ "files": [
7
+ "dist"
8
+ ],
9
+ "bin": {
10
+ "sp-rag": "dist/index.js"
11
+ },
12
+ "scripts": {
13
+ "build": "tsc -p tsconfig.json",
14
+ "prepack": "npm run build",
15
+ "start": "node dist/index.js",
16
+ "test": "vitest run"
17
+ },
18
+ "engines": {
19
+ "node": ">=20"
20
+ },
21
+ "keywords": [
22
+ "rag",
23
+ "mcp",
24
+ "gitnexus",
25
+ "cli",
26
+ "sp-rag"
27
+ ],
28
+ "devDependencies": {
29
+ "@types/node": "^22.13.5",
30
+ "typescript": "^5.7.3",
31
+ "vitest": "^3.0.7"
32
+ }
33
+ }