claude-spotter 1.5.12 → 1.5.13
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 +16 -0
- package/docs/11_dashboard-operations.md +2 -2
- package/package.json +1 -1
- package/src/cli/codex-hook-cmd.mjs +1 -1
- package/src/cli/doctor.mjs +3 -7
- package/src/cli/install.mjs +1 -1
- package/src/core/codex-cli-availability.mjs +8 -37
- package/src/core/codex-cli-backend.mjs +1 -1
- package/src/core/codex-sidecar-auditor-backend.mjs +1 -1
- package/src/core/codex-sidecar-runner.mjs +1 -1
- package/src/daemon/daemon.mjs +5 -9
- package/src/daemon/haiku-caller.mjs +4 -7
- package/src/daemon/transport.mjs +4 -47
- package/src/host/adapters.mjs +53 -0
- package/src/platform/ipc.mjs +52 -0
- package/src/platform/paths.mjs +60 -0
- package/src/{core/windows-cli-shim.mjs → platform/spawn.mjs} +44 -1
- package/src/tool-db/investigate-claude.mjs +71 -0
- package/src/tool-db/investigate-codex.mjs +3 -9
- package/src/tool-db/investigate-mcp.mjs +7 -44
- package/src/tool-db/loader.mjs +6 -16
- package/src/tool-db/mcp-config.mjs +4 -19
- package/src/tool-db/refresh.mjs +10 -75
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,22 @@
|
|
|
3
3
|
各節はそのversion公開時点の変更記録であり、後続versionにより置換された仕様を含む。
|
|
4
4
|
現行runtime契約は[`docs/00_overview.md`](docs/00_overview.md)から辿る。
|
|
5
5
|
|
|
6
|
+
## 1.5.13 — 2026-08-23
|
|
7
|
+
|
|
8
|
+
- **OS依存を`src/platform/`へ集約。** 6ファイルへ重複していたWindowsのcmd.exe /c wrap・
|
|
9
|
+
`.cmd` shim解決・`windowsHide`強制を[spawn.mjs](src/platform/spawn.mjs)の
|
|
10
|
+
`windowsCompatibleCommand` / `execFileWindowsSafe`へ一本化し、socket/Named Pipeの
|
|
11
|
+
path生成・権限・stale socket除去を[ipc.mjs](src/platform/ipc.mjs)へ、パス表記正規化と
|
|
12
|
+
PATH実行体探索を[paths.mjs](src/platform/paths.mjs)へ移設した。`windows-cli-shim.mjs`は
|
|
13
|
+
`platform/spawn.mjs`へ吸収した。
|
|
14
|
+
- **ベンダー依存の決定点を`src/host/adapters.mjs`へ集約。** hostごとのtool-db file名と
|
|
15
|
+
snapshot builder選択をadapter tableで引き、loader / refreshの`if (hostAgent === 'codex')`
|
|
16
|
+
分岐を業務ロジックから消した。Claude側discovery実装は`investigate-codex.mjs`と対称の
|
|
17
|
+
[investigate-claude.mjs](src/tool-db/investigate-claude.mjs)へ逐語移設した。
|
|
18
|
+
- **公開契約は変更なし。** 既存のexport名・`{cmd, cmdArgs}` shape・hook/daemon IPC・
|
|
19
|
+
CLI表面・test fixtureは再exportで維持し、挙動変更ゼロ(`node --test` 593 pass)。
|
|
20
|
+
配置規約はAGENTS.md「依存の配置規約」として正典化した。
|
|
21
|
+
|
|
6
22
|
## 1.5.12 — 2026-08-18
|
|
7
23
|
|
|
8
24
|
- **Stop未観測の評価turnを次prompt時に採点して閉じる。** 実測でoutcome_missing 2,091件の94%が
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Spotter評価dashboard運用
|
|
2
2
|
|
|
3
|
-
現行npm配布版: **v1.5.
|
|
4
|
-
|
|
3
|
+
現行npm配布版: **v1.5.13**(2026-08-23)。v1.5.13はOS依存・ベンダー依存の内部配置だけを
|
|
4
|
+
変更した挙動同一リファクタで、評価・dashboardのrouting構成は変更していない。
|
|
5
5
|
|
|
6
6
|
この文書はservice設定の正本であり、各端末に現在installされているnpm versionの台帳ではない。
|
|
7
7
|
端末versionは対象端末で`spotter --version`を実行して確認する。
|
package/package.json
CHANGED
|
@@ -7,7 +7,7 @@ import { delimiter, dirname, join, resolve } from 'node:path';
|
|
|
7
7
|
import { fileURLToPath } from 'node:url';
|
|
8
8
|
import { createAuditorBackend, selectAuditorBackend } from '../core/auditor-backend.mjs';
|
|
9
9
|
import { resolveCodexAuditorModelSelection } from '../core/codex-auditor-model-policy.mjs';
|
|
10
|
-
import { buildWindowsCompatibleInvocation } from '../
|
|
10
|
+
import { buildWindowsCompatibleInvocation } from '../platform/spawn.mjs';
|
|
11
11
|
import { codexLastAssistantMessage, readCodexToolUsage } from '../core/codex-transcript.mjs';
|
|
12
12
|
import { readLocal } from '../tool-db/refresh.mjs';
|
|
13
13
|
import { spawnRefreshDetached } from '../hooks/spawn-daemon.mjs';
|
package/src/cli/doctor.mjs
CHANGED
|
@@ -8,7 +8,7 @@ import { promisify } from 'node:util';
|
|
|
8
8
|
import { loadDb, globalDbPath, localDbPath } from '../tool-db/loader.mjs';
|
|
9
9
|
import { findSpotterMarker } from '../hooks/lib.mjs';
|
|
10
10
|
import { codexHookDiagnostics } from './codex-hook-cmd.mjs';
|
|
11
|
-
import { buildWindowsCompatibleInvocation } from '../
|
|
11
|
+
import { buildWindowsCompatibleInvocation, execFileWindowsSafe } from '../platform/spawn.mjs';
|
|
12
12
|
|
|
13
13
|
const execFileP = promisify(execFile);
|
|
14
14
|
|
|
@@ -23,13 +23,9 @@ export async function runDoctor() {
|
|
|
23
23
|
mark(okNode, `Node.js ${nodeVersion}`, 'need >= 22.13');
|
|
24
24
|
if (!okNode) failures += 1;
|
|
25
25
|
|
|
26
|
-
// claude CLI —
|
|
27
|
-
// rather than shell:true (DEP0190 on Node 24+).
|
|
26
|
+
// claude CLI — Windows の .cmd shim 解決は src/platform/spawn.mjs が所有する。
|
|
28
27
|
try {
|
|
29
|
-
const
|
|
30
|
-
const { stdout } = process.platform === 'win32'
|
|
31
|
-
? await execFileP('cmd.exe', ['/c', 'claude', '--version'], opts)
|
|
32
|
-
: await execFileP('claude', ['--version'], opts);
|
|
28
|
+
const { stdout } = await execFileWindowsSafe('claude', ['--version'], { timeout: 5_000 });
|
|
33
29
|
mark(true, `claude CLI: ${stdout.trim()}`);
|
|
34
30
|
} catch (err) {
|
|
35
31
|
mark(false, 'claude CLI', `not found or failed: ${err.message}`);
|
package/src/cli/install.mjs
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
import { mkdir, writeFile, readFile, access, realpath } from 'node:fs/promises';
|
|
16
16
|
import { constants as fsConstants } from 'node:fs';
|
|
17
17
|
import { spawnSync } from 'node:child_process';
|
|
18
|
-
import { buildWindowsCompatibleInvocation } from '../
|
|
18
|
+
import { buildWindowsCompatibleInvocation } from '../platform/spawn.mjs';
|
|
19
19
|
import { homedir } from 'node:os';
|
|
20
20
|
import { join, resolve, dirname, delimiter, extname } from 'node:path';
|
|
21
21
|
import { fileURLToPath } from 'node:url';
|
|
@@ -1,9 +1,5 @@
|
|
|
1
|
-
import { statSync } from 'node:fs';
|
|
2
|
-
import { posix, win32 } from 'node:path';
|
|
3
|
-
|
|
4
1
|
// Configuration-time (synchronous) check for whether `codex` is on PATH.
|
|
5
|
-
// No subprocess is spawned.
|
|
6
|
-
// with `fs.statSync`; PATHEXT-equivalent extensions are added on Windows.
|
|
2
|
+
// No subprocess is spawned. OS依存のPATH walkは src/platform/paths.mjs が所有する。
|
|
7
3
|
//
|
|
8
4
|
// This is consumed by `selectByPolicy` in auditor-backend.mjs to choose the
|
|
9
5
|
// Claude-host primary auditor: when codex is reachable, the daemon prefers
|
|
@@ -11,41 +7,16 @@ import { posix, win32 } from 'node:path';
|
|
|
11
7
|
// throw `AuditorBackendError` — selection-time availability does not become a
|
|
12
8
|
// runtime fallback.
|
|
13
9
|
|
|
14
|
-
|
|
10
|
+
import { isExecutableOnPath } from '../platform/paths.mjs';
|
|
15
11
|
|
|
16
12
|
export function isCodexCliAvailable({
|
|
17
13
|
env = process.env,
|
|
18
14
|
platform = process.platform,
|
|
19
|
-
fileExists
|
|
15
|
+
fileExists,
|
|
20
16
|
} = {}) {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
? WINDOWS_EXTS.map((ext) => `codex${ext}`)
|
|
27
|
-
: ['codex'];
|
|
28
|
-
for (const rawDir of pathVar.split(sep)) {
|
|
29
|
-
const dir = rawDir.trim();
|
|
30
|
-
if (dir.length === 0) continue;
|
|
31
|
-
for (const candidate of candidates) {
|
|
32
|
-
if (fileExists(join(dir, candidate))) return true;
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
return false;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
function pickPathVar(env, platform) {
|
|
39
|
-
if (platform === 'win32') {
|
|
40
|
-
return env?.Path ?? env?.PATH ?? env?.path ?? '';
|
|
41
|
-
}
|
|
42
|
-
return env?.PATH ?? '';
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function defaultFileExists(p) {
|
|
46
|
-
try {
|
|
47
|
-
return statSync(p).isFile();
|
|
48
|
-
} catch {
|
|
49
|
-
return false;
|
|
50
|
-
}
|
|
17
|
+
return isExecutableOnPath('codex', {
|
|
18
|
+
env,
|
|
19
|
+
platform,
|
|
20
|
+
...(fileExists ? { fileExists } : {}),
|
|
21
|
+
});
|
|
51
22
|
}
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
resolveCodexAuditorModelSelection,
|
|
11
11
|
} from './codex-auditor-model-policy.mjs';
|
|
12
12
|
import { serializeAuditorPromptData, validateRecentContext } from './auditor-prompt-data.mjs';
|
|
13
|
-
import { buildWindowsCompatibleInvocation, terminateProcessTree } from '
|
|
13
|
+
import { buildWindowsCompatibleInvocation, terminateProcessTree } from '../platform/spawn.mjs';
|
|
14
14
|
|
|
15
15
|
const DEFAULT_CODEX_CLI_TIMEOUT_MS = 45_000;
|
|
16
16
|
const DEFAULT_CODEX_CLI_MODEL = CODEX_AUDITOR_MODEL_POLICY.production.model;
|
|
@@ -6,7 +6,7 @@ import { promisify } from 'node:util';
|
|
|
6
6
|
import { toSpotterJudgment } from './judgment.mjs';
|
|
7
7
|
import { filterCatalogMisses } from './auditor-response.mjs';
|
|
8
8
|
import { AuditorBackendError } from './auditor-error.mjs';
|
|
9
|
-
import { buildWindowsCompatibleInvocation } from '
|
|
9
|
+
import { buildWindowsCompatibleInvocation } from '../platform/spawn.mjs';
|
|
10
10
|
|
|
11
11
|
const execFileP = promisify(execFile);
|
|
12
12
|
const DEFAULT_CODEX_SIDECAR_AUDITOR_TIMEOUT_MS = 45_000;
|
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
createSidecarResultRecord,
|
|
17
17
|
spotterFindingsToSidecarContextBlocks,
|
|
18
18
|
} from './sidecar-context.mjs';
|
|
19
|
-
import { buildWindowsCompatibleInvocation } from '
|
|
19
|
+
import { buildWindowsCompatibleInvocation } from '../platform/spawn.mjs';
|
|
20
20
|
|
|
21
21
|
const execFileP = promisify(execFile);
|
|
22
22
|
const WORKFLOW_MAP = {
|
package/src/daemon/daemon.mjs
CHANGED
|
@@ -483,7 +483,7 @@ export async function startDaemon({
|
|
|
483
483
|
} catch (error) {
|
|
484
484
|
await observeFailure('daemon_persistence');
|
|
485
485
|
await new Promise((resolve) => server.close(resolve)).catch(() => {});
|
|
486
|
-
|
|
486
|
+
await removeStaleSocketFile(path).catch(() => {});
|
|
487
487
|
throw error;
|
|
488
488
|
}
|
|
489
489
|
|
|
@@ -538,14 +538,10 @@ async function shutdown(server, sessionId, logFn, evaluationStore = null) {
|
|
|
538
538
|
} catch (err) {
|
|
539
539
|
logFn(`shutdown: server.close failed: ${err.message}`);
|
|
540
540
|
}
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
if (err.code !== 'ENOENT') {
|
|
546
|
-
logFn(`shutdown: unlink socket failed: ${err.message}`);
|
|
547
|
-
}
|
|
548
|
-
}
|
|
541
|
+
try {
|
|
542
|
+
await removeStaleSocketFile(socketPath(sessionId));
|
|
543
|
+
} catch (err) {
|
|
544
|
+
logFn(`shutdown: unlink socket failed: ${err.message}`);
|
|
549
545
|
}
|
|
550
546
|
try {
|
|
551
547
|
await unlink(pidFilePath(sessionId));
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
// puts the catalog only in the first turn's user message; the session retains it for free.
|
|
15
15
|
|
|
16
16
|
import { spawn } from 'node:child_process';
|
|
17
|
+
import { windowsCompatibleCommand } from '../platform/spawn.mjs';
|
|
17
18
|
import { homedir, tmpdir } from 'node:os';
|
|
18
19
|
import { join } from 'node:path';
|
|
19
20
|
import { mkdir, writeFile, unlink, open } from 'node:fs/promises';
|
|
@@ -241,9 +242,7 @@ export async function preparePromptFile(wirePrompt) {
|
|
|
241
242
|
};
|
|
242
243
|
}
|
|
243
244
|
|
|
244
|
-
//
|
|
245
|
-
// without going through the shell. We use cmd.exe /c explicitly rather than spawn({ shell:
|
|
246
|
-
// true }) because the latter triggers DEP0190 on Node 24+.
|
|
245
|
+
// Windows の .cmd shim 解決 (cmd.exe /c wrap) は src/platform/spawn.mjs が所有する。
|
|
247
246
|
//
|
|
248
247
|
// v1.3.0: `--strict-mcp-config --mcp-config <empty>` を必ず付けて MCP load を無効化する。
|
|
249
248
|
// `mcpConfigPath` は呼び出し側 (createHaikuCaller) が必ず渡す前提。テストで shape を pin。
|
|
@@ -255,10 +254,8 @@ export function buildSpawnArgs({ claudeBin, model, sessionId, resume, mcpConfigP
|
|
|
255
254
|
? ['-p', '--resume', sessionId, '--model', model]
|
|
256
255
|
: ['-p', '--session-id', sessionId, '--model', model];
|
|
257
256
|
const args = [...baseArgs, '--strict-mcp-config', '--mcp-config', mcpConfigPath];
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
}
|
|
261
|
-
return { cmd: claudeBin, cmdArgs: args };
|
|
257
|
+
const invocation = windowsCompatibleCommand(claudeBin, args);
|
|
258
|
+
return { cmd: invocation.command, cmdArgs: invocation.args };
|
|
262
259
|
}
|
|
263
260
|
|
|
264
261
|
// Invoke `claude -p` in the isolated workdir. Returns raw stdout.
|
package/src/daemon/transport.mjs
CHANGED
|
@@ -4,54 +4,11 @@
|
|
|
4
4
|
|
|
5
5
|
import net from 'node:net';
|
|
6
6
|
import { randomUUID } from 'node:crypto';
|
|
7
|
-
import {
|
|
8
|
-
import { homedir } from 'node:os';
|
|
9
|
-
import { join } from 'node:path';
|
|
7
|
+
import { socketPath } from '../platform/ipc.mjs';
|
|
10
8
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
export
|
|
14
|
-
if (!sessionId || typeof sessionId !== 'string') {
|
|
15
|
-
throw new TypeError('sessionId must be a non-empty string');
|
|
16
|
-
}
|
|
17
|
-
if (process.platform === 'win32') {
|
|
18
|
-
return `\\\\.\\pipe\\spotter-${sessionId}`;
|
|
19
|
-
}
|
|
20
|
-
return join(RUNTIME_DIR, `session-${sessionId}.sock`);
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export async function ensureRuntimeDir() {
|
|
24
|
-
await mkdir(RUNTIME_DIR, { recursive: true, mode: 0o700 });
|
|
25
|
-
if (process.platform !== 'win32') {
|
|
26
|
-
await chmod(RUNTIME_DIR, 0o700);
|
|
27
|
-
}
|
|
28
|
-
return RUNTIME_DIR;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export async function secureSocketFile(path) {
|
|
32
|
-
if (process.platform === 'win32') return;
|
|
33
|
-
await chmod(path, 0o600);
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
// Remove a Unix domain socket file left behind by a daemon that died ungracefully (SIGKILL,
|
|
37
|
-
// crash, or machine sleep before SessionEnd ran stop()'s cleanup). stop() unlinks the socket only
|
|
38
|
-
// on graceful shutdown, so an orphan file persists otherwise. The CALLER MUST have already
|
|
39
|
-
// confirmed no live daemon owns this session (assertNoLiveDaemon) — only then is the file known to
|
|
40
|
-
// be stale. Without removing it, server.listen(path) fails with EADDRINUSE and the daemon dies
|
|
41
|
-
// before "daemon listening", so every resurrect attempt crash-loops and the session is never
|
|
42
|
-
// audited again (observed: Kikoeru session 83d7aa04 — 5 failed restarts, all stuck at backend
|
|
43
|
-
// selection). ENOENT is the normal first-run / no-stale-file case and is ignored; any other error
|
|
44
|
-
// is rethrown (§0: no silent swallow). No-op on Windows — a Named Pipe vanishes when its owning
|
|
45
|
-
// process exits, so there is no filesystem artifact to remove.
|
|
46
|
-
export async function removeStaleSocketFile(path) {
|
|
47
|
-
if (process.platform === 'win32') return;
|
|
48
|
-
try {
|
|
49
|
-
await unlink(path);
|
|
50
|
-
} catch (err) {
|
|
51
|
-
if (err?.code === 'ENOENT') return;
|
|
52
|
-
throw err;
|
|
53
|
-
}
|
|
54
|
-
}
|
|
9
|
+
// OS依存のsocket/pipe面 (path生成・権限・stale socket除去) は src/platform/ipc.mjs が
|
|
10
|
+
// 所有する。既存importerのためここから同名で再exportする。
|
|
11
|
+
export { socketPath, ensureRuntimeDir, secureSocketFile, removeStaleSocketFile } from '../platform/ipc.mjs';
|
|
55
12
|
|
|
56
13
|
// Request/response over a single connection. Used by hooks.
|
|
57
14
|
export function sendRequest({ sessionId, event, payload, timeoutMs }) {
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// AIベンダー(host agent)依存の決定点の唯一の置き場。
|
|
2
|
+
// 「どのhostがどのtool-db fileを持ち、どのdiscovery経路でsnapshotを作るか」は
|
|
3
|
+
// このtableだけが知る。呼び出し側(loader / refresh / CLI)は adapter を引いて
|
|
4
|
+
// 使うだけで、`if (hostAgent === 'codex')` 分岐を業務ロジックに書かない。
|
|
5
|
+
//
|
|
6
|
+
// ベンダー固有の実装本体は従来どおり investigate-claude.mjs / investigate-codex.mjs
|
|
7
|
+
// が持つ。片方のhostのrefreshがもう片方のDBをprune / overwriteしない契約
|
|
8
|
+
// (AGENTS.md「ツールカタログはhost-local tool-db」)はこの分離が担保する。
|
|
9
|
+
|
|
10
|
+
import { buildInvestigationSnapshot } from '../tool-db/investigate-claude.mjs';
|
|
11
|
+
import { buildCodexInvestigationSnapshot } from '../tool-db/investigate-codex.mjs';
|
|
12
|
+
|
|
13
|
+
const CLAUDE_ADAPTER = Object.freeze({
|
|
14
|
+
hostAgent: 'claude',
|
|
15
|
+
toolDbFileName: 'tool-db.json',
|
|
16
|
+
buildSnapshot: ({ logFn, claudeBin, projectRoot }) =>
|
|
17
|
+
buildInvestigationSnapshot({ logFn, claudeBin, projectRoot }),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const CODEX_ADAPTER = Object.freeze({
|
|
21
|
+
hostAgent: 'codex',
|
|
22
|
+
toolDbFileName: 'tool-db.codex.json',
|
|
23
|
+
buildSnapshot: ({ logFn, codexBin, projectRoot }) =>
|
|
24
|
+
buildCodexInvestigationSnapshot({ logFn, codexBin, projectRoot }),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// automation (CI等) は従来からClaude経路のdiscoveryを使い、DB fileだけ分離する。
|
|
28
|
+
const AUTOMATION_ADAPTER = Object.freeze({
|
|
29
|
+
hostAgent: 'automation',
|
|
30
|
+
toolDbFileName: 'tool-db.automation.json',
|
|
31
|
+
buildSnapshot: ({ logFn, claudeBin, projectRoot }) =>
|
|
32
|
+
buildInvestigationSnapshot({ logFn, claudeBin, projectRoot }),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const ADAPTERS = Object.freeze({
|
|
36
|
+
claude: CLAUDE_ADAPTER,
|
|
37
|
+
codex: CODEX_ADAPTER,
|
|
38
|
+
automation: AUTOMATION_ADAPTER,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
export function normalizeToolDbHostAgent(hostAgent = 'claude') {
|
|
42
|
+
if (hostAgent === undefined || hostAgent === null || hostAgent === '') {
|
|
43
|
+
return 'claude';
|
|
44
|
+
}
|
|
45
|
+
if (Object.hasOwn(ADAPTERS, hostAgent)) {
|
|
46
|
+
return hostAgent;
|
|
47
|
+
}
|
|
48
|
+
throw new TypeError(`tool-db hostAgent must be claude, codex, or automation; got ${hostAgent}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function getHostAdapter(hostAgent = 'claude') {
|
|
52
|
+
return ADAPTERS[normalizeToolDbHostAgent(hostAgent)];
|
|
53
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// OS依存のhook⇄daemon IPC面の唯一の置き場。
|
|
2
|
+
// Unix domain socket (macOS/Linux) と Named Pipe (Windows) の差はこのファイルが
|
|
3
|
+
// 所有し、呼び出し側 (transport / daemon) は process.platform を見ない。
|
|
4
|
+
|
|
5
|
+
import { chmod, mkdir, unlink } from 'node:fs/promises';
|
|
6
|
+
import { homedir } from 'node:os';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
|
|
9
|
+
const RUNTIME_DIR = join(homedir(), '.spotter', 'runtime');
|
|
10
|
+
|
|
11
|
+
export function socketPath(sessionId) {
|
|
12
|
+
if (!sessionId || typeof sessionId !== 'string') {
|
|
13
|
+
throw new TypeError('sessionId must be a non-empty string');
|
|
14
|
+
}
|
|
15
|
+
if (process.platform === 'win32') {
|
|
16
|
+
return `\\\\.\\pipe\\spotter-${sessionId}`;
|
|
17
|
+
}
|
|
18
|
+
return join(RUNTIME_DIR, `session-${sessionId}.sock`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function ensureRuntimeDir() {
|
|
22
|
+
await mkdir(RUNTIME_DIR, { recursive: true, mode: 0o700 });
|
|
23
|
+
if (process.platform !== 'win32') {
|
|
24
|
+
await chmod(RUNTIME_DIR, 0o700);
|
|
25
|
+
}
|
|
26
|
+
return RUNTIME_DIR;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function secureSocketFile(path) {
|
|
30
|
+
if (process.platform === 'win32') return;
|
|
31
|
+
await chmod(path, 0o600);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Remove a Unix domain socket file left behind by a daemon that died ungracefully (SIGKILL,
|
|
35
|
+
// crash, or machine sleep before SessionEnd ran stop()'s cleanup). stop() unlinks the socket only
|
|
36
|
+
// on graceful shutdown, so an orphan file persists otherwise. The CALLER MUST have already
|
|
37
|
+
// confirmed no live daemon owns this session (assertNoLiveDaemon) — only then is the file known to
|
|
38
|
+
// be stale. Without removing it, server.listen(path) fails with EADDRINUSE and the daemon dies
|
|
39
|
+
// before "daemon listening", so every resurrect attempt crash-loops and the session is never
|
|
40
|
+
// audited again (observed: Kikoeru session 83d7aa04 — 5 failed restarts, all stuck at backend
|
|
41
|
+
// selection). ENOENT is the normal first-run / no-stale-file case and is ignored; any other error
|
|
42
|
+
// is rethrown (§0: no silent swallow). No-op on Windows — a Named Pipe vanishes when its owning
|
|
43
|
+
// process exits, so there is no filesystem artifact to remove.
|
|
44
|
+
export async function removeStaleSocketFile(path) {
|
|
45
|
+
if (process.platform === 'win32') return;
|
|
46
|
+
try {
|
|
47
|
+
await unlink(path);
|
|
48
|
+
} catch (err) {
|
|
49
|
+
if (err?.code === 'ENOENT') return;
|
|
50
|
+
throw err;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// OS依存のパス表記・PATH探索の唯一の置き場。
|
|
2
|
+
|
|
3
|
+
import { statSync } from 'node:fs';
|
|
4
|
+
import { posix, win32 } from 'node:path';
|
|
5
|
+
|
|
6
|
+
// Synchronous PATH walk: is `command` reachable as an executable file on PATH?
|
|
7
|
+
// No subprocess is spawned. Windows adds PATHEXT-equivalent extensions and reads
|
|
8
|
+
// PATH case-insensitively (Path / PATH / path).
|
|
9
|
+
export function isExecutableOnPath(command, {
|
|
10
|
+
env = process.env,
|
|
11
|
+
platform = process.platform,
|
|
12
|
+
windowsExtensions = ['.cmd', '.exe', '.bat'],
|
|
13
|
+
fileExists = defaultFileExists,
|
|
14
|
+
} = {}) {
|
|
15
|
+
const pathVar = platform === 'win32'
|
|
16
|
+
? (env?.Path ?? env?.PATH ?? env?.path ?? '')
|
|
17
|
+
: (env?.PATH ?? '');
|
|
18
|
+
if (typeof pathVar !== 'string' || pathVar.length === 0) return false;
|
|
19
|
+
const sep = platform === 'win32' ? ';' : ':';
|
|
20
|
+
const join = platform === 'win32' ? win32.join : posix.join;
|
|
21
|
+
const candidates = platform === 'win32'
|
|
22
|
+
? windowsExtensions.map((ext) => `${command}${ext}`)
|
|
23
|
+
: [command];
|
|
24
|
+
for (const rawDir of pathVar.split(sep)) {
|
|
25
|
+
const dir = rawDir.trim();
|
|
26
|
+
if (dir.length === 0) continue;
|
|
27
|
+
for (const candidate of candidates) {
|
|
28
|
+
if (fileExists(join(dir, candidate))) return true;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function defaultFileExists(p) {
|
|
35
|
+
try {
|
|
36
|
+
return statSync(p).isFile();
|
|
37
|
+
} catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Normalize an absolute project path for matching against config keys (e.g.
|
|
43
|
+
// `~/.claude.json` `projects[]`). Representation can drift across:
|
|
44
|
+
// - separator: `\` on Windows vs `/`
|
|
45
|
+
// - drive-letter case: `C:\` vs `c:\` on Windows
|
|
46
|
+
// - trailing slash: `/foo/bar` vs `/foo/bar/`
|
|
47
|
+
// On Windows we canonicalize to forward slashes and lower-case (case-insensitive
|
|
48
|
+
// filesystem). On POSIX, `\` is a legal filename character — collapsing it to `/`
|
|
49
|
+
// would conflate genuinely distinct paths (e.g. literal `C:\Users\u\proj` vs the
|
|
50
|
+
// hypothetical POSIX path `C:/Users/u/proj`), and case stays significant. POSIX
|
|
51
|
+
// only normalizes the trailing slash.
|
|
52
|
+
export function normalizeProjectPath(p) {
|
|
53
|
+
if (typeof p !== 'string' || p.length === 0) return '';
|
|
54
|
+
let s = p;
|
|
55
|
+
if (process.platform === 'win32') {
|
|
56
|
+
s = s.replace(/\\/g, '/').toLowerCase();
|
|
57
|
+
}
|
|
58
|
+
s = s.replace(/\/+$/, '');
|
|
59
|
+
return s;
|
|
60
|
+
}
|
|
@@ -1,6 +1,49 @@
|
|
|
1
|
-
|
|
1
|
+
// OS依存のプロセス起動プリミティブの唯一の置き場。
|
|
2
|
+
// Windows の .cmd shim 解決・cmd.exe /c wrap・windowsHide・process tree 終了は
|
|
3
|
+
// すべてこのファイルが所有し、呼び出し側は process.platform を見ない。
|
|
4
|
+
//
|
|
5
|
+
// 2 種類の Windows コマンド解決を提供する:
|
|
6
|
+
// - windowsCompatibleCommand: 素朴な cmd.exe /c wrap (.exe は直接起動)。
|
|
7
|
+
// bare 名 / .cmd shim を PATHEXT 経由で起動する高頻度経路用。
|
|
8
|
+
// - buildWindowsCompatibleInvocation: npm shim の実体 (.cjs/.mjs/.js) を
|
|
9
|
+
// 解決して node で直接起動する精密経路。cmd.exe を避けたい場合に使う。
|
|
10
|
+
|
|
11
|
+
import { execFile, spawn } from 'node:child_process';
|
|
2
12
|
import { readFileSync, statSync } from 'node:fs';
|
|
3
13
|
import { win32 } from 'node:path';
|
|
14
|
+
import { promisify } from 'node:util';
|
|
15
|
+
|
|
16
|
+
const execFileP = promisify(execFile);
|
|
17
|
+
|
|
18
|
+
// On Windows, npm-global CLI tools (e.g. `claude`, `codex`, `claude-mermaid`) ship as
|
|
19
|
+
// `<name>.cmd` batch wrappers. Node's `child_process` without `shell: true` calls
|
|
20
|
+
// Windows `CreateProcess`, which only directly executes `.exe` files — it does NOT
|
|
21
|
+
// search PATHEXT for `.cmd`/`.bat` shims when given a bare command name, so
|
|
22
|
+
// `spawn('claude', ...)` fails with ENOENT even though `claude.cmd` is on PATH.
|
|
23
|
+
//
|
|
24
|
+
// We route any non-`.exe` command through `cmd.exe /c` on Windows, which makes
|
|
25
|
+
// PATHEXT lookup apply and runs both `.cmd` shims and bare names transparently.
|
|
26
|
+
// Absolute `.exe` paths stay un-wrapped because (a) they spawn correctly as-is and
|
|
27
|
+
// (b) wrapping them through `cmd.exe /c "<path with spaces>" args` runs into
|
|
28
|
+
// cmd.exe's quoting rules for paths containing spaces, which add risk for zero
|
|
29
|
+
// benefit. We use `cmd.exe /c` explicitly rather than `spawn({ shell: true })`
|
|
30
|
+
// because the latter triggers DEP0190 on Node 24+ and re-introduces
|
|
31
|
+
// argument-quoting risks (caveat:
|
|
32
|
+
// `windows-node-spawn-claude-fails-with-enoent-because-claude-is-a-cmd-wrapper`).
|
|
33
|
+
export function windowsCompatibleCommand(command, args = [], { platform = process.platform } = {}) {
|
|
34
|
+
if (platform !== 'win32' || /\.exe$/i.test(command)) {
|
|
35
|
+
return { command, args };
|
|
36
|
+
}
|
|
37
|
+
return { command: 'cmd.exe', args: ['/c', command, ...args] };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// execFile を windowsCompatibleCommand + windowsHide 強制で実行する。
|
|
41
|
+
// `windowsHide: true` はこの層で強制する — 無いと Windows で cmd.exe の console
|
|
42
|
+
// window が毎回 flash して入力フォーカスを奪う (v1.1.5 の実被弾)。
|
|
43
|
+
export async function execFileWindowsSafe(command, args = [], opts = {}) {
|
|
44
|
+
const invocation = windowsCompatibleCommand(command, args);
|
|
45
|
+
return execFileP(invocation.command, invocation.args, { ...opts, windowsHide: true });
|
|
46
|
+
}
|
|
4
47
|
|
|
5
48
|
// Windows の npm global CLI は多くが `<name>.cmd` shim であり、Node の shell:false
|
|
6
49
|
// 直接 spawn では PATHEXT 解決されない。cmd.exe を明示して Node 24 の shell:true
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Claude-native catalog investigation (investigate-codex.mjs の Claude 対称)。
|
|
2
|
+
// `claude mcp list` + Claude skills / sub-agents から (name → description) snapshot を作る。
|
|
3
|
+
|
|
4
|
+
import { listMcpToolsAll, listMcpServers, bellVisibleName } from './investigate-mcp.mjs';
|
|
5
|
+
import { getClaudeAiBaselineByServer } from './claude-ai-baseline.mjs';
|
|
6
|
+
import { listSkillsAll } from './investigate-skills.mjs';
|
|
7
|
+
import { listAgentsAll } from './investigate-agents.mjs';
|
|
8
|
+
|
|
9
|
+
// Pure filter: returns the subset of the claude.ai baseline whose server name is
|
|
10
|
+
// present in `presentServerNames`. Accepts a Set for O(1) membership. Extracted as
|
|
11
|
+
// a named export so it can be unit-tested without a live `claude` CLI.
|
|
12
|
+
export function filterClaudeAiBaseline(presentServerNames) {
|
|
13
|
+
const out = new Map();
|
|
14
|
+
for (const [serverName, tools] of getClaudeAiBaselineByServer()) {
|
|
15
|
+
if (!presentServerNames.has(serverName)) continue;
|
|
16
|
+
for (const [toolName, description] of Object.entries(tools)) {
|
|
17
|
+
out.set(toolName, description);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return out;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Build the (name → description) map for an investigation pass across all sources:
|
|
24
|
+
// - claude.ai MCP baseline (Gmail / Calendar / Drive — OAuth, not locally introspectable)
|
|
25
|
+
// - MCP servers via stdio + HTTP/SSE (user + project .mcp.json, live fetched)
|
|
26
|
+
// - Skills from user scope, project scope, and enabled plugins
|
|
27
|
+
// - Sub-agents from user scope, project scope, and enabled plugins
|
|
28
|
+
//
|
|
29
|
+
// Returns an in-memory snapshot the caller can use as the investigate() backend.
|
|
30
|
+
export async function buildInvestigationSnapshot({ logFn = () => {}, claudeBin = 'claude', projectRoot } = {}) {
|
|
31
|
+
const snapshot = new Map();
|
|
32
|
+
|
|
33
|
+
// Anthropic-provided `claude.ai ...` MCP servers. Hardcoded because the OAuth proxy
|
|
34
|
+
// is not reachable without reading ~/.claude/.credentials.json (deliberately avoided).
|
|
35
|
+
// Injected only for servers actually present in `claude mcp list` — otherwise phantom
|
|
36
|
+
// tools (Gmail/Calendar/Drive) leak into environments where those servers are not
|
|
37
|
+
// connected. See filterClaudeAiBaseline above.
|
|
38
|
+
const servers = await listMcpServers({ claudeBin, projectRoot });
|
|
39
|
+
const presentServerNames = new Set(servers.map((s) => s.name));
|
|
40
|
+
const baseline = filterClaudeAiBaseline(presentServerNames);
|
|
41
|
+
for (const [name, description] of baseline) {
|
|
42
|
+
snapshot.set(name, description);
|
|
43
|
+
}
|
|
44
|
+
if (baseline.size > 0) {
|
|
45
|
+
logFn(`claude.ai baseline injected: ${baseline.size} tools from ${[...presentServerNames].filter((n) => n.startsWith('claude.ai ')).join(', ')}`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// MCP servers (stdio + user-registered HTTP/SSE). projectRoot forwards for
|
|
49
|
+
// project-scope `.mcp.json` merge (see mcp-config.mjs).
|
|
50
|
+
const mcp = await listMcpToolsAll({ logFn, claudeBin, projectRoot });
|
|
51
|
+
for (const [serverName, tools] of mcp.entries()) {
|
|
52
|
+
for (const tool of tools) {
|
|
53
|
+
if (!tool.description || tool.description.length === 0) continue;
|
|
54
|
+
snapshot.set(bellVisibleName(serverName, tool.name), tool.description);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Skills (plugin-namespaced as `<plugin>:<name>`, or bare for user/project scope).
|
|
59
|
+
const skills = await listSkillsAll({ logFn, projectRoot });
|
|
60
|
+
for (const [name, description] of skills) {
|
|
61
|
+
snapshot.set(name, description);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Sub-agents (bare name; project > user > plugin precedence resolved internally).
|
|
65
|
+
const agents = await listAgentsAll({ logFn, projectRoot });
|
|
66
|
+
for (const [name, description] of agents) {
|
|
67
|
+
snapshot.set(name, description);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return snapshot;
|
|
71
|
+
}
|
|
@@ -4,22 +4,16 @@
|
|
|
4
4
|
// discovery path. Codex and Claude expose different MCP servers and skills, so a
|
|
5
5
|
// Codex refresh must build a separate snapshot and write it to tool-db.codex.json.
|
|
6
6
|
|
|
7
|
-
import { execFile } from 'node:child_process';
|
|
8
7
|
import { readdir, readFile } from 'node:fs/promises';
|
|
9
8
|
import { homedir } from 'node:os';
|
|
10
9
|
import { join } from 'node:path';
|
|
11
|
-
import {
|
|
10
|
+
import { execFileWindowsSafe } from '../platform/spawn.mjs';
|
|
12
11
|
import { bellVisibleName, listMcpToolsOne, splitArgs } from './investigate-mcp.mjs';
|
|
13
12
|
import { readFrontmatter } from './frontmatter.mjs';
|
|
14
13
|
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
// Windows の .cmd shim 解決と windowsHide 強制は src/platform/spawn.mjs が所有する。
|
|
17
15
|
async function execCodex(codexBin, args, opts) {
|
|
18
|
-
|
|
19
|
-
if (process.platform === 'win32') {
|
|
20
|
-
return execFileP('cmd.exe', ['/c', codexBin, ...args], execOpts);
|
|
21
|
-
}
|
|
22
|
-
return execFileP(codexBin, args, execOpts);
|
|
16
|
+
return execFileWindowsSafe(codexBin, args, opts);
|
|
23
17
|
}
|
|
24
18
|
|
|
25
19
|
export async function buildCodexInvestigationSnapshot({
|
|
@@ -10,29 +10,17 @@
|
|
|
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 {
|
|
14
|
-
import { promisify } from 'node:util';
|
|
13
|
+
import { execFileWindowsSafe, windowsCompatibleCommand } from '../platform/spawn.mjs';
|
|
15
14
|
import { listToolsHttp } from './investigate-mcp-http.mjs';
|
|
16
15
|
import { readMcpServers, describeServer } from './mcp-config.mjs';
|
|
17
16
|
import { version as SPOTTER_VERSION } from '../version.mjs';
|
|
18
17
|
|
|
19
|
-
const execFileP = promisify(execFile);
|
|
20
|
-
|
|
21
18
|
const PROTOCOL_VERSION = '2025-03-26'; // MCP protocol version we claim to speak.
|
|
22
19
|
const HANDSHAKE_TIMEOUT_MS = 10_000;
|
|
23
20
|
|
|
24
|
-
//
|
|
25
|
-
// going through cmd.exe. Matches the pattern in src/daemon/haiku-caller.mjs buildSpawnArgs.
|
|
26
|
-
// We use cmd.exe /c rather than shell:true to avoid DEP0190 on Node 24+.
|
|
27
|
-
// `windowsHide: true` is forced at this layer so every caller (listMcpServers,
|
|
28
|
-
// getStdioConfig, etc.) is silent — without it a cmd.exe console window flashes on every
|
|
29
|
-
// refresh, and those flashes steal keyboard focus on Windows.
|
|
21
|
+
// Windows の .cmd shim 解決と windowsHide 強制は src/platform/spawn.mjs が所有する。
|
|
30
22
|
async function execClaude(claudeBin, args, opts) {
|
|
31
|
-
|
|
32
|
-
if (process.platform === 'win32') {
|
|
33
|
-
return execFileP('cmd.exe', ['/c', claudeBin, ...args], execOpts);
|
|
34
|
-
}
|
|
35
|
-
return execFileP(claudeBin, args, execOpts);
|
|
23
|
+
return execFileWindowsSafe(claudeBin, args, opts);
|
|
36
24
|
}
|
|
37
25
|
|
|
38
26
|
export class McpInvestigationError extends Error {
|
|
@@ -257,36 +245,11 @@ export function splitArgs(s) {
|
|
|
257
245
|
return out;
|
|
258
246
|
}
|
|
259
247
|
|
|
260
|
-
//
|
|
261
|
-
//
|
|
262
|
-
// `CreateProcess`, which only directly executes `.exe` files — it does NOT search
|
|
263
|
-
// PATHEXT for `.cmd`/`.bat` shims when given a bare command name. So
|
|
264
|
-
// `spawn('claude-mermaid', ...)` fails with ENOENT even though `claude-mermaid.cmd`
|
|
265
|
-
// is on PATH.
|
|
266
|
-
//
|
|
267
|
-
// Until v1.2.1 this function only wrapped commands whose name literally ended in
|
|
268
|
-
// `.cmd`/`.bat`, which missed the common case where the registered command is a
|
|
269
|
-
// bare name (the CLI as installed). v1.2.2 routes any non-`.exe` command through
|
|
270
|
-
// `cmd.exe /c` on Windows, which makes PATHEXT lookup apply and runs both `.cmd`
|
|
271
|
-
// shims and bare names transparently.
|
|
272
|
-
//
|
|
273
|
-
// We keep absolute `.exe` paths un-wrapped because (a) they spawn correctly as-is
|
|
274
|
-
// and (b) wrapping them through `cmd.exe /c "<path with spaces>" args` runs into
|
|
275
|
-
// cmd.exe's quoting rules for paths containing spaces, which add risk for zero
|
|
276
|
-
// benefit.
|
|
277
|
-
//
|
|
278
|
-
// We use `cmd.exe /c` explicitly rather than `spawn({ shell: true })` because the
|
|
279
|
-
// latter triggers DEP0190 on Node 24+ and re-introduces argument-quoting risks
|
|
280
|
-
// (matches the rationale in haiku-caller's buildSpawnArgs and the caveat
|
|
281
|
-
// `windows-node-spawn-claude-fails-with-enoent-because-claude-is-a-cmd-wrapper`).
|
|
248
|
+
// Windows の cmd.exe /c wrap 規則 (.exe は直接起動) は src/platform/spawn.mjs が所有する。
|
|
249
|
+
// 歴史的な {cmd, cmdArgs} shape は既存呼び出し・test 契約のため維持する。
|
|
282
250
|
export function buildStdioSpawn(command, args) {
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
}
|
|
286
|
-
if (/\.exe$/i.test(command)) {
|
|
287
|
-
return { cmd: command, cmdArgs: args };
|
|
288
|
-
}
|
|
289
|
-
return { cmd: 'cmd.exe', cmdArgs: ['/c', command, ...args] };
|
|
251
|
+
const invocation = windowsCompatibleCommand(command, args);
|
|
252
|
+
return { cmd: invocation.command, cmdArgs: invocation.args };
|
|
290
253
|
}
|
|
291
254
|
|
|
292
255
|
async function spawnAndQuery({ command, args, env = {} }, serverName) {
|
package/src/tool-db/loader.mjs
CHANGED
|
@@ -23,26 +23,16 @@ export class ToolDbSchemaError extends Error {
|
|
|
23
23
|
}
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
// host別のDB file名と正規化は src/host/adapters.mjs が所有する。
|
|
27
|
+
import { getHostAdapter } from '../host/adapters.mjs';
|
|
28
|
+
export { normalizeToolDbHostAgent } from '../host/adapters.mjs';
|
|
29
|
+
|
|
26
30
|
export function globalDbPath(hostAgent = 'claude') {
|
|
27
|
-
|
|
28
|
-
const file = host === 'claude' ? 'tool-db.json' : `tool-db.${host}.json`;
|
|
29
|
-
return join(homedir(), '.spotter', file);
|
|
31
|
+
return join(homedir(), '.spotter', getHostAdapter(hostAgent).toolDbFileName);
|
|
30
32
|
}
|
|
31
33
|
|
|
32
34
|
export function localDbPath(projectRoot, hostAgent = 'claude') {
|
|
33
|
-
|
|
34
|
-
const file = host === 'claude' ? 'tool-db.json' : `tool-db.${host}.json`;
|
|
35
|
-
return join(projectRoot, '.spotter', file);
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export function normalizeToolDbHostAgent(hostAgent = 'claude') {
|
|
39
|
-
if (hostAgent === undefined || hostAgent === null || hostAgent === '') {
|
|
40
|
-
return 'claude';
|
|
41
|
-
}
|
|
42
|
-
if (hostAgent === 'claude' || hostAgent === 'codex' || hostAgent === 'automation') {
|
|
43
|
-
return hostAgent;
|
|
44
|
-
}
|
|
45
|
-
throw new TypeError(`tool-db hostAgent must be claude, codex, or automation; got ${hostAgent}`);
|
|
35
|
+
return join(projectRoot, '.spotter', getHostAdapter(hostAgent).toolDbFileName);
|
|
46
36
|
}
|
|
47
37
|
|
|
48
38
|
// Load a DB file. Missing file → returns empty db (this is normal, not an error).
|
|
@@ -87,25 +87,10 @@ async function readMcpServersFile(path) {
|
|
|
87
87
|
return (data.mcpServers && typeof data.mcpServers === 'object') ? data.mcpServers : {};
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
// - trailing slash: `/foo/bar` vs `/foo/bar/`
|
|
95
|
-
// On Windows we canonicalize to forward slashes and lower-case (case-insensitive
|
|
96
|
-
// filesystem). On POSIX, `\` is a legal filename character — collapsing it to `/`
|
|
97
|
-
// would conflate genuinely distinct paths (e.g. literal `C:\Users\u\proj` vs the
|
|
98
|
-
// hypothetical POSIX path `C:/Users/u/proj`), and case stays significant. POSIX
|
|
99
|
-
// only normalizes the trailing slash.
|
|
100
|
-
export function normalizeProjectPath(p) {
|
|
101
|
-
if (typeof p !== 'string' || p.length === 0) return '';
|
|
102
|
-
let s = p;
|
|
103
|
-
if (process.platform === 'win32') {
|
|
104
|
-
s = s.replace(/\\/g, '/').toLowerCase();
|
|
105
|
-
}
|
|
106
|
-
s = s.replace(/\/+$/, '');
|
|
107
|
-
return s;
|
|
108
|
-
}
|
|
90
|
+
// OS依存のパス表記正規化は src/platform/paths.mjs が所有する。
|
|
91
|
+
// `~/.claude.json` `projects[]` キー照合の用途と正規化ルールの根拠はそちらを参照。
|
|
92
|
+
import { normalizeProjectPath } from '../platform/paths.mjs';
|
|
93
|
+
export { normalizeProjectPath };
|
|
109
94
|
|
|
110
95
|
// Extract user-scope `mcpServers` from a parsed `~/.claude.json` object.
|
|
111
96
|
export function extractUserScopeServers(claudeJson) {
|
package/src/tool-db/refresh.mjs
CHANGED
|
@@ -11,76 +11,13 @@
|
|
|
11
11
|
// sub-agents.
|
|
12
12
|
|
|
13
13
|
import { resolveAll } from './lookup.mjs';
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import { buildCodexInvestigationSnapshot } from './investigate-codex.mjs';
|
|
17
|
-
import { listSkillsAll } from './investigate-skills.mjs';
|
|
18
|
-
import { listAgentsAll } from './investigate-agents.mjs';
|
|
19
|
-
import { localDbPath, globalDbPath, normalizeToolDbHostAgent } from './loader.mjs';
|
|
14
|
+
import { getHostAdapter } from '../host/adapters.mjs';
|
|
15
|
+
import { localDbPath, globalDbPath } from './loader.mjs';
|
|
20
16
|
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
export
|
|
25
|
-
const out = new Map();
|
|
26
|
-
for (const [serverName, tools] of getClaudeAiBaselineByServer()) {
|
|
27
|
-
if (!presentServerNames.has(serverName)) continue;
|
|
28
|
-
for (const [toolName, description] of Object.entries(tools)) {
|
|
29
|
-
out.set(toolName, description);
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
return out;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
// Build the (name → description) map for an investigation pass across all sources:
|
|
36
|
-
// - claude.ai MCP baseline (Gmail / Calendar / Drive — OAuth, not locally introspectable)
|
|
37
|
-
// - MCP servers via stdio + HTTP/SSE (user + project .mcp.json, live fetched)
|
|
38
|
-
// - Skills from user scope, project scope, and enabled plugins
|
|
39
|
-
// - Sub-agents from user scope, project scope, and enabled plugins
|
|
40
|
-
//
|
|
41
|
-
// Returns an in-memory snapshot the caller can use as the investigate() backend.
|
|
42
|
-
export async function buildInvestigationSnapshot({ logFn = () => {}, claudeBin = 'claude', projectRoot } = {}) {
|
|
43
|
-
const snapshot = new Map();
|
|
44
|
-
|
|
45
|
-
// Anthropic-provided `claude.ai ...` MCP servers. Hardcoded because the OAuth proxy
|
|
46
|
-
// is not reachable without reading ~/.claude/.credentials.json (deliberately avoided).
|
|
47
|
-
// Injected only for servers actually present in `claude mcp list` — otherwise phantom
|
|
48
|
-
// tools (Gmail/Calendar/Drive) leak into environments where those servers are not
|
|
49
|
-
// connected. See filterClaudeAiBaseline above.
|
|
50
|
-
const servers = await listMcpServers({ claudeBin, projectRoot });
|
|
51
|
-
const presentServerNames = new Set(servers.map((s) => s.name));
|
|
52
|
-
const baseline = filterClaudeAiBaseline(presentServerNames);
|
|
53
|
-
for (const [name, description] of baseline) {
|
|
54
|
-
snapshot.set(name, description);
|
|
55
|
-
}
|
|
56
|
-
if (baseline.size > 0) {
|
|
57
|
-
logFn(`claude.ai baseline injected: ${baseline.size} tools from ${[...presentServerNames].filter((n) => n.startsWith('claude.ai ')).join(', ')}`);
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
// MCP servers (stdio + user-registered HTTP/SSE). projectRoot forwards for
|
|
61
|
-
// project-scope `.mcp.json` merge (see mcp-config.mjs).
|
|
62
|
-
const mcp = await listMcpToolsAll({ logFn, claudeBin, projectRoot });
|
|
63
|
-
for (const [serverName, tools] of mcp.entries()) {
|
|
64
|
-
for (const tool of tools) {
|
|
65
|
-
if (!tool.description || tool.description.length === 0) continue;
|
|
66
|
-
snapshot.set(bellVisibleName(serverName, tool.name), tool.description);
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
// Skills (plugin-namespaced as `<plugin>:<name>`, or bare for user/project scope).
|
|
71
|
-
const skills = await listSkillsAll({ logFn, projectRoot });
|
|
72
|
-
for (const [name, description] of skills) {
|
|
73
|
-
snapshot.set(name, description);
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
// Sub-agents (bare name; project > user > plugin precedence resolved internally).
|
|
77
|
-
const agents = await listAgentsAll({ logFn, projectRoot });
|
|
78
|
-
for (const [name, description] of agents) {
|
|
79
|
-
snapshot.set(name, description);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
return snapshot;
|
|
83
|
-
}
|
|
17
|
+
// ベンダー別のsnapshot builderと実装本体は investigate-claude.mjs /
|
|
18
|
+
// investigate-codex.mjs へ移設し、選択は src/host/adapters.mjs が所有する。
|
|
19
|
+
// 既存importer・test契約のため同名で再exportする。
|
|
20
|
+
export { buildInvestigationSnapshot, filterClaudeAiBaseline } from './investigate-claude.mjs';
|
|
84
21
|
|
|
85
22
|
// Refresh the tool-db. Discovers all currently available tools, resolves each via the
|
|
86
23
|
// 3-tier lookup, writes through. Returns the resolved Map.
|
|
@@ -91,17 +28,15 @@ export async function refresh({
|
|
|
91
28
|
codexBin = 'codex',
|
|
92
29
|
hostAgent = 'claude',
|
|
93
30
|
} = {}) {
|
|
94
|
-
const
|
|
95
|
-
const snapshot =
|
|
96
|
-
? await buildCodexInvestigationSnapshot({ logFn, codexBin, projectRoot })
|
|
97
|
-
: await buildInvestigationSnapshot({ logFn, claudeBin, projectRoot });
|
|
31
|
+
const adapter = getHostAdapter(hostAgent);
|
|
32
|
+
const snapshot = await adapter.buildSnapshot({ logFn, claudeBin, codexBin, projectRoot });
|
|
98
33
|
const toolNames = Array.from(snapshot.keys());
|
|
99
34
|
const investigate = async (name) => snapshot.get(name) ?? null;
|
|
100
35
|
|
|
101
36
|
return resolveAll({
|
|
102
37
|
toolNames,
|
|
103
|
-
localPath: localDbPath(projectRoot,
|
|
104
|
-
globalPath: globalDbPath(
|
|
38
|
+
localPath: localDbPath(projectRoot, adapter.hostAgent),
|
|
39
|
+
globalPath: globalDbPath(adapter.hostAgent),
|
|
105
40
|
investigate,
|
|
106
41
|
logFn,
|
|
107
42
|
});
|