claude-spotter 1.5.13 → 1.6.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/CHANGELOG.md CHANGED
@@ -3,6 +3,25 @@
3
3
  各節はそのversion公開時点の変更記録であり、後続versionにより置換された仕様を含む。
4
4
  現行runtime契約は[`docs/00_overview.md`](docs/00_overview.md)から辿る。
5
5
 
6
+ ## 1.6.0 — 2026-08-24
7
+
8
+ - **Cursor を tool-db host として足した。** `src/host/adapters.mjs` に
9
+ `hostAgent: 'cursor'` / `tool-db.cursor.json` を追加し、MCP は `~/.cursor/mcp.json` と
10
+ project `.cursor/mcp.json`、skills / agents は `~/.cursor` と `.cursor` だけを読む。
11
+ Cursor 製品同梱の `skills-cursor` はカタログに入れない。Claude / Codex の DB は触らない。
12
+ - **`spotter cursor-hook`** は `~/.cursor/hooks.json` の `sessionStart` へ flat
13
+ `{command, timeout: 5}` を冪等マージする。工場 hook は残し、Cursor envelope を Claude 形へ
14
+ 変換しない。`spotter install` は `~/.cursor` があるときこの hook を配線し、
15
+ `tool-db.cursor.json` を seed する。
16
+
17
+ ## 1.5.14 — 2026-08-24
18
+
19
+ - **挙動不変のOS層整理(harness用語統一campaignの分離規約)。** Windows絶対パス表記
20
+ (ドライブレター/UNC)の判定を`src/platform/paths.mjs`の`isWindowsAbsolutePath()`へ集約し、
21
+ tool-db側の独自regexを委譲へ置換。runtime-error-storeの`killWorkerTree`には
22
+ `terminateProcessTree`と意図的に別物である理由(絶対にrejectしないbest-effort掃除)を明記した。
23
+ 公開面・挙動は不変。
24
+
6
25
  ## 1.5.13 — 2026-08-23
7
26
 
8
27
  - **OS依存を`src/platform/`へ集約。** 6ファイルへ重複していたWindowsのcmd.exe /c wrap・
package/bin/spotter.mjs CHANGED
@@ -9,6 +9,7 @@ import { runStatus } from '../src/cli/status.mjs';
9
9
  import { runDbList, runDbRefresh, runDbRebuild } from '../src/cli/db-cmd.mjs';
10
10
  import { runCodexCommand } from '../src/cli/codex-cmd.mjs';
11
11
  import { runCodexHookCommand } from '../src/cli/codex-hook-cmd.mjs';
12
+ import { runCursorHookCommand } from '../src/cli/cursor-hook-cmd.mjs';
12
13
  import { runAuditorCommand } from '../src/cli/auditor-cmd.mjs';
13
14
  import { runDiagnosticsCommand } from '../src/cli/diagnostics-cmd.mjs';
14
15
  import { runEvaluationCommand } from '../src/cli/evaluation-cmd.mjs';
@@ -66,6 +67,8 @@ Usage:
66
67
  run approved codex-sidecar worktree workflow
67
68
  spotter codex-hook install|uninstall|diagnostics
68
69
  (experimental) manage Codex native hooks
70
+ spotter cursor-hook install|uninstall|diagnostics
71
+ manage Cursor native catalog-refresh hooks
69
72
  spotter auditor judge --stage STAGE --input FILE
70
73
  (experimental) run primary auditor backend once
71
74
  spotter auditor matrix --stage STAGE --input FILE
