mellos-mapping 0.25.0 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -94,6 +94,32 @@ committed, so a clone runs as-is —
94
94
  `dist/store-paths.mjs` (the store's path vocabulary, which the plain-node pane
95
95
  launcher imports instead of restating filenames).
96
96
 
97
+ **omp (Oh My Pi)** reads the same plugin layout the Claude Code edition ships —
98
+ the same marketplace catalog, `.mcp.json`, skill and slash command — so one
99
+ release serves both hosts:
100
+
101
+ ```
102
+ omp plugin marketplace add GuangminJu/mellos-mapping
103
+ omp plugin install mellos-mapping@mellos-mapping
104
+ ```
105
+
106
+ That install follows `main`, where a release is a version bump. To freeze a
107
+ version instead, add a checkout of one — the Claude Code edition ZIP from the
108
+ GitHub Release, or a clone at the release tag:
109
+
110
+ ```
111
+ omp plugin marketplace add "<checkout or extracted release directory>"
112
+ omp plugin install mellos-mapping@mellos-mapping
113
+ ```
114
+
115
+ omp never reads `hooks/hooks.json`, so the session paragraph arrives through the
116
+ plugin's omp host adapter (`dist/omp-extension.mjs`, declared in
117
+ `package.json#omp.extensions`): the same store, the same policy text, and the
118
+ same `mmap` shim install as the Claude hook. The pane is the same terminal
119
+ split beside the session. Install it *as a plugin*: neither `omp plugin link`
120
+ nor the npm package is a plugin, and both would leave the tools behind. See
121
+ [omp installation and limitations](docs/distributions/omp.md).
122
+
97
123
  The first session after installing asks you **one** question — how eager
98
124
  mapping should be — and records the answer for every project you will ever
99
125
  open. From then on the hook carries it into each new session by itself; there
@@ -110,8 +136,14 @@ Terminal inherits the old environment, so close the terminal app entirely and
110
136
  reopen it before the first `mmap`. The PATH edit keeps the
111
137
  installer's guarantees: nothing happens when the entry is already there, and
112
138
  a PATH that `setx` would damage (flattened `%VARIABLE%` references, truncation
113
- past its limit) is refused outright, with the entry to add by hand named
114
- instead.
139
+ past its limit) is refused outright.
140
+
141
+ A refused PATH is not a dead end: the same two shims are then written into
142
+ `%LOCALAPPDATA%\Microsoft\WindowsApps` (or `~/.local/bin`) — a directory your
143
+ PATH already names — so `mmap` is runnable in a new terminal with no PATH
144
+ change at all. Machines with a long PATH are exactly where that happens, and
145
+ where it matters most. The command takes the page to open as an argument
146
+ (`mmap omp-host-support`), and closes the pane when it is already open.
115
147
 
116
148
  The step behind it is still a command of its own, for the cases the hook does
117
149
  not cover — `--uninstall`, or re-adding a PATH entry you removed while the
@@ -136,6 +168,26 @@ marketplace clone — the first command is what actually pulls this repo.
136
168
  Restart Claude Code to apply. Releases are version bumps on `main`.
137
169
  (In-app, `/plugin` opens the same management UI.)
138
170
 
171
+ omp updates with its own two steps:
172
+
173
+ ```
174
+ omp plugin marketplace update mellos-mapping && omp plugin upgrade mellos-mapping@mellos-mapping
175
+ ```
176
+
177
+ Close the pane first (`q` in it): the watcher runs from the plugin copy, and on
178
+ Windows an open file cannot be renamed away, so an upgrade attempted while a
179
+ pane is open can fail with `EPERM` and leave the plugin cache empty — repaired
180
+ by `omp plugin install mellos-mapping@mellos-mapping --force`. Then: the first
181
+ command refreshes the catalog, the second reinstalls from it. omp also
182
+ refreshes a catalog entry it has not updated for a day at startup, unless
183
+ `marketplace.autoUpdate` is `off` — in the default `notify` mode that check
184
+ writes its finding to the debug log only, so `marketplace update` is the step
185
+ that makes an update visible. `upgrade` does not compare versions: it
186
+ force-reinstalls whatever the catalog names, which is why a release is
187
+ identified by its version bump rather than gated by it. Restart omp afterwards,
188
+ and reopen a pane still showing the old bundle (`q` in it, then `mmap_open`
189
+ again).
190
+
139
191
  ### Upgrading from 0.19
140
192
 
141
193
  0.20 moved the store out of `.claude/` — the map belongs to this tool, not to
package/README.zh-CN.md CHANGED
@@ -84,6 +84,30 @@ claude plugin marketplace add GuangminJu/mellos-mapping && claude plugin install
84
84
  钩子),以及 `dist/store-paths.mjs`(存储的路径词汇;纯 node 的面板启动
85
85
  脚本从这里导入,而不是自己抄一份文件名)。
86
86
 
87
+ **omp(Oh My Pi)** 读的是 Claude Code 版本用的同一套插件布局——同一份市场
88
+ 目录、`.mcp.json`、技能与斜杠命令——一份发行包同时服务两个宿主:
89
+
90
+ ```
91
+ omp plugin marketplace add GuangminJu/mellos-mapping
92
+ omp plugin install mellos-mapping@mellos-mapping
93
+ ```
94
+
95
+ 这样装跟的是 `main`,而发布就是它上面的版本号提升。要冻结版本,就改把某个
96
+ 版本的检出目录加为市场——GitHub Release 里的 Claude Code 版本 ZIP,或按
97
+ tag 检出的克隆:
98
+
99
+ ```
100
+ omp plugin marketplace add "<检出或解压出来的发行目录>"
101
+ omp plugin install mellos-mapping@mellos-mapping
102
+ ```
103
+
104
+ omp 不读 `hooks/hooks.json`,所以那段会话说明由插件的 omp 宿主适配器送达
105
+ (`dist/omp-extension.mjs`,在 `package.json#omp.extensions` 里声明):同一个
106
+ 存储、同一段策略文字,Windows 上的 `mmap` shim 也由它安装。面板仍旧是会话
107
+ 旁边的终端分屏。请把它**当插件**安装——`omp plugin link` 与 npm 包都不是插
108
+ 件,用它们装会只剩适配器、没有工具。见
109
+ [omp 安装与限制](docs/distributions/omp.md)。
110
+
87
111
  装完之后的第一个会话只会问你**一个**问题——建图要多积极——并把答案记成
88
112
  你以后打开的每一个项目的默认。此后钩子会自己把它带进每个新会话;再也没有
89
113
  "每个项目设置一遍"这回事。见
@@ -97,7 +121,13 @@ PowerShell)和 `mmap`(git-bash)写进 `%LOCALAPPDATA%\mellos-mapping\bin`
97
121
  所以第一次敲 `mmap` 之前要把终端应用整个关掉重开。PATH 的编辑保持安装器
98
122
  原有的承诺:条目已经在里面
