@quolu/lattice 0.68.3 → 0.69.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 CHANGED
@@ -156,12 +156,23 @@ the operating AI.
156
156
 
157
157
  ```bash
158
158
  npm install -g @quolu/lattice
159
+ lattice setup --json
159
160
  ```
160
161
 
161
162
  Requires **Node.js 22.13 or newer, except 25.x** (`engines: >=22.13 <25 || >=26`; Node 25's V8
162
163
  turboshaft WASM JIT breaks the bundled sensor, so it is blocked with a banner). The structure sensor ships inside the package — there is nothing
163
164
  else to install, and Lattice never falls back to a sensor on your `PATH`.
164
165
 
166
+ `lattice setup`は既存のClaude・Codex・Grok・Cursor設定を検出し、LatticeのMCP登録、
167
+ 対応する製品hookの準備、設定読戻し、実MCPの初期化とtool一覧確認を一回で行う。
168
+ AIの設定がまだ無い初回は`lattice setup --host claude --json`のように対象を指定する。
169
+ 全AIを明示する時は`--host all`を使う。再実行とnpm更新後も同じ入口である。
170
+
171
+ 結果はOS・AI・機能別に返す。Windows nativeの製品hookとGrokの製品hookは`unsupported`、
172
+ 利用者が無効化したMCPは`disabled`として保持する。対応MCPの登録・確認は続行し、
173
+ 全機能確認済みの`ready`だけexit 0、`partial`と`failed`はexit 1である。
174
+ 設定形式と機能表は[導入契約](https://github.com/kitepon/Lattice/blob/main/docs/01_integration-package.md#製品所有のai導入入口)を参照。
175
+
165
176
  ## Quick start
166
177
 
167
178
  Every project begins with typed discovery. Never guess from directory layout:
@@ -276,11 +287,15 @@ host where it is enabled; its supervised process replaces an older loaded versio
276
287
 
277
288
  ```bash
278
289
  npm install -g @quolu/lattice@latest
290
+ lattice setup --json
279
291
  lattice --version
280
292
  lattice status --json
281
293
  lattice bridge status --json
282
294
  ```
283
295
 
296
+ AI登録の診断は`lattice setup status --json`。`--host <AI>`で対象を限定でき、
297
+ 設定を変更せず現在の登録とMCP接続を確認する。既存の製品hook単独操作も維持する。
298
+
284
299
  ## Release
285
300
 
286
301
  Maintainers release Lattice from this repository. Update `package.json` and `CHANGELOG.md`, pass the
@@ -291,8 +306,9 @@ remote default branch.
291
306
  ```bash
292
307
  npm run ci
293
308
  npm run verify:release-commit
294
- npm publish --access public
309
+ gh workflow run publish.yml --repo kitepon/Lattice --ref main
295
310
  npm install -g @quolu/lattice@<version>
311
+ lattice setup --json
296
312
  lattice --version
297
313
  lattice status --json
298
314
  lattice bridge status --json
package/bin/lattice.mjs CHANGED
@@ -123,6 +123,9 @@ if (help !== null) {
123
123
  process.exitCode = await runBridgeCli({
124
124
  argv: args.slice(1), stdout: process.stdout, stderr: process.stderr, env: process.env,
125
125
  });
126
+ } else if (args[0] === 'setup') {
127
+ const { runSetupCli } = await import('../src/setup-cli.mjs');
128
+ process.exitCode = await runSetupCli({ argv: args.slice(1), stdout: process.stdout });
126
129
  } else if (args[0] === 'hooks') {
127
130
  const { runHooksCli } = await import('../src/hooks-cli.mjs');
128
131
  process.exitCode = await runHooksCli({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quolu/lattice",
3
- "version": "0.68.3",
3
+ "version": "0.69.0",
4
4
  "description": "Schedulability compiler for multi-agent development: observe real code boundaries, refactor the conflicting seam, recompile the plan for parallel execution",
5
5
  "author": {
6
6
  "name": "Quo / クオ at kitepon.dev",
@@ -67,6 +67,7 @@
67
67
  "remark-gfm": "^4.0.1",
68
68
  "remark-parse": "^11.0.0",
69
69
  "sisteransi": "^1.0.5",
70
+ "toml-eslint-parser": "^1.0.3",
70
71
  "tree-sitter-wasms": "^0.1.11",
71
72
  "unified": "^11.0.5",
72
73
  "web-tree-sitter": "^0.25.3"
package/src/cli-help.mjs CHANGED
@@ -14,6 +14,7 @@ Commands:
14
14
  runtime-errors <command> Inspect the local runtime error store
15
15
  bridge <command> Configure the optional network bridge
16
16
  hooks <command> Install and run sensor-awareness hooks
17
+ setup [status] AI別のMCP登録・製品hook準備・接続確認
17
18
 
18
19
  Options:
19
20
  -h, --help Show help
@@ -205,12 +206,21 @@ Commands:
205
206
 
206
207
  registerはLATTICE_BRIDGE_REGISTRAR_SSH_HOSTとLATTICE_BRIDGE_REGISTRAR_SCRIPTが
207
208
  両方設定されている時だけ動く。アドレスは送らず、remote側がssh送信元から決める。
209
+ `,
210
+ setup: `Usage: lattice setup [status] [--host <claude|codex|grok|cursor|all>] [--json]
211
+
212
+ host省略時は既存AI設定を検出する。明示hostは初回設定を作成する。
213
+ setupは登録・hook準備・MCP接続確認まで実行する。更新後も同じコマンドを使う。
214
+ statusは設定変更をせず登録とMCP接続を診断する。
215
+ 結果は機能別のverified/unsupported/disabled/failed。readyだけexit 0、partial/failedはexit 1。
216
+ Windows製品hookとGrok製品hookは未対応として返し、MCPは独立して実行する。
208
217
  `,
209
218
  hooks: `Usage: lattice hooks <install|status|uninstall|emit> --host <claude|codex|cursor>
210
219
  `,
211
220
  });
