llm-wiki-kit 0.2.4 → 0.2.6

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
@@ -16,6 +16,20 @@ llm-wiki doctor --workspace /apps
16
16
 
17
17
  Restart Claude Code and Codex sessions after installation.
18
18
 
19
+ ### Native Windows
20
+
21
+ Native Windows is supported through the npm-generated `llm-wiki.cmd` shim. Use PowerShell or Windows Terminal with Node.js 20+:
22
+
23
+ ```powershell
24
+ npm install -g llm-wiki-kit@latest
25
+ llm-wiki install --workspace C:\path\to\project --profile standard
26
+ llm-wiki doctor --workspace C:\path\to\project
27
+ ```
28
+
29
+ On Windows, `llm-wiki install` does not create a Unix-style `~/.local/bin` symlink. It verifies the npm shim on `PATH`, installs Codex hooks with `commandWindows`, and writes Claude Code hooks with a Windows-safe `node.exe <bin>` command. Restart Codex and Claude Code after installation.
30
+
31
+ Use WSL2 instead when your repository and tooling already live in Linux. Native Windows support is for Windows-hosted projects and the native Codex/Claude Code surfaces.
32
+
19
33
  The default install mode is npm global install. On servers where the global npm prefix is root-owned, use sudo:
20
34
 
21
35
  ```bash
@@ -121,9 +135,11 @@ Installed npm runtimes also perform a cached update notice check from hooks whil
121
135
 
122
136
  For `llm-wiki-kit` code releases, source tests are not enough. After code changes, publish the package, install the newly published version, and verify the installed CLI and hooks with `version`, `status`, `doctor`, `update`, `lint`, and hook smoke checks before calling the release complete.
123
137
 
138
+ Native Windows changes require a real Windows smoke before release. The release gate is: install the packed candidate on a Windows host, run `llm-wiki install`, `llm-wiki status`, and `llm-wiki doctor` against a temporary Windows project, inspect `%USERPROFILE%\.codex\hooks.json` and `%USERPROFILE%\.claude\settings.json`, then repeat the minimal smoke after `npm install -g llm-wiki-kit@latest` once published. Simulated unit tests are not enough for the Windows support claim.
139
+
124
140
  After a plain `npm install -g llm-wiki-kit@latest`, existing hooks keep working when they already point at the global npm package path. The next `SessionStart`/`InstructionsLoaded` hook automatically reapplies safe managed template updates for the active project root. Clearly generated older `llm-wiki/AGENTS.md` and procedure files are refreshed even when old state is missing; user-edited files are not overwritten and are surfaced to the active agent as cleanup work. If hooks point at a source checkout or stale shim, run `llm-wiki post-update --workspace <project>` or `llm-wiki install --workspace <project>` once to reconnect them.
125
141
 
126
- `llm-wiki install` no longer creates a user-local `~/.local/bin/llm-wiki` shim when an npm/nvm global `llm-wiki` command already resolves to the current runtime. If an older kit-managed local shim is shadowing that npm command, install backs it up and removes it.
142
+ On Linux/macOS, `llm-wiki install` no longer creates a user-local `~/.local/bin/llm-wiki` shim when an npm/nvm global `llm-wiki` command already resolves to the current runtime. If an older kit-managed local shim is shadowing that npm command, install backs it up and removes it. On Windows, the npm-generated `llm-wiki.cmd` shim is the supported command entrypoint and no local symlink is created.
127
143
 
128
144
  On PCs that use nvm or user-local npm, prefer the non-sudo global install and make sure the `llm-wiki` command resolves to that npm package:
129
145
 
package/docs/manual.md CHANGED
@@ -120,6 +120,14 @@ llm-wiki install --workspace /path/to/project --profile standard
120
120
  llm-wiki install --workspace /path/to/project --profile standard --no-project
121
121
  ```
122
122
 
123
+ Native Windows에서는 PowerShell/Windows Terminal에서 npm shim을 사용한다.
124
+
125
+ ```powershell
126
+ npm install -g llm-wiki-kit@latest
127
+ llm-wiki install --workspace C:\path\to\project --profile standard
128
+ llm-wiki doctor --workspace C:\path\to\project
129
+ ```
130
+
123
131
  `standard` profile은 기본 profile이다.
124
132
 
125
133
  - context injection
@@ -130,7 +138,7 @@ llm-wiki install --workspace /path/to/project --profile standard --no-project
130
138
 
131
139
  `--no-project`는 hook/bin 설치만 하고 현재 workspace bootstrap은 하지 않을 때 사용한다.
132
140
 
133
- global npm package 설치 자체는 환경에 따라 `npm install -g` 또는 `sudo npm install -g`로 수행한다. hook 설정 갱신은 보통 일반 사용자 home 아래 파일을 수정하므로 `llm-wiki install`은 해당 user로 실행한다.
141
+ global npm package 설치 자체는 환경에 따라 `npm install -g` 또는 `sudo npm install -g`로 수행한다. hook 설정 갱신은 보통 일반 사용자 home 아래 파일을 수정하므로 `llm-wiki install`은 해당 user로 실행한다. Windows에서는 npm이 만든 `llm-wiki.cmd`를 표준 command shim으로 사용하며 Unix-style `~/.local/bin` symlink를 만들지 않는다. Codex hook에는 Windows 실행용 `commandWindows`가 함께 기록된다.
134
142
 
135
143
  ### `llm-wiki update`
136
144
 
@@ -416,6 +424,18 @@ node --test
416
424
  npm pack --dry-run
417
425
  ```
418
426
 
427
+ Native Windows 관련 변경은 publish 전 실제 Windows 설치 검증이 필수다. 후보 tarball을 Windows host에 설치하고 임시 project에서 다음을 확인한다.
428
+
429
+ ```powershell
430
+ npm install -g .\llm-wiki-kit-<version>.tgz
431
+ llm-wiki version
432
+ llm-wiki install --workspace C:\Temp\llm-wiki-kit-smoke --profile standard
433
+ llm-wiki status --workspace C:\Temp\llm-wiki-kit-smoke
434
+ llm-wiki doctor --workspace C:\Temp\llm-wiki-kit-smoke
435
+ ```
436
+
437
+ 검증에는 `%USERPROFILE%\.codex\hooks.json`의 `commandWindows`, `%USERPROFILE%\.claude\settings.json`의 Windows-safe `node.exe` command, project-local `llm-wiki/` 생성, sample hook roundtrip이 포함된다. 원격 자동화는 WinRM을 사용하되 credential은 환경변수나 secret store로만 전달하고 wiki/log에 저장하지 않는다.
438
+
419
439
  publish 후 검증:
420
440
 
421
441
  ```bash
@@ -431,6 +451,8 @@ llm-wiki update --workspace /apps --current-only
431
451
 
432
452
  `llm-wiki-kit` 코드 변경 릴리스는 publish와 새 published version 설치 후 검증까지 끝나야 완료로 본다. root-owned global npm prefix에서는 package install에 `sudo npm install -g`가 필요할 수 있고, user home hook 갱신은 일반 user로 `llm-wiki install` 또는 `post-update`를 실행한다.
433
453
 
454
+ Windows 지원 변경이 포함된 릴리스는 publish 후에도 같은 Windows host에서 `npm install -g llm-wiki-kit@latest` 후 최소 `version/status/doctor` smoke를 반복한다.
455
+
434
456
  ## Troubleshooting Shortcuts
435
457
 
436
458
  hook이 안 돈다면:
@@ -449,6 +471,14 @@ npm root -g
449
471
  node "$(npm root -g)/llm-wiki-kit/bin/llm-wiki.js" version
450
472
  ```
451
473
 
474
+ Windows에서는 `where llm-wiki`와 npm prefix를 먼저 확인한다.
475
+
476
+ ```powershell
477
+ where llm-wiki
478
+ npm root -g
479
+ node "$(npm root -g)\llm-wiki-kit\bin\llm-wiki.js" version
480
+ ```
481
+
452
482
  local shim이 npm global command를 shadowing하면:
453
483
 
454
484
  ```bash
@@ -24,6 +24,14 @@ npm install -g llm-wiki-kit
24
24
  llm-wiki install --workspace /apps --profile standard
25
25
  ```
26
26
 
27
+ Native Windows:
28
+
29
+ ```powershell
30
+ npm install -g llm-wiki-kit@latest
31
+ llm-wiki install --workspace C:\path\to\project --profile standard
32
+ llm-wiki doctor --workspace C:\path\to\project
33
+ ```
34
+
27
35
  The default install mode is npm global install. On servers where the global npm prefix is root-owned, use sudo:
28
36
 
29
37
  ```bash
@@ -42,8 +50,9 @@ Avoid mixing root-owned and user-local installs unless you intentionally choose
42
50
  The installer:
43
51
 
44
52
  - uses an npm/nvm global `llm-wiki` command when it already resolves to the current runtime
45
- - removes an older kit-managed `~/.local/bin/llm-wiki` shim when it shadows that npm/nvm command
46
- - creates a user-local shim only when no `PATH` command points at the current runtime, such as source checkout development or user-local fallback installs
53
+ - on Linux/macOS, removes an older kit-managed `~/.local/bin/llm-wiki` shim when it shadows that npm/nvm command
54
+ - on Linux/macOS, creates a user-local shim only when no `PATH` command points at the current runtime, such as source checkout development or user-local fallback installs
55
+ - on Windows, relies on the npm-generated `llm-wiki.cmd` shim and does not create a Unix-style local symlink
47
56
  - backs up existing Codex/Claude settings before editing
48
57
  - merges hook entries without removing existing hooks
49
58
  - bootstraps the workspace `llm-wiki/`
@@ -58,6 +67,13 @@ npm install
58
67
  ./install.sh --workspace /apps --profile standard
59
68
  ```
60
69
 
70
+ On Windows source checkouts, run the CLI through Node instead of `install.sh`:
71
+
72
+ ```powershell
73
+ npm install
74
+ node bin\llm-wiki.js install --workspace C:\path\to\project --profile standard
75
+ ```
76
+
61
77
  Pre-publish server smoke tests can use a local tarball:
62
78
 
63
79
  ```bash
@@ -211,6 +227,25 @@ llm-wiki version
211
227
 
212
228
  Do not delete the whole `~/.local/bin` directory. It may contain unrelated user tools.
213
229
 
230
+ ## Updating Native Windows Installs
231
+
232
+ Windows uses npm's command shim. If `llm-wiki` is missing or old after install, inspect the Windows command resolution:
233
+
234
+ ```powershell
235
+ where llm-wiki
236
+ npm root -g
237
+ node "$(npm root -g)\llm-wiki-kit\bin\llm-wiki.js" version
238
+ llm-wiki status --workspace C:\path\to\project
239
+ ```
240
+
241
+ If `where llm-wiki` does not show the npm shim, reinstall the package in the active Node installation:
242
+
243
+ ```powershell
244
+ npm uninstall -g llm-wiki-kit
245
+ npm install -g llm-wiki-kit@latest --registry=https://registry.npmjs.org/ --prefer-online
246
+ llm-wiki install --workspace C:\path\to\project --profile standard
247
+ ```
248
+
214
249
  Use package-name-only uninstall syntax:
215
250
 
216
251
  ```bash
@@ -242,6 +277,18 @@ npm pack --dry-run
242
277
 
243
278
  Check that the tarball includes `bin/`, `src/`, `docs/`, `examples/`, `README.md`, `LICENSE`, `install.sh`, and `package.json`, but does not include project-local `llm-wiki/` contents.
244
279
 
280
+ For Native Windows changes, a real Windows smoke is mandatory before publishing. Install the candidate tarball on a Windows host and verify:
281
+
282
+ ```powershell
283
+ npm install -g .\llm-wiki-kit-<version>.tgz
284
+ llm-wiki version
285
+ llm-wiki install --workspace C:\Temp\llm-wiki-kit-smoke --profile standard
286
+ llm-wiki status --workspace C:\Temp\llm-wiki-kit-smoke
287
+ llm-wiki doctor --workspace C:\Temp\llm-wiki-kit-smoke
288
+ ```
289
+
290
+ Inspect `%USERPROFILE%\.codex\hooks.json` for `commandWindows`, `%USERPROFILE%\.claude\settings.json` for a Windows-safe `node.exe` hook command, and the temporary project for the generated `llm-wiki/` tree. If this is automated from Linux, use WinRM and keep credentials only in environment variables or a secret store.
291
+
245
292
  After publishing:
246
293
 
247
294
  ```bash
@@ -253,6 +300,8 @@ llm-wiki doctor --workspace /apps
253
300
  llm-wiki update --check --workspace /apps
254
301
  ```
255
302
 
303
+ For Native Windows changes, repeat a minimal post-publish smoke on the same Windows host after `npm install -g llm-wiki-kit@latest`.
304
+
256
305
  ## Uninstall
257
306
 
258
307
  ```bash
@@ -123,6 +123,15 @@ npm root -g
123
123
  node "$(npm root -g)/llm-wiki-kit/bin/llm-wiki.js" version
124
124
  ```
125
125
 
126
+ On Native Windows, use PowerShell:
127
+
128
+ ```powershell
129
+ where llm-wiki
130
+ npm ls -g llm-wiki-kit --depth=0
131
+ npm root -g
132
+ node "$(npm root -g)\llm-wiki-kit\bin\llm-wiki.js" version
133
+ ```
134
+
126
135
  If the direct `node "$(npm root -g)/llm-wiki-kit/bin/llm-wiki.js" version` command prints the latest version but plain `llm-wiki` is old, reconnect through the installer:
127
136
 
128
137
  ```bash
@@ -135,7 +144,7 @@ After a manual `sudo npm install -g llm-wiki-kit@latest`, a normal-user `llm-wik
135
144
 
136
145
  If `readlink -f "$(command -v llm-wiki)"` points at a repository checkout such as `/mnt/d/dev_proj/llm-wiki-kit/bin/llm-wiki.js`, `npm install -g` is not updating that checkout. Either switch the shim to the npm package as above, or update the checkout itself with `git pull` and run it intentionally as source.
137
146
 
138
- Running `llm-wiki post-update --workspace /path/to/project` or `llm-wiki install --workspace /path/to/project --profile standard` also reconnects hook entries to the current runtime. `install` uses an npm/nvm global command when it already resolves to the current runtime, removes an older kit-managed local shim when it shadows that command, and creates a local shim only when no `PATH` command points at the current runtime.
147
+ Running `llm-wiki post-update --workspace /path/to/project` or `llm-wiki install --workspace /path/to/project --profile standard` also reconnects hook entries to the current runtime. On Linux/macOS, `install` uses an npm/nvm global command when it already resolves to the current runtime, removes an older kit-managed local shim when it shadows that command, and creates a local shim only when no `PATH` command points at the current runtime. On Windows, `install` relies on npm's `llm-wiki.cmd` shim and does not create a Unix-style symlink.
139
148
 
140
149
  If `which -a llm-wiki` shows both `~/.local/bin/llm-wiki` and an nvm path such as `~/.nvm/versions/node/v20.20.2/bin/llm-wiki`, the `~/.local/bin` entry usually wins because it appears earlier on `PATH`. Let install handle managed shims first:
141
150
 
@@ -159,6 +168,16 @@ llm-wiki version
159
168
 
160
169
  Use `npm uninstall -g llm-wiki-kit` with the package name only. `npm uninstall -g llm-wiki-kit@latest` is not the correct uninstall syntax and does not remove source checkouts or manually-created command shims.
161
170
 
171
+ If `where llm-wiki` is empty on Windows after npm install, the active Node npm prefix is not on `PATH` or npm did not create the shim. Reinstall with the active Node installation, then restart the terminal:
172
+
173
+ ```powershell
174
+ npm uninstall -g llm-wiki-kit
175
+ npm install -g llm-wiki-kit@latest --registry=https://registry.npmjs.org/ --prefer-online
176
+ where llm-wiki
177
+ llm-wiki install --workspace C:\path\to\project --profile standard
178
+ llm-wiki doctor --workspace C:\path\to\project
179
+ ```
180
+
162
181
  ## npm install -g Fails With EACCES
163
182
 
164
183
  If npm tries to write under `/usr` and fails with `EACCES`, use sudo when the server policy allows system-wide global packages:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "llm-wiki-kit",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
4
4
  "description": "Hook-first living LLM Wiki runtime for Codex and Claude Code.",
5
5
  "type": "module",
6
6
  "files": [
package/src/cli.js CHANGED
@@ -131,7 +131,7 @@ Usage:
131
131
  `- workspace: ${value.workspace}`,
132
132
  `- runtime bin: ${value.binPath}`,
133
133
  `- command: ${value.commandPath || 'not found on PATH'}`,
134
- `- local shim: ${value.localBin} (${value.localBinAction})`,
134
+ `- command shim: ${value.localBin || value.commandPath || 'not found'} (${value.localBinAction})`,
135
135
  `- changed hooks: ${value.changed.length ? value.changed.join(', ') : 'none (already installed)'}`,
136
136
  'Restart Codex/Claude Code sessions so new hooks are loaded.',
137
137
  ].join('\n'));
@@ -281,11 +281,12 @@ function formatStatus(value) {
281
281
  return [
282
282
  'llm-wiki-kit status',
283
283
  `- version: ${value.runtimeVersion}`,
284
+ `- platform: ${value.platform || process.platform}`,
284
285
  `- runtime: ${value.runtimeVersion} (${value.installSource})`,
285
286
  `- bin: ${value.binPath}`,
286
287
  `- command: ${value.commandPath || 'not found'}`,
287
288
  `- command matches runtime: ${value.commandMatchesRuntime ? 'yes' : 'no'}`,
288
- `- local shim: ${value.localBin?.path || 'unknown'} (${value.localBin?.exists ? (value.localBin.managed ? 'managed' : 'unmanaged') : 'absent'}${value.localBin?.matchesRuntime ? ', current' : ''})`,
289
+ `- command shim: ${value.localBin?.path || 'unknown'} (${value.localBin?.exists ? (value.localBin.managed ? 'managed' : 'unmanaged') : 'absent'}${value.localBin?.matchesRuntime ? ', current' : ''})`,
289
290
  `- hooks current: ${value.hooksCurrent ? 'yes' : 'no'}`,
290
291
  `- codex hook: ${value.codexInstalled ? 'current' : 'missing/outdated'}`,
291
292
  `- claude hook: ${value.claudeInstalled ? 'current' : 'missing/outdated'}`,
package/src/doctor.js CHANGED
@@ -1,9 +1,11 @@
1
1
  import { spawnSync } from 'child_process';
2
- import { join } from 'path';
2
+ import { dirname, join } from 'path';
3
3
  import { mkdtemp } from 'fs/promises';
4
4
  import { tmpdir } from 'os';
5
+ import { fileURLToPath } from 'url';
5
6
  import { exists, kitDataDir } from './fs-utils.js';
6
7
  import { status } from './install.js';
8
+ import { isWindows } from './platform.js';
7
9
 
8
10
  function nodeMajor() {
9
11
  return Number.parseInt(process.versions.node.split('.')[0], 10);
@@ -28,8 +30,8 @@ export async function runDoctor(options = {}) {
28
30
  `version=${stat.claudeVersion || 'unknown'}; unsupported=${(stat.claudeUnsupportedKitEvents || []).join(', ') || 'none'}`
29
31
  );
30
32
  add('project-templates', 'project templates current', stat.project.managedFilesCurrent, stat.project.statePath);
31
- add('codex-command', 'codex command available', spawnSync('codex', ['--version'], { encoding: 'utf8' }).status === 0, 'codex --version');
32
- add('claude-command', 'claude command available', spawnSync('claude', ['--version'], { encoding: 'utf8' }).status === 0, 'claude --version');
33
+ add('codex-command', 'codex command available', commandVersionAvailable('codex'), 'codex --version');
34
+ add('claude-command', 'claude command available', commandVersionAvailable('claude'), 'claude --version');
33
35
  add('state-writable', 'state directory writable', await canWrite(join(kitDataDir(), '.doctor')), kitDataDir());
34
36
  add('docs', 'docs present', await docsPresent(), 'README.md and docs/');
35
37
  add('sample-hook', 'sample hook roundtrip', await sampleHookRoundtrip(stat.binPath), 'UserPromptSubmit fixture');
@@ -38,8 +40,17 @@ export async function runDoctor(options = {}) {
38
40
  return { ok: allOk, checks, workspace: stat.workspace, status: stat };
39
41
  }
40
42
 
43
+ function commandVersionAvailable(command) {
44
+ const result = spawnSync(command, ['--version'], {
45
+ encoding: 'utf8',
46
+ shell: isWindows(),
47
+ timeout: 10000,
48
+ });
49
+ return result.status === 0;
50
+ }
51
+
41
52
  async function docsPresent() {
42
- const root = new URL('..', import.meta.url).pathname;
53
+ const root = dirname(dirname(fileURLToPath(import.meta.url)));
43
54
  return (await exists(join(root, 'README.md'))) &&
44
55
  (await exists(join(root, 'docs', 'concepts.md'))) &&
45
56
  (await exists(join(root, 'docs', 'security.md')));
@@ -105,17 +116,22 @@ function failed(result, id) {
105
116
 
106
117
  function doctorRemediation(result) {
107
118
  const workspace = result.workspace || process.cwd();
119
+ const windows = isWindows({ platform: result.status?.platform });
108
120
  const suggestions = [];
109
121
  if (failed(result, 'install-source')) {
110
122
  suggestions.push(`normal install: npm install -g llm-wiki-kit@latest && llm-wiki install --workspace ${workspace} --profile standard`);
111
- suggestions.push(`source checkout development: ./install.sh --workspace ${workspace} --profile standard`);
123
+ suggestions.push(windows
124
+ ? `source checkout development: node bin\\llm-wiki.js install --workspace ${workspace} --profile standard`
125
+ : `source checkout development: ./install.sh --workspace ${workspace} --profile standard`);
112
126
  }
113
127
  if (failed(result, 'command-path')) {
114
128
  const local = result.status?.localBin;
115
- if (result.status?.installSource === 'npm' && local?.exists && local.managed) {
129
+ if (!windows && result.status?.installSource === 'npm' && local?.exists && local.managed) {
116
130
  suggestions.push(`remove stale managed local shim if it still shadows npm: rm -f ${local.path} && hash -r`);
117
131
  }
118
- suggestions.push(`reconnect command and hooks: llm-wiki install --workspace ${workspace} --profile standard`);
132
+ suggestions.push(windows
133
+ ? `reinstall npm shim and hooks: npm install -g llm-wiki-kit@latest && llm-wiki install --workspace ${workspace} --profile standard`
134
+ : `reconnect command and hooks: llm-wiki install --workspace ${workspace} --profile standard`);
119
135
  }
120
136
  if (failed(result, 'codex-hook') || failed(result, 'claude-hook') || failed(result, 'claude-settings-compatible')) {
121
137
  suggestions.push(`install hooks: llm-wiki install --workspace ${workspace} --profile standard, then restart Codex/Claude Code sessions`);
package/src/fs-utils.js CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  } from 'fs/promises';
15
15
  import { basename, dirname, join, parse, resolve } from 'path';
16
16
  import { PROJECT_MARKERS } from './constants.js';
17
+ import { cacheHomeRelative, dataHomeRelative, isWindows } from './platform.js';
17
18
  import { normalizeForStorage } from './redaction.js';
18
19
 
19
20
  export function homeDir() {
@@ -21,13 +22,23 @@ export function homeDir() {
21
22
  }
22
23
 
23
24
  export function dataHome() {
25
+ if (isWindows() && process.env.LOCALAPPDATA) return process.env.LOCALAPPDATA;
24
26
  return process.env.XDG_DATA_HOME || join(homeDir(), '.local', 'share');
25
27
  }
26
28
 
27
29
  export function cacheHome() {
30
+ if (isWindows() && process.env.LOCALAPPDATA) return join(process.env.LOCALAPPDATA, 'llm-wiki-kit', 'cache');
28
31
  return process.env.XDG_CACHE_HOME || join(homeDir(), '.cache');
29
32
  }
30
33
 
34
+ export function defaultDataHome(options = {}) {
35
+ return join(homeDir(), dataHomeRelative(options));
36
+ }
37
+
38
+ export function defaultCacheHome(options = {}) {
39
+ return join(homeDir(), cacheHomeRelative(options));
40
+ }
41
+
31
42
  export function kitDataDir() {
32
43
  return join(dataHome(), 'llm-wiki-kit');
33
44
  }
package/src/install.js CHANGED
@@ -1,50 +1,26 @@
1
- import { realpathSync } from 'fs';
2
1
  import { chmod, lstat, readlink, unlink } from 'fs/promises';
3
- import { spawnSync } from 'child_process';
4
2
  import { join, resolve } from 'path';
5
3
  import { CODEX_EVENTS, KIT_NAME } from './constants.js';
6
4
  import { detectClaudeVersion, supportedClaudeEvents } from './claude-compat.js';
7
5
  import { backupFile, exists, homeDir, readJson, safeSymlink, writeJson } from './fs-utils.js';
8
6
  import { maintenanceSummary } from './maintenance.js';
7
+ import {
8
+ commandForNodeScript,
9
+ commandMatchesRuntime,
10
+ commandPaths as findCommandPaths,
11
+ inspectCommandPath,
12
+ isWindows,
13
+ realpathOrOriginal,
14
+ samePath,
15
+ sameResolvedPath,
16
+ } from './platform.js';
9
17
  import { inspectProjectState } from './project-state.js';
10
18
  import { bootstrapProject } from './project.js';
11
19
  import { recordProject } from './projects.js';
12
20
  import { binPath, detectInstallSource, packageRoot, runtimeVersion } from './version.js';
13
21
 
14
- function shellQuote(value) {
15
- return `"${String(value).replace(/(["\\$`])/g, '\\$1')}"`;
16
- }
17
-
18
- export function hookCommand(provider, eventName) {
19
- return `${shellQuote(process.execPath)} ${shellQuote(binPath)} hook ${provider} ${eventName}`;
20
- }
21
-
22
- function llmWikiCommandPaths() {
23
- const result = spawnSync('sh', ['-lc', 'which -a llm-wiki 2>/dev/null || true'], {
24
- encoding: 'utf8',
25
- });
26
- if (result.error) return [];
27
- return [...new Set(result.stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean))];
28
- }
29
-
30
- function realpathOrOriginal(path) {
31
- if (!path) return null;
32
- try {
33
- return realpathSync(path);
34
- } catch {
35
- return path;
36
- }
37
- }
38
-
39
- function sameResolvedPath(left, right) {
40
- const resolvedLeft = realpathOrOriginal(left);
41
- const resolvedRight = realpathOrOriginal(right);
42
- return Boolean(resolvedLeft && resolvedRight && resolvedLeft === resolvedRight);
43
- }
44
-
45
- function samePath(left, right) {
46
- if (!left || !right) return false;
47
- return resolve(left) === resolve(right);
22
+ export function hookCommand(provider, eventName, options = {}) {
23
+ return commandForNodeScript(binPath, ['hook', provider, eventName], options);
48
24
  }
49
25
 
50
26
  function isKitPath(path) {
@@ -52,8 +28,12 @@ function isKitPath(path) {
52
28
  }
53
29
 
54
30
  function isKitHookEntry(entry) {
55
- const serialized = JSON.stringify(entry || {});
56
- return serialized.includes(KIT_NAME) || serialized.includes(binPath) || serialized.includes('/llm-wiki-kit/');
31
+ const serialized = normalizeHookPathText(JSON.stringify(entry || {}));
32
+ return serialized.includes(KIT_NAME) || serialized.includes(normalizeHookPathText(binPath)) || serialized.includes('/llm-wiki-kit/');
33
+ }
34
+
35
+ function normalizeHookPathText(value) {
36
+ return String(value || '').replace(/\\\\/g, '/').replace(/\\/g, '/');
57
37
  }
58
38
 
59
39
  async function inspectLocalBin(localBinPath) {
@@ -85,7 +65,7 @@ async function inspectLocalBin(localBinPath) {
85
65
  }
86
66
 
87
67
  async function reconcileLocalBin(localBinPath) {
88
- const commandPathsBefore = llmWikiCommandPaths();
68
+ const commandPathsBefore = await findCommandPaths('llm-wiki');
89
69
  const localBefore = await inspectLocalBin(localBinPath);
90
70
  const alternateRuntimeCommand = commandPathsBefore.some((path) => (
91
71
  !samePath(path, localBinPath) && sameResolvedPath(path, binPath)
@@ -114,7 +94,7 @@ async function reconcileLocalBin(localBinPath) {
114
94
  await safeSymlink(binPath, localBinPath);
115
95
  }
116
96
 
117
- const commandPathsAfter = llmWikiCommandPaths();
97
+ const commandPathsAfter = await findCommandPaths('llm-wiki');
118
98
  const localAfter = await inspectLocalBin(localBinPath);
119
99
  return {
120
100
  ...localAfter,
@@ -126,6 +106,23 @@ async function reconcileLocalBin(localBinPath) {
126
106
  };
127
107
  }
128
108
 
109
+ async function reconcileWindowsCommand() {
110
+ const commandPathsBefore = await findCommandPaths('llm-wiki', { platform: 'win32' });
111
+ const commandPath = commandPathsBefore[0] || null;
112
+ const command = await inspectCommandPath(commandPath, binPath, {
113
+ platform: 'win32',
114
+ installSource: detectInstallSource(),
115
+ });
116
+ return {
117
+ ...command,
118
+ action: command.matchesRuntime ? 'skipped-windows-npm-shim-available' : 'skipped-windows-no-local-shim',
119
+ alternateRuntimeCommand: command.matchesRuntime,
120
+ commandPathsBefore,
121
+ commandPathsAfter: commandPathsBefore,
122
+ commandPath,
123
+ };
124
+ }
125
+
129
126
  function addHook(hooks, eventName, command, options = {}) {
130
127
  hooks[eventName] = Array.isArray(hooks[eventName]) ? hooks[eventName] : [];
131
128
  const already = hooks[eventName].some((entry) => (
@@ -142,6 +139,7 @@ function addHook(hooks, eventName, command, options = {}) {
142
139
  },
143
140
  ],
144
141
  };
142
+ if (options.commandWindows) entry.hooks[0].commandWindows = options.commandWindows;
145
143
  if (options.matcher) entry.matcher = options.matcher;
146
144
  hooks[eventName].push(entry);
147
145
  return true;
@@ -188,12 +186,23 @@ function unsupportedKitClaudeEvents(hooks, supportedEvents) {
188
186
  .sort();
189
187
  }
190
188
 
189
+ function hookTextIncludes(text, value) {
190
+ return normalizeHookPathText(text).includes(normalizeHookPathText(value));
191
+ }
192
+
193
+ function hookEventIncludesBin(entries) {
194
+ return hookTextIncludes(JSON.stringify(entries || {}), binPath);
195
+ }
196
+
191
197
  export async function install(options = {}) {
192
198
  const workspace = resolve(options.workspace || process.cwd());
193
199
  await chmod(binPath, 0o755).catch(() => {});
194
- const localBin = join(homeDir(), '.local', 'bin');
195
- const localBinPath = join(localBin, 'llm-wiki');
196
- const localBinResult = await reconcileLocalBin(localBinPath);
200
+ const platform = options.platform || process.platform;
201
+ const localBin = isWindows({ platform }) ? null : join(homeDir(), '.local', 'bin');
202
+ const localBinPath = localBin ? join(localBin, 'llm-wiki') : null;
203
+ const localBinResult = isWindows({ platform })
204
+ ? await reconcileWindowsCommand()
205
+ : await reconcileLocalBin(localBinPath);
197
206
  if (!options.noProject) {
198
207
  await bootstrapProject(workspace, { profile: options.profile || 'standard', recordState: true });
199
208
  await recordProject(workspace, 'install');
@@ -213,7 +222,9 @@ export async function install(options = {}) {
213
222
  }
214
223
  for (const eventName of CODEX_EVENTS) {
215
224
  const matcher = eventName === 'SessionStart' ? 'startup|resume|clear' : undefined;
216
- if (addHook(codex.hooks, eventName, hookCommand('codex', eventName), { matcher })) {
225
+ const command = hookCommand('codex', eventName, { platform });
226
+ const commandWindows = isWindows({ platform }) ? hookCommand('codex', eventName, { platform: 'win32' }) : undefined;
227
+ if (addHook(codex.hooks, eventName, command, { matcher, commandWindows })) {
217
228
  codexChanged = true;
218
229
  changed.push(`codex:${eventName}`);
219
230
  }
@@ -241,7 +252,7 @@ export async function install(options = {}) {
241
252
  }
242
253
  for (const eventName of claudeEvents) {
243
254
  const matcher = eventName === 'SessionStart' ? 'startup|resume|clear' : undefined;
244
- if (addHook(claude.hooks, eventName, hookCommand('claude', eventName), { matcher })) {
255
+ if (addHook(claude.hooks, eventName, hookCommand('claude', eventName, { platform }), { matcher })) {
245
256
  claudeChanged = true;
246
257
  changed.push(`claude:${eventName}`);
247
258
  }
@@ -294,31 +305,39 @@ export async function uninstall(options = {}) {
294
305
 
295
306
  export async function status(options = {}) {
296
307
  const workspace = resolve(options.workspace || process.cwd());
308
+ const platform = options.platform || process.platform;
297
309
  const codexHooksPath = join(homeDir(), '.codex', 'hooks.json');
298
310
  const claudeSettingsPath = join(homeDir(), '.claude', 'settings.json');
299
311
  const codex = await readJson(codexHooksPath, {});
300
312
  const claude = await readJson(claudeSettingsPath, {});
301
313
  const claudeDetection = detectClaudeVersion();
302
314
  const claudeEvents = supportedClaudeEvents(claudeDetection);
303
- const claudeMissingEvents = claudeEvents.filter((eventName) => !JSON.stringify(claude.hooks?.[eventName] || []).includes(binPath));
315
+ const claudeMissingEvents = claudeEvents.filter((eventName) => !hookEventIncludesBin(claude.hooks?.[eventName] || []));
304
316
  const claudeUnsupportedKitEvents = unsupportedKitClaudeEvents(claude.hooks || {}, claudeEvents);
305
- const codexInstalled = JSON.stringify(codex.hooks || {}).includes(binPath);
317
+ const codexInstalled = hookEventIncludesBin(codex.hooks || {});
306
318
  const claudeInstalled = claudeMissingEvents.length === 0 && claudeUnsupportedKitEvents.length === 0;
307
- const commandPaths = llmWikiCommandPaths();
308
- const commandPath = commandPaths[0] || null;
319
+ const discoveredCommandPaths = await findCommandPaths('llm-wiki', { platform });
320
+ const commandPath = discoveredCommandPaths[0] || null;
309
321
  const resolvedCommandPath = realpathOrOriginal(commandPath);
310
322
  const resolvedBinPath = realpathOrOriginal(binPath);
311
- const localBinPath = join(homeDir(), '.local', 'bin', 'llm-wiki');
312
- const localBin = await inspectLocalBin(localBinPath);
323
+ const localBinPath = isWindows({ platform }) ? null : join(homeDir(), '.local', 'bin', 'llm-wiki');
324
+ const localBin = localBinPath
325
+ ? await inspectLocalBin(localBinPath)
326
+ : await inspectCommandPath(commandPath, binPath, { platform, installSource: detectInstallSource() });
327
+ const commandMatches = await commandMatchesRuntime(commandPath, binPath, {
328
+ platform,
329
+ installSource: detectInstallSource(),
330
+ });
313
331
  return {
314
332
  workspace,
333
+ platform,
315
334
  runtimeVersion: runtimeVersion(),
316
335
  packageRoot,
317
336
  installSource: detectInstallSource(),
318
337
  binPath,
319
338
  commandPath,
320
- commandPaths,
321
- commandMatchesRuntime: Boolean(resolvedCommandPath && resolvedBinPath && resolvedCommandPath === resolvedBinPath),
339
+ commandPaths: discoveredCommandPaths,
340
+ commandMatchesRuntime: Boolean(commandMatches || (resolvedCommandPath && resolvedBinPath && resolvedCommandPath === resolvedBinPath)),
322
341
  localBin,
323
342
  codexInstalled,
324
343
  claudeInstalled,
@@ -0,0 +1,178 @@
1
+ import { realpathSync } from 'fs';
2
+ import { access, lstat, readFile } from 'fs/promises';
3
+ import { constants as fsConstants } from 'fs';
4
+ import { delimiter, dirname, extname, join, resolve } from 'path';
5
+ import { packageName, packageRoot } from './version.js';
6
+
7
+ export function runtimePlatform(options = {}) {
8
+ return options.platform || process.platform;
9
+ }
10
+
11
+ export function isWindows(options = {}) {
12
+ return runtimePlatform(options) === 'win32';
13
+ }
14
+
15
+ export function pathDelimiter(options = {}) {
16
+ return isWindows(options) ? ';' : delimiter;
17
+ }
18
+
19
+ export function pathEnvValue(env = process.env, options = {}) {
20
+ if (!isWindows(options)) return env.PATH || '';
21
+ const key = Object.keys(env).find((name) => name.toLowerCase() === 'path');
22
+ return key ? env[key] || '' : env.PATH || '';
23
+ }
24
+
25
+ export function dataHomeRelative(options = {}) {
26
+ return isWindows(options)
27
+ ? join('AppData', 'Local')
28
+ : join('.local', 'share');
29
+ }
30
+
31
+ export function cacheHomeRelative(options = {}) {
32
+ return isWindows(options)
33
+ ? join('AppData', 'Local', 'llm-wiki-kit', 'cache')
34
+ : '.cache';
35
+ }
36
+
37
+ export function commandQuote(value, options = {}) {
38
+ return isWindows(options)
39
+ ? `"${String(value).replace(/"/g, '""')}"`
40
+ : `"${String(value).replace(/(["\\$`])/g, '\\$1')}"`;
41
+ }
42
+
43
+ export function commandForNodeScript(scriptPath, args = [], options = {}) {
44
+ if (!isWindows(options)) {
45
+ return [commandQuote(process.execPath, options), commandQuote(scriptPath, options), ...args].join(' ');
46
+ }
47
+ return [process.execPath, scriptPath, ...args]
48
+ .map((part) => commandQuote(part, options))
49
+ .join(' ');
50
+ }
51
+
52
+ export function realpathOrOriginal(path) {
53
+ if (!path) return null;
54
+ try {
55
+ return realpathSync(path);
56
+ } catch {
57
+ return path;
58
+ }
59
+ }
60
+
61
+ export function sameResolvedPath(left, right) {
62
+ const resolvedLeft = realpathOrOriginal(left);
63
+ const resolvedRight = realpathOrOriginal(right);
64
+ return Boolean(resolvedLeft && resolvedRight && resolvedLeft === resolvedRight);
65
+ }
66
+
67
+ export function samePath(left, right) {
68
+ if (!left || !right) return false;
69
+ return resolve(left) === resolve(right);
70
+ }
71
+
72
+ function unique(values) {
73
+ return [...new Set(values.filter(Boolean))];
74
+ }
75
+
76
+ function windowsExtensions(env = process.env) {
77
+ return unique((env.PATHEXT || '.COM;.EXE;.BAT;.CMD;.PS1')
78
+ .split(';')
79
+ .map((part) => part.trim())
80
+ .filter(Boolean)
81
+ .flatMap((part) => [part, part.toLowerCase(), part.toUpperCase()]));
82
+ }
83
+
84
+ function commandCandidates(dir, command, options = {}) {
85
+ const direct = join(dir, command);
86
+ if (!isWindows(options) || extname(command)) return [direct];
87
+ return [direct, ...windowsExtensions(options.env).map((ext) => join(dir, `${command}${ext}`))];
88
+ }
89
+
90
+ async function commandFileExists(path, options = {}) {
91
+ try {
92
+ await access(path, isWindows(options) ? fsConstants.F_OK : fsConstants.X_OK);
93
+ return true;
94
+ } catch {
95
+ return false;
96
+ }
97
+ }
98
+
99
+ export async function commandPaths(command, options = {}) {
100
+ const env = options.env || process.env;
101
+ const pathValue = pathEnvValue(env, options);
102
+ const paths = [];
103
+ for (const dir of pathValue.split(pathDelimiter(options)).filter(Boolean)) {
104
+ for (const candidate of commandCandidates(dir, command, { ...options, env })) {
105
+ if (await commandFileExists(candidate, options)) paths.push(candidate);
106
+ }
107
+ }
108
+ return unique(paths);
109
+ }
110
+
111
+ function normalizePathText(value) {
112
+ return String(value || '').replace(/\\/g, '/').toLowerCase();
113
+ }
114
+
115
+ async function commandShimReferencesRuntime(commandPath, binPath, options = {}) {
116
+ if (!isWindows(options) || !commandPath) return false;
117
+ const extension = extname(commandPath).toLowerCase();
118
+ if (!['', '.cmd', '.bat', '.ps1'].includes(extension)) return false;
119
+ let content = '';
120
+ try {
121
+ content = await readFile(commandPath, 'utf8');
122
+ } catch {
123
+ return false;
124
+ }
125
+ const normalized = normalizePathText(content);
126
+ const normalizedBin = normalizePathText(binPath);
127
+ if (normalized.includes(normalizedBin)) return true;
128
+ if (options.installSource === 'npm') {
129
+ const commandDirPackageRoot = join(dirname(commandPath), 'node_modules', packageName());
130
+ return sameResolvedPath(commandDirPackageRoot, packageRoot) &&
131
+ normalized.includes(`node_modules/${packageName()}/bin/llm-wiki.js`);
132
+ }
133
+ return false;
134
+ }
135
+
136
+ export async function commandMatchesRuntime(commandPath, binPath, options = {}) {
137
+ if (!commandPath || !binPath) return false;
138
+ if (sameResolvedPath(commandPath, binPath)) return true;
139
+ return commandShimReferencesRuntime(commandPath, binPath, options);
140
+ }
141
+
142
+ export async function inspectCommandPath(commandPath, binPath, options = {}) {
143
+ if (!commandPath) {
144
+ return {
145
+ path: null,
146
+ exists: false,
147
+ symlink: false,
148
+ target: null,
149
+ resolved: null,
150
+ managed: false,
151
+ matchesRuntime: false,
152
+ };
153
+ }
154
+ try {
155
+ const stat = await lstat(commandPath);
156
+ const resolved = realpathOrOriginal(commandPath);
157
+ const matchesRuntime = await commandMatchesRuntime(commandPath, binPath, options);
158
+ return {
159
+ path: commandPath,
160
+ exists: true,
161
+ symlink: stat.isSymbolicLink(),
162
+ target: null,
163
+ resolved,
164
+ managed: matchesRuntime,
165
+ matchesRuntime,
166
+ };
167
+ } catch {
168
+ return {
169
+ path: commandPath,
170
+ exists: false,
171
+ symlink: false,
172
+ target: null,
173
+ resolved: null,
174
+ managed: false,
175
+ matchesRuntime: false,
176
+ };
177
+ }
178
+ }
@@ -1,6 +1,7 @@
1
1
  import { spawnSync } from 'child_process';
2
2
  import { join, resolve } from 'path';
3
3
  import { cacheHome, readJson, writeJson } from './fs-utils.js';
4
+ import { isWindows } from './platform.js';
4
5
  import { commandForProject } from './projects.js';
5
6
  import { compareVersions, parseRegistryVersion } from './update.js';
6
7
  import { detectInstallSource, packageName, runtimeVersion } from './version.js';
@@ -62,6 +63,7 @@ function trimDetail(value) {
62
63
  function checkRegistry(target) {
63
64
  const result = spawnSync(npmCommand(), ['view', `${packageName()}@${target}`, 'version'], {
64
65
  encoding: 'utf8',
66
+ shell: isWindows(),
65
67
  timeout: timeoutMs(),
66
68
  });
67
69
  if (result.status !== 0 || result.error) {
package/src/update.js CHANGED
@@ -3,6 +3,7 @@ import { join, resolve } from 'path';
3
3
  import { exists } from './fs-utils.js';
4
4
  import { appendWikiLog } from './project.js';
5
5
  import { install } from './install.js';
6
+ import { isWindows } from './platform.js';
6
7
  import { applyProjectTemplateUpdate, inspectProjectState } from './project-state.js';
7
8
  import { knownProjectRoots, recordProject } from './projects.js';
8
9
  import { binPath, detectInstallSource, packageName, runtimeVersion } from './version.js';
@@ -24,7 +25,8 @@ async function runCommand(command, args, options = {}) {
24
25
  const killGraceMs = options.killGraceMs || 2000;
25
26
  const label = options.label || commandLine(command, args);
26
27
  const startedAt = Date.now();
27
- const detached = process.platform !== 'win32';
28
+ const windows = isWindows(options);
29
+ const detached = !windows;
28
30
  let stdout = '';
29
31
  let stderr = '';
30
32
  let settled = false;
@@ -67,6 +69,7 @@ async function runCommand(command, args, options = {}) {
67
69
  child = spawn(command, args, {
68
70
  detached,
69
71
  env: options.env || process.env,
72
+ shell: windows,
70
73
  stdio: ['ignore', 'pipe', 'pipe'],
71
74
  });
72
75
  } catch (error) {