@@ -128,6 +131,9 @@ async function main() {
128
131
  case 'codex-hook':
129
132
  await runCodexHookCommand({ argv: rest });
130
133
  return;
134
+ case 'cursor-hook':
135
+ await runCursorHookCommand({ argv: rest });
136
+ return;
131
137
  case 'auditor':
132
138
  await runAuditorCommand({ argv: rest });
133
139
  return;
@@ -1,6 +1,6 @@
1
1
  # Spotter評価dashboard運用
2
2
 
3
- 現行npm配布版: **v1.5.13**(2026-08-23)。v1.5.13はOS依存・ベンダー依存の内部配置だけを
3
+ 現行npm配布版: **v1.6.0**(2026-08-24)。v1.5.13はOS依存・ベンダー依存の内部配置だけを
4
4
  変更した挙動同一リファクタで、評価・dashboardのrouting構成は変更していない。
5
5
 
6
6
  この文書はservice設定の正本であり、各端末に現在installされているnpm versionの台帳ではない。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-spotter",
3
- "version": "1.5.13",
3
+ "version": "1.6.0",
4
4
  "description": "Audit agent running alongside Claude Code that catches missed tool calls — 気づく役と実行する役の分離",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,242 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
3
+ import { homedir } from 'node:os';
4
+ import { dirname, join, resolve } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { spawnRefreshDetached } from '../hooks/spawn-daemon.mjs';
7
+ import {
8
+ die,
9
+ findSpotterMarker,
10
+ isChildCall,
11
+ readStdinJson,
12
+ } from '../hooks/lib.mjs';
13
+
14
+ const HERE = dirname(fileURLToPath(import.meta.url));
15
+ const PACKAGE_ROOT = resolve(HERE, '..', '..');
16
+ const SPOTTER_BIN = join(PACKAGE_ROOT, 'bin', 'spotter.mjs');
17
+ const CURSOR_SESSION_START_TIMEOUT_SEC = 5;
18
+ const CURSOR_HOOK_FRAGMENT = 'cursor-hook session-start';
19
+
20
+ const CURSOR_HOOK_USAGE = `spotter cursor-hook — Cursor native hook adapter
21
+
22
+ Usage:
23
+ spotter cursor-hook install [--cursor-home DIR]
24
+ spotter cursor-hook uninstall [--cursor-home DIR]
25
+ spotter cursor-hook diagnostics [--cursor-home DIR]
26
+ spotter cursor-hook session-start
27
+
28
+ Installs a Cursor sessionStart hook that refreshes tool-db.cursor.json.
29
+ Does not convert the Cursor envelope to Claude shape. Factory hooks stay in place.
30
+ `;
31
+
32
+ export async function runCursorHookCommand({ argv = process.argv.slice(2) } = {}) {
33
+ const sub = argv[0];
34
+ if (!sub || sub === '--help' || sub === '-h') {
35
+ process.stdout.write(CURSOR_HOOK_USAGE);
36
+ return;
37
+ }
38
+ if (sub === 'install') {
39
+ const opts = parseCursorHomeArgs(argv.slice(1));
40
+ const result = await installCursorHooks({ cursorHome: opts.cursorHome });
41
+ process.stdout.write(`${JSON.stringify({
42
+ installation: result.hooks.sessionStart === 'unchanged' ? 'already_wired' : 'wired',
43
+ hooksPath: result.hooksPath,
44
+ hooks: result.hooks,
45
+ }, null, 2)}\n`);
46
+ return;
47
+ }
48
+ if (sub === 'uninstall') {
49
+ const opts = parseCursorHomeArgs(argv.slice(1));
50
+ const result = await uninstallCursorHooks({ cursorHome: opts.cursorHome });
51
+ process.stdout.write(`${JSON.stringify({
52
+ hooksPath: result.hooksPath,
53
+ hooks: result.hooks,
54
+ }, null, 2)}\n`);
55
+ return;
56
+ }
57
+ if (sub === 'diagnostics') {
58
+ const opts = parseCursorHomeArgs(argv.slice(1));
59
+ const result = cursorHookDiagnostics({ cursorHome: opts.cursorHome });
60
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
61
+ return;
62
+ }
63
+ if (sub === 'session-start') {
64
+ await runCursorSessionStartHook();
65
+ return;
66
+ }
67
+ process.stderr.write(`unknown cursor-hook subcommand: ${sub}\n${CURSOR_HOOK_USAGE}`);
68
+ process.exit(2);
69
+ }
70
+
71
+ export async function runCursorSessionStartHook({
72
+ readInput = readStdinJson,
73
+ spawnRefreshDetachedFn = spawnRefreshDetached,
74
+ } = {}) {
75
+ if (isChildCall()) return;
76
+ const input = await readInput();
77
+ const cwd = cursorCwd(input);
78
+ if (!cwd) return;
79
+ const projectRoot = findSpotterMarker(cwd);
80
+ if (!projectRoot) return;
81
+ spawnRefreshDetachedFn({ projectRoot, hostAgent: 'cursor' });
82
+ }
83
+
84
+ export function cursorCwd(input) {
85
+ if (typeof input?.cwd === 'string' && input.cwd.length > 0) return input.cwd;
86
+ const root = input?.workspace_roots?.[0];
87
+ if (typeof root === 'string' && root.length > 0) return root;
88
+ return null;
89
+ }
90
+
91
+ export async function installCursorHooks({
92
+ cursorHome = defaultCursorHome(),
93
+ nodePath = process.execPath,
94
+ spotterBin = SPOTTER_BIN,
95
+ } = {}) {
96
+ const hooksPath = join(cursorHome, 'hooks.json');
97
+ const file = await loadCursorHooks(hooksPath);
98
+ const command = hookCommand(nodePath, spotterBin);
99
+ const sessionStart = upsertSessionStart(file, command);
100
+ if (sessionStart === 'added') await persistCursorHooks(hooksPath, file);
101
+ return {
102
+ cursorHome,
103
+ hooksPath,
104
+ hooks: { sessionStart },
105
+ };
106
+ }
107
+
108
+ export async function uninstallCursorHooks({
109
+ cursorHome = defaultCursorHome(),
110
+ } = {}) {
111
+ const hooksPath = join(cursorHome, 'hooks.json');
112
+ const file = await loadCursorHooks(hooksPath);
113
+ const removed = removeSessionStart(file);
114
+ if (removed) await persistCursorHooks(hooksPath, file);
115
+ return {
116
+ cursorHome,
117
+ hooksPath,
118
+ hooks: { sessionStart: removed ? 'removed' : 'not present' },
119
+ };
120
+ }
121
+
122
+ export function cursorHookDiagnostics({ cursorHome = defaultCursorHome() } = {}) {
123
+ const hooksPath = join(cursorHome, 'hooks.json');
124
+ const present = existsSync(hooksPath);
125
+ const file = present ? JSON.parse(readFileSync(hooksPath, 'utf8')) : { version: 1, hooks: {} };
126
+ if (file === null || typeof file !== 'object' || Array.isArray(file)) {
127
+ return {
128
+ installation: 'not-installed',
129
+ cursorHome,
130
+ hooksPath,
131
+ installedHooks: { sessionStart: false },
132
+ };
133
+ }
134
+ if (file.hooks == null || typeof file.hooks !== 'object' || Array.isArray(file.hooks)) {
135
+ file.hooks = {};
136
+ }
137
+ const installed = listFor(file, 'sessionStart').some((entry) => isSpotterCursorHook(entry));
138
+ return {
139
+ installation: installed ? 'installed' : 'not-installed',
140
+ cursorHome,
141
+ hooksPath,
142
+ installedHooks: { sessionStart: installed },
143
+ };
144
+ }
145
+
146
+ export function isCursorHomePresent(cursorHome = defaultCursorHome()) {
147
+ return existsSync(cursorHome);
148
+ }
149
+
150
+ function defaultCursorHome() {
151
+ return join(homedir(), '.cursor');
152
+ }
153
+
154
+ function parseCursorHomeArgs(argv) {
155
+ let cursorHome = defaultCursorHome();
156
+ for (let index = 0; index < argv.length; index += 1) {
157
+ if (argv[index] === '--cursor-home') {
158
+ const value = argv[index + 1];
159
+ if (!value || value.startsWith('--')) {
160
+ throw Object.assign(new Error('--cursor-home requires a value'), { exitCode: 2 });
161
+ }
162
+ cursorHome = value;
163
+ index += 1;
164
+ }
165
+ }
166
+ return { cursorHome };
167
+ }
168
+
169
+ function hookCommand(nodePath, spotterBin) {
170
+ return `${quoteArg(nodePath)} ${quoteArg(spotterBin)} cursor-hook session-start`;
171
+ }
172
+
173
+ function quoteArg(value) {
174
+ const text = String(value);
175
+ if (!/[\s"']/.test(text)) return text;
176
+ return `"${text.replaceAll('"', '\\"')}"`;
177
+ }
178
+
179
+ function isSpotterCursorHook(entry) {
180
+ return typeof entry?.command === 'string' && entry.command.includes(CURSOR_HOOK_FRAGMENT);
181
+ }
182
+
183
+ function listFor(file, event) {
184
+ const current = file.hooks?.[event];
185
+ return Array.isArray(current) ? current : [];
186
+ }
187
+
188
+ function upsertSessionStart(file, command) {
189
+ file.hooks ??= {};
190
+ const list = listFor(file, 'sessionStart');
191
+ for (const entry of list) {
192
+ if (typeof entry.command !== 'string') continue;
193
+ if (entry.command === command && entry.timeout === CURSOR_SESSION_START_TIMEOUT_SEC) {
194
+ return 'unchanged';
195
+ }
196
+ if (entry.command === command || isSpotterCursorHook(entry)) {
197
+ entry.command = command;
198
+ entry.timeout = CURSOR_SESSION_START_TIMEOUT_SEC;
199
+ file.hooks.sessionStart = list;
200
+ return 'added';
201
+ }
202
+ }
203
+ list.push({ command, timeout: CURSOR_SESSION_START_TIMEOUT_SEC });
204
+ file.hooks.sessionStart = list;
205
+ return 'added';
206
+ }
207
+
208
+ function removeSessionStart(file) {
209
+ const list = listFor(file, 'sessionStart');
210
+ const kept = list.filter((entry) => !isSpotterCursorHook(entry));
211
+ if (kept.length === list.length) return false;
212
+ if (kept.length > 0) file.hooks.sessionStart = kept;
213
+ else delete file.hooks.sessionStart;
214
+ return true;
215
+ }
216
+
217
+ async function loadCursorHooks(path) {
218
+ try {
219
+ const parsed = JSON.parse(await readFile(path, 'utf8'));
220
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
221
+ throw new Error(`${path} は object である必要があります`);
222
+ }
223
+ if (parsed.hooks == null) parsed.hooks = {};
224
+ if (typeof parsed.hooks !== 'object' || Array.isArray(parsed.hooks)) {
225
+ throw new Error(`${path} の hooks は object である必要があります`);
226
+ }
227
+ return parsed;
228
+ } catch (err) {
229
+ if (err?.code === 'ENOENT') return { version: 1, hooks: {} };
230
+ throw err;
231
+ }
232
+ }
233
+
234
+ async function persistCursorHooks(path, file) {
235
+ await mkdir(dirname(path), { recursive: true });
236
+ file.version = file.version ?? 1;
237
+ await writeFile(path, `${JSON.stringify(file, null, 2)}\n`, 'utf8');
238
+ }
239
+
240
+ if (import.meta.url === `file://${process.argv[1]?.replace(/\\/g, '/')}`) {
241
+ runCursorHookCommand().catch((err) => die(err.message, err.exitCode ?? 2));
242
+ }
@@ -14,9 +14,9 @@ import { writeFile } from 'node:fs/promises';
14
14
  const DB_USAGE = `spotter db — manage the host-specific tool-db
15
15
 
16
16
  Usage:
17
- spotter db list [--host-agent claude|codex|automation]
18
- spotter db refresh [--host-agent claude|codex|automation]
19
- spotter db rebuild [--host-agent claude|codex|automation]
17
+ spotter db list [--host-agent claude|codex|automation|cursor]
18
+ spotter db refresh [--host-agent claude|codex|automation|cursor]
19
+ spotter db rebuild [--host-agent claude|codex|automation|cursor]
20
20
  `;
21
21
 
22
22
  function requireProjectRoot() {
@@ -24,6 +24,7 @@ import { version as SPOTTER_VERSION } from '../version.mjs';
24
24
  import { refresh } from '../tool-db/refresh.mjs';
25
25
  import { localDbPath, globalDbPath } from '../tool-db/loader.mjs';
26
26
  import { installCodexHooks } from './codex-hook-cmd.mjs';
27
+ import { installCursorHooks, isCursorHomePresent } from './cursor-hook-cmd.mjs';
27
28
  import { prepareRuntimeErrorStoreDirectory } from '../core/runtime-error-store.mjs';
28
29
 
29
30
  const HERE = dirname(fileURLToPath(import.meta.url));
@@ -57,9 +58,12 @@ export async function runInstall({
57
58
  cwd = process.cwd(),
58
59
  skipRefresh = false,
59
60
  skipCodexHooks = skipRefresh,
61
+ skipCursorHooks = skipRefresh,
60
62
  refreshFn = refresh,
61
63
  codexCliPresentFn = isCodexCliPresent,
62
64
  installCodexHooksFn = installCodexHooks,
65
+ cursorHomePresentFn = isCursorHomePresent,
66
+ installCursorHooksFn = installCursorHooks,
63
67
  prepareRuntimeErrorStoreDirectoryFn = prepareRuntimeErrorStoreDirectory,
64
68
  auditorContext,
65
69
  resolveDefaultAuditorContextFn = resolveDefaultAuditorContext,
@@ -150,6 +154,18 @@ export async function runInstall({
150
154
  }
151
155
  }
152
156
 
157
+ let cursorHooksRegistered = false;
158
+ if (target === 'project' && !skipCursorHooks) {
159
+ if (cursorHomePresentFn()) {
160
+ const result = await installCursorHooksFn();
161
+ cursorHooksRegistered = true;
162
+ console.log(' Cursor hooks registered');
163
+ console.log(` Cursor hooks: ${result.hooksPath}`);
164
+ } else {
165
+ console.log(' Cursor home not found — Cursor hooks not registered');
166
+ }
167
+ }
168
+
153
169
  // Seed the tool-db so the first session has something to audit against.
154
170
  // Runs regardless of whether settings.json changed — re-running `spotter install`
155
171
  // on an already-installed project is the canonical way to refresh tool-db drift
@@ -170,12 +186,18 @@ export async function runInstall({
170
186
  console.log(` Codex local DB: ${localDbPath(cwd, 'codex')}`);
171
187
  console.log(` Codex global DB: ${globalDbPath('codex')}`);
172
188
  }
189
+ if (cursorHooksRegistered) {
190
+ const cursorResolved = await refreshFn({ projectRoot: cwd, hostAgent: 'cursor', logFn: log });
191
+ console.log(` ${cursorResolved.size} Cursor tool(s) resolved`);
192
+ console.log(` Cursor local DB: ${localDbPath(cwd, 'cursor')}`);
193
+ console.log(` Cursor global DB: ${globalDbPath('cursor')}`);
194
+ }
173
195
  } catch (err) {
174
196
  // §0: throw (fallback 禁止). But surface the recovery path so the user isn't
175
197
  // left with "hooks registered, tool-db missing" and no clue what to run.
176
198
  process.stderr.write(`\nspotter install: tool-db seeding failed.\n`);
177
199
  process.stderr.write(` hooks are registered but tool-db is not ready.\n`);
178
- process.stderr.write(` recover with: spotter db refresh and, for Codex, spotter db refresh --host-agent codex\n`);
200
+ process.stderr.write(` recover with: spotter db refresh and, for Codex, spotter db refresh --host-agent codex; for Cursor, spotter db refresh --host-agent cursor\n`);
179
201
  throw err;
180
202
  }
181
203
  }
@@ -187,6 +209,11 @@ export async function runInstall({
187
209
  } else if (target === 'project' && !skipCodexHooks) {
188
210
  console.log(' Codex hooks are not active: rerun `spotter install` where `codex --version` succeeds');
189
211
  }
212
+ if (cursorHooksRegistered) {
213
+ console.log(' Cursor catalog refresh is active: new Cursor Agent sessions refresh tool-db.cursor.json');
214
+ } else if (target === 'project' && !skipCursorHooks) {
215
+ console.log(' Cursor hooks are not active: rerun `spotter install` where ~/.cursor exists');
216
+ }
190
217
  }
191
218
 
192
219
  async function readExistingAuditorContext(markerPath, resolveDefaultAuditorContextFn) {
@@ -292,6 +292,8 @@ function runRuntimeWorker(workerPath, args, timeoutMs, signal) {
292
292
  });
293
293
  }
294
294
 
295
+ // platform/spawn.mjs の terminateProcessTree と似るが意図的に別物: こちらは受領記録の
296
+ // best-effort掃除で、絶対にrejectせず(SIGKILL直行・225ms fallback)呼び出し元を止めない。
295
297
  function killWorkerTree(child) {
296
298
  if (!Number.isSafeInteger(child?.pid) || child.pid <= 0) return Promise.resolve();
297
299
  if (process.platform === 'win32') {
@@ -3,12 +3,13 @@
3
3
  // このtableだけが知る。呼び出し側(loader / refresh / CLI)は adapter を引いて
4
4
  // 使うだけで、`if (hostAgent === 'codex')` 分岐を業務ロジックに書かない。
5
5
  //
6
- // ベンダー固有の実装本体は従来どおり investigate-claude.mjs / investigate-codex.mjs
7
- // が持つ。片方のhostのrefreshがもう片方のDBをprune / overwriteしない契約
8
- // AGENTS.md「ツールカタログはhost-local tool-db」)はこの分離が担保する。
6
+ // ベンダー固有の実装本体は investigate-claude.mjs / investigate-codex.mjs /
7
+ // investigate-cursor.mjs が持つ。片方のhostのrefreshがもう片方のDBをprune /
8
+ // overwriteしない契約(AGENTS.md「ツールカタログはhost-local tool-db」)はこの分離が担保する。
9
9
 
10
10
  import { buildInvestigationSnapshot } from '../tool-db/investigate-claude.mjs';
11
11
  import { buildCodexInvestigationSnapshot } from '../tool-db/investigate-codex.mjs';
12
+ import { buildCursorInvestigationSnapshot } from '../tool-db/investigate-cursor.mjs';
12
13
 
13
14
  const CLAUDE_ADAPTER = Object.freeze({
14
15
  hostAgent: 'claude',
@@ -32,10 +33,18 @@ const AUTOMATION_ADAPTER = Object.freeze({
32
33
  buildInvestigationSnapshot({ logFn, claudeBin, projectRoot }),
33
34
  });
34
35
 
36
+ const CURSOR_ADAPTER = Object.freeze({
37
+ hostAgent: 'cursor',
38
+ toolDbFileName: 'tool-db.cursor.json',
39
+ buildSnapshot: ({ logFn, projectRoot }) =>
40
+ buildCursorInvestigationSnapshot({ logFn, projectRoot }),
41
+ });
42
+
35
43
  const ADAPTERS = Object.freeze({
36
44
  claude: CLAUDE_ADAPTER,
37
45
  codex: CODEX_ADAPTER,
38
46
  automation: AUTOMATION_ADAPTER,
47
+ cursor: CURSOR_ADAPTER,
39
48
  });
40
49
 
41
50
  export function normalizeToolDbHostAgent(hostAgent = 'claude') {
@@ -45,7 +54,7 @@ export function normalizeToolDbHostAgent(hostAgent = 'claude') {
45
54
  if (Object.hasOwn(ADAPTERS, hostAgent)) {
46
55
  return hostAgent;
47
56
  }
48
- throw new TypeError(`tool-db hostAgent must be claude, codex, or automation; got ${hostAgent}`);
57
+ throw new TypeError(`tool-db hostAgent must be claude, codex, automation, or cursor; got ${hostAgent}`);
49
58
  }
50
59
 
51
60
  export function getHostAdapter(hostAgent = 'claude') {
@@ -58,3 +58,8 @@ export function normalizeProjectPath(p) {
58
58
  s = s.replace(/\/+$/, '');
59
59
  return s;
60
60
  }
61
+
62
+ // Windows絶対パス表記(ドライブレター/UNC)の判定。パス表記のOS差はこのfileが所有する。
63
+ export function isWindowsAbsolutePath(value) {
64
+ return /^(?:[A-Za-z]:\\|\\\\)/u.test(value);
65
+ }
@@ -49,7 +49,7 @@ export async function listAgentsAll({ logFn = () => {}, projectRoot } = {}) {
49
49
 
50
50
  // Scan `<dir>/<name>.md` files. Returns Map<agent-name, description>. Missing directories
51
51
  // or malformed agents are skipped silently.
52
- async function scanAgentsDir(dir, logFn) {
52
+ export async function scanAgentsDir(dir, logFn) {
53
53
  const out = new Map();
54
54
  let entries;
55
55
  try {
@@ -0,0 +1,123 @@
1
+ // Cursor-native catalog investigation.
2
+ //
3
+ // Cursor の MCP / skills / agents は Claude / Codex の設定面とは別物なので、
4
+ // この snapshot は ~/.cursor と project の .cursor だけを読む。
5
+ // Claude の skills や Codex の mcp list を混ぜない。
6
+ // ~/.cursor/skills-cursor は Cursor 製品同梱であり、工場カタログに入れない。
7
+
8
+ import { readFile } from 'node:fs/promises';
9
+ import { homedir } from 'node:os';
10
+ import { join } from 'node:path';
11
+ import { scanAgentsDir } from './investigate-agents.mjs';
12
+ import { bellVisibleName, listMcpToolsOne } from './investigate-mcp.mjs';
13
+ import { scanSkillsDir } from './investigate-skills.mjs';
14
+ import { describeServer } from './mcp-config.mjs';
15
+
16
+ export async function buildCursorInvestigationSnapshot({
17
+ logFn = () => {},
18
+ projectRoot,
19
+ cursorHome = join(homedir(), '.cursor'),
20
+ } = {}) {
21
+ const snapshot = new Map();
22
+
23
+ const mcp = await listCursorMcpToolsAll({ logFn, projectRoot, cursorHome });
24
+ for (const [serverName, tools] of mcp.entries()) {
25
+ for (const tool of tools) {
26
+ if (!tool.description || tool.description.length === 0) continue;
27
+ snapshot.set(bellVisibleName(serverName, tool.name), tool.description);
28
+ }
29
+ }
30
+
31
+ const skills = await listCursorSkillsAll({ logFn, projectRoot, cursorHome });
32
+ for (const [name, description] of skills) {
33
+ snapshot.set(name, description);
34
+ }
35
+
36
+ const agents = await listCursorAgentsAll({ logFn, projectRoot, cursorHome });
37
+ for (const [name, description] of agents) {
38
+ snapshot.set(name, description);
39
+ }
40
+
41
+ return snapshot;
42
+ }
43
+
44
+ export async function listCursorMcpToolsAll({
45
+ logFn = () => {},
46
+ projectRoot,
47
+ cursorHome = join(homedir(), '.cursor'),
48
+ } = {}) {
49
+ const servers = await listCursorMcpServers({ projectRoot, cursorHome });
50
+ const out = new Map();
51
+ for (const server of servers) {
52
+ try {
53
+ const tools = await listMcpToolsOne({ server, logFn, projectRoot });
54
+ out.set(server.name, tools);
55
+ } catch (err) {
56
+ logFn(`cursor mcp investigate failed for "${server.name}": ${err.message}`);
57
+ }
58
+ }
59
+ return out;
60
+ }
61
+
62
+ export async function listCursorMcpServers({
63
+ projectRoot,
64
+ cursorHome = join(homedir(), '.cursor'),
65
+ } = {}) {
66
+ const user = await readMcpServersFile(join(cursorHome, 'mcp.json'));
67
+ const project = projectRoot ? await readMcpServersFile(join(projectRoot, '.cursor', 'mcp.json')) : {};
68
+ const merged = { ...user, ...project };
69
+ const servers = [];
70
+ for (const [name, entry] of Object.entries(merged)) {
71
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue;
72
+ const described = describeServer(name, entry);
73
+ if (described) servers.push(described);
74
+ }
75
+ return servers;
76
+ }
77
+
78
+ export async function listCursorSkillsAll({
79
+ logFn = () => {},
80
+ projectRoot,
81
+ cursorHome = join(homedir(), '.cursor'),
82
+ } = {}) {
83
+ const out = new Map();
84
+ for (const [name, description] of await scanSkillsDir(join(cursorHome, 'skills'), logFn)) {
85
+ out.set(name, description);
86
+ }
87
+ if (projectRoot) {
88
+ for (const [name, description] of await scanSkillsDir(join(projectRoot, '.cursor', 'skills'), logFn)) {
89
+ out.set(name, description);
90
+ }
91
+ }
92
+ return out;
93
+ }
94
+
95
+ export async function listCursorAgentsAll({
96
+ logFn = () => {},
97
+ projectRoot,
98
+ cursorHome = join(homedir(), '.cursor'),
99
+ } = {}) {
100
+ const out = new Map();
101
+ for (const [name, description] of await scanAgentsDir(join(cursorHome, 'agents'), logFn)) {
102
+ out.set(name, description);
103
+ }
104
+ if (projectRoot) {
105
+ for (const [name, description] of await scanAgentsDir(join(projectRoot, '.cursor', 'agents'), logFn)) {
106
+ out.set(name, description);
107
+ }
108
+ }
109
+ return out;
110
+ }
111
+
112
+ async function readMcpServersFile(path) {
113
+ let text;
114
+ try {
115
+ text = await readFile(path, 'utf8');
116
+ } catch (err) {
117
+ if (err.code === 'ENOENT') return {};
118
+ throw err;
119
+ }
120
+ const data = JSON.parse(text);
121
+ if (!data || typeof data !== 'object') return {};
122
+ return (data.mcpServers && typeof data.mcpServers === 'object') ? data.mcpServers : {};
123
+ }
@@ -10,6 +10,7 @@
10
10
  // 5. ← tools/list result (response with tools[] each having {name, description})
11
11
 
12
12
  import { spawn } from 'node:child_process';
13
+ import { isWindowsAbsolutePath } from '../platform/paths.mjs';
13
14
  import { execFileWindowsSafe, windowsCompatibleCommand } from '../platform/spawn.mjs';
14
15
  import { listToolsHttp } from './investigate-mcp-http.mjs';
15
16
  import { readMcpServers, describeServer } from './mcp-config.mjs';
@@ -186,7 +187,7 @@ export function splitCommandLine(s) {
186
187
  }
187
188
 
188
189
  function extractUnquotedWindowsExecutable(s) {
189
- if (!/^(?:[A-Za-z]:\\|\\\\)/u.test(s)) return null;
190
+ if (!isWindowsAbsolutePath(s)) return null;
190
191
  const match = s.match(/^(.+?\.(?:exe|cmd|bat))(?:\s+|$)(.*)$/iu);
191
192
  if (!match) return null;
192
193
  return { command: match[1], rest: match[2].trim() };
@@ -55,7 +55,7 @@ export async function listSkillsAll({ logFn = () => {}, projectRoot } = {}) {
55
55
 
56
56
  // Scan `<dir>/<name>/SKILL.md` files. Returns Map<skill-name, description>. Missing
57
57
  // directories or malformed skills are skipped silently.
58
- async function scanSkillsDir(dir, logFn) {
58
+ export async function scanSkillsDir(dir, logFn) {
59
59
  const out = new Map();
60
60
  let entries;
61
61
  try {