@quolu/aishell 0.5.0 → 0.6.1

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.ja.md CHANGED
@@ -28,9 +28,7 @@ AIShellはfile identity、filesystem照合state、直接起動したprocess、
28
28
  Apple Silicon Mac、macOS 15以降が必要。
29
29
 
30
30
  ```sh
31
- npm install -g @quolu/aishell
32
- aishell-open
33
- codex mcp add aishell --env AISHELL_CAPABILITY_SET=expanded-v1 -- aishell-mcp
31
+ npm install -g @quolu/aishell && aishell-setup
34
32
  ```
35
33
 
36
34
  フォルダの事前登録は不要。新しいCodex taskで対象フォルダを指定して実行する。
@@ -60,7 +58,7 @@ semantic search、project profile、Git branch/worktree modeが加わる。
60
58
  Codexでは次のように登録する。
61
59
 
62
60
  ```sh
63
- codex mcp add aishell --env AISHELL_CAPABILITY_SET=expanded-v1 -- aishell-mcp
61
+ aishell-setup --ai codex
64
62
  ```
65
63
 
66
64
  未知値または空の`AISHELL_CAPABILITY_SET`と`AISHELL_TOOL_PROFILE`はtyped errorでstartup停止し、
@@ -101,16 +99,16 @@ flowchart LR
101
99
 
102
100
  ## npmからinstall
103
101
 
104
- global packageは`aishell-mcp`と`aishell-open`を`PATH`へ追加する。`aishell-open`は同梱された管理アプリをLaunchServicesで開く。install scriptは実行しない。
102
+ global packageは`aishell-mcp`、`aishell-open`、`aishell-setup`を`PATH`へ追加する。npm install自体ではスクリプトも管理アプリも起動しない。
105
103
 