212
221
 
213
222
  const SUBCOMMAND_USAGE = Object.freeze({
223
+ 'setup status': 'setup status [--host <claude|codex|grok|cursor|all>] [--json]',
214
224
  status: 'status --json',
215
225
  'session-context': 'session-context --json',
216
226
  'plan create': 'plan create --input <file> [--serialization-reviewed] | --schema --json | --schema-version <1|2|3|4> --json',
package/src/hooks-cli.mjs CHANGED
@@ -8,6 +8,7 @@ import {
8
8
  import os from 'node:os';
9
9
  import path from 'node:path';
10
10
  import { fileURLToPath } from 'node:url';
11
+ import { hostPaths } from './setup-hosts.mjs';
11
12
 
12
13
  const INFO = 'INFO: このrepoにはLattice sensor index(.lattice/sensor/)があります。コード構造の調査はsensor入口(MCP: lattice_sensor_explore 等/CLI: lattice sensor)を優先できます。';
13
14
  const HOSTS = new Set(['claude', 'codex', 'cursor']);
@@ -32,10 +33,8 @@ function failure(stdout, code, message, exit = 1, detail) {
32
33
  return exit;
33
34
  }
34
35
 
35
- function configPath(home, host) {
36
- if (host === 'claude') return path.join(home, '.claude/settings.json');
37
- if (host === 'cursor') return path.join(home, '.cursor/hooks.json');
38
- return path.join(home, '.codex/hooks.json');
36
+ function configPath(env, host) {
37
+ return hostPaths(host, env).hooks;
39
38
  }
40
39
 
41
40
  function eventName(host) {
@@ -650,8 +649,8 @@ function exactHandler(item, expected) {
650
649
  return JSON.stringify(item) === JSON.stringify(expected);
651
650
  }
652
651
 
653
- async function hostDirectory(home, host) {
654
- const directory = path.dirname(configPath(home, host));
652
+ async function hostDirectory(env, host) {
653
+ const directory = path.dirname(configPath(env, host));
655
654
  try {
656
655
  const info = await lstat(directory);
657
656
  if (!info.isDirectory() || info.isSymbolicLink()) throw new Error('not a directory');
@@ -662,15 +661,14 @@ async function hostDirectory(home, host) {
662
661
  }
663
662
 
664
663
  async function mutate(host, env, stdout, uninstall, source, platform, testHooks) {
665
- const home = env.HOME ?? os.homedir();
666
- if (await hostDirectory(home, host) === null) {
664
+ if (await hostDirectory(env, host) === null) {
667
665
  return failure(stdout, 'HOST_NOT_PRESENT', 'host home directory is not present');
668
666
  }
669
667
  let current;
670
668
  try { current = await resolveCanonical(host, source, platform); } catch {
671
669
  return failure(stdout, 'INSTALL_SOURCE_UNRESOLVED', 'install source cannot be resolved');
672
670
  }
673
- const target = configPath(home, host);
671
+ const target = configPath(env, host);
674
672
  for (let attempt = 0; attempt < 2; attempt += 1) {
675
673
  let prestate;
676
674
  try { prestate = await readConfig(target); } catch (error) {
@@ -759,14 +757,13 @@ function statusResult(host, target, canonicalCommand, state, matches, executable
759
757
  }
760
758
 
761
759
  async function status(host, env, stdout, source, platform, testHooks) {
762
- const home = env.HOME ?? os.homedir();
763
- const target = configPath(home, host);
760
+ const target = configPath(env, host);
764
761
  let argv;
765
762
  try { argv = await resolveCanonical(host, source, platform); } catch {
766
763
  writeJson(stdout, statusResult(host, target, null, 'unreadable', 0, false, 0));
767
764
  return 1;
768
765
  }
769
- if (platform === 'win32' || await hostDirectory(home, host) === null) {
766
+ if (platform === 'win32' || await hostDirectory(env, host) === null) {
770
767
  writeJson(stdout, statusResult(host, target, shell(argv), 'unreadable', 0, false, 0));
771
768
  return 1;
772
769
  }
@@ -0,0 +1,120 @@
1
+ import { access, lstat, mkdir, open, unlink } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { runHooksCli, resolveStableNodePath } from './hooks-cli.mjs';
5
+ import { SETUP_HOSTS, hostPaths } from './setup-hosts.mjs';
6
+ import { mcpEntry, parseSetupConfig, readSetupFile, updateMcpConfig, writeSetupFile, setupError } from './setup-config.mjs';
7
+ import { probeSetupMcp } from './setup-mcp-probe.mjs';
8
+
9
+ const root = fileURLToPath(new URL('..', import.meta.url));
10
+ const usage = 'lattice setup [status] [--host claude|codex|grok|cursor|all] [--json]';
11
+ const typed = (error) => ({ state: 'failed', code: error.code ?? 'SETUP_FAILED', message: error.message });
12
+
13
+ async function hookResult(host, options, statusOnly) {
14
+ if (host === 'grok') return { state: 'unsupported', code: 'HOST_FEATURE_UNSUPPORTED', message: 'Grokの製品hookは未対応です' };
15
+ if (options.platform === 'win32') return { state: 'unsupported', code: 'HOST_PLATFORM_UNSUPPORTED', message: 'Windows nativeの製品hookは未対応です' };
16
+ const invoke = async (command) => {
17
+ let output = '';
18
+ const exit = await runHooksCli({ argv: [command, '--host', host], env: options.env,
19
+ platform: options.platform, source: options.hookSource, stdout: { write: (text) => { output += text; } } });
20
+ return { exit, result: JSON.parse(output) };
21
+ };
22
+ if (!statusOnly) {
23
+ const installed = await invoke('install');
24
+ if (installed.exit !== 0) return { ...installed.result, state: 'failed' };
25
+ }
26
+ const status = await invoke('status');
27
+ return { ...status.result, state: status.exit === 0 && status.result.state === 'wired' ? 'verified' : 'failed' };
28
+ }
29
+
30
+ async function configureHost(host, options, statusOnly) {
31
+ const paths = hostPaths(host, options.env);
32
+ const result = { host, mcp: null, hooks: null };
33
+ try {
34
+ const before = await readSetupFile(paths.mcp);
35
+ const existing = mcpEntry(before.text, paths.format);
36
+ const previousArgs = existing?.args ?? [];
37
+ if (!Array.isArray(previousArgs) || previousArgs.some((arg) => typeof arg !== 'string')) {
38
+ throw setupError('CONFIG_INVALID', 'MCP引数は文字列配列である必要があります');
39
+ }
40
+ // 配布入口を交換し、利用者が指定した --path 等の引数は保持する。
41
+ const hasScript = /(?:^|[\\/])lattice-mcp\.mjs$/u.test(previousArgs[0] ?? '');
42
+ const preservedArgs = hasScript ? previousArgs.slice(1) : previousArgs;
43
+ const desired = { command: await resolveStableNodePath(options.execPath, { platform: options.platform }),
44
+ args: [options.mcpBin, ...preservedArgs] };
45
+ await access(options.mcpBin);
46
+ let action = 'unchanged';
47
+ if (!statusOnly) action = await writeSetupFile(paths.mcp, before, updateMcpConfig(before.text, paths.format, desired));
48
+ const saved = (await readSetupFile(paths.mcp)).text;
49
+ const entry = mcpEntry(saved, paths.format);
50
+ if (!entry || entry.command !== desired.command || JSON.stringify(entry.args) !== JSON.stringify(desired.args)) {
51
+ throw setupError('MCP_REGISTRATION_DRIFT', 'MCP登録が現在のLattice実行体と一致しません');
52
+ }
53
+ const disabledNames = host === 'grok' ? parseSetupConfig(saved, paths.format).value.disabled_mcp_servers : [];
54
+ if (entry.enabled === false || entry.disabled === true || (Array.isArray(disabledNames) && disabledNames.includes('lattice'))) {
55
+ result.mcp = { state: 'disabled', code: 'USER_DISABLED', config_path: paths.mcp, action };
56
+ } else {
57
+ result.mcp = { ...await options.probe(entry, { env: options.env }), config_path: paths.mcp, action };
58
+ }
59
+ } catch (error) { result.mcp = { ...typed(error), config_path: paths.mcp }; }
60
+ try {
61
+ if (!statusOnly && host !== 'grok' && options.platform !== 'win32') await mkdir(paths.directory, { recursive: true });
62
+ result.hooks = await hookResult(host, options, statusOnly);
63
+ } catch (error) { result.hooks = typed(error); }
64
+ return result;
65
+ }
66
+
67
+ export async function runSetupCli({ argv, stdout, env = process.env, platform = process.platform,
68
+ execPath = process.execPath, mcpBin = path.join(root, 'bin/lattice-mcp.mjs'),
69
+ hookSource = { execPath, binPath: path.join(root, 'bin/lattice.mjs') }, probe = probeSetupMcp }) {
70
+ let statusOnly = false;
71
+ let selection;
72
+ const args = [...argv];
73
+ if (args[0] === 'status') { statusOnly = true; args.shift(); }
74
+ if (args.at(-1) === '--json') args.pop();
75
+ if (args.length === 2 && args[0] === '--host' && [...SETUP_HOSTS, 'all'].includes(args[1])) selection = args[1];
76
+ else if (args.length !== 0) {
77
+ stdout.write(`${JSON.stringify({ schema: 'lattice.setup_error.v1', code: 'USAGE', message: usage })}\n`);
78
+ return 2;
79
+ }
80
+ let hosts = selection && selection !== 'all' ? [selection] : SETUP_HOSTS;
81
+ if (!selection) {
82
+ const detected = [];
83
+ for (const host of hosts) {
84
+ const paths = hostPaths(host, env);
85
+ try { await access(paths.directory); detected.push(host); } catch (error) {
86
+ if (error.code !== 'ENOENT') { detected.push(host); continue; }
87
+ if (host === 'claude') {
88
+ try { if ((await readSetupFile(paths.mcp)).exists) detected.push(host); }
89
+ catch { detected.push(host); }
90
+ }
91
+ }
92
+ }
93
+ hosts = detected;
94
+ }
95
+ const options = { env, platform, execPath, mcpBin, hookSource, probe };
96
+ const results = [];
97
+ for (const host of hosts) {
98
+ const paths = hostPaths(host, env);
99
+ let lock;
100
+ const lockPath = path.join(paths.directory, '.lattice-setup.lock');
101
+ try {
102
+ if (!statusOnly) {
103
+ await mkdir(paths.directory, { recursive: true });
104
+ if (!(await lstat(paths.directory)).isDirectory()) throw setupError('CONFIG_PATH_UNSUPPORTED', 'AI設定directoryが通常directoryではありません');
105
+ try { lock = await open(lockPath, 'wx', 0o600); } catch (error) {
106
+ if (error.code === 'EEXIST') throw setupError('SETUP_BUSY', '同じAI設定へのLattice setupが実行中です');
107
+ throw error;
108
+ }
109
+ }
110
+ results.push(await configureHost(host, options, statusOnly));
111
+ } catch (error) { results.push({ host, mcp: typed(error), hooks: typed(error) }); }
112
+ finally { if (lock) { await lock.close(); await unlink(lockPath); } }
113
+ }
114
+ const features = results.flatMap((host) => [host.mcp, host.hooks]);
115
+ const state = !hosts.length || features.some((feature) => feature.state === 'failed') ? 'failed'
116
+ : features.every((feature) => feature.state === 'verified') ? 'ready' : 'partial';
117
+ stdout.write(`${JSON.stringify({ schema: 'lattice.setup_result.v1', operation: statusOnly ? 'status' : 'setup',
118
+ platform, state, ...(hosts.length ? {} : { code: 'HOST_NOT_PRESENT' }), hosts: results })}\n`);
119
+ return state === 'ready' ? 0 : 1;
120
+ }
@@ -0,0 +1,124 @@
1
+ import { parseForESLint, getStaticTOMLValue } from 'toml-eslint-parser';
2
+ import { parseTree, getNodeValue, modify, applyEdits } from 'jsonc-parser';
3
+ import { lstat, readFile, mkdir, open, rename, unlink, copyFile } from 'node:fs/promises';
4
+ import { randomUUID } from 'node:crypto';
5
+ import path from 'node:path';
6
+ export const setupError = (code, message) => Object.assign(new Error(message), { code });
7
+ const object = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
8
+
9
+ export function parseSetupConfig(text, format) {
10
+ if (text.startsWith('\uFEFF')) text = text.slice(1);
11
+ try {
12
+ if (format === 'toml') {
13
+ const { ast } = parseForESLint(text, { tomlVersion: '1.0.0' });
14
+ return { ast, value: getStaticTOMLValue(ast) };
15
+ }
16
+ if (!text.trim()) return { value: {} };
17
+ const errors = [];
18
+ const ast = parseTree(text, errors, { allowTrailingComma: true });
19
+ if (errors.length || !ast || ast.type !== 'object') throw new Error('JSON構文が不正');
20
+ function uniqueKeys(node) {
21
+ if (node.type === 'object') {
22
+ const keys = node.children.map((property) => property.children[0].value);
23
+ if (new Set(keys).size !== keys.length) throw new Error('JSONキーが重複');
24
+ }
25
+ for (const child of node.children ?? []) uniqueKeys(child);
26
+ }
27
+ uniqueKeys(ast);
28
+ return { ast, value: getNodeValue(ast) };
29
+ } catch {
30
+ throw setupError('CONFIG_INVALID', 'AI設定の構文を解釈できないため変更しません');
31
+ }
32
+ }
33
+
34
+ const keyParts = (key) => key.keys.map((part) => part.name ?? part.value);
35
+ const equalPath = (a, b) => a.length === b.length && a.every((part, i) => part === b[i]);
36
+ const prefixPath = (a, b) => a.every((part, i) => part === b[i]);
37
+
38
+ function setToml(text, keys, value) {
39
+ const { ast } = parseSetupConfig(text, 'toml');
40
+ let found;
41
+ let container = { prefix: [], node: ast.body[0] };
42
+ function visit(node, prefix) {
43
+ if (node.type === 'TOMLTable') prefix = node.resolvedKey;
44
+ if (['TOMLTable', 'TOMLInlineTable'].includes(node.type)
45
+ && prefixPath(prefix, keys) && prefix.length > container.prefix.length) container = { prefix, node };
46
+ if (node.type === 'TOMLKeyValue') {
47
+ const full = [...prefix, ...keyParts(node.key)];
48
+ if (equalPath(full, keys)) found = node.value;
49
+ if (node.value.type === 'TOMLInlineTable') visit(node.value, full);
50
+ } else for (const child of node.body ?? []) visit(child, prefix);
51
+ }
52
+ visit(ast.body[0], []);
53
+ const serialized = JSON.stringify(value);
54
+ if (found) return text.slice(0, found.range[0]) + serialized + text.slice(found.range[1]);
55
+ const assignment = `${keys.slice(container.prefix.length).map((part) => JSON.stringify(part)).join('.')} = ${serialized}`;
56
+ if (container.node.type === 'TOMLInlineTable') {
57
+ const at = container.node.range[1] - 1;
58
+ return text.slice(0, at) + (container.node.body.length ? ', ' : '') + assignment + text.slice(at);
59
+ }
60
+ if (container.node.type === 'TOMLTable') {
61
+ const newline = text.indexOf('\n', container.node.key.range[1]);
62
+ const at = newline < 0 ? text.length : newline + 1;
63
+ return text.slice(0, at) + (newline < 0 ? '\n' : '') + assignment + '\n' + text.slice(at);
64
+ }
65
+ return assignment + '\n' + text;
66
+ }
67
+
68
+ export function mcpEntry(text, format) {
69
+ const { value } = parseSetupConfig(text, format);
70
+ const servers = value[format === 'toml' ? 'mcp_servers' : 'mcpServers'];
71
+ if (servers !== undefined && !object(servers)) throw setupError('CONFIG_INVALID', 'MCP登録一覧がobjectではありません');
72
+ const entry = servers?.lattice;
73
+ if (entry !== undefined && !object(entry)) throw setupError('CONFIG_INVALID', 'Lattice登録がobjectではありません');
74
+ return entry;
75
+ }
76
+
77
+ export function updateMcpConfig(text, format, desired) {
78
+ if (text.startsWith('\uFEFF')) return '\uFEFF' + updateMcpConfig(text.slice(1), format, desired);
79
+ const current = mcpEntry(text, format);
80
+ if (current?.url || (current?.type && current.type !== 'stdio')) {
81
+ throw setupError('MCP_REGISTRATION_CONFLICT', 'lattice名の登録がstdioではないため保持しました');
82
+ }
83
+ let updated = text;
84
+ for (const [key, value] of Object.entries(desired)) {
85
+ if (JSON.stringify(current?.[key]) === JSON.stringify(value)) continue;
86
+ const keys = [format === 'toml' ? 'mcp_servers' : 'mcpServers', 'lattice', key];
87
+ updated = format === 'toml' ? setToml(updated, keys, value)
88
+ : applyEdits(updated, modify(updated, keys, value, {
89
+ formattingOptions: { insertSpaces: true, tabSize: 2, eol: text.includes('\r\n') ? '\r\n' : '\n' },
90
+ }));
91
+ }
92
+ mcpEntry(updated, format);
93
+ return updated;
94
+ }
95
+
96
+ export async function readSetupFile(target) {
97
+ let info;
98
+ try { info = await lstat(target); } catch (error) {
99
+ if (error.code === 'ENOENT') return { text: '', mode: 0o600, exists: false };
100
+ throw error;
101
+ }
102
+ if (!info.isFile() || info.isSymbolicLink()) throw setupError('CONFIG_PATH_UNSUPPORTED', '設定は通常fileだけを扱います');
103
+ return { text: await readFile(target, 'utf8'), mode: info.mode & 0o777, exists: true };
104
+ }
105
+
106
+ export async function writeSetupFile(target, before, text) {
107
+ if (text === before.text) return 'unchanged';
108
+ await mkdir(path.dirname(target), { recursive: true });
109
+ const current = await readSetupFile(target);
110
+ if (current.text !== before.text || current.exists !== before.exists) throw setupError('CONFIG_CHANGED', '設定が同時に変更されました');
111
+ const temporary = `${target}.lattice-${randomUUID()}`;
112
+ try {
113
+ const handle = await open(temporary, 'wx', before.mode);
114
+ try { await handle.writeFile(text); await handle.sync(); } finally { await handle.close(); }
115
+ const latest = await readSetupFile(target);
116
+ if (latest.text !== before.text || latest.exists !== before.exists) throw setupError('CONFIG_CHANGED', '設定が同時に変更されました');
117
+ if (before.exists) await copyFile(target, `${temporary}.backup`);
118
+ await rename(temporary, target);
119
+ if ((await readSetupFile(target)).text !== text) throw setupError('CONFIG_READBACK_FAILED', '設定の読戻しが一致しません');
120
+ } finally {
121
+ await unlink(temporary).catch((error) => { if (error.code !== 'ENOENT') throw error; });
122
+ }
123
+ return before.exists ? 'updated' : 'created';
124
+ }
@@ -0,0 +1,15 @@
1
+ import path from 'node:path';
2
+ import os from 'node:os';
3
+
4
+ export const SETUP_HOSTS = ['claude', 'codex', 'grok', 'cursor'];
5
+
6
+ export function hostPaths(host, env = process.env) {
7
+ const home = env.HOME || env.USERPROFILE || os.homedir();
8
+ const directory = path.resolve((host === 'claude' ? env.CLAUDE_CONFIG_DIR : null)
9
+ || (host === 'codex' ? env.CODEX_HOME : null)
10
+ || (host === 'grok' ? env.GROK_HOME : null) || path.join(home, `.${host}`));
11
+ return { directory, format: ['codex', 'grok'].includes(host) ? 'toml' : 'json',
12
+ mcp: host === 'claude' ? path.join(env.CLAUDE_CONFIG_DIR ? directory : home, '.claude.json')
13
+ : path.join(directory, ['codex', 'grok'].includes(host) ? 'config.toml' : 'mcp.json'),
14
+ hooks: path.join(directory, host === 'claude' ? 'settings.json' : 'hooks.json') };
15
+ }
@@ -0,0 +1,71 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { setupError } from './setup-config.mjs';
3
+
4
+ /** 登録から起動したstdioの初期化とtool一覧を読戻す。AIへの推論依頼は行わない。 */
5
+ export async function probeSetupMcp(entry, { env = process.env, timeoutMs = 15000 } = {}) {
6
+ const child = spawn(entry.command, entry.args ?? [], {
7
+ shell: false, stdio: ['pipe', 'pipe', 'pipe'], cwd: entry.cwd,
8
+ env: { ...env, ...entry.env }, windowsHide: true,
9
+ });
10
+ let buffer = '';
11
+ let initialized = false;
12
+ let result;
13
+ let failure;
14
+ const closed = new Promise((resolve) => child.once('close', resolve));
15
+ try {
16
+ result = await new Promise((resolve, reject) => {
17
+ const timer = setTimeout(() => reject(setupError('MCP_TIMEOUT', 'MCP接続確認が時間内に完了しません')), timeoutMs);
18
+ const finish = (error, value) => {
19
+ clearTimeout(timer);
20
+ if (error) reject(error); else resolve(value);
21
+ };
22
+ const send = (message) => child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', ...message })}\n`);
23
+ child.once('error', () => finish(setupError('MCP_LAUNCH_FAILED', '登録されたMCP実行体を起動できません')));
24
+ child.stdin.on('error', () => finish(setupError('MCP_CONNECTION_CLOSED', 'MCP入力が閉じました')));
25
+ child.stderr.resume();
26
+ child.once('exit', () => finish(setupError('MCP_CONNECTION_CLOSED', '接続確認前にMCPが終了しました')));
27
+ child.stdout.setEncoding('utf8');
28
+ child.stdout.on('data', (chunk) => {
29
+ buffer += chunk;
30
+ if (buffer.length > 4 * 1024 * 1024) return finish(setupError('MCP_PROTOCOL_INVALID', 'MCP出力が上限を超えました'));
31
+ while (buffer.includes('\n')) {
32
+ const at = buffer.indexOf('\n');
33
+ const line = buffer.slice(0, at);
34
+ buffer = buffer.slice(at + 1);
35
+ if (!line.trim()) continue;
36
+ let response;
37
+ try { response = JSON.parse(line); } catch {
38
+ return finish(setupError('MCP_PROTOCOL_INVALID', 'MCP出力がJSON-RPCではありません'));
39
+ }
40
+ if (response.id === 1) {
41
+ if (response.error || response.result?.serverInfo?.name !== 'lattice-sensor') {
42
+ return finish(setupError('MCP_IDENTITY_MISMATCH', 'Lattice MCPの初期化結果を確認できません'));
43
+ }
44
+ initialized = true;
45
+ send({ method: 'notifications/initialized' });
46
+ send({ id: 2, method: 'tools/list', params: {} });
47
+ } else if (response.id === 2) {
48
+ const names = response.result?.tools?.map((tool) => tool.name);
49
+ if (!initialized || response.error || !names?.includes('lattice_sensor_explore')
50
+ || names.some((name) => !name.startsWith('lattice_sensor_'))) {
51
+ return finish(setupError('MCP_TOOLS_MISMATCH', 'Lattice sensorの公開toolを確認できません'));
52
+ }
53
+ finish(null, { state: 'verified', tool_count: names.length });
54
+ }
55
+ }
56
+ });
57
+ send({ id: 1, method: 'initialize', params: {
58
+ protocolVersion: '2024-11-05', capabilities: {},
59
+ clientInfo: { name: 'lattice-setup', version: '1' },
60
+ } });
61
+ });
62
+ } catch (error) { failure = error; }
63
+ finally {
64
+ child.stdin.end();
65
+ const timer = setTimeout(() => child.kill('SIGKILL'), 2000);
66
+ await closed;
67
+ clearTimeout(timer);
68
+ }
69
+ if (failure) throw failure;
70
+ return result;
71
+ }