@ijfw/install 1.2.5 → 1.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,62 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.2.6] -- 2026-05-01
4
+
5
+ **Token sandbox + parallel workflow dispatch + DeepSeek frontier upgrade.** A new `ijfw_run` MCP tool keeps large command output out of your context window entirely -- builds, test suites, grep runs, and log tails are sandboxed to disk and summarized in a few lines instead of flooding thousands of tokens. The `ijfw-workflow` execution engine gains a formal Wave Table that makes parallel agent dispatch deterministic rather than inferred. DeepSeek moves to `deepseek-v4-pro` -- the actual frontier model -- so the Trident gets Frontier AI checking Frontier AI.
6
+
7
+ ### `ijfw_run` -- command output sandbox
8
+
9
+ Large shell commands (builds, test suites, `grep -r`, log tails) routinely produce hundreds or thousands of lines that consume a disproportionate share of the context window. `ijfw_run` solves this at the tool level: run the command via `child_process.spawn` (never `exec` -- no RAM buffer ceiling), stream output to `~/.ijfw/session-sandbox/`, and return a domain-aware summary to context instead of the raw flood.
10
+
11
+ **Domain-aware summarizers** detect output type by pattern and extract only what matters:
12
+ - **Test runner** (Jest/Vitest/pytest/go test/cargo test): pass/fail counts + failing test names only
13
+ - **Build** (tsc/cargo/webpack/vite/rollup): error lines only + exit code
14
+ - **Grep**: match count + top file paths
15
+ - **Log**: ERROR/WARN lines + counts
16
+ - **Raw fallback**: first 15 + last 10 lines + "N lines omitted"
17
+
18
+ Every summary appends the last 10 raw lines as a reliability backstop (catches segfaults, OOM kills, and non-standard failures that heuristics miss), and includes the command, exit code, duration, and a retrieval label. Commands at or under 40 lines / 50 KB return inline with zero overhead -- `ijfw_run` only sandboxes when it pays off.
19
+
20
+ **Retrieval**: full output is indexed to `~/.ijfw/session-sandbox/{label}.txt` with a `.json` metadata sidecar. Retrieve with `ijfw_memory_search({ scope: "sandbox", label: "..." })` or list all current sandbox entries with `ijfw_memory_search({ scope: "sandbox" })`. Sandbox files auto-purge after 24 hours (TTL sweep runs on every `ijfw_run` call).
21
+
22
+ **Security**: all labels are sanitized before becoming filenames; sandbox files are written at mode `0o600` (user-read-only); all SQLite interactions use parameterized queries; ANSI escape codes are stripped before heuristic detection and before content is returned to the LLM context.
23
+
24
+ **Routing rule**: `ijfw-core` SKILL.md now carries the one-line routing rule -- large-output commands → `ijfw_run`; git, navigation, and quick ops → Bash directly.
25
+
26
+ **`ijfw_memory_status` retired** to free the MCP tool slot. The case handler is preserved for backward compatibility; the tool no longer appears in `tools/list`. Status information remains available via `ijfw_memory_prelude`.
27
+
28
+ **New `sanitizeForSandbox()`** in `sanitizer.js`: a sandbox-specific sanitizer that preserves newlines (unlike `sanitizeContent` which collapses to `" | "`), strips ANSI codes, defangs structural markdown elements (`#` headings, fenced code delimiters, `<system>/<prompt>/<assistant>` tags), and truncates lines over 2000 characters. Used for all LLM-facing sandbox output.
29
+
30
+ **`sandbox-nudge.sh` PreToolUse hook**: registered alongside the existing `pre-tool-use.sh`, this advisory hook pattern-matches known large-output command prefixes (`npm test`, `jest`, `vitest`, `pytest`, `cargo build`, `cargo test`, `make`, `gradle`, `mvn`, `go test`, `node --test`, `tsc --`, `webpack`, `vite build`, `rollup`, `grep -r`, `find /`) and emits a one-line nudge. Advisory only -- never blocks.
31
+
32
+ Files: `mcp-server/src/sandbox.js` (new), `mcp-server/src/server.js`, `mcp-server/src/sanitizer.js`, `mcp-server/test-sandbox.js` (new, 32 tests), `mcp-server/test.js` (slot-swap update), `claude/skills/ijfw-core/SKILL.md`, `claude/hooks/hooks.json`, `claude/hooks/scripts/sandbox-nudge.sh` (new).
33
+
34
+ ### Parallel workflow dispatch -- Wave Table
35
+
36
+ The `ijfw-workflow` execution engine had a structural gap: Step 5 (Plan) described dependency relationships in prose, which meant Step 6 (Execute) had to re-infer parallelism at dispatch time. Re-inference defaults to sequential to avoid mistakes. The result: agents that could run concurrently ran one-by-one.
37
+
38
+ **Step 5 now emits a Wave Table** as the first section of `plan.md`:
39
+
40
+ ```
41
+ | Wave | Tasks | Mode | Depends on | Reason |
42
+ |------|-----------|------------|------------|---------------------|
43
+ | W1 | t1, t2, t3 | PARALLEL | -- | independent files |
44
+ | W2 | t4 | SEQUENTIAL | W1 | needs t2 output |
45
+ | W3 | t5, t6 | PARALLEL | W2 | independent of each |
46
+ ```
47
+
48
+ Wave mode is determined by a four-question dependency test before the table is written: (a) shared file writes? (b) one reads what the other writes? (c) output dependency? (d) otherwise → PARALLEL. The Wave Table is the execution contract -- decided once at plan time.
49
+
50
+ **Step 6 reads the Wave Table directly**: PARALLEL waves → all tasks dispatched as Agent tool calls in a single response (they run concurrently); SEQUENTIAL waves → one Agent call, wait for result, advance. If `plan.md` has no Wave Table (legacy plans, quick-mode tasks), Step 6 builds one on the spot using the same four-question test before dispatching anything. The instruction is now unambiguous: parallel waves produce multiple tool calls in one response block, not one-by-one messages.
51
+
52
+ Files: `claude/skills/ijfw-workflow/SKILL.md`.
53
+
54
+ ### DeepSeek Trident auditor upgraded to `deepseek-v4-pro`
55
+
56
+ The 1.2.5 DeepSeek roster entry used `deepseek-v4-flash` as the API model ID -- a model that does not exist on DeepSeek Platform. Calls returned 4xx errors that surfaced as apparent timeouts. The entry is corrected to `deepseek-v4-pro`: DeepSeek's 1.6T-parameter frontier model (49B activated), supporting 1M context and dual thinking/non-thinking modes. `deepseek-chat` and `deepseek-reasoner` -- the previous canonical aliases -- are deprecated aliases for V4-Flash non-thinking and thinking modes respectively, scheduled for removal 2026-07-24. `deepseek-v4-pro` is the correct Trident-grade choice: Frontier AI checking Frontier AI.
57
+
58
+ Files: `mcp-server/src/audit-roster.js`.
59
+
3
60
  ## [1.2.5] -- 2026-04-30