106
- upgradeは開いたままの管理アプリの足元でapp bundleを置き換えるため、その窓は壊れる。削除済みbundleを
107
- 掴み続け、ファイル選択を伴う操作を無言で失う。窓自身がこれを検知してbannerで示す。新版が同じpathに
108
- 在る場合はbannerの再起動1回で新版の窓へ移り、pathごと消えている場合は終了と`aishell-open`での開き直しを
109
- 促す。実際の`npm install -g` upgradeで検証済みである。
104
+ 対象AIのCLI(`claude`、`codex`、`grok`、Cursorの`agent`)を先に導入する。setupは各CLIからの読戻しも確認する。
105
+
106
+ `aishell-setup`は導入済みのClaude Code・Codex・Grok Build・Cursorを検出し、管理アプリ準備、MCP登録、設定の読戻し、実際のMCP操作まで確認する。登録はbare `aishell-mcp`+`AISHELL_CAPABILITY_SET=expanded-v1`。利用者のenv、PATH、他の設定を保持する。`--ai`で対象を指定でき、`--check`は設定やアプリ起動を変更せず診断する。Windows/LinuxとIntel Macは対象外。詳細は[製品単体の導入契約](https://github.com/kitepon/aishell/blob/main/docs/setup.md)を参照。
107
+
108
+ 更新後も同じ`aishell-setup`を実行する。旧管理アプリを正常終了して導入済みのアプリを開き、登録保持・読戻し・MCP実操作まで確認する。接続済みのMCPは、hostで再接続すると新版へ切り替わる。
110
109
 
111
110
  ```sh
112
- npm install -g @quolu/aishell
113
- aishell-open
111
+ npm install -g @quolu/aishell && aishell-setup
114
112
  ```
115
113
 
116
114
  現在の実験版はDeveloper ID署名・notarization前である。
@@ -134,9 +132,8 @@ MCP実行ファイルは`build/AIShell.app/Contents/Helpers/aishell-mcp`へ同
134
132
  global npm install後は`PATH`上のcommand名とexpanded development surfaceを登録する。
135
133
 
136
134
  ```sh
137
- codex mcp add aishell --env AISHELL_CAPABILITY_SET=expanded-v1 -- aishell-mcp
138
- claude mcp add --scope user aishell --env AISHELL_CAPABILITY_SET=expanded-v1 -- aishell-mcp
139
- codex mcp get aishell
135
+ aishell-setup --ai claude,codex,grok,cursor
136
+ aishell-setup --check
140
137
  ```
141
138
 
142
139
  解除:
@@ -156,6 +153,10 @@ AISHELL_CAPABILITY_SET=expanded-v1 AISHELL_TOOL_PROFILE=full /opt/homebrew/bin/a
156
153
 
157
154
  full profileにはfile一覧・read、SHA-256競合検出付きatomic update、copy/move/rename/Trash、直接process実行、app discovery/launch、runtime status、管理アプリの前面化が含まれる。
158
155
 
156
+ `apply_change_set`のKeychain認証が必要な場合は、UIを待たず`CHANGE_SET_SECRET_STORE_UNAVAILABLE`を返す。
157
+ 取引開始前の失敗は`error.request_status: aborted_before_side_effect`と空の`changed_paths`で確認できる。
158
+ 既存鍵のアクセス認証を解決してから再実行する。transport timeoutだけを編集中止の証拠にしない。
159
+
159
160
  ## 実行と安全性の境界
160
161
 
161
162
  - shell command文字列を評価しない。開発programを`PATH`からexecutable URLへ解決し、arguments、environment、working directoryと分離する。
@@ -166,8 +167,8 @@ full profileにはfile一覧・read、SHA-256競合検出付きatomic update、c
166
167
 
167
168
  ## 現在の制限
168
169
 
169
- - stdio serverは1 requestずつ処理する。
170
- - MCP cancellationと並列run pollingは未実装。
170
+ - stdio requestは復旧操作・読み取り・実行の3系統で処理する。読み取りと復旧操作は長時間の実行中も応答するが、実行系requestは直列化する。
171
+ - MCPの`notifications/cancelled`を受け付ける。管理対象processの明示的な停止は`run_observe`の`cancel`で行う。
171
172
  - timeout時は直接所有するprocess treeを終了するが、終了までに許可workerがopen-worldな副作用を起こし得る。
172
173
  - 初回workspace entryはbounded previewで、後続deltaはcursor pageになる。
173
174
  - Developer ID署名とnotarizationは未設定。
@@ -177,8 +178,7 @@ full profileにはfile一覧・read、SHA-256競合検出付きatomic update、c
177
178
  単独installの更新は初回と同じ公式npm経路を使い、新版の管理アプリを開く。
178
179
 
179
180
  ```sh
180
- npm install -g @quolu/aishell@latest
181
- aishell-open
181
+ npm install -g @quolu/aishell@latest && aishell-setup
182
182
  ```
183
183
 
184
184
  停止中の復旧入口は`runtime_status`と`runtime_open_manager`である。
@@ -194,13 +194,28 @@ npm test
194
194
  npm run test:package
195
195
  git fetch origin
196
196
  npm run verify:release-commit
197
- npm publish --access public
197
+ npm whoami
198
+ npm publish --access public --browser=false
198
199
  ```
199
200
 
200
201
  release gateはdirty treeと既定branchへ未着地のcommitを拒否する。publish後は対応する
201
202
  GitHub Releaseを作り、`@quolu/aishell@latest`を再installしてMCP initializeと
202
203
  `factory_diagnostics`をsmokeする。公開済みversionの正本はGitHub Releasesである。
203
204
 
205
+ ### 公開認証と導入確認
206
+
207
+ `npm whoami`が認証エラーを返した場合は、対話端末で`npm login --registry=https://registry.npmjs.org/ --browser=false`を実行し、表示された新しいURLを利用するブラウザで開いて認証する。公開コマンドも対話端末で実行し、出力をファイルへリダイレクトしない。公開用の認証URLが表示された場合は、ログインとは別に認証する。URLが失効した場合はコマンドを再実行して新しいURLを使う。
208
+
209
+ npmのログインsessionは2時間で失効し、公開時には二要素認証が適用される([npm公式説明](https://github.blog/changelog/2025-12-09-npm-classic-tokens-revoked-session-based-auth-and-cli-token-management-now-available/))。公開の成功後に、registryとグローバルインストールを確認する。
210
+
211
+ ```sh
212
+ npm view @quolu/aishell dist-tags.latest
213
+ npm install -g @quolu/aishell@latest
214
+ npm ls -g @quolu/aishell --depth=0
215
+ ```
216
+
217
+ MCPを再接続し、`initialize`のversion、`runtime_status`、事前登録のない対象フォルダの検索と実行を確認する。工場診断は別processを`AISHELL_TOOL_PROFILE=factory`で起動し、`AISHELL_CAPABILITY_SET`を設定せずに確認する。
218
+
204
219
  ## 開発検証
205
220
 
206
221
  ```sh
package/README.md CHANGED
@@ -29,9 +29,7 @@ AIShell owns the OS-facing state below the model: file identity, filesystem reco
29
29
  Requires an Apple Silicon Mac running macOS 15 or later.
30
30
 
31
31
  ```sh
32
- npm install -g @quolu/aishell
33
- aishell-open
34
- codex mcp add aishell --env AISHELL_CAPABILITY_SET=expanded-v1 -- aishell-mcp
32
+ npm install -g @quolu/aishell && aishell-setup
35
33
  ```
36
34
 
37
35
  フォルダの事前登録は不要です。新しいCodex taskで対象フォルダを指定して実行します。
@@ -58,7 +56,7 @@ Set `AISHELL_CAPABILITY_SET=expanded-v1` on the MCP server process to opt in to
58
56
  For Codex, register the expanded surface explicitly:
59
57
 
60
58
  ```sh
61
- codex mcp add aishell --env AISHELL_CAPABILITY_SET=expanded-v1 -- aishell-mcp
59
+ aishell-setup --ai codex
62
60
  ```
63
61
 
64
62
  Unknown or empty `AISHELL_CAPABILITY_SET` and `AISHELL_TOOL_PROFILE` values fail startup with typed errors; they never fall back to another profile.
@@ -99,17 +97,16 @@ flowchart LR
99
97
 
100
98
  ## Install from npm
101
99
 
102
- The global package adds `aishell-mcp` and `aishell-open` to `PATH`. `aishell-open` opens the bundled manager app through LaunchServices. The package runs no install script.
100
+ global packageは`aishell-mcp`、`aishell-open`、`aishell-setup`を`PATH`へ追加する。npm install自体ではスクリプトも管理アプリも起動しない。
103
101
 
104
- Upgrading replaces the app bundle underneath any manager window left open, which breaks that window —
105
- it keeps holding a deleted bundle and silently loses every operation that opens a file panel. The
106
- window detects this itself and says so with a banner. When the new version sits at the same path, the
107
- banner restarts the window into it in one click; otherwise it asks for a quit and a fresh
108
- `aishell-open`. Verified against real `npm install -g` upgrades, not only simulated ones.
102
+ 対象AIのCLI(`claude`、`codex`、`grok`、Cursorの`agent`)を先に導入する。setupは各CLIからの読戻しも確認する。
103
+
104
+ `aishell-setup`は導入済みのClaude Code・Codex・Grok Build・Cursorを検出し、管理アプリ準備、MCP登録、設定の読戻し、実際のMCP操作まで確認する。登録はbare `aishell-mcp`+`AISHELL_CAPABILITY_SET=expanded-v1`。利用者のenv、PATH、他の設定を保持する。`--ai`で対象を指定でき、`--check`は設定やアプリ起動を変更せず診断する。Windows/LinuxとIntel Macは対象外。詳細は[製品単体の導入契約](https://github.com/kitepon/aishell/blob/main/docs/setup.md)を参照。
105
+
106
+ 更新後も同じ`aishell-setup`を実行する。旧管理アプリを正常終了して導入済みのアプリを開き、登録保持・読戻し・MCP実操作まで確認する。接続済みのMCPは、hostで再接続すると新版へ切り替わる。
109
107
 
110
108
  ```sh
111
- npm install -g @quolu/aishell
112
- aishell-open
109
+ npm install -g @quolu/aishell && aishell-setup
113
110
  ```
114
111
 
115
112
  The current experimental build is not yet Developer ID signed or notarized.
@@ -133,9 +130,8 @@ The MCP executable is bundled at `build/AIShell.app/Contents/Helpers/aishell-mcp
133
130
  For a global npm installation, register the executable name from `PATH` and the expanded development surface:
134
131
 
135
132
  ```sh
136
- codex mcp add aishell --env AISHELL_CAPABILITY_SET=expanded-v1 -- aishell-mcp
137
- claude mcp add --scope user aishell --env AISHELL_CAPABILITY_SET=expanded-v1 -- aishell-mcp
138
- codex mcp get aishell
133
+ aishell-setup --ai claude,codex,grok,cursor
134
+ aishell-setup --check
139
135
  ```
140
136
 
141
137
  Remove the registration with:
@@ -153,6 +149,10 @@ AISHELL_CAPABILITY_SET=expanded-v1 AISHELL_TOOL_PROFILE=full /opt/homebrew/bin/a
153
149
 
154
150
  The full profile includes file listing and reads, atomic SHA-256-guarded updates, copy/move/rename/Trash, direct process execution, app discovery and launch, runtime status, and manager activation.
155
151
 
152
+ `apply_change_set`のKeychain認証が必要な場合は、UIを待たず`CHANGE_SET_SECRET_STORE_UNAVAILABLE`を返す。
153
+ 取引開始前の失敗は`error.request_status: aborted_before_side_effect`と空の`changed_paths`で確認できる。
154
+ 既存鍵のアクセス認証を解決してから再実行する。transport timeoutだけを編集中止の証拠にしない。
155
+
156
156
  ## Execution and safety boundaries
157
157
 
158
158
  - AIShell never evaluates a shell command string. It resolves a development program from `PATH` to an executable URL and keeps arguments, environment, and working directory separate.
@@ -167,8 +167,8 @@ The full profile includes file listing and reads, atomic SHA-256-guarded updates
167
167
 
168
168
  ## Current limitations
169
169
 
170
- - The stdio server handles one request at a time.
171
- - MCP cancellation and concurrent run polling are not implemented.
170
+ - stdio requestは復旧操作・読み取り・実行の3系統で処理する。読み取りと復旧操作は長時間の実行中も応答するが、実行系requestは直列化する。
171
+ - MCPの`notifications/cancelled`を受け付ける。管理対象processの明示的な停止は`run_observe`の`cancel`で行う。
172
172
  - A timeout terminates the directly owned process tree, but an allowed worker remains capable of open-world side effects before termination.
173
173
  - Initial workspace entries are a bounded preview; later deltas are cursor-paged.
174
174
  - Developer ID signing and notarization are not yet configured.
@@ -176,14 +176,13 @@ The full profile includes file listing and reads, atomic SHA-256-guarded updates
176
176
  ## Operations, updates, and releases
177
177
 
178
178
  Upgrade a standalone installation through the same official npm path used for
179
- initial installation, then open the newly installed manager:
179
+ initial installation, then run the explicit setup:
180
180
 
181
181
  ```sh
182
- npm install -g @quolu/aishell@latest
183
- aishell-open
182
+ npm install -g @quolu/aishell@latest && aishell-setup
184
183
  ```
185
184
 
186
- `runtime_status` and `runtime_open_manager` are the recovery entrypoints for an
185
+ `runtime_status` and `runtime_open_manager` are the recovery entrypoints for a
187
186
  paused runtime. Factory consumers call `factory_diagnostics`
188
187
  through the dedicated `AISHELL_TOOL_PROFILE=factory` MCP surface; its schema and
189
188
  privacy boundary are owned by [the product contract](https://github.com/kitepon/aishell/blob/main/docs/factory-diagnostics.md).
@@ -196,14 +195,29 @@ npm test
196
195
  npm run test:package
197
196
  git fetch origin
198
197
  npm run verify:release-commit
199
- npm publish --access public
198
+ npm whoami
199
+ npm publish --access public --browser=false
200
200
  ```
201
201
 
202
202
  The release gate rejects a dirty tree or a commit that has not landed on the
203
203
  default branch. After publishing, create the matching GitHub Release, reinstall
204
- `@quolu/aishell@latest`, and smoke MCP initialize plus `factory_diagnostics`.
204
+ `@quolu/aishell@latest`, run `aishell-setup` and `aishell-setup --check`, and separately smoke `factory_diagnostics`.
205
205
  GitHub Releases are the public record of shipped versions.
206
206
 
207
+ ### 公開認証と導入確認
208
+
209
+ `npm whoami`が認証エラーを返した場合は、対話端末で`npm login --registry=https://registry.npmjs.org/ --browser=false`を実行し、表示された新しいURLを利用するブラウザで開いて認証する。公開コマンドも対話端末で実行し、出力をファイルへリダイレクトしない。公開用の認証URLが表示された場合は、ログインとは別に認証する。URLが失効した場合はコマンドを再実行して新しいURLを使う。
210
+
211
+ npmのログインsessionは2時間で失効し、公開時には二要素認証が適用される([npm公式説明](https://github.blog/changelog/2025-12-09-npm-classic-tokens-revoked-session-based-auth-and-cli-token-management-now-available/))。公開の成功後に、registryとグローバルインストールを確認する。
212
+
213
+ ```sh
214
+ npm view @quolu/aishell dist-tags.latest
215
+ npm install -g @quolu/aishell@latest
216
+ npm ls -g @quolu/aishell --depth=0
217
+ ```
218
+
219
+ MCPを再接続し、`initialize`のversion、`runtime_status`、事前登録のない対象フォルダの検索と実行を確認する。工場診断は別processを`AISHELL_TOOL_PROFILE=factory`で起動し、`AISHELL_CAPABILITY_SET`を設定せずに確認する。
220
+
207
221
  ## Development
208
222
 
209
223
  ```sh
@@ -17,9 +17,9 @@
17
17
  <key>CFBundlePackageType</key>
18
18
  <string>APPL</string>
19
19
  <key>CFBundleShortVersionString</key>
20
- <string>0.5.0</string>
20
+ <string>0.6.1</string>
21
21
  <key>CFBundleVersion</key>
22
- <string>20</string>
22
+ <string>22</string>
23
23
  <key>LSApplicationCategoryType</key>
24
24
  <string>public.app-category.utilities</string>
25
25
  <key>LSMinimumSystemVersion</key>
@@ -10,19 +10,19 @@
10
10
  <dict>
11
11
  <key>cdhash</key>
12
12
  <data>
13
- YtRYGFYE+Yh3gPk+NxMYVUyUi6M=
13
+ ePq8//KV5qOWBc2hXCEaZY2mxtg=
14
14
  </data>
15
15
  <key>requirement</key>
16
- <string>cdhash H"62d458185604f9887780f93e371318554c948ba3"</string>
16
+ <string>cdhash H"78fabcfff295e6a39605cda15c211a658da6c6d8"</string>
17
17
  </dict>
18
18
  <key>Helpers/aishell-run-supervisor</key>
19
19
  <dict>
20
20
  <key>cdhash</key>
21
21
  <data>
22
- TcWgD4ePO0zxS53P4eI9iGT41LE=
22
+ Ix16azVuPTYiEaEOHkhvw5NZanA=
23
23
  </data>
24
24
  <key>requirement</key>
25
- <string>cdhash H"4dc5a00f878f3b4cf14b9dcfe1e23d8864f8d4b1"</string>
25
+ <string>cdhash H"231d7a6b356e3d362211a10e1e486fc393596a70"</string>
26
26
  </dict>
27
27
  </dict>
28
28
  <key>rules</key>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quolu/aishell",
3
- "version": "0.5.0",
3
+ "version": "0.6.1",
4
4
  "description": "A native macOS state runtime with high-density MCP tools for AI development",
5
5
  "license": "Apache-2.0",
6
6
  "author": {
@@ -30,17 +30,25 @@
30
30
  ],
31
31
  "bin": {
32
32
  "aishell-mcp": "dist/AIShell.app/Contents/Helpers/aishell-mcp",
33
- "aishell-open": "scripts/aishell-open.mjs"
33
+ "aishell-open": "scripts/aishell-open.mjs",
34
+ "aishell-setup": "scripts/aishell-setup.mjs"
34
35
  },
35
36
  "files": [
36
37
  "dist/AIShell.app",
37
38
  "scripts/aishell-open.mjs",
39
+ "scripts/aishell-setup.mjs",
40
+ "scripts/setup/hosts.mjs",
41
+ "scripts/setup/host-cli.mjs",
42
+ "scripts/setup/setup.mjs",
43
+ "scripts/setup/mcp-client.mjs",
44
+ "scripts/setup/toml-registration.mjs",
38
45
  "README.md"
39
46
  ],
40
47
  "scripts": {
41
48
  "build:npm": "node scripts/prepare-npm-release.mjs",
42
49
  "prepack": "npm run build:npm",
43
- "test": "swift test && npm run test:release-gate && npm run test:repository-contract",
50
+ "test": "swift test && npm run test:setup && npm run test:release-gate && npm run test:repository-contract",
51
+ "test:setup": "node --test scripts/setup/*.test.mjs",
44
52
  "test:release-gate": "node --test scripts/verify-release-commit.test.mjs",
45
53
  "test:repository-contract": "node --test scripts/repository-contract.test.mjs",
46
54
  "test:package": "npm run build:npm && node scripts/verify-npm-package.mjs",
@@ -59,5 +67,9 @@
59
67
  },
60
68
  "publishConfig": {
61
69
  "access": "public"
70
+ },
71
+ "dependencies": {
72
+ "smol-toml": "1.8.0",
73
+ "toml-eslint-parser": "0.10.0"
62
74
  }
63
75
  }
@@ -0,0 +1,24 @@
1
+ #!/usr/bin/env node
2
+ import { setup } from './setup/setup.mjs';
3
+ import { SetupError } from './setup/mcp-client.mjs';
4
+ import { aiNames } from './setup/hosts.mjs';
5
+
6
+ try {
7
+ const args = process.argv.slice(2);
8
+ if (args.length === 1 && ['--help', '-h'].includes(args[0])) {
9
+ console.log('aishell-setup [--ai claude,codex,grok,cursor] [--check]\n初回・再実行・更新後に管理アプリ準備、MCP登録、読戻し、実操作を確認します。\n省略時は導入済みAIを検出。--checkは設定変更と管理アプリ起動を行いません。');
10
+ } else {
11
+ const options = {};
12
+ for (let index = 0; index < args.length; index++) {
13
+ if (args[index] === '--check' && options.check === undefined) options.check = true;
14
+ else if (args[index] === '--ai' && options.ais === undefined) {
15
+ options.ais = (args[++index] ?? '').split(',');
16
+ if (options.ais.some(ai => !aiNames.includes(ai)) || new Set(options.ais).size !== options.ais.length) throw new SetupError('AI_UNSUPPORTED', '--aiにはclaude,codex,grok,cursorを重複なしで指定してください。');
17
+ } else throw new SetupError('ARGUMENT_INVALID', '引数を確認してください。aishell-setup --helpで使い方を表示できます。');
18
+ }
19
+ console.log(JSON.stringify(await setup(options)));
20
+ }
21
+ } catch (error) {
22
+ console.error(JSON.stringify({ ...(error.report ?? { schemaVersion: 'aishell.setup.v1', status: 'failed' }), error: { code: error.code ?? 'SETUP_FAILED', message: error instanceof SetupError ? error.message : 'setup中の入出力処理に失敗しました。設定fileの権限と導入状態を確認してください。' } }));
23
+ process.exitCode = 1;
24
+ }
@@ -0,0 +1,63 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { lstat, readdir } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { backupFiles } from './hosts.mjs';
5
+ import { SetupError } from './mcp-client.mjs';
6
+
7
+ export function runHostCLI(command, args, options) {
8
+ try { return execFileSync(command, args, { ...options, encoding: 'utf8', timeout: 45000, stdio: ['ignore', 'pipe', 'pipe'] }); }
9
+ catch (error) {
10
+ throw new SetupError(error.code === 'ENOENT' ? 'AI_CLI_NOT_FOUND' : 'AI_VERIFICATION_FAILED', `${command}によるMCP確認に失敗しました。AI本体の導入・接続・承認状態を確認してください。`);
11
+ }
12
+ }
13
+
14
+ async function present(file) {
15
+ try {
16
+ const info = await lstat(file);
17
+ if (!info.isFile()) throw new SetupError('CONFIG_PATH_UNSUPPORTED', 'Cursorの設定は通常fileである必要があります。');
18
+ return true;
19
+ } catch (error) { if (error.code === 'ENOENT') return false; throw error; }
20
+ }
21
+
22
+ async function backupCursor(spec, backupDirectory) {
23
+ const files = [];
24
+ const config = path.join(spec.base, 'cli-config.json');
25
+ if (await present(config)) files.push(config);
26
+ const directory = path.join(spec.base, 'projects');
27
+ let entries;
28
+ try { entries = await readdir(directory, { withFileTypes: true }); }
29
+ catch (error) { if (error.code !== 'ENOENT') throw error; entries = []; }
30
+ for (const entry of entries) if (entry.isDirectory()) {
31
+ const file = path.join(directory, entry.name, 'mcp-approvals.json');
32
+ if (await present(file)) files.push(file);
33
+ }
34
+ await backupFiles(files, backupDirectory, 'cursor-approval');
35
+ }
36
+
37
+ export async function verifyHostCLI(spec, registration, { check, env, cwd, backupDirectory, run = runHostCLI }) {
38
+ const options = { env: { ...env, NO_COLOR: '1', TERM: 'dumb' }, cwd };
39
+ let actual;
40
+ if (spec.ai === 'codex') {
41
+ const value = JSON.parse(run('codex', ['mcp', 'get', 'aishell', '--json'], options));
42
+ if (value.enabled !== true || value.transport?.type !== 'stdio') throw new SetupError('AI_REGISTRATION_MISMATCH', 'Codexの実効登録が有効なstdioではありません。');
43
+ actual = value.transport;
44
+ } else if (spec.ai === 'grok') {
45
+ const values = JSON.parse(run('grok', ['mcp', 'list', '--json'], options)).filter(item => item.name === 'aishell');
46
+ if (values.length !== 1 || values[0].enabled !== true) throw new SetupError('AI_REGISTRATION_MISMATCH', 'Grokの実効登録を一意に確認できません。');
47
+ actual = values[0];
48
+ } else if (spec.ai === 'claude') {
49
+ const output = run('claude', ['mcp', 'get', 'aishell'], options);
50
+ if (!/^\s*Command: aishell-mcp\s*$/m.test(output) || !output.includes('expanded-v1') || !/Status:.*Connected/.test(output)) throw new SetupError('AI_REGISTRATION_MISMATCH', 'Claudeの実効登録またはMCP接続が一致しません。');
51
+ } else {
52
+ if (!check) {
53
+ await backupCursor(spec, backupDirectory);
54
+ run('agent', ['mcp', 'enable', 'aishell'], options);
55
+ } else if (!await present(path.join(spec.base, 'cli-config.json'))) {
56
+ throw new SetupError('AI_VERIFICATION_FAILED', 'Cursor CLIが未準備です。aishell-setupを実行してください。');
57
+ }
58
+ const output = run('agent', ['mcp', 'list-tools', 'aishell'], options);
59
+ if (!['runtime_status', 'workspace_snapshot', 'apply_change_set'].every(name => output.includes(name))) throw new SetupError('AI_REGISTRATION_MISMATCH', 'Cursorからexpanded-v1のtoolを確認できません。');
60
+ }
61
+ if (actual && (actual.command !== registration.command || (actual.args ?? []).length !== 0 || actual.env?.AISHELL_CAPABILITY_SET !== 'expanded-v1')) throw new SetupError('AI_REGISTRATION_MISMATCH', `${spec.ai}の実効登録が製品登録と一致しません。project設定の上書きも確認してください。`);
62
+ return { hostVerified: true };
63
+ }
@@ -0,0 +1,115 @@
1
+ import { readFile, lstat, mkdir, writeFile, rename, rm } from 'node:fs/promises';
2
+ import { execFileSync } from 'node:child_process';
3
+ import { randomUUID } from 'node:crypto';
4
+ import path from 'node:path';
5
+ import { isDeepStrictEqual } from 'node:util';
6
+ import { parse } from 'smol-toml';
7
+ import { replaceTOMLRegistration, replaceTopLevelValue } from './toml-registration.mjs';
8
+ import { SetupError } from './mcp-client.mjs';
9
+
10
+ export const aiNames = ['claude', 'codex', 'grok', 'cursor'];
11
+ export function hostSpec(ai, home, env) {
12
+ if (ai === 'cursor' && env.CURSOR_HOME && path.resolve(env.CURSOR_HOME) !== path.join(home, '.cursor')) {
13
+ throw new SetupError('AI_CONFIG_LOCATION_UNSUPPORTED', 'CursorはCURSOR_HOMEを読みません。通常の~/.cursorを使う環境でsetupを実行してください。');
14
+ }
15
+ const bases = {
16
+ claude: env.CLAUDE_CONFIG_DIR || path.join(home, '.claude'),
17
+ codex: env.CODEX_HOME || path.join(home, '.codex'),
18
+ grok: env.GROK_HOME || path.join(home, '.grok'),
19
+ cursor: path.join(home, '.cursor'),
20
+ };
21
+ if (!aiNames.includes(ai)) throw new SetupError('AI_UNSUPPORTED', `未対応のAIです: ${ai}`);
22
+ const base = path.resolve(bases[ai]);
23
+ return {
24
+ ai, base,
25
+ file: ai === 'claude' ? (env.CLAUDE_CONFIG_DIR ? path.join(base, '.claude.json') : path.join(home, '.claude.json'))
26
+ : path.join(base, ai === 'cursor' ? 'mcp.json' : 'config.toml'),
27
+ format: ['codex', 'grok'].includes(ai) ? 'toml' : 'json',
28
+ key: ['codex', 'grok'].includes(ai) ? 'mcp_servers' : 'mcpServers',
29
+ };
30
+ }
31
+
32
+ function object(value, label) {
33
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new SetupError('CONFIG_INVALID', `${label}はobjectである必要があります。`);
34
+ return value;
35
+ }
36
+
37
+ export function canonicalRegistration(current = {}, ai) {
38
+ object(current, 'AIShell設定');
39
+ const next = { ...current, command: 'aishell-mcp', args: [], env: { ...object(current.env ?? {}, 'AIShell env'), AISHELL_CAPABILITY_SET: 'expanded-v1' } };
40
+ if (Object.values(next.env).some(value => typeof value !== 'string')) throw new SetupError('CONFIG_INVALID', 'AIShell envの値は文字列である必要があります。');
41
+ // transportの旧値はAIShellが所有する。利用者のtimeout・許可tool等は保持する。
42
+ for (const key of ['url', 'http_headers', 'env_http_headers', 'bearer_token_env_var', 'headers']) delete next[key];
43
+ if (ai === 'claude' || ai === 'cursor') next.type = 'stdio';
44
+ else { delete next.type; next.enabled = true; }
45
+ if ('enabled' in next) next.enabled = true;
46
+ if ('disabled' in next) next.disabled = false;
47
+ return next;
48
+ }
49
+
50
+ export function decode(text, spec) {
51
+ try { return object(text.trim() ? (spec.format === 'toml' ? parse(text) : JSON.parse(text)) : {}, 'AI設定'); }
52
+ catch (error) {
53
+ if (error instanceof SetupError) throw error;
54
+ throw new SetupError('CONFIG_INVALID', `${spec.ai}の設定を解析できません。`);
55
+ }
56
+ }
57
+
58
+ export async function readHost(spec) {
59
+ let info;
60
+ try { info = await lstat(spec.file); }
61
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
62
+ if (info && !info.isFile()) throw new SetupError('CONFIG_PATH_UNSUPPORTED', `${spec.ai}の設定は通常fileである必要があります。`);
63
+ const text = info ? await readFile(spec.file, 'utf8') : '';
64
+ const data = decode(text, spec);
65
+ const servers = object(data[spec.key] ?? {}, `${spec.ai} MCP設定`);
66
+ return { text, data, info, registration: servers.aishell };
67
+ }
68
+
69
+ export async function planHost(spec) {
70
+ const before = await readHost(spec);
71
+ const registration = canonicalRegistration(before.registration, spec.ai);
72
+ const data = { ...before.data, [spec.key]: { ...before.data[spec.key], aishell: registration } };
73
+ if (spec.ai === 'grok' && data.disabled_mcp_servers !== undefined) {
74
+ if (!Array.isArray(data.disabled_mcp_servers) || data.disabled_mcp_servers.some(name => typeof name !== 'string')) throw new SetupError('CONFIG_INVALID', 'Grokのdisabled_mcp_serversは文字列配列である必要があります。');
75
+ data.disabled_mcp_servers = data.disabled_mcp_servers.filter(name => name !== 'aishell');
76
+ }
77
+ const changed = !isDeepStrictEqual(before.data, data);
78
+ let text;
79
+ try { text = changed ? (spec.format === 'toml' ? replaceTOMLRegistration(before.text, registration) : JSON.stringify(data, null, 2) + '\n') : before.text; }
80
+ catch { throw new SetupError('CONFIG_INVALID', `${spec.ai}の設定形式を保持して更新できません。`); }
81
+ if (!isDeepStrictEqual(before.data.disabled_mcp_servers, data.disabled_mcp_servers)) text = replaceTopLevelValue(text, 'disabled_mcp_servers', data.disabled_mcp_servers);
82
+ // serializeで他の設定値が変わる形式は、書込み前に明示拒否する。
83
+ if (!isDeepStrictEqual(decode(text, spec), data)) throw new SetupError('CONFIG_SERIALIZATION_LOSS', `${spec.ai}の設定値を保持できません。`);
84
+ return { spec, before, text, data, registration, changed };
85
+ }
86
+
87
+ export async function backupFiles(files, backupDirectory, label) {
88
+ if (!files.length) return;
89
+ await mkdir(backupDirectory, { recursive: true, mode: 0o700 });
90
+ // 設定の原本をtarへ保存してから更新する。秘密を含み得るため作成時から0600に限定する。
91
+ const archive = path.join(backupDirectory, `${label}-${randomUUID()}.tar`);
92
+ await writeFile(archive, '', { flag: 'wx', mode: 0o600 });
93
+ execFileSync('/usr/bin/tar', ['-cf', archive, '-C', '/', ...files.map(file => file.slice(1))], { stdio: 'pipe' });
94
+ }
95
+
96
+ export async function writeHost(plan, backupDirectory) {
97
+ if (!plan.changed) return;
98
+ const { spec, before, text } = plan;
99
+ if (before.info) await backupFiles([spec.file], backupDirectory, spec.ai);
100
+ await mkdir(path.dirname(spec.file), { recursive: true, mode: 0o700 });
101
+ const temporary = path.join(path.dirname(spec.file), `.aishell-${randomUUID()}.tmp`);
102
+ try {
103
+ await writeFile(temporary, text, { flag: 'wx', mode: before.info ? before.info.mode & 0o777 : 0o600 });
104
+ const current = await readHost(spec);
105
+ if (current.text !== before.text || Boolean(current.info) !== Boolean(before.info)) throw new SetupError('CONFIG_CHANGED', `${spec.ai}の設定が同時に更新されました。再実行してください。`);
106
+ await rename(temporary, spec.file);
107
+ } finally { await rm(temporary, { force: true }); }
108
+ }
109
+
110
+ export async function verifyHost(plan) {
111
+ const actual = await readHost(plan.spec);
112
+ if (!isDeepStrictEqual(actual.registration, plan.registration)) throw new SetupError('REGISTRATION_MISMATCH', `${plan.spec.ai}の登録読戻しが一致しません。`);
113
+ if (!isDeepStrictEqual(actual.data, plan.data)) throw new SetupError('CONFIG_CHANGED', `${plan.spec.ai}の他設定が変更されました。`);
114
+ return actual.registration;
115
+ }
@@ -0,0 +1,64 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { createInterface } from 'node:readline';
3
+
4
+ export class SetupError extends Error {
5
+ constructor(code, message) { super(message); this.code = code; }
6
+ }
7
+
8
+ // 読戻した登録をそのまま起動する。env値と子process出力は診断へ転載しない。
9
+ export async function withMCP(registration, operation, { cwd, env = process.env, timeout = 30000 } = {}) {
10
+ const child = spawn(registration.command, registration.args ?? [], {
11
+ cwd, env: { ...env, ...registration.env }, stdio: ['pipe', 'pipe', 'pipe'],
12
+ });
13
+ const pending = new Map();
14
+ let sequence = 0;
15
+ let failure;
16
+ const fail = (error) => {
17
+ failure = error;
18
+ for (const item of pending.values()) item.reject(error);
19
+ pending.clear();
20
+ };
21
+ child.on('error', () => fail(new SetupError('MCP_START_FAILED', '登録されたMCPを起動できません。PATHと導入状態を確認してください。')));
22
+ child.stdin.on('error', () => fail(new SetupError('MCP_CLOSED', 'MCPの入力が閉じました。')));
23
+ child.stderr.resume();
24
+ const closed = new Promise(resolve => child.once('close', resolve));
25
+ child.once('close', () => fail(new SetupError('MCP_CLOSED', 'MCPが応答完了前に終了しました。')));
26
+ const lines = createInterface({ input: child.stdout });
27
+ lines.on('line', line => {
28
+ let response;
29
+ try { response = JSON.parse(line); }
30
+ catch { fail(new SetupError('MCP_PROTOCOL_INVALID', 'MCPがJSON以外を返しました。')); return; }
31
+ const item = pending.get(response.id);
32
+ if (!item) return;
33
+ pending.delete(response.id);
34
+ if (response.error || response.result?.isError) item.reject(new SetupError('MCP_OPERATION_FAILED', `${item.method}が失敗しました。`));
35
+ else item.resolve(response.result);
36
+ });
37
+ const timer = setTimeout(() => {
38
+ fail(new SetupError('MCP_TIMEOUT', 'MCP応答が制限時間を超えました。'));
39
+ child.kill('SIGTERM');
40
+ }, timeout);
41
+ const request = (method, params) => new Promise((resolve, reject) => {
42
+ if (failure) { reject(failure); return; }
43
+ const id = ++sequence;
44
+ pending.set(id, { resolve, reject, method });
45
+ child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n');
46
+ });
47
+ try {
48
+ const initialized = await request('initialize', {
49
+ protocolVersion: '2025-11-25', capabilities: {},
50
+ clientInfo: { name: 'aishell-setup', version: '1' },
51
+ });
52
+ if (initialized?.protocolVersion !== '2025-11-25') throw new SetupError('MCP_PROTOCOL_INVALID', 'MCP protocol versionが一致しません。');
53
+ child.stdin.write(JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }) + '\n');
54
+ return await operation({ initialized, request, call: (name, args = {}) => request('tools/call', { name, arguments: args }) });
55
+ } finally {
56
+ clearTimeout(timer);
57
+ child.stdin.end();
58
+ const killTimer = setTimeout(() => child.kill('SIGKILL'), 1000);
59
+ child.kill('SIGTERM');
60
+ await closed;
61
+ clearTimeout(killTimer);
62
+ lines.close();
63
+ }
64
+ }
@@ -0,0 +1,104 @@
1
+ import { access, mkdtemp, rm, writeFile } from 'node:fs/promises';
2
+ import { constants } from 'node:fs';
3
+ import { execFileSync } from 'node:child_process';
4
+ import { homedir, tmpdir } from 'node:os';
5
+ import path from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { aiNames, hostSpec, planHost, verifyHost, writeHost } from './hosts.mjs';
8
+ import { SetupError, withMCP } from './mcp-client.mjs';
9
+ import { verifyHostCLI } from './host-cli.mjs';
10
+
11
+ export const packageDirectory = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
12
+
13
+ export function supportedPlatform(platform, arch, macVersion) {
14
+ if (platform !== 'darwin' || arch !== 'arm64' || !/^\d+(\.\d+)*$/.test(macVersion ?? '') || Number(macVersion.split('.')[0]) < 15) {
15
+ throw new SetupError('PLATFORM_UNSUPPORTED', '対応環境はmacOS 15以降のApple Silicon(arm64)だけです。設定・processは作成しません。');
16
+ }
17
+ }
18
+
19
+ async function exists(file, mode) {
20
+ try { await access(file, mode); return true; }
21
+ catch (error) { if (['ENOENT', 'EACCES'].includes(error.code)) return false; throw error; }
22
+ }
23
+
24
+ async function installed(spec, env) {
25
+ if (await exists(spec.base) || await exists(spec.file)) return true;
26
+ const commands = spec.ai === 'cursor' ? ['cursor', 'agent'] : [spec.ai];
27
+ for (const directory of (env.PATH ?? '').split(path.delimiter)) {
28
+ if (!directory) continue;
29
+ for (const command of commands) if (await exists(path.join(directory, command), constants.X_OK)) return true;
30
+ }
31
+ return false;
32
+ }
33
+
34
+ export function prepareManager() {
35
+ const binary = path.join(packageDirectory, 'dist/AIShell.app/Contents/Helpers/aishell-mcp');
36
+ let value;
37
+ try { value = JSON.parse(execFileSync(binary, ['--prepare-manager'], { encoding: 'utf8', timeout: 30000, stdio: ['ignore', 'pipe', 'pipe'] })); }
38
+ catch { throw new SetupError('MANAGER_PREPARATION_FAILED', '管理アプリの準備に失敗しました。aishell-openで状態を確認してください。'); }
39
+ if (!Number.isInteger(value.processIdentifier) || value.processIdentifier <= 0) throw new SetupError('MANAGER_PREPARATION_FAILED', '管理アプリの実行確認が返りませんでした。');
40
+ return { ready: true, processIdentifier: value.processIdentifier };
41
+ }
42
+
43
+ export async function smoke(registration, expectedVersion, { env = process.env } = {}) {
44
+ const directory = await mkdtemp(path.join(tmpdir(), 'aishell-setup-smoke-'));
45
+ try {
46
+ await writeFile(path.join(directory, 'probe.txt'), 'AIShell setup smoke\n');
47
+ return await withMCP(registration, async ({ initialized, request, call }) => {
48
+ if (initialized?.serverInfo?.version !== expectedVersion) throw new SetupError('MCP_VERSION_MISMATCH', '登録先のMCP版が導入済みpackageと一致しません。PATHを確認してください。');
49
+ const list = await request('tools/list', {});
50
+ if (!list?.tools?.some(tool => tool.name === 'workspace_snapshot') || !list.tools.some(tool => tool.name === 'apply_change_set')) throw new SetupError('MCP_CAPABILITY_MISMATCH', 'expanded-v1の開発toolを利用できません。AISHELL_TOOL_PROFILEなどのenvを確認してください。');
51
+ const status = (await call('runtime_status')).structuredContent;
52
+ if (status?.isPaused !== false) throw new SetupError('RUNTIME_NOT_READY', 'AIShellが停止中か、状態を確認できません。管理アプリで再開してから再実行してください。');
53
+ const snapshot = (await call('workspace_snapshot', { path: directory, context_budget: 0, entry_limit: 10, project_profile: { mode: 'none' } })).structuredContent;
54
+ if (snapshot?.freshness !== 'fresh' || !snapshot.entries?.some(entry => entry.path === 'probe.txt' || entry.relativePath === 'probe.txt' || entry.path === path.join(directory, 'probe.txt'))) throw new SetupError('MCP_SMOKE_FAILED', '未登録フォルダの実fileをMCPで確認できません。');
55
+ return { version: initialized.serverInfo.version, toolCount: list.tools.length, operation: 'workspace_snapshot', ready: true };
56
+ }, { cwd: directory, env });
57
+ } finally { await rm(directory, { recursive: true, force: true }); }
58
+ }
59
+
60
+ export async function setup(options = {}, dependencies = {}) {
61
+ const env = dependencies.env ?? process.env;
62
+ const platform = dependencies.platform ?? process.platform;
63
+ const arch = dependencies.arch ?? process.arch;
64
+ // 対応外OSではmacOS commandを含めて一切の準備処理を実行しない。
65
+ const macVersion = platform === 'darwin' ? (dependencies.macVersion ?? execFileSync('/usr/bin/sw_vers', ['-productVersion'], { encoding: 'utf8' }).trim()) : null;
66
+ supportedPlatform(platform, arch, macVersion);
67
+ const home = dependencies.home ?? homedir();
68
+ const chosen = options.ais ? options.ais.map(ai => hostSpec(ai, home, env)) : [];
69
+ if (!options.ais) for (const ai of aiNames) {
70
+ const spec = hostSpec(ai, home, { ...env, CURSOR_HOME: undefined });
71
+ if (await installed(spec, env)) chosen.push(hostSpec(ai, home, env));
72
+ }
73
+ if (!chosen.length) throw new SetupError('AI_NOT_FOUND', '対応AIを検出できません。対象を--ai claude,codex,grok,cursorで指定してください。');
74
+ const report = { schemaVersion: 'aishell.setup.v1', mode: options.check ? 'diagnose' : 'setup', platform: { os: platform, arch, version: macVersion }, manager: null, hosts: [], skipped: aiNames.filter(ai => !chosen.some(spec => spec.ai === ai)) };
75
+ const version = dependencies.version ?? JSON.parse(await (await import('node:fs/promises')).readFile(path.join(packageDirectory, 'package.json'), 'utf8')).version;
76
+ const backupDirectory = path.join(home, 'Library/Application Support/AIShell/setup-backups');
77
+ let stage = 'preflight';
78
+ try {
79
+ // 全対象を先に解析する。壊れた設定があるままappや別AIを更新しない。
80
+ const plans = [];
81
+ for (const spec of chosen) plans.push(await planHost(spec));
82
+ if (!options.check) {
83
+ stage = 'prepare';
84
+ report.manager = await (dependencies.prepare ?? prepareManager)();
85
+ }
86
+ for (const plan of plans) {
87
+ stage = `register:${plan.spec.ai}`;
88
+ if (!options.check) await (dependencies.write ?? writeHost)(plan, backupDirectory);
89
+ else if (plan.changed) throw new SetupError('REGISTRATION_MISMATCH', `${plan.spec.ai}のAIShell登録が正規契約と一致しません。setupを実行してください。`);
90
+ const registration = await verifyHost(plan);
91
+ const host = { ai: plan.spec.ai, registration: options.check ? 'verified' : plan.changed ? 'updated' : 'unchanged', ready: false };
92
+ report.hosts.push(host);
93
+ stage = `host:${plan.spec.ai}`;
94
+ Object.assign(host, await (dependencies.verifyCLI ?? verifyHostCLI)(plan.spec, registration, { check: options.check, env, cwd: dependencies.cwd ?? process.cwd(), backupDirectory }));
95
+ stage = `smoke:${plan.spec.ai}`;
96
+ const result = await (dependencies.smoke ?? smoke)(registration, version, { env });
97
+ Object.assign(host, result);
98
+ }
99
+ return { ...report, status: 'ready' };
100
+ } catch (error) {
101
+ error.report = { ...report, status: 'failed', stage };
102
+ throw error;
103
+ }
104
+ }
@@ -0,0 +1,53 @@
1
+ import { parseForESLint } from 'toml-eslint-parser';
2
+ import { stringify } from 'smol-toml';
3
+
4
+ const target = ['mcp_servers', 'aishell'];
5
+ const prefix = (left, right) => left.every((key, index) => right[index] === key);
6
+ const keyPath = node => node.keys.map(key => key.type === 'TOMLBare' ? key.name : key.value);
7
+
8
+ export function replaceTopLevelValue(text, key, value) {
9
+ const { ast } = parseForESLint(text);
10
+ const node = ast.body[0].body.find(node => node.type === 'TOMLKeyValue' && keyPath(node.key).length === 1 && keyPath(node.key)[0] === key);
11
+ if (!node) throw new Error('トップレベルの設定値を特定できません。');
12
+ return text.slice(0, node.value.range[0]) + inline(value) + text.slice(node.value.range[1]);
13
+ }
14
+
15
+ function inline(value) {
16
+ if (Array.isArray(value)) return `[${value.map(inline).join(', ')}]`;
17
+ if (value && typeof value === 'object' && !(value instanceof Date)) return `{ ${Object.entries(value).map(([key, item]) => `${JSON.stringify(key)} = ${inline(item)}`).join(', ')} }`;
18
+ return stringify({ value }).slice('value = '.length).trim();
19
+ }
20
+
21
+ // 構文木の範囲だけを変更し、別server、コメント、float/date表記をそのまま残す。
22
+ // 複数行文字列にある偽のtable headerを正規表現で誤認しない。
23
+ export function replaceTOMLRegistration(text, registration) {
24
+ const { ast } = parseForESLint(text);
25
+ const edits = [];
26
+ let replaced = false;
27
+ function visitKeyValue(node, base) {
28
+ const keys = [...base, ...keyPath(node.key)];
29
+ if (keys.length === target.length && prefix(target, keys)) {
30
+ edits.push({ range: node.value.range, text: inline(registration) });
31
+ replaced = true;
32
+ } else if (prefix(target, keys)) {
33
+ edits.push({ range: node.range, text: '' });
34
+ } else if (prefix(keys, target) && node.value.type === 'TOMLInlineTable') {
35
+ for (const child of node.value.body) visitKeyValue(child, keys);
36
+ if (!replaced) {
37
+ const at = node.value.range[1] - 1;
38
+ edits.push({ range: [at, at], text: `${node.value.body.length ? ', ' : ''}${JSON.stringify(target.at(-1))} = ${inline(registration)}` });
39
+ replaced = true;
40
+ }
41
+ }
42
+ }
43
+ for (const node of ast.body[0].body) {
44
+ if (node.type === 'TOMLTable') {
45
+ if (prefix(target, node.resolvedKey)) edits.push({ range: node.range, text: '' });
46
+ else for (const child of node.body) visitKeyValue(child, node.resolvedKey);
47
+ } else visitKeyValue(node, []);
48
+ }
49
+ let result = text;
50
+ for (const edit of edits.sort((a, b) => b.range[0] - a.range[0])) result = result.slice(0, edit.range[0]) + edit.text + result.slice(edit.range[1]);
51
+ if (!replaced) result += `\n${stringify({ mcp_servers: { aishell: registration } })}`;
52
+ return result;
53
+ }