99
123
  就什么都不做;遇到 `setx` 会损坏的 PATH(`%VARIABLE%` 被展平、超长被截
100
- 断),它干脆拒绝,改为把要手动添加的条目说清楚。
124
+ 断),它干脆拒绝。
125
+
126
+ PATH 被拒绝不等于没命令可用:同样两个 shim 会再写一份到
127
+ `%LOCALAPPDATA%\Microsoft\WindowsApps`(或 `~/.local/bin`)——一个你的 PATH
128
+ 本来就有名字的目录——于是**新终端里直接敲 `mmap` 就能用,完全不用改 PATH**。
129
+ PATH 很长的机器正是这种情况,也最需要它。命令可以带要打开的页名
130
+ (`mmap omp-host-support`);面板已经开着时再敲一次就是关掉它。
101
131
 
102
132
  它背后的那一步仍然是个独立命令,留给钩子管不到的情形——`--uninstall`,
103
133
  或者 shim 还在、PATH 条目却被你删掉之后重新加回去:
@@ -120,6 +150,22 @@ claude plugin marketplace update mellos-mapping && claude plugin update mellos-m
120
150
  拉取本仓库的是第一条命令。重启 Claude Code 生效。发布即 `main` 分支
121
151
  上的版本号提升。(在对话里输入 `/plugin` 也能打开同一个管理界面。)
122
152
 
153
+ omp 也有自己的两步:
154
+
155
+ ```
156
+ omp plugin marketplace update mellos-mapping && omp plugin upgrade mellos-mapping@mellos-mapping
157
+ ```
158
+
159
+ **先关面板**(面板里按 `q`):watcher 跑的就是插件副本里的文件,而 Windows 不
160
+ 允许改名一个正被打开的文件——开着面板升级会以 `EPERM` 失败并把插件缓存清空
161
+ (旧副本已删、新副本未就位),用 `omp plugin install mellos-mapping@mellos-mapping
162
+ --force` 修回来。然后:第一条刷新市场目录,第二条按目录重装。omp 还会在启动时尽力刷新超过一天没更新过
163
+ 的目录条目——除非 `marketplace.autoUpdate` 设为 `off`;默认的 `notify` 模式只把
164
+ "有可用更新"写进 debug 日志,所以让更新**看得见**的是 `marketplace update` 这一
165
+ 步。`upgrade` 不比较版本:它按目录里现在指的内容强制重装,同版本号也会重下——
166
+ 因此版本号提升是给发行**贴标签**,而不是送达的门槛。之后重启 omp,并把仍显示旧
167
+ 运行文件的面板关掉重开(面板里按 `q`,再 `mmap_open`)。
168
+
123
169
  ### 从 0.19 升级
124
170
 
125
171
  0.20 把地图存储从 `.claude/` 挪到了 `.mellos/`——地图属于这个工具,不属于
@@ -129,11 +129,47 @@ function sessionStartContext(input) {
129
129
  function hasMap(stateFile) {
130
130
  return existsSync(stateFile) || existsSync(join4(dirname4(stateFile), PAGES_DIR_NAME));
131
131
  }
132
+ function mmapBinDir(localAppData) {
133
+ return join4(localAppData, "mellos-mapping", "bin");
134
+ }
132
135
  function mmapShimFilePath(localAppData) {
133
- return join4(localAppData, "mellos-mapping", "bin", "mmap.cmd");
136
+ return join4(mmapBinDir(localAppData), "mmap.cmd");
137
+ }
138
+ function mmapFallbackDirs(localAppData, home) {
139
+ return [join4(localAppData, "Microsoft", "WindowsApps"), join4(home, ".local", "bin")];
140
+ }
141
+ function pathNames(rawPath, dir) {
142
+ const norm = (s) => s.trim().replace(/^"|"$/g, "").replace(/[\\/]+$/, "").toLowerCase();
143
+ return rawPath.split(";").some((entry) => entry.trim() !== "" && norm(entry) === norm(dir));
144
+ }
145
+ function readIfPresent(path) {
146
+ try {
147
+ return readFileSync2(path, "utf8");
148
+ } catch {
149
+ return void 0;
150
+ }
134
151
  }
135
152
  function mmapShimCurrent(shimContent, mmapPath) {
136
- return shimContent !== void 0 && shimContent.includes(`"${mmapPath}"`);
153
+ if (shimContent === void 0) return false;
154
+ return shimContent.includes(`"${mmapPath}"`) || shimContent.includes(`"${mmapPath.replaceAll("\\", "/")}"`);
155
+ }
156
+ function shimIsOurs(content) {
157
+ if (content.includes("mellos-mapping mmap shim")) return true;
158
+ const quoted = /"([^"]*mmap\.mjs)"/.exec(content)?.[1];
159
+ return quoted !== void 0 && /mellos-mapping/i.test(quoted);
160
+ }
161
+ function mmapCommandResolves(localAppData, mmapPath, environment, read, exists) {
162
+ if (pathNames(environment.path, mmapBinDir(localAppData))) return true;
163
+ if (environment.home === void 0 || environment.home === "") return false;
164
+ for (const dir of mmapFallbackDirs(localAppData, environment.home)) {
165
+ if (!exists(dir) || !pathNames(environment.path, dir)) continue;
166
+ const files = ["mmap.cmd", "mmap"].map((name) => read(join4(dir, name))).filter((content) => content !== void 0);
167
+ if (files.length === 0) continue;
168
+ const ours = files.filter((content) => shimIsOurs(content));
169
+ if (ours.length === 0) return true;
170
+ return ours.every((content) => mmapShimCurrent(content, mmapPath));
171
+ }
172
+ return false;
137
173
  }
138
174
  function installContextLine(outcome) {
139
175
  if (typeof outcome !== "object" || outcome === null) return void 0;
@@ -150,11 +186,22 @@ function installContextLine(outcome) {
150
186
  }
151
187
  if (o.path === "refused" || o.path === "error") {
152
188
  const reason = typeof o.reason === "string" ? o.reason : "the PATH edit failed";
189
+ const aliasDir = typeof o.alias === "object" && o.alias !== null && "dir" in o.alias && typeof o.alias.dir === "string" ? o.alias.dir : void 0;
190
+ if (aliasDir !== void 0) {
191
+ return [
192
+ `mellos-mapping: the \`mmap\` terminal command should work: its shims live in`,
193
+ `${o.binDir}, and \u2014 because the user PATH could not be changed (${reason}) \u2014 a second copy`,
194
+ `was written to ${aliasDir}, a directory PATH already names (so \`mmap\` resolves there unless`,
195
+ "something earlier in PATH claims the name first).",
196
+ "The user can therefore type `mmap` in a new terminal right now; the command opens the map",
197
+ "pane for the project it is typed in, or closes the open one."
198
+ ].join("\n");
199
+ }
153
200
  return [
154
201
  `mellos-mapping: the \`mmap\` command's launcher was written to ${o.binDir},`,
155
202
  `but the user PATH was NOT changed: ${reason}.`,
156
203
  "If the user wants the `mmap` pane-toggle command, tell them to add that directory to",
157
- 'their user PATH (Settings > "Edit environment variables for your account").'
204
+ 'their user PATH (Settings > "Edit environment variables for their account").'
158
205
  ].join("\n");
159
206
  }
160
207
  return void 0;
@@ -164,15 +211,18 @@ function ensureMmapCommand(pluginRoot) {
164
211
  const localAppData = process.env["LOCALAPPDATA"];
165
212
  if (localAppData === void 0 || localAppData === "") return void 0;
166
213
  const mmapPath = join4(pluginRoot, "dist", "mmap.mjs");
167
- let shim;
168
- try {
169
- shim = readFileSync2(mmapShimFilePath(localAppData), "utf8");
170
- } catch {
171
- shim = void 0;
172
- }
173
- if (mmapShimCurrent(shim, mmapPath)) return void 0;
214
+ const shim = readIfPresent(mmapShimFilePath(localAppData));
215
+ const resolves = mmapShimCurrent(shim, mmapPath) && mmapCommandResolves(
216
+ localAppData,
217
+ mmapPath,
218
+ { path: process.env["PATH"] ?? "", home: process.env["USERPROFILE"] ?? process.env["HOME"] },
219
+ readIfPresent,
220
+ existsSync
221
+ );
222
+ if (resolves) return void 0;
223
+ const runtime = "bun" in process.versions ? "node" : process.execPath;
174
224
  const run = spawnSync(
175
- process.execPath,
225
+ runtime,
176
226
  [join4(pluginRoot, "scripts", "install-mmap-command.mjs"), "--json"],
177
227
  { encoding: "utf8", windowsHide: true, timeout: 15e3 }
178
228
  );
@@ -236,12 +286,18 @@ if (launchedAsEntry(process.argv[1], import.meta.url)) {
236
286
  main().catch(() => process.exit(0));
237
287
  }
238
288
  export {
289
+ ensureMmapCommand,
239
290
  hasMap,
240
291
  hookOutput,
241
292
  installContextLine,
242
293
  launchedAsEntry,
294
+ mmapBinDir,
295
+ mmapCommandResolves,
296
+ mmapFallbackDirs,
243
297
  mmapShimCurrent,
244
298
  mmapShimFilePath,
245
299
  parseHookInput,
246
- sessionStartContext
300
+ pathNames,
301
+ sessionStartContext,
302
+ shimIsOurs
247
303
  };
@@ -0,0 +1,359 @@
1
+ // src/host/omp/extension.ts
2
+ import { homedir as homedir2 } from "node:os";
3
+ import { dirname as dirname5, join as join5 } from "node:path";
4
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
5
+
6
+ // src/hook/session-start.ts
7
+ import { spawnSync } from "node:child_process";
8
+ import { existsSync, readFileSync as readFileSync2, realpathSync } from "node:fs";
9
+ import { homedir } from "node:os";
10
+ import { dirname as dirname4, join as join4 } from "node:path";
11
+ import { fileURLToPath, pathToFileURL } from "node:url";
12
+
13
+ // src/domain/types.ts
14
+ var ok = (value) => ({ ok: true, value });
15
+ var err = (error) => ({ ok: false, error });
16
+ var RANK_MIN = 0;
17
+ var RANK_MAX = 99;
18
+ var RANK_RULE_TEXT = `an integer in ${RANK_MIN}..${RANK_MAX}, 0 = bottom / most primitive`;
19
+
20
+ // src/store/pages.ts
21
+ import { basename, dirname, join } from "node:path";
22
+ var STORE_DIR_NAME = ".mellos";
23
+ var STATE_FILE_RELATIVE_PATH = join(STORE_DIR_NAME, "map.json");
24
+ var PAGES_DIR_NAME = "pages";
25
+
26
+ // src/store/json-text.ts
27
+ function isRecord(v) {
28
+ return typeof v === "object" && v !== null && !Array.isArray(v);
29
+ }
30
+ function stripBom(text) {
31
+ return text.charCodeAt(0) === 65279 ? text.slice(1) : text;
32
+ }
33
+
34
+ // src/store/policy.ts
35
+ import { readFileSync } from "node:fs";
36
+ import { dirname as dirname2, join as join2 } from "node:path";
37
+ var CONFIG_FILE_NAME = "config.json";
38
+ var CONFIG_FILE_VERSION = 1;
39
+ function configFilePath(defaultFile) {
40
+ return join2(dirname2(defaultFile), CONFIG_FILE_NAME);
41
+ }
42
+ function userConfigFilePath(userBase) {
43
+ return join2(userBase, STORE_DIR_NAME, CONFIG_FILE_NAME);
44
+ }
45
+ var MAPPING_POLICIES = ["always", "complex", "on-request"];
46
+ function makeMappingPolicy(raw) {
47
+ return MAPPING_POLICIES.includes(raw) ? ok(raw) : err({ kind: "invalid-policy", raw, allowed: MAPPING_POLICIES });
48
+ }
49
+ function describeMappingPolicy(policy) {
50
+ switch (policy) {
51
+ case "always":
52
+ return "map every structured task \u2014 workflows, designs, architecture, technical dependencies";
53
+ case "complex":
54
+ return "map only medium or complex tasks \u2014 several modules, a new subsystem, roughly an hour or more";
55
+ case "on-request":
56
+ return "map only when the user explicitly asks";
57
+ }
58
+ }
59
+ function loadMappingPolicy(path) {
60
+ let text;
61
+ try {
62
+ text = readFileSync(path, "utf8");
63
+ } catch (e) {
64
+ if (e.code === "ENOENT") return ok(void 0);
65
+ throw e;
66
+ }
67
+ let raw;
68
+ try {
69
+ raw = JSON.parse(stripBom(text));
70
+ } catch (e) {
71
+ return err({ kind: "malformed-json", path, detail: e.message });
72
+ }
73
+ if (!isRecord(raw)) return err({ kind: "bad-shape", path, detail: "root is not an object" });
74
+ if (raw["version"] !== CONFIG_FILE_VERSION) {
75
+ return err({ kind: "bad-shape", path, detail: `version is ${String(raw["version"])}, expected ${CONFIG_FILE_VERSION}` });
76
+ }
77
+ const rawPolicy = raw["policy"];
78
+ if (rawPolicy === void 0) return ok(void 0);
79
+ if (typeof rawPolicy !== "string") return err({ kind: "bad-shape", path, detail: "policy is not a string" });
80
+ const policy = makeMappingPolicy(rawPolicy);
81
+ return policy.ok ? ok(policy.value) : err({ kind: "bad-shape", path, detail: `policy is "${rawPolicy}", expected one of: ${MAPPING_POLICIES.join(" | ")}` });
82
+ }
83
+ function effectiveMappingPolicy(projectConfigFile, userConfigFile) {
84
+ const project = loadMappingPolicy(projectConfigFile);
85
+ if (!project.ok) return project;
86
+ const user = loadMappingPolicy(userConfigFile);
87
+ if (!user.ok) return user;
88
+ const effective = project.value ?? user.value;
89
+ const source = project.value !== void 0 ? "project" : user.value !== void 0 ? "user" : void 0;
90
+ return ok({ project: project.value, user: user.value, effective, source });
91
+ }
92
+
93
+ // src/store/migration.ts
94
+ import { dirname as dirname3, join as join3 } from "node:path";
95
+ var LEGACY_STATE_FILE_RELATIVE_PATH = join3(".claude", "mellos-mapping.json");
96
+
97
+ // src/hook/session-start.ts
98
+ function sessionStartContext(input) {
99
+ const { policy, hasStore } = input;
100
+ if (policy === void 0) {
101
+ return [
102
+ "mellos-mapping \u2014 first-run setup. This question is asked ONCE EVER, not once per project.",
103
+ "Before starting structured work here, ask the user how eager mapping should be:",
104
+ ...MAPPING_POLICIES.map((p) => ` ${p} \u2014 ${describeMappingPolicy(p)}`),
105
+ 'Record their answer with mmap_setup {policy: "<their choice>", scope: "user"} \u2014 it then applies',
106
+ 'to every project they open, and a single project can still override it with scope: "project".',
107
+ "Do not choose for them, and do not ask again once it is recorded."
108
+ ].join("\n");
109
+ }
110
+ if (policy === "on-request") {
111
+ return hasStore ? "mellos-mapping: this project has a Mellos map. The user's policy is on-request \u2014 use the mmap tools only when they ask." : void 0;
112
+ }
113
+ const scope = policy === "always" ? "For ANY structured task in this project \u2014 workflows, designs, architecture, technical dependencies:" : "For any MEDIUM OR COMPLEX task in this project \u2014 several modules, a new subsystem, roughly an hour of work or more (skip trivial edits):";
114
+ return [
115
+ `mellos-mapping policy: ${policy} \u2014 ${describeMappingPolicy(policy)}.`,
116
+ scope,
117
+ " 1. Load the mellos-mapping skill and follow it.",
118
+ " 2. Read existing pages and context with mmap_read; a new conversation is not a new effort.",
119
+ " Reuse the matching page and verified nodes. Use mmap_declare only for missing structure.",
120
+ " 3. Open the map pane WITHOUT asking \u2014 the recorded policy is the user's standing consent:",
121
+ ' mmap_open {page: "<the page this effort lives on>"}',
122
+ " It opens the pane beside this conversation, or retargets one that is already open.",
123
+ " 4. Watch the `pane:` line every write answers with: it says whether anybody is actually",
124
+ " looking. `pane: CLOSED` means the user cannot see this map \u2014 call mmap_open then too.",
125
+ " 5. Keep the map current as the work proceeds: in-progress when a node is started,",
126
+ " done WITH EVIDENCE when its verification passes, regressed when something breaks.",
127
+ " Save a concise context.next checkpoint and use expectedRevision for incremental writes.",
128
+ "An explicit request from the user always outranks this."
129
+ ].join("\n");
130
+ }
131
+ function hasMap(stateFile) {
132
+ return existsSync(stateFile) || existsSync(join4(dirname4(stateFile), PAGES_DIR_NAME));
133
+ }
134
+ function mmapBinDir(localAppData) {
135
+ return join4(localAppData, "mellos-mapping", "bin");
136
+ }
137
+ function mmapShimFilePath(localAppData) {
138
+ return join4(mmapBinDir(localAppData), "mmap.cmd");
139
+ }
140
+ function mmapFallbackDirs(localAppData, home) {
141
+ return [join4(localAppData, "Microsoft", "WindowsApps"), join4(home, ".local", "bin")];
142
+ }
143
+ function pathNames(rawPath, dir) {
144
+ const norm = (s) => s.trim().replace(/^"|"$/g, "").replace(/[\\/]+$/, "").toLowerCase();
145
+ return rawPath.split(";").some((entry) => entry.trim() !== "" && norm(entry) === norm(dir));
146
+ }
147
+ function readIfPresent(path) {
148
+ try {
149
+ return readFileSync2(path, "utf8");
150
+ } catch {
151
+ return void 0;
152
+ }
153
+ }
154
+ function mmapShimCurrent(shimContent, mmapPath) {
155
+ if (shimContent === void 0) return false;
156
+ return shimContent.includes(`"${mmapPath}"`) || shimContent.includes(`"${mmapPath.replaceAll("\\", "/")}"`);
157
+ }
158
+ function shimIsOurs(content) {
159
+ if (content.includes("mellos-mapping mmap shim")) return true;
160
+ const quoted = /"([^"]*mmap\.mjs)"/.exec(content)?.[1];
161
+ return quoted !== void 0 && /mellos-mapping/i.test(quoted);
162
+ }
163
+ function mmapCommandResolves(localAppData, mmapPath, environment, read, exists) {
164
+ if (pathNames(environment.path, mmapBinDir(localAppData))) return true;
165
+ if (environment.home === void 0 || environment.home === "") return false;
166
+ for (const dir of mmapFallbackDirs(localAppData, environment.home)) {
167
+ if (!exists(dir) || !pathNames(environment.path, dir)) continue;
168
+ const files = ["mmap.cmd", "mmap"].map((name) => read(join4(dir, name))).filter((content) => content !== void 0);
169
+ if (files.length === 0) continue;
170
+ const ours = files.filter((content) => shimIsOurs(content));
171
+ if (ours.length === 0) return true;
172
+ return ours.every((content) => mmapShimCurrent(content, mmapPath));
173
+ }
174
+ return false;
175
+ }
176
+ function installContextLine(outcome) {
177
+ if (typeof outcome !== "object" || outcome === null) return void 0;
178
+ const o = outcome;
179
+ if (o.kind !== "installed" || typeof o.binDir !== "string") return void 0;
180
+ if (o.path === "updated") {
181
+ return [
182
+ "mellos-mapping: the `mmap` terminal command was just installed for the user",
183
+ `(${o.binDir} was added to their user PATH). Typed in any project terminal, \`mmap\``,
184
+ "toggles the map pane. The PATH change reaches only NEW processes \u2014 and a new tab of",
185
+ "a running Windows Terminal inherits the old environment, so if the user says `mmap`",
186
+ "is not recognized, tell them to close Windows Terminal entirely and reopen it."
187
+ ].join("\n");
188
+ }
189
+ if (o.path === "refused" || o.path === "error") {
190
+ const reason = typeof o.reason === "string" ? o.reason : "the PATH edit failed";
191
+ const aliasDir = typeof o.alias === "object" && o.alias !== null && "dir" in o.alias && typeof o.alias.dir === "string" ? o.alias.dir : void 0;
192
+ if (aliasDir !== void 0) {
193
+ return [
194
+ `mellos-mapping: the \`mmap\` terminal command should work: its shims live in`,
195
+ `${o.binDir}, and \u2014 because the user PATH could not be changed (${reason}) \u2014 a second copy`,
196
+ `was written to ${aliasDir}, a directory PATH already names (so \`mmap\` resolves there unless`,
197
+ "something earlier in PATH claims the name first).",
198
+ "The user can therefore type `mmap` in a new terminal right now; the command opens the map",
199
+ "pane for the project it is typed in, or closes the open one."
200
+ ].join("\n");
201
+ }
202
+ return [
203
+ `mellos-mapping: the \`mmap\` command's launcher was written to ${o.binDir},`,
204
+ `but the user PATH was NOT changed: ${reason}.`,
205
+ "If the user wants the `mmap` pane-toggle command, tell them to add that directory to",
206
+ 'their user PATH (Settings > "Edit environment variables for their account").'
207
+ ].join("\n");
208
+ }
209
+ return void 0;
210
+ }
211
+ function ensureMmapCommand(pluginRoot) {
212
+ if (process.platform !== "win32") return void 0;
213
+ const localAppData = process.env["LOCALAPPDATA"];
214
+ if (localAppData === void 0 || localAppData === "") return void 0;
215
+ const mmapPath = join4(pluginRoot, "dist", "mmap.mjs");
216
+ const shim = readIfPresent(mmapShimFilePath(localAppData));
217
+ const resolves = mmapShimCurrent(shim, mmapPath) && mmapCommandResolves(
218
+ localAppData,
219
+ mmapPath,
220
+ { path: process.env["PATH"] ?? "", home: process.env["USERPROFILE"] ?? process.env["HOME"] },
221
+ readIfPresent,
222
+ existsSync
223
+ );
224
+ if (resolves) return void 0;
225
+ const runtime = "bun" in process.versions ? "node" : process.execPath;
226
+ const run = spawnSync(
227
+ runtime,
228
+ [join4(pluginRoot, "scripts", "install-mmap-command.mjs"), "--json"],
229
+ { encoding: "utf8", windowsHide: true, timeout: 15e3 }
230
+ );
231
+ if (run.status !== 0 || typeof run.stdout !== "string") return void 0;
232
+ let outcome;
233
+ try {
234
+ outcome = JSON.parse(run.stdout);
235
+ } catch {
236
+ return void 0;
237
+ }
238
+ return installContextLine(outcome);
239
+ }
240
+ function parseHookInput(raw) {
241
+ let parsed;
242
+ try {
243
+ parsed = JSON.parse(raw);
244
+ } catch {
245
+ return { cwd: void 0 };
246
+ }
247
+ if (typeof parsed !== "object" || parsed === null) return { cwd: void 0 };
248
+ const cwd = parsed.cwd;
249
+ return { cwd: typeof cwd === "string" && cwd !== "" ? cwd : void 0 };
250
+ }
251
+ function hookOutput(additionalContext) {
252
+ return JSON.stringify({ hookSpecificOutput: { hookEventName: "SessionStart", additionalContext } });
253
+ }
254
+ async function readAll(stream) {
255
+ const chunks = [];
256
+ for await (const chunk of stream) chunks.push(Buffer.from(chunk));
257
+ return Buffer.concat(chunks).toString("utf8");
258
+ }
259
+ async function main() {
260
+ const raw = process.stdin.isTTY === true ? "" : await readAll(process.stdin);
261
+ const projectDir = parseHookInput(raw).cwd ?? process.cwd();
262
+ const stateFile = join4(projectDir, STATE_FILE_RELATIVE_PATH);
263
+ const scopes = effectiveMappingPolicy(configFilePath(stateFile), userConfigFilePath(homedir()));
264
+ if (!scopes.ok) return;
265
+ const pluginRoot = dirname4(dirname4(fileURLToPath(import.meta.url)));
266
+ const context = sessionStartContext({
267
+ policy: scopes.value.effective,
268
+ hasStore: hasMap(stateFile)
269
+ });
270
+ let installNote;
271
+ try {
272
+ installNote = ensureMmapCommand(pluginRoot);
273
+ } catch {
274
+ installNote = void 0;
275
+ }
276
+ const parts = [context, installNote].filter((p) => p !== void 0);
277
+ if (parts.length > 0) process.stdout.write(hookOutput(parts.join("\n\n")));
278
+ }
279
+ function launchedAsEntry(argv1, moduleUrl) {
280
+ if (argv1 === void 0) return false;
281
+ try {
282
+ return realpathSync(argv1) === realpathSync(fileURLToPath(moduleUrl));
283
+ } catch {
284
+ return pathToFileURL(argv1).href === moduleUrl;
285
+ }
286
+ }
287
+ if (launchedAsEntry(process.argv[1], import.meta.url)) {
288
+ main().catch(() => process.exit(0));
289
+ }
290
+
291
+ // src/host/omp/extension.ts
292
+ var SESSION_CONTEXT_TYPE = "mellos-mapping.session-context";
293
+ function sessionParagraph(projectDir) {
294
+ const stateFile = join5(projectDir, STATE_FILE_RELATIVE_PATH);
295
+ const scopes = effectiveMappingPolicy(configFilePath(stateFile), userConfigFilePath(homedir2()));
296
+ if (!scopes.ok) return void 0;
297
+ return sessionStartContext({ policy: scopes.value.effective, hasStore: hasMap(stateFile) });
298
+ }
299
+ function mellosMappingOmp(pi) {
300
+ const pluginRoot = dirname5(dirname5(fileURLToPath2(import.meta.url)));
301
+ let armed = true;
302
+ let installNote;
303
+ let told;
304
+ const rearm = async () => {
305
+ armed = true;
306
+ };
307
+ pi.setLabel?.("Mellos Mapping");
308
+ pi.on("session_start", async () => {
309
+ armed = true;
310
+ try {
311
+ installNote = ensureMmapCommand(pluginRoot);
312
+ } catch {
313
+ installNote = void 0;
314
+ }
315
+ });
316
+ pi.on("session_switch", rearm);
317
+ pi.on("session_branch", rearm);
318
+ pi.on("session_compact", rearm);
319
+ pi.on("before_agent_start", async (_event, ctx) => {
320
+ const cwd = ctx !== null && typeof ctx === "object" && "cwd" in ctx ? ctx.cwd : void 0;
321
+ const projectDir = typeof cwd === "string" && cwd !== "" ? cwd : process.cwd();
322
+ let paragraph;
323
+ let broken = false;
324
+ try {
325
+ paragraph = sessionParagraph(projectDir);
326
+ } catch {
327
+ paragraph = void 0;
328
+ }
329
+ if (paragraph === void 0) {
330
+ broken = !storeIsReadable(projectDir);
331
+ }
332
+ const content = [paragraph, installNote].filter((part) => part !== void 0).join("\n\n");
333
+ const owed = armed || paragraph !== void 0 && paragraph !== told;
334
+ if (!owed || broken || content === "") {
335
+ if (!broken) armed = false;
336
+ return void 0;
337
+ }
338
+ armed = false;
339
+ told = paragraph;
340
+ const message = {
341
+ customType: SESSION_CONTEXT_TYPE,
342
+ content,
343
+ // Standing instruction, not conversation: the model reads it, and the
344
+ // transcript does not grow a message the user never sent.
345
+ display: false,
346
+ attribution: "agent"
347
+ };
348
+ return { message };
349
+ });
350
+ }
351
+ function storeIsReadable(projectDir) {
352
+ const stateFile = join5(projectDir, STATE_FILE_RELATIVE_PATH);
353
+ return effectiveMappingPolicy(configFilePath(stateFile), userConfigFilePath(homedir2())).ok;
354
+ }
355
+ export {
356
+ SESSION_CONTEXT_TYPE,
357
+ mellosMappingOmp as default,
358
+ sessionParagraph
359
+ };
package/dist/server.mjs CHANGED
@@ -24383,7 +24383,7 @@ function viewTool() {
24383
24383
  function openTool() {
24384
24384
  return {
24385
24385
  title: "Open the map pane",
24386
- description: 'For automatic display beside the current ChatGPT desktop conversation in Codex mode, use surface: "web-terminal", then call open_in_codex with the returned browser hostOpen object. No paste or Computer Use is needed. For the native host terminal, use surface: "codex-terminal": prepare absolute watcher commands for the current project, then ask the host to open its right terminal. This does not launch the watcher or type into that terminal. Agent exec PTYs cannot be attached using their numeric session ids. For a document panel, use surface: "markdown": generate MD + SVG files, enable automatic preview updates after successful map writes, then use the HOST file-opening tool to display the returned absolute Markdown path on the right of the current conversation. Generated does not mean visible: this server cannot open or observe the desktop side panel. For interactive maps, choose surface: "web": start or reuse a project-local web viewer and pass the returned URL to the host browser-opening tool. Markdown and terminal remain available. The default surface is "terminal", preserving the terminal workflow. Put the live map on the user\'s screen: a terminal pane beside this conversation that redraws on every write. Call it whenever a result says `pane: CLOSED` \u2014 and do NOT ask permission first, because a user who has set a mapping policy has already said they want to see the map. With a pane already open this RETARGETS it to `page` instead of opening a second one, so it is also how you show the user a particular page when they ask for one. It never closes a pane: taking the map off the screen belongs to the user (the `q` key in the pane, or typing `mmap` in a terminal). The reply says whether a pane actually reported itself in afterwards, not merely that a command was run. Automatic terminal opening supports Windows Terminal and tmux on Linux/macOS. If opening fails, relay the reason and copyable command; retry only after the environment changes or the user asks.',
24386
+ description: 'For automatic display beside the current ChatGPT desktop conversation in Codex mode, use surface: "web-terminal", then call open_in_codex with the returned browser hostOpen object. No paste or Computer Use is needed. For the native host terminal, use surface: "codex-terminal": prepare absolute watcher commands for the current project, then ask the host to open its right terminal. This does not launch the watcher or type into that terminal. Agent exec PTYs cannot be attached using their numeric session ids. For a document panel, use surface: "markdown": generate MD + SVG files, enable automatic preview updates after successful map writes, then use the HOST file-opening tool to display the returned absolute Markdown path on the right of the current conversation. Generated does not mean visible: this server cannot open or observe the desktop side panel. For interactive maps, choose surface: "web": start or reuse a project-local web viewer and pass the returned URL to the host browser-opening tool. Markdown and terminal remain available. The default surface is "terminal", preserving the terminal workflow. Put the live map on the user\'s screen: a terminal pane beside this conversation that redraws on every write. Call it whenever a result says `pane: CLOSED` \u2014 and do NOT ask permission first, because a user who has set a mapping policy has already said they want to see the map. With a pane already open this RETARGETS it to `page` instead of opening a second one, so it is also how you show the user a particular page when they ask for one. It never closes a pane: taking the map off the screen belongs to the user (the `q` key in the pane, or typing `mmap` in a terminal). The reply says whether a pane actually reported itself in afterwards, not merely that a command was run. Automatic terminal opening supports Windows Terminal and tmux on Linux/macOS. omp (Oh My Pi) has no side panel of its own: there the routes are this same terminal pane (a Windows Terminal split, or a tmux split on Linux/macOS) and the web viewer, whose URL the session opens with its own browser tool. If opening fails, relay the reason and copyable command; retry only after the environment changes or the user asks.',
24387
24387
  inputSchema: closed({
24388
24388
  surface: external_exports.enum(["terminal", "codex-terminal", "markdown", "web", "web-terminal"]).optional().describe("web-terminal = automatically started mmap terminal in a local browser page; codex-terminal = prepare a command for the desktop host terminal; web = local browser viewer; markdown = MD/SVG; terminal = Windows Terminal or tmux launcher (default)"),
24389
24389
  page: id(
@@ -24650,7 +24650,7 @@ function launcherViewerPid(run) {
24650
24650
 
24651
24651
  // src/server/server.ts
24652
24652
  var SERVER_NAME = "mellos-mapping";
24653
- var SERVER_VERSION = "0.25.0";
24653
+ var SERVER_VERSION = "0.26.0";
24654
24654
  function text(s, isError = false) {
24655
24655
  return { content: [{ type: "text", text: s }], ...isError ? { isError: true } : {} };
24656
24656
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mellos-mapping",
3
- "version": "0.25.0",
3
+ "version": "0.26.0",
4
4
  "mcpName": "io.github.GuangminJu/mellos-mapping",
5
5
  "description": "A live layered dependency map for bottom-up development — MCP server + terminal pane. Ghost the design first, then light nodes up from the bottom as they are built and verified.",
6
6
  "type": "module",
@@ -65,6 +65,11 @@
65
65
  "publishConfig": {
66
66
  "registry": "https://registry.npmjs.org/"
67
67
  },
68
+ "omp": {
69
+ "extensions": [
70
+ "./dist/omp-extension.mjs"
71
+ ]
72
+ },
68
73
  "files": [
69
74
  "dist",
70
75
  "lib",
@@ -37,7 +37,7 @@
37
37
  * point, so importing this file is inert.
38
38
  */
39
39
  import { spawnSync } from 'node:child_process';
40
- import { chmodSync, existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
40
+ import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
41
41
  import { join } from 'node:path';
42
42
 
43
43
  import { launchedAsEntry, pluginRootOf } from './pane-core.mjs';
@@ -56,9 +56,36 @@ export function binDirIn(localAppData) {
56
56
  return join(localAppData, 'mellos-mapping', 'bin');
57
57
  }
58
58
 
59
+ /**
60
+ * Per-user command directories that are USUALLY already on the PATH, in the
61
+ * order they should be tried, given a `%LOCALAPPDATA%` and a home directory.
62
+ *
63
+ * These exist for one case: `setx` refuses to write a user PATH this script
64
+ * will not carry safely (see setxRefusal), and the canonical `bin` directory is
65
+ * therefore unreachable for good. A machine with a long PATH — conda, five
66
+ * Pythons, several toolchains — then has no way to run `mmap` at all, which is
67
+ * the opposite of what the command is for. Dropping the same two shims into a
68
+ * directory the PATH already names fixes that without editing anything:
69
+ *
70
+ * - `WindowsApps` is Windows' own per-user command directory (the one App
71
+ * Execution Aliases live in) and is on the PATH of every interactive user;
72
+ * - `~/.local/bin` is the same idea as the rest of the world spells it.
73
+ *
74
+ * Only ever consulted when the PATH route is refused, and only if the directory
75
+ * exists, is reachable on the CURRENT PATH, and accepts a write.
76
+ */
77
+ export function linkDirCandidates(localAppData, home) {
78
+ return [join(localAppData, 'Microsoft', 'WindowsApps'), join(home, '.local', 'bin')];
79
+ }
80
+
81
+ /** The candidates this PATH can already reach, in the order they were given. */
82
+ export function reachableDirs(candidates, rawPath, exists) {
83
+ return candidates.filter((dir) => exists(dir) && pathContains(rawPath, dir));
84
+ }
85
+
59
86
  /** The cmd/PowerShell shim: forwards every argument, prints nothing of its own. */
60
87
  export function cmdShim(mmapPath) {
61
- return ['@echo off', `node "${mmapPath}" %*`, ''].join('\r\n');
88
+ return ['@echo off', `rem ${SHIM_MARKER}`, `node "${mmapPath}" %*`, ''].join('\r\n');
62
89
  }
63
90
 
64
91
  /**
@@ -67,7 +94,7 @@ export function cmdShim(mmapPath) {
67
94
  * `C:\Users` is one escape away from being someone else's bug.
68
95
  */
69
96
  export function shShim(mmapPath) {
70
- return ['#!/bin/sh', `exec node "${mmapPath.replaceAll('\\', '/')}" "$@"`, ''].join('\n');
97
+ return ['#!/bin/sh', `# ${SHIM_MARKER}`, `exec node "${mmapPath.replaceAll('\\', '/')}" "$@"`, ''].join('\n');
71
98
  }
72
99
 
73
100
  /** PATH entries as written, empties dropped — the unit every rule below works on. */
@@ -175,6 +202,68 @@ function applyPath(raw, wanted) {
175
202
  }
176
203
  }
177
204
 
205
+ /** The command name this plugin installs, and the script it must point at. */
206
+ export const SHIM_COMMAND = 'mmap';
207
+ export const SHIM_SCRIPT = 'mmap.mjs';
208
+
209
+ /**
210
+ * Written into both shim shapes. The path alone says nothing reliable about
211
+ * who wrote a file in a shared directory — the same plugin may be installed
212
+ * under any checkout name — so ownership gets its own line, and old shims are
213
+ * still recognized by their target.
214
+ */
215
+ export const SHIM_MARKER = 'mellos-mapping mmap shim';
216
+
217
+ /**
218
+ * The `mmap.mjs` bundle a shim launches, normalized to forward slashes, or
219
+ * undefined when the text quotes no such path. One reader for every question
220
+ * about ownership: the cmd shape writes the Windows path, the git-bash shape
221
+ * the forward-slash one, and a rule that knew only one form would answer
222
+ * differently about a shim's two files.
223
+ */
224
+ export function shimTarget(content) {
225
+ const quoted = /"([^"]*mmap\.mjs)"/.exec(content)?.[1];
226
+ return quoted === undefined ? undefined : quoted.replaceAll('\\', '/');
227
+ }
228
+
229
+ /**
230
+ * Is this shim file one of ours — a `mmap` command that launches a
231
+ * `dist/mmap.mjs` of this plugin family?
232
+ *
233
+ * The fallback directory is SHARED (`WindowsApps`, `~/.local/bin`): a file
234
+ * called `mmap` there may belong to somebody else, and a plugin that overwrites
235
+ * a stranger's command on its way in has no standing to be careful on the way
236
+ * out. Ownership is read from the file's own text. Every shim written since the
237
+ * marker exists carries it — an install under ANY directory name owns its
238
+ * copies — and shims written before it are recognized by their target, whose
239
+ * path named the plugin.
240
+ */
241
+ export function shimIsOurs(content) {
242
+ if (content.includes(SHIM_MARKER)) return true;
243
+ const target = shimTarget(content);
244
+ return target !== undefined && /mellos-mapping/i.test(target);
245
+ }
246
+
247
+ /**
248
+ * Does this shim launch exactly `mmapPath`? Both shapes write the path their
249
+ * own way, so the comparison happens on the normalized form — the rule every
250
+ * removal in this file uses, or a git-bash copy would outlive the uninstall
251
+ * that removed its cmd sibling.
252
+ */
253
+ export function shimLaunches(content, mmapPath) {
254
+ const target = shimTarget(content);
255
+ return target !== undefined && target === mmapPath.replaceAll('\\', '/');
256
+ }
257
+
258
+ /** Read a file's text, or undefined when it is absent or unreadable. */
259
+ function readIfPresent(path) {
260
+ try {
261
+ return readFileSync(path, 'utf8');
262
+ } catch {
263
+ return undefined;
264
+ }
265
+ }
266
+
178
267
  /**
179
268
  * The install itself, with the narration stripped out: write the shims, bring
180
269
  * the PATH in line, report what happened as data. This outcome object IS the
@@ -183,13 +272,16 @@ function applyPath(raw, wanted) {
183
272
  *
184
273
  * @returns one of
185
274
  * {kind: 'not-built', missing} — dist/mmap.mjs absent, nothing written;
186
- * {kind: 'installed', binDir, cmdPath, shPath, path, wanted, reason?} —
275
+ * {kind: 'installed', binDir, cmdPath, shPath, path, wanted, reason?, alias?} —
187
276
  * shims written; `path` says what happened to the USER PATH:
188
277
  * 'unchanged' — the entry was already there,
189
278
  * 'updated' — the entry was appended,
190
279
  * 'refused' — setx would damage this PATH (`reason` says how); the
191
280
  * entry in `wanted` must be added by hand,
192
281
  * 'error' — setx itself failed (`reason` is its message).
282
+ * `alias` is the second copy in a directory the PATH already names, written
283
+ * only when the PATH route came up short OR when one is already there and
284
+ * needs to keep pointing at this install.
193
285
  */
194
286
  export function install(localAppData, pluginRoot) {
195
287
  const mmapPath = join(pluginRoot, 'dist', 'mmap.mjs');
@@ -211,14 +303,56 @@ export function install(localAppData, pluginRoot) {
211
303
  const raw = readUserPath();
212
304
  const wanted = pathWith(raw, binDir);
213
305
  const plan = planPath(raw, wanted);
214
- if (plan.action === 'unchanged') return { kind: 'installed', binDir, cmdPath, shPath, path: 'unchanged', wanted };
306
+ /**
307
+ * When the PATH cannot be brought to the shim, bring the shim to the PATH:
308
+ * same two files, dropped into a directory this machine already searches.
309
+ *
310
+ * Two rules keep that from becoming a second install nobody owns. A
311
+ * candidate is skipped when its `mmap` belongs to somebody else — a shared
312
+ * directory is not ours to overwrite. And a copy that IS ours is refreshed
313
+ * on every install, not only when the PATH route fails: the shim embeds the
314
+ * absolute path of one plugin copy, and an untouched copy from an earlier
315
+ * install would keep launching a directory the next upgrade deletes.
316
+ *
317
+ * @param force - create the copy even when none exists yet (the PATH route
318
+ * came up short, so this is the only runnable command).
319
+ * @returns `{dir, cmdPath, shPath, refreshed}` for the copy, or undefined.
320
+ */
321
+ const alias = (force) => {
322
+ const home = process.env['USERPROFILE'] ?? process.env['HOME'];
323
+ if (home === undefined || home === '') return undefined;
324
+ // every candidate gets its own try: a directory that exists and is on the
325
+ // PATH can still refuse the write, and that is a reason to try the next
326
+ // one, not to give up on having a runnable command
327
+ for (const dir of reachableDirs(linkDirCandidates(localAppData, home), process.env['PATH'] ?? '', existsSync)) {
328
+ const linkedCmd = join(dir, 'mmap.cmd');
329
+ const linkedSh = join(dir, 'mmap');
330
+ const existing = [readIfPresent(linkedCmd), readIfPresent(linkedSh)].filter((text) => text !== undefined);
331
+ const foreign = existing.some((text) => !shimIsOurs(text));
332
+ if (foreign) continue; // somebody else's command: not ours to touch
333
+ const refreshed = existing.length > 0;
334
+ if (!refreshed && !force) continue; // nothing there and nothing to fix
335
+ try {
336
+ writeFileSync(linkedCmd, cmdShim(mmapPath));
337
+ writeFileSync(linkedSh, shShim(mmapPath));
338
+ return { dir, cmdPath: linkedCmd, shPath: linkedSh, refreshed };
339
+ } catch {
340
+ continue;
341
+ }
342
+ }
343
+ return undefined;
344
+ };
215
345
  if (plan.action === 'refused') {
216
- return { kind: 'installed', binDir, cmdPath, shPath, path: 'refused', wanted, reason: plan.reason };
346
+ // refused means setx would damage this PATH: the alias is the whole answer
347
+ return { kind: 'installed', binDir, cmdPath, shPath, path: 'refused', wanted, reason: plan.reason, alias: alias(true) };
348
+ }
349
+ if (plan.action === 'unchanged') {
350
+ return { kind: 'installed', binDir, cmdPath, shPath, path: 'unchanged', wanted, alias: alias(false) };
217
351
  }
218
352
  const written = writeUserPath(wanted);
219
353
  return written.ok
220
- ? { kind: 'installed', binDir, cmdPath, shPath, path: 'updated', wanted }
221
- : { kind: 'installed', binDir, cmdPath, shPath, path: 'error', wanted, reason: written.error };
354
+ ? { kind: 'installed', binDir, cmdPath, shPath, path: 'updated', wanted, alias: alias(false) }
355
+ : { kind: 'installed', binDir, cmdPath, shPath, path: 'error', wanted, reason: written.error, alias: alias(true) };
222
356
  }
223
357
 
224
358
  function main() {
@@ -247,11 +381,37 @@ function main() {
247
381
  if (uninstall) {
248
382
  const binDir = binDirIn(localAppData);
249
383
  const raw = readUserPath();
384
+ const mine = join(pluginRootOf(import.meta.url), 'dist', 'mmap.mjs');
385
+ // Remove only what launches THIS install. The canonical pair is shared
386
+ // with every other host running this plugin (one file per shape, last
387
+ // installer wins), so when a file belongs to another install — or to a
388
+ // stranger — it stays, and with it the directory and its PATH entry: the
389
+ // command those files leave behind is theirs, not this uninstall's to take.
390
+ let stillInUse = false;
250
391
  for (const path of [join(binDir, 'mmap.cmd'), join(binDir, 'mmap')]) {
251
- rmSync(path, { force: true });
252
- console.log(`removed ${path}`);
392
+ const content = readIfPresent(path);
393
+ if (content === undefined) continue;
394
+ if (shimLaunches(content, mine)) {
395
+ rmSync(path, { force: true });
396
+ console.log(`removed ${path}`);
397
+ } else stillInUse = true;
398
+ }
399
+ // The fallback copies — the route a refused PATH leaves — obey the same
400
+ // rule, per file, in every candidate directory.
401
+ const home = process.env['USERPROFILE'] ?? process.env['HOME'] ?? '';
402
+ for (const dir of home === '' ? [] : linkDirCandidates(localAppData, home)) {
403
+ for (const candidate of [join(dir, 'mmap.cmd'), join(dir, 'mmap')]) {
404
+ const content = readIfPresent(candidate);
405
+ if (content === undefined || !shimLaunches(content, mine)) continue;
406
+ rmSync(candidate, { force: true });
407
+ console.log(`removed ${candidate}`);
408
+ }
409
+ }
410
+ if (stillInUse) {
411
+ console.log(`kept ${binDir} and its PATH entry: another install of this plugin still launches from there.`);
412
+ } else {
413
+ applyPath(raw, pathWithout(raw, binDir));
253
414
  }
254
- applyPath(raw, pathWithout(raw, binDir));
255
415
  console.log('Close your terminal app entirely and reopen it for the change to take effect — a new tab keeps the old PATH.');
256
416
  return;
257
417
  }
@@ -277,8 +437,14 @@ function main() {
277
437
  break;
278
438
  case 'refused':
279
439
  console.log(`NOT touching your user PATH: ${outcome.reason}.`);
280
- console.log('Add this entry yourself, in Settings > "Edit environment variables for your account":');
281
- console.log(` ${outcome.binDir}`);
440
+ if (outcome.alias !== undefined) {
441
+ console.log(`The command works anyway: both shims were also written to ${outcome.alias.dir},`);
442
+ console.log('which is already on your PATH — open a new terminal and type `mmap`.');
443
+ console.log(`(${outcome.binDir} stays the canonical home; add it in Settings if you prefer one place.)`);
444
+ } else {
445
+ console.log('Add this entry yourself, in Settings > "Edit environment variables for your account":');
446
+ console.log(` ${outcome.binDir}`);
447
+ }
282
448
  break;
283
449
  case 'error':
284
450
  console.error(`setx refused: ${outcome.reason}`);