4
61
 
5
62
  **Trident roster opens to the community + actionable auditor errors + Obsidian-friendly memory + audit-cleanup pass.** A one-page contribution playbook plus two new worked examples ship the auditor roster from "what Sean ships" to "what the community can grow." DeepSeek and Kimi land as openai-compat API entries. The 1.2.4 visibility surface gets a translation layer that tells you exactly how to fix a stalled auditor. Memory layer reaffirmed as Obsidian-vault-compatible with a walkthrough. Six surfaces from a full-system Trident audit land alongside as polish. Plus a routine dev-dependency bump.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @ijfw/install
2
2
 
3
- One-command installer for [IJFW](https://github.com/TheRealSeanDonahoe/ijfw) -- the AI
3
+ One-command installer for [IJFW](https://gitlab.com/therealseandonahoe/ijfw) -- the AI
4
4
  efficiency layer for Claude Code, Codex, Gemini, Cursor, Windsurf, Copilot.
5
5
 
6
6
  ## Install
@@ -43,7 +43,7 @@ Requires `node >=18`, `git`, `bash`. On native Windows use the PowerShell
43
43
  installer (PS 5.1+), which shells Git Bash under the hood -- no WSL required:
44
44
 
45
45
  ```powershell
46
- iwr https://raw.githubusercontent.com/TheRealSeanDonahoe/ijfw/main/installer/src/install.ps1 -OutFile install.ps1
46
+ iwr https://gitlab.com/therealseandonahoe/ijfw/-/raw/main/installer/src/install.ps1 -OutFile install.ps1
47
47
  .\install.ps1 -Dir $env:USERPROFILE\.ijfw
48
48
  ```
49
49
 
package/dist/ijfw.js CHANGED
@@ -736,7 +736,8 @@ async function run10(ctx) {
736
736
  const build = spawnSync10("npm", ["run", "build"], {
737
737
  encoding: "utf8",
738
738
  cwd: installerDir,
739
- timeout: 6e4
739
+ timeout: 6e4,
740
+ shell: process.platform === "win32"
740
741
  });
741
742
  if (build.status !== 0) {
742
743
  return {
@@ -750,7 +751,8 @@ async function run10(ctx) {
750
751
  const pack = spawnSync10("npm", ["pack", "--silent"], {
751
752
  encoding: "utf8",
752
753
  cwd: installerDir,
753
- timeout: 3e4
754
+ timeout: 3e4,
755
+ shell: process.platform === "win32"
754
756
  });
755
757
  if (pack.status !== 0) {
756
758
  return {
@@ -783,7 +785,8 @@ async function run10(ctx) {
783
785
  encoding: "utf8",
784
786
  cwd: installDir,
785
787
  timeout: 6e4,
786
- env: { ...process.env, HOME: fakeHome, npm_config_prefix: fakeHome }
788
+ env: { ...process.env, HOME: fakeHome, npm_config_prefix: fakeHome },
789
+ shell: process.platform === "win32"
787
790
  });
788
791
  if (install.status !== 0) {
789
792
  return {
@@ -889,7 +892,8 @@ async function run11(ctx) {
889
892
  const build = spawnSync11("npm", ["run", "build"], {
890
893
  encoding: "utf8",
891
894
  cwd: installerDir,
892
- timeout: 6e4
895
+ timeout: 6e4,
896
+ shell: process.platform === "win32"
893
897
  });
894
898
  if (build.status !== 0) {
895
899
  return {
@@ -903,7 +907,8 @@ async function run11(ctx) {
903
907
  const pack = spawnSync11("npm", ["pack", "--silent"], {
904
908
  encoding: "utf8",
905
909
  cwd: installerDir,
906
- timeout: 3e4
910
+ timeout: 3e4,
911
+ shell: process.platform === "win32"
907
912
  });
908
913
  if (pack.status !== 0) {
909
914
  return {
@@ -929,7 +934,8 @@ async function run11(ctx) {
929
934
  encoding: "utf8",
930
935
  cwd: installDir,
931
936
  timeout: 6e4,
932
- env: { ...process.env, HOME: fakeHome, npm_config_prefix: fakeHome }
937
+ env: { ...process.env, HOME: fakeHome, npm_config_prefix: fakeHome },
938
+ shell: process.platform === "win32"
933
939
  });
934
940
  if (install.status !== 0) {
935
941
  return {
@@ -2584,7 +2590,7 @@ async function main() {
2584
2590
  ];
2585
2591
  const guidePath = candidates.find((p) => existsSync3(p));
2586
2592
  if (!guidePath) {
2587
- console.error("[ijfw] Guide not found. Run `ijfw install` to fetch the full guide, or visit https://github.com/TheRealSeanDonahoe/ijfw/blob/main/docs/GUIDE.md");
2593
+ console.error("[ijfw] Guide not found. Run `ijfw install` to fetch the full guide, or visit https://gitlab.com/therealseandonahoe/ijfw/-/blob/main/docs/GUIDE.md");
2588
2594
  process.exit(1);
2589
2595
  }
2590
2596
  if (wantsBrowser) {
package/dist/install.js CHANGED
@@ -3,17 +3,22 @@
3
3
  // src/install.js
4
4
  import { spawnSync } from "node:child_process";
5
5
  import { existsSync as existsSync2, rmSync, mkdirSync as mkdirSync2, realpathSync, renameSync as renameSync2 } from "node:fs";
6
- import { resolve, join as join2, dirname as dirname2 } from "node:path";
6
+ import { resolve as resolve2, join as join2, dirname as dirname2 } from "node:path";
7
7
  import { homedir as homedir2, platform } from "node:os";
8
8
  import { fileURLToPath } from "node:url";
9
9
 
10
10
  // src/marketplace.js
11
11
  import { readFileSync, writeFileSync, renameSync, existsSync, mkdirSync } from "node:fs";
12
- import { dirname, join } from "node:path";
12
+ import { dirname, join, resolve } from "node:path";
13
13
  import { homedir } from "node:os";
14
14
  function claudeSettingsPath() {
15
15
  return join(homedir(), ".claude", "settings.json");
16
16
  }
17
+ function pluginInstallPath(rootDir) {
18
+ if (rootDir) return resolve(rootDir, "claude");
19
+ if (process.env.IJFW_HOME) return resolve(process.env.IJFW_HOME, "claude");
20
+ return join(homedir(), ".ijfw", "claude");
21
+ }
17
22
  function stripJsoncComments(raw) {
18
23
  if (raw.charCodeAt(0) === 65279) raw = raw.slice(1);
19
24
  let out = "";
@@ -69,7 +74,7 @@ function tolerantJsonParse(raw, filepath) {
69
74
  throw err;
70
75
  }
71
76
  }
72
- function mergeMarketplace(settingsPath = claudeSettingsPath()) {
77
+ function mergeMarketplace(settingsPath = claudeSettingsPath(), options = {}) {
73
78
  let settings = {};
74
79
  if (existsSync(settingsPath)) {
75
80
  const raw = readFileSync(settingsPath, "utf8");
@@ -77,9 +82,10 @@ function mergeMarketplace(settingsPath = claudeSettingsPath()) {
77
82
  } else {
78
83
  mkdirSync(dirname(settingsPath), { recursive: true });
79
84
  }
85
+ const pluginPath = options.pluginPath || pluginInstallPath(options.rootDir);
80
86
  settings.extraKnownMarketplaces = settings.extraKnownMarketplaces || {};
81
87
  settings.extraKnownMarketplaces.ijfw = {
82
- source: { source: "github", repo: "TheRealSeanDonahoe/ijfw" }
88
+ source: { source: "directory", path: pluginPath }
83
89
  };
84
90
  settings.enabledPlugins = settings.enabledPlugins || {};
85
91
  if ("ijfw-core@ijfw" in settings.enabledPlugins) {
@@ -93,7 +99,7 @@ function mergeMarketplace(settingsPath = claudeSettingsPath()) {
93
99
  }
94
100
 
95
101
  // src/install.js
96
- var DEFAULT_REPO = "https://github.com/TheRealSeanDonahoe/ijfw.git";
102
+ var DEFAULT_REPO = "https://gitlab.com/therealseandonahoe/ijfw.git";
97
103
  var DEFAULT_BRANCH = "main";
98
104
  function parseArgs(argv) {
99
105
  const out = { yes: false, dir: null, noMarketplace: false, branch: DEFAULT_BRANCH, branchExplicit: false, purge: false };
@@ -202,8 +208,8 @@ function findBash() {
202
208
  return null;
203
209
  }
204
210
  function resolveTarget(opt) {
205
- if (opt.dir) return resolve(opt.dir);
206
- if (process.env.IJFW_HOME) return resolve(process.env.IJFW_HOME);
211
+ if (opt.dir) return resolve2(opt.dir);
212
+ if (process.env.IJFW_HOME) return resolve2(process.env.IJFW_HOME);
207
213
  return join2(homedir2(), ".ijfw");
208
214
  }
209
215
  function runCheck(cmd, args, opts) {
@@ -253,7 +259,7 @@ function runInstallScript(dir) {
253
259
  const script = join2(dir, "scripts", "install.sh");
254
260
  if (!existsSync2(script)) throw new Error(`IJFW install script not found at ${script} -- re-run the installer to restore it.`);
255
261
  const canonicalDir = join2(homedir2(), ".ijfw");
256
- const isCustomDir = resolve(dir) !== canonicalDir ? "1" : "0";
262
+ const isCustomDir = resolve2(dir) !== canonicalDir ? "1" : "0";
257
263
  const env = {
258
264
  ...process.env,
259
265
  IJFW_NONINTERACTIVE: process.env.CI ? "1" : process.env.IJFW_NONINTERACTIVE ?? "",
@@ -296,7 +302,7 @@ async function main() {
296
302
  console.log(" platform configs applied");
297
303
  if (!opts.noMarketplace) {
298
304
  const settingsPath = claudeSettingsPath();
299
- mergeMarketplace(settingsPath);
305
+ mergeMarketplace(settingsPath, { rootDir: target });
300
306
  console.log(` marketplace registered in ${settingsPath}`);
301
307
  }
302
308
  console.log("");
package/dist/uninstall.js CHANGED
@@ -2,13 +2,13 @@
2
2
 
3
3
  // src/uninstall.js
4
4
  import { existsSync as existsSync2, rmSync, cpSync, mkdtempSync, readFileSync as readFileSync2, writeFileSync as writeFileSync2, readdirSync } from "node:fs";
5
- import { resolve, join as join2 } from "node:path";
5
+ import { resolve as resolve2, join as join2 } from "node:path";
6
6
  import { homedir as homedir2, tmpdir } from "node:os";
7
7
  import { spawnSync } from "node:child_process";
8
8
 
9
9
  // src/marketplace.js
10
10
  import { readFileSync, writeFileSync, renameSync, existsSync, mkdirSync } from "node:fs";
11
- import { dirname, join } from "node:path";
11
+ import { dirname, join, resolve } from "node:path";
12
12
  import { homedir } from "node:os";
13
13
  function claudeSettingsPath() {
14
14
  return join(homedir(), ".claude", "settings.json");
@@ -291,8 +291,8 @@ function cleanPlatforms() {
291
291
  return removed;
292
292
  }
293
293
  function resolveTarget(opt) {
294
- if (opt.dir) return resolve(opt.dir);
295
- if (process.env.IJFW_HOME) return resolve(process.env.IJFW_HOME);
294
+ if (opt.dir) return resolve2(opt.dir);
295
+ if (process.env.IJFW_HOME) return resolve2(process.env.IJFW_HOME);
296
296
  return join2(homedir2(), ".ijfw");
297
297
  }
298
298
  async function main() {
package/docs/GUIDE.md CHANGED
@@ -1,5 +1,5 @@
1
1
  <p align="center">
2
- <img src="https://github.com/TheRealSeanDonahoe/ijfw/releases/download/v1.1.1/ijfw-hero.png" alt="IJFW" width="100%"/>
2
+ <img src="https://gitlab.com/therealseandonahoe/ijfw/releases/download/v1.1.1/ijfw-hero.png" alt="IJFW" width="100%"/>
3
3
  </p>
4
4
 
5
5
  # The IJFW Guide
@@ -503,7 +503,7 @@ cat ~/.ijfw/dashboard.port
503
503
 
504
504
  ### Still stuck
505
505
 
506
- Every install writes a log to `~/.ijfw/install.log`. Every session writes observations to `~/.ijfw/observations.jsonl`. Open an issue at [github.com/TheRealSeanDonahoe/ijfw/issues](https://github.com/TheRealSeanDonahoe/ijfw/issues) with both files redacted and attached.
506
+ Every install writes a log to `~/.ijfw/install.log`. Every session writes observations to `~/.ijfw/observations.jsonl`. Open an issue at [gitlab.com/therealseandonahoe/ijfw/-/issues](https://gitlab.com/therealseandonahoe/ijfw/-/issues) with both files redacted and attached.
507
507
 
508
508
  ---
509
509
 
@@ -546,7 +546,7 @@ Yes. `.ijfw/team/` is git-committed by default. Decisions, patterns, and stack c
546
546
  </p>
547
547
 
548
548
  <p align="center">
549
- <a href="https://github.com/TheRealSeanDonahoe/ijfw">github.com/TheRealSeanDonahoe/ijfw</a>
549
+ <a href="https://gitlab.com/therealseandonahoe/ijfw">gitlab.com/therealseandonahoe/ijfw</a>
550
550
  &nbsp;|&nbsp;
551
551
  <a href="https://www.npmjs.com/package/@ijfw/install">npm</a>
552
552
  &nbsp;|&nbsp;
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ijfw/install",
3
- "version": "1.2.5",
3
+ "version": "1.2.7",
4
4
  "description": "One-command installer for IJFW -- the AI efficiency layer. One install, every AI coding agent, zero config.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -46,13 +46,13 @@
46
46
  ],
47
47
  "license": "MIT",
48
48
  "author": "Sean Donahoe",
49
- "homepage": "https://github.com/TheRealSeanDonahoe/ijfw",
49
+ "homepage": "https://gitlab.com/therealseandonahoe/ijfw",
50
50
  "bugs": {
51
- "url": "https://github.com/TheRealSeanDonahoe/ijfw/issues"
51
+ "url": "https://gitlab.com/therealseandonahoe/ijfw/-/issues"
52
52
  },
53
53
  "repository": {
54
54
  "type": "git",
55
- "url": "git+https://github.com/TheRealSeanDonahoe/ijfw.git"
55
+ "url": "git+https://gitlab.com/therealseandonahoe/ijfw.git"
56
56
  },
57
57
  "publishConfig": {
58
58
  "access": "public",
package/src/install.ps1 CHANGED
@@ -6,7 +6,7 @@
6
6
  # -> merge marketplace into %USERPROFILE%\.claude\settings.json -> summary.
7
7
  #
8
8
  # Usage:
9
- # Invoke-Expression (iwr https://raw.githubusercontent.com/TheRealSeanDonahoe/ijfw/main/installer/src/install.ps1).Content
9
+ # Invoke-Expression (iwr https://gitlab.com/therealseandonahoe/ijfw/-/raw/main/installer/src/install.ps1).Content
10
10
  # or:
11
11
  # .\install.ps1 -Dir C:\Users\me\.ijfw -Branch main
12
12
 
@@ -19,7 +19,7 @@ param(
19
19
  )
20
20
 
21
21
  $ErrorActionPreference = "Stop"
22
- $DEFAULT_REPO = "https://github.com/TheRealSeanDonahoe/ijfw.git"
22
+ $DEFAULT_REPO = "https://gitlab.com/therealseandonahoe/ijfw.git"
23
23
 
24
24
  function Write-Ok($msg) { Write-Host " [ok] $msg" -ForegroundColor Green }
25
25
  function Write-Info($msg) { Write-Host " ... $msg" -ForegroundColor Gray }
@@ -208,10 +208,172 @@ function ConvertFrom-Jsonc($raw) {
208
208
  return ($intermediate -replace ',(\s*[}\]])','$1')
209
209
  }
210
210
 
211
+ function Remove-StalePosixLaunchers {
212
+ # Pre-1.2.7 installs (and any install run from WSL) wrote POSIX bash
213
+ # launchers into ~/.local/bin/ even on Windows. Windows can't execute
214
+ # files without an extension, so PATH lookup finds them ahead of npm's
215
+ # ijfw.cmd shim and Windows hands them to Notepad as plain text.
216
+ #
217
+ # On every install, sweep ~/.local/bin/ for the five known POSIX
218
+ # launchers. Defensive: only remove files whose first line is a shebang
219
+ # ("#!") so we never delete user content or Windows binaries that
220
+ # happened to share a name.
221
+ $localBin = Join-Path $env:USERPROFILE ".local\bin"
222
+ if (-not (Test-Path $localBin)) { return }
223
+
224
+ $launchers = @("ijfw", "ijfw-dashboard", "ijfw-dispatch-plan", "ijfw-memorize", "ijfw-memory")
225
+ $removed = 0
226
+ foreach ($name in $launchers) {
227
+ $path = Join-Path $localBin $name
228
+ if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { continue }
229
+ try {
230
+ $first = (Get-Content -LiteralPath $path -TotalCount 1 -ErrorAction Stop)
231
+ if ($first -and $first.StartsWith("#!")) {
232
+ Remove-Item -LiteralPath $path -Force -ErrorAction Stop
233
+ $removed++
234
+ }
235
+ } catch {
236
+ # Best-effort: keep going on locked / inaccessible files.
237
+ }
238
+ }
239
+ if ($removed -gt 0) {
240
+ Write-Ok "Cleared $removed stale POSIX launcher$(if ($removed -ne 1) { 's' }) from $localBin (npm shim now wins PATH)"
241
+ }
242
+ }
243
+
244
+ function Provision-Plugin {
245
+ param(
246
+ [string]$Src, # Source dir inside repo (relative to $IjfwHome), e.g. "wayland\plugins\ijfw"
247
+ [string]$Dst, # Absolute destination path, e.g. "$env:USERPROFILE\.wayland\plugins\ijfw"
248
+ [string]$IjfwHome
249
+ )
250
+ $srcPath = Join-Path $IjfwHome $Src
251
+ if (-not (Test-Path $srcPath)) {
252
+ Write-Info "Plugin source not found at $srcPath -- skipping."
253
+ return
254
+ }
255
+ New-Item -ItemType Directory -Force -Path $Dst | Out-Null
256
+ Copy-Item -Recurse -Force -Path (Join-Path $srcPath "*") -Destination $Dst
257
+ }
258
+
259
+ function Merge-PluginsEnabled {
260
+ param(
261
+ [string]$ConfigPath # Full path to ~/.hermes/config.yaml
262
+ )
263
+ $pluginName = "ijfw"
264
+ $configDir = Split-Path -Parent $ConfigPath
265
+
266
+ # Ensure the directory exists.
267
+ if (-not (Test-Path $configDir)) { New-Item -ItemType Directory -Force -Path $configDir | Out-Null }
268
+
269
+ # Backup before modifying (mirrors Merge-Marketplace pattern).
270
+ if (Test-Path $ConfigPath) {
271
+ $ts = Get-Date -Format 'yyyyMMdd-HHmmss'
272
+ $backup = "$ConfigPath.bak.plugins.$ts"
273
+ Copy-Item -LiteralPath $ConfigPath -Destination $backup -Force
274
+ }
275
+
276
+ # Read existing YAML or start from empty string.
277
+ $content = if (Test-Path $ConfigPath) { Get-Content -Raw -LiteralPath $ConfigPath } else { "" }
278
+ if ($null -eq $content) { $content = "" }
279
+
280
+ # Strategy: try python3 first (has real YAML support); fall back to sentinel-anchored
281
+ # regex that appends to a plugins.enabled: block or creates one.
282
+
283
+ $python = Get-Command python3 -ErrorAction SilentlyContinue
284
+ if ($python) {
285
+ $pyScript = @"
286
+ import sys, re
287
+ path = sys.argv[1]
288
+ name = sys.argv[2]
289
+ try:
290
+ with open(path, 'r', encoding='utf-8') as f:
291
+ text = f.read()
292
+ except FileNotFoundError:
293
+ text = ''
294
+
295
+ # Find or create plugins.enabled block.
296
+ block_re = re.compile(r'(?m)^(plugins\s*:\s*\n(?:[ \t]+\S[^\n]*\n)*[ \t]+enabled\s*:\s*\[)([^\]]*)\]')
297
+ inline_re = re.compile(r'(?m)^([ \t]+enabled\s*:\s*\[)([^\]]*)\]')
298
+ plugins_section_re = re.compile(r'(?m)^plugins\s*:', re.MULTILINE)
299
+
300
+ def ensure_name(lst_str, name):
301
+ items = [x.strip().strip('"').strip("'") for x in lst_str.split(',') if x.strip()]
302
+ if name in items:
303
+ return lst_str
304
+ items.append(name)
305
+ return ', '.join(repr(i) for i in items)
306
+
307
+ m = block_re.search(text)
308
+ if m:
309
+ new_list = ensure_name(m.group(2), name)
310
+ text = text[:m.start(2)] + new_list + text[m.end(2):]
311
+ else:
312
+ m2 = inline_re.search(text)
313
+ if m2:
314
+ new_list = ensure_name(m2.group(2), name)
315
+ text = text[:m2.start(2)] + new_list + text[m2.end(2):]
316
+ elif plugins_section_re.search(text):
317
+ # plugins: exists but no enabled key -- append it.
318
+ text = plugins_section_re.sub(lambda mo: mo.group(0) + '\n enabled: [' + repr(name) + ']', text, count=1)
319
+ else:
320
+ # No plugins section at all.
321
+ if text and not text.endswith('\n'):
322
+ text += '\n'
323
+ text += 'plugins:\n enabled: [' + repr(name) + ']\n'
324
+
325
+ with open(path, 'w', encoding='utf-8') as f:
326
+ f.write(text)
327
+ sys.exit(0)
328
+ "@
329
+ $tmp = [System.IO.Path]::GetTempFileName() + ".py"
330
+ [System.IO.File]::WriteAllText($tmp, $pyScript)
331
+ try {
332
+ & python3 $tmp $ConfigPath $pluginName
333
+ $ok = ($LASTEXITCODE -eq 0)
334
+ } finally {
335
+ Remove-Item -LiteralPath $tmp -ErrorAction SilentlyContinue
336
+ }
337
+ if ($ok) { return $true }
338
+ }
339
+
340
+ # Fallback: pure PowerShell sentinel-anchored regex approach.
341
+ # If the file has a plugins.enabled: [...] line, splice in the name.
342
+ # Otherwise append a plugins.enabled block.
343
+ $enabledRe = [regex]'(?m)^([ \t]+enabled\s*:\s*\[)([^\]]*)\]'
344
+ $pluginsSectionRe = [regex]'(?m)^plugins\s*:'
345
+
346
+ $m = $enabledRe.Match($content)
347
+ if ($m.Success) {
348
+ $lst = $m.Groups[2].Value
349
+ $items = $lst -split ',' | ForEach-Object { $_.Trim().Trim('"').Trim("'") } | Where-Object { $_ -ne '' }
350
+ if ($pluginName -notin $items) {
351
+ $items += $pluginName
352
+ $newLst = ($items | ForEach-Object { '"' + $_ + '"' }) -join ', '
353
+ $content = $content.Substring(0, $m.Groups[2].Index) + $newLst + $content.Substring($m.Groups[2].Index + $m.Groups[2].Length)
354
+ }
355
+ } elseif ($pluginsSectionRe.IsMatch($content)) {
356
+ $content = $pluginsSectionRe.Replace($content, "plugins:`n enabled: [""$pluginName""]", 1)
357
+ } else {
358
+ if ($content -and -not $content.EndsWith("`n")) { $content += "`n" }
359
+ $content += "plugins:`n enabled: [""$pluginName""]`n"
360
+ }
361
+
362
+ $tmp2 = "$ConfigPath.tmp"
363
+ [System.IO.File]::WriteAllText($tmp2, $content, [System.Text.Encoding]::UTF8)
364
+ Move-Item -Force -LiteralPath $tmp2 -Destination $ConfigPath
365
+ return $true
366
+ }
367
+
211
368
  function Merge-Marketplace {
369
+ param(
370
+ [string]$IjfwHome # Install root; the plugin lives at "$IjfwHome\claude".
371
+ )
212
372
  $settingsPath = Join-Path $env:USERPROFILE ".claude\settings.json"
213
373
  $settingsDir = Split-Path -Parent $settingsPath
214
374
  if (-not (Test-Path $settingsDir)) { New-Item -ItemType Directory -Force -Path $settingsDir | Out-Null }
375
+ if (-not $IjfwHome) { $IjfwHome = Join-Path $env:USERPROFILE ".ijfw" }
376
+ $pluginPath = Join-Path $IjfwHome "claude"
215
377
 
216
378
  $settings = @{}
217
379
  if (Test-Path $settingsPath) {
@@ -236,7 +398,11 @@ function Merge-Marketplace {
236
398
  }
237
399
  }
238
400
  if (-not $settings.ContainsKey('extraKnownMarketplaces')) { $settings['extraKnownMarketplaces'] = @{} }
239
- $settings.extraKnownMarketplaces['ijfw'] = @{ source = @{ source = 'github'; repo = 'TheRealSeanDonahoe/ijfw' } }
401
+ # Self-heal: write the directory source matching the actual install. Prior
402
+ # installs (<= 1.2.6) wrote a github source pointing at TheRealSeanDonahoe/
403
+ # ijfw, but Claude Code never clones that repo into its marketplaces cache,
404
+ # so the entry resolved to "Marketplace file not found". Idempotent.
405
+ $settings.extraKnownMarketplaces['ijfw'] = @{ source = @{ source = 'directory'; path = $pluginPath } }
240
406
  if (-not $settings.ContainsKey('enabledPlugins')) { $settings['enabledPlugins'] = @{} }
241
407
  # Opportunistically clean up the legacy key written by v1.0.0-1.0.2.
242
408
  if ($settings.enabledPlugins.ContainsKey('ijfw-core@ijfw')) {
@@ -260,15 +426,49 @@ if ($issues.Count -gt 0) {
260
426
 
261
427
  $target = Get-Target
262
428
 
429
+ # Sweep stale POSIX bash launchers from ~/.local/bin/ before any other
430
+ # install logic. Without this, leftover #!/usr/bin/env bash files from a
431
+ # pre-1.2.7 install (or one run from WSL) shadow npm's ijfw.cmd shim and
432
+ # Windows opens them in Notepad. (Bug report: John H.)
433
+ Remove-StalePosixLaunchers
434
+
263
435
  # scripts/install.sh owns the summary (Live now / Standing by / next step).
264
436
  # Keep clone/pull output suppressed so the final banner reads clean.
265
437
  Invoke-CloneOrPull $target $Branch | Out-Null
266
438
 
267
439
  Invoke-InstallScript $target
268
440
 
441
+ # Provision Wayland plugin (~/.wayland/plugins/ijfw/).
442
+ $waylandPluginDst = Join-Path $env:USERPROFILE ".wayland\plugins\ijfw"
443
+ Provision-Plugin -Src "wayland\plugins\ijfw" -Dst $waylandPluginDst -IjfwHome $target
444
+
445
+ # Provision Hermes plugin (~/.hermes/plugins/ijfw/).
446
+ $hermesPluginDst = Join-Path $env:USERPROFILE ".hermes\plugins\ijfw"
447
+ Provision-Plugin -Src "hermes\plugins\ijfw" -Dst $hermesPluginDst -IjfwHome $target
448
+
449
+ # Deploy shared patterns.json (~/.ijfw/shared/lib/patterns.json).
450
+ $patternsDir = Join-Path $env:USERPROFILE ".ijfw\shared\lib"
451
+ $patternsSrc = Join-Path $target "shared\lib\patterns.json"
452
+ if (Test-Path $patternsSrc) {
453
+ New-Item -ItemType Directory -Force -Path $patternsDir | Out-Null
454
+ Copy-Item -Force -LiteralPath $patternsSrc -Destination (Join-Path $patternsDir "patterns.json")
455
+ }
456
+
457
+ # Add "ijfw" to plugins.enabled[] in ~/.hermes/config.yaml (Hermes opt-in allow-list).
458
+ $hermesConfig = Join-Path $env:USERPROFILE ".hermes\config.yaml"
459
+ [void](Merge-PluginsEnabled -ConfigPath $hermesConfig)
460
+
461
+ # Regenerate per-platform rules from shared source.
462
+ if (Get-Command node -ErrorAction SilentlyContinue) {
463
+ $rulesGen = Join-Path $target "scripts\generate-platform-rules.js"
464
+ if (Test-Path $rulesGen) { & node $rulesGen }
465
+ }
466
+
269
467
  if (-not $NoMarketplace) {
270
468
  # Best-effort: returns $true on success, prints its own message on fallback.
271
- [void](Merge-Marketplace)
469
+ # Pass the resolved install root so the marketplace path matches reality
470
+ # for both default and --dir installs.
471
+ [void](Merge-Marketplace -IjfwHome $target)
272
472
  }
273
473
 
274
474
  $log = Join-Path $env:USERPROFILE ".ijfw\install.log"