osdy-pi 1.5.0 → 1.6.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
@@ -34,7 +34,7 @@ Osdy Pi gives [Pi](https://github.com/earendil-works/pi) a themed, responsive te
34
34
  pi install git:github.com/OsdyOrtiz/Osdy-Pi
35
35
  ```
36
36
 
37
- Already using a pinned npm version? `pi update` does not move a pinned version. First-party TODO is included starting in 1.5.0; until that version is published, npm `latest` may still resolve to an earlier release. Check the resolved version or use a checkout to evaluate 1.5.0 before publication. Installing Osdy does not automatically install Joker; see [explicit agents setup](#explicit-joker-agents-setup-in-normal-pi).
37
+ Already using a pinned npm version? `pi update` does not move a pinned version. First-party TODO is included starting in 1.5.0. Starting in 1.6.0, `/osdy-pi uninstall` and explicit agent provider modes are available. Check the resolved version when installing from npm or use a 1.6.0-or-later checkout to try these changes. Installing Osdy does not automatically install Joker; see [explicit agent provider modes](#explicit-agent-provider-modes-in-normal-pi).
38
38
 
39
39
  2. Start Pi:
40
40
 
@@ -100,7 +100,7 @@ git clone https://github.com/Gentleman-Programming/gentle-pi.git
100
100
  osdy-pi gentle setup "$(pwd)/gentle-pi"
101
101
  ```
102
102
 
103
- ## Explicit Joker agents setup in normal Pi
103
+ ## Explicit agent provider modes in normal Pi
104
104
 
105
105
  After `pi install npm:osdy-pi`, open **normal personal Pi** (not `osdy`'s isolated profile) and run:
106
106
 
@@ -108,9 +108,11 @@ After `pi install npm:osdy-pi`, open **normal personal Pi** (not `osdy`'s isolat
108
108
  /osdy-pi agents setup
109
109
  ```
110
110
 
111
- Review the confirmation: it names `~/.pi/agent/settings.json`, installs `npm:pi-subagents-j0k3r` using Pi's package command, and adds only `-extensions/gentle-agents.ts` to each eligible Gentle package entry. It preserves unrelated settings and Gentle resources; if no Gentle package is present, it only installs Joker. After success, Pi reloads resources (restart Pi if reload fails). `/osdy-pi agents status` checks the personal Joker/Gentle setup without writing. Cancel changes nothing; rerunning is safe. This command is available inside Pi after the Pi package install—no global npm CLI or package postinstall hook is required.
111
+ `/osdy-pi agents setup` and `/osdy-pi agents on` ensure **Joker mode**: install Joker with Pi if absent, enable its package extension (`./index.ts`), and exclude only `-extensions/gentle-agents.ts` from eligible Gentle entries. `/osdy-pi agents off` selects **Gentle mode**: require an eligible Gentle package first, exclude only Joker's `./index.ts` using `-./index.ts` in its package entry (if installed), and remove Gentle's agents exclusion. Both packages and all unrelated resources stay installed. `/osdy-pi agents status` reports the settings-derived mode (`joker`, `gentle`, `mixed`, or `unavailable`) without writing. Confirm the named `~/.pi/agent/settings.json` change; after success Pi reloads resources, or prompts a restart if reload fails. Cancel changes nothing; repeating either mode is safe. No runtime task-failure fallback is provided.
112
112
 
113
- Gentle package detection supports `npm:gentle-pi` and validated local checkouts (absolute, settings-relative, `~/`, and `file:` paths). Remote Git/HTTPS Gentle sources are not reconciled: when recognized, setup stops before installing Joker rather than reporting a false success. Register a local Gentle checkout or npm Gentle source in personal Pi first; the existing `osdy-pi gentle setup /absolute/path/to/gentle-pi` command can configure local coexistence if you have that CLI. Setup refuses an isolated/custom `$PI_CODING_AGENT_DIR` or a project-local Gentle override because a personal filter cannot reliably control those contexts. Run it in normal Pi without a conflicting project package; an invalid settings file or failed install produces an error rather than silently applying only part of the filter. It does not edit credentials.
113
+ A pre-existing Joker `-./index.ts` filter without Osdy's ownership marker is ambiguous and blocks switching rather than being removed. Osdy records ownership in the personal settings field `osdyPiJokerExclusionOwned` while it owns that filter and removes the field on return to Joker mode. Existing Gentle agents exclusions are removed by `off`, including exclusions configured before this feature; review that change before confirming. Other package fields and extension filters remain intact. An extension allowlist that omits the agent extension needed for the selected mode blocks switching rather than silently reporting success.
114
+
115
+ Gentle package detection supports `npm:gentle-pi` and validated local checkouts (absolute, settings-relative, `~/`, and `file:` paths). Remote Git/HTTPS Gentle sources are not reconciled: when recognized, setup stops before installing Joker rather than reporting a false success. Register a local Gentle checkout or npm Gentle source in personal Pi first; the existing `osdy-pi gentle setup /absolute/path/to/gentle-pi` command can configure local coexistence if you have that CLI. Mode switching refuses an isolated/custom `$PI_CODING_AGENT_DIR` or a project-local Gentle override because a personal filter cannot reliably control those contexts. Run it in normal Pi without a conflicting project package; an invalid settings file or failed install produces an error rather than silently applying only part of the filter. It does not edit credentials.
114
116
 
115
117
  ## Gentle coexistence setup
116
118
 
@@ -124,7 +126,7 @@ The command validates that the absolute source is a readable `gentle-pi` package
124
126
 
125
127
  Gentle Shell intentionally remains fully active underneath Osdy, including its footer and widgets. While Osdy is enabled, Osdy claims the footer and editor; disabling Osdy restores the editor Gentle Shell provided at session startup. Gentle's changes widget may coexist with Osdy's visual widgets.
126
128
 
127
- The exclusions prevent Gentle's todo, questionnaire, and agents extensions from competing with the suite. Osdy's session-branch snapshots are authoritative for its `todo` tool and read-only `/todos` command; `pi-subagents-j0k3r` remains the subagent system and `@juicesharp/rpiv-ask-user-question` remains the structured-question plugin. This setup does **not** remove an independently installed `@juicesharp/rpiv-todo` package or edit its package entry. Remove that package yourself as described below to avoid duplicate `todo` and `/todos` registrations.
129
+ The exclusions prevent Gentle's todo, questionnaire, and agents extensions from competing with the suite. Osdy's session-branch snapshots are authoritative for its `todo` tool and read-only `/todos` command; `pi-subagents-j0k3r` is the subagent system in Joker mode (Gentle agents in Gentle mode) and `@juicesharp/rpiv-ask-user-question` remains the structured-question plugin. This setup does **not** remove an independently installed `@juicesharp/rpiv-todo` package or edit its package entry. Remove that package yourself as described below to avoid duplicate `todo` and `/todos` registrations.
128
130
 
129
131
  ## First-party TODO included starting in 1.5.0
130
132
 
@@ -134,7 +136,7 @@ In interactive Pi, the persistent widget appears above the editor while visible
134
136
 
135
137
  ### Try session TODO
136
138
 
137
- Use a 1.5.0 checkout (or an installed 1.5.0 package once published), ask the agent to track a task with `todo`, then run `/todos`. The widget shows up to five tasks by default; the modal shows the entire grouped list with arrow/Page Up/Down scrolling. If your `maxWidgetLines` configuration overrides the default, set it to `7` to show five task rows.
139
+ Use an installed 1.5.0-or-later package (or a checkout), ask the agent to track a task with `todo`, then run `/todos`. The widget shows up to five tasks by default; the modal shows the entire grouped list with arrow/Page Up/Down scrolling. If your `maxWidgetLines` configuration overrides the default, set it to `7` to show five task rows.
138
140
 
139
141
  **Verification to date (not complete):** Prior feature checks passed 187 extension tests, 60 script tests, typecheck, and lint. A disposable Pi TUI with seeded tasks showed the modal's progress meter, grouped rows, scroll footer, and close behavior. Authenticated RPC turns exercised all six `todo` actions, and an authenticated TUI smoke observed widget and `/todos` updates. An RPC lifecycle check observed an empty new session and the completed task on switching back. Compaction replay remains unverified because Pi returned “Nothing to compact (session too small).” Language switching in the authenticated TODO flow remains unverified (an isolated TUI with both extensions did switch English to Spanish). Visual strikethrough of completed rows in the authenticated TUI remains unverified. Launch under an installed Osdy account profile remains unverified. These checks do not establish full feature parity or validate a published npm artifact.
140
142
 
@@ -337,8 +339,11 @@ The enabled state, editor mode, working-tree visibility preference, header, and
337
339
  | Working tree | `/osdy-pi working-tree position top\|bottom\|status` |
338
340
  | Audio | `/osdy-pi sound setup` |
339
341
  | Diff | `/osdy-pi diff` |
342
+ | Package | `/osdy-pi uninstall` |
340
343
  | Codex subscription | `/usage` |
341
344
 
345
+ `/osdy-pi uninstall` finds a unique Osdy Pi package in Pi's configured package list and asks you to confirm its exact source and user/project scope before calling Pi's `remove` command. Run it from a trusted project with interactive UI. Git registrations are checked against Pi's host/path identity; a local registration must point to this running extension's package root and have a matching manifest. Ambiguous or changed registrations, unrecognized sources, and cancelled confirmation remove nothing. It removes only the Pi package registration, not Osdy profiles, accounts, the globally installed CLI, or other packages. Restart Pi afterward to unload the extension. If no unique source is found, inspect `pi list` and use `pi remove <source> [-l]` manually.
346
+
342
347
  `/osdy-pi` reports status. `enable` (or `on`) applies the Osdy Pi UI without changing the selected Pi theme; `disable` (or `off`) restores the Gentle Shell or Pi UI captured at session startup while preserving that theme. The enabled state, editor mode, working-tree visibility, and sound configuration persist globally.
343
348
 
344
349
  ### Codex subscription usage
@@ -11,7 +11,7 @@ registerHooks({ resolve(specifier, context, nextResolve) {
11
11
  return { shortCircuit: true, url: new URL("./agent-coexistence-setup.ts", context.parentURL).href };
12
12
  return nextResolve(specifier, context);
13
13
  } });
14
- const { setupJokerAgents } = await import("./agent-coexistence-setup.js");
14
+ const { setupJokerAgents, switchAgentMode, inspectJokerAgents } = await import("./agent-coexistence-setup.js");
15
15
 
16
16
  async function fixture() {
17
17
  const root = await mkdtemp(join(tmpdir(), "osdy-joker-"));
@@ -30,6 +30,167 @@ const base = (f: Awaited<ReturnType<typeof fixture>>) => ({
30
30
  agentDir: f.agentDir, home: f.root, cwd: f.root, env: {},
31
31
  });
32
32
 
33
+ void test("off filters only Joker's extension and enables Gentle; on reverses, both idempotently", async () => {
34
+ const f = await fixture();
35
+ await writeFile(f.settingsPath, JSON.stringify({ packages: [...f.packages, { source: "npm:pi-subagents-j0k3r", skills: ["skills/*"], extensions: ["-other.ts"] }] }));
36
+ await switchAgentMode({ ...base(f), mode: "off" });
37
+ const off = JSON.parse(await readFile(f.settingsPath, "utf8")) as { packages: Array<unknown>; osdyPiJokerExclusionOwned: boolean };
38
+ assert.equal(off.osdyPiJokerExclusionOwned, true);
39
+ assert.deepEqual(off.packages.at(-1), { source: "npm:pi-subagents-j0k3r", skills: ["skills/*"], extensions: ["-other.ts", "-./index.ts"] });
40
+ assert.deepEqual(off.packages.slice(1, 4), [
41
+ { source: f.gentle, extensions: ["-extensions/gentle-todo.ts"], themes: [], skills: ["skills/*"] },
42
+ "npm:gentle-pi@1.2.0", "npm:gentle-pi",
43
+ ]);
44
+ assert.equal((await inspectJokerAgents(base(f))).mode, "gentle");
45
+ assert.equal((await switchAgentMode({ ...base(f), mode: "off" })).changed, false);
46
+ await switchAgentMode({ ...base(f), mode: "on", install: () => Promise.reject(new Error("should not install")) });
47
+ assert.equal((await inspectJokerAgents(base(f))).mode, "joker");
48
+ assert.equal((await switchAgentMode({ ...base(f), mode: "on" })).changed, false);
49
+ const on = JSON.parse(await readFile(f.settingsPath, "utf8")) as { packages: Array<unknown>; osdyPiJokerExclusionOwned?: boolean };
50
+ assert.equal(on.osdyPiJokerExclusionOwned, undefined);
51
+ assert.deepEqual(on.packages.at(-1), { source: "npm:pi-subagents-j0k3r", skills: ["skills/*"], extensions: ["-other.ts"] });
52
+ });
53
+
54
+ void test("off refuses without Gentle and unowned Joker filters without writes", async () => {
55
+ const f = await fixture();
56
+ await writeFile(f.settingsPath, '{"packages":["npm:pi-subagents-j0k3r"]}');
57
+ const original = await readFile(f.settingsPath, "utf8");
58
+ await assert.rejects(switchAgentMode({ ...base(f), mode: "off" }), /Gentle/i);
59
+ assert.equal(await readFile(f.settingsPath, "utf8"), original);
60
+ await writeFile(f.settingsPath, '{"packages":["npm:gentle-pi",{"source":"npm:pi-subagents-j0k3r","extensions":["-./index.ts"]}]}');
61
+ const existing = await readFile(f.settingsPath, "utf8");
62
+ await assert.rejects(switchAgentMode({ ...base(f), mode: "on" }), /unowned|ownership/i);
63
+ await assert.rejects(switchAgentMode({ ...base(f), mode: "off" }), /unowned|ownership/i);
64
+ assert.equal(await readFile(f.settingsPath, "utf8"), existing);
65
+ });
66
+
67
+ void test("off enables Gentle without installing an absent Joker", async () => {
68
+ const f = await fixture();
69
+ const result = await switchAgentMode({ ...base(f), mode: "off", install: () => Promise.reject(new Error("must not install")) });
70
+ assert.equal(result.installed, false);
71
+ assert.equal((await inspectJokerAgents(base(f))).mode, "gentle");
72
+ const settings = JSON.parse(await readFile(f.settingsPath, "utf8")) as { packages: unknown[]; osdyPiJokerExclusionOwned?: boolean };
73
+ assert.equal(settings.osdyPiJokerExclusionOwned, undefined);
74
+ assert.equal(settings.packages.at(-1), "npm:gentle-pi");
75
+ });
76
+
77
+ void test("off restores string and object Gentle registrations without leaving empty filters", async () => {
78
+ const f = await fixture();
79
+ await writeFile(f.settingsPath, JSON.stringify({ theme: "dark", packages: [
80
+ { source: "npm:gentle-pi@1.2.0", extensions: ["-extensions/gentle-agents.ts"] },
81
+ { source: "npm:gentle-pi", skills: ["skills/*"], extensions: ["-extensions/gentle-agents.ts"] },
82
+ "npm:pi-subagents-j0k3r",
83
+ ] }));
84
+ await switchAgentMode({ ...base(f), mode: "off" });
85
+ const settings = JSON.parse(await readFile(f.settingsPath, "utf8")) as { theme: string; packages: unknown[] };
86
+ assert.equal(settings.theme, "dark");
87
+ assert.deepEqual(settings.packages.slice(0, 2), [
88
+ "npm:gentle-pi@1.2.0",
89
+ { source: "npm:gentle-pi", skills: ["skills/*"] },
90
+ ]);
91
+ assert.equal((await inspectJokerAgents(base(f))).mode, "gentle");
92
+ });
93
+
94
+ void test("empty extension arrays disable selected agents, including Joker, without writes", async () => {
95
+ const f = await fixture();
96
+ for (const [mode, packages] of [
97
+ ["off", [{ source: "npm:gentle-pi", extensions: [] }, "npm:pi-subagents-j0k3r"]],
98
+ ["on", ["npm:gentle-pi", { source: "npm:pi-subagents-j0k3r", extensions: [] }]],
99
+ ] as const) {
100
+ await writeFile(f.settingsPath, JSON.stringify({ packages }));
101
+ const before = await readFile(f.settingsPath, "utf8");
102
+ assert.notEqual((await inspectJokerAgents(base(f))).mode, mode === "off" ? "gentle" : "joker");
103
+ await assert.rejects(switchAgentMode({ ...base(f), mode }), /allowlist/i);
104
+ assert.equal(await readFile(f.settingsPath, "utf8"), before);
105
+ }
106
+ });
107
+
108
+ void test("mode switches do not turn disabled extension arrays into all-but-agent filters", async () => {
109
+ const f = await fixture();
110
+ for (const [mode, packages] of [
111
+ ["on", [{ source: "npm:gentle-pi", extensions: [] }, "npm:pi-subagents-j0k3r", "npm:other"]],
112
+ ["off", ["npm:gentle-pi", { source: "npm:pi-subagents-j0k3r", extensions: [] }, "npm:other"]],
113
+ ] as const) {
114
+ await writeFile(f.settingsPath, JSON.stringify({ theme: "dark", packages }));
115
+ const before = await readFile(f.settingsPath, "utf8");
116
+ let installs = 0;
117
+ await assert.rejects(switchAgentMode({ ...base(f), mode, install: () => { installs++; return Promise.resolve(); } }), /disabled|empty|extensions/i);
118
+ assert.equal(installs, 0);
119
+ assert.equal(await readFile(f.settingsPath, "utf8"), before);
120
+ }
121
+ });
122
+
123
+ void test("autoload disabled and Pi exclusion patterns cannot select an agent", async () => {
124
+ const f = await fixture();
125
+ for (const [mode, packages] of [
126
+ ["off", [{ source: "npm:gentle-pi", autoload: false }, "npm:pi-subagents-j0k3r"]],
127
+ ["on", ["npm:gentle-pi", { source: "npm:pi-subagents-j0k3r", autoload: false }]],
128
+ ["off", [{ source: "npm:gentle-pi", extensions: ["extensions/gentle-agents.ts", "!extensions/gentle-agents.ts"] }, "npm:pi-subagents-j0k3r"]],
129
+ ["on", ["npm:gentle-pi", { source: "npm:pi-subagents-j0k3r", extensions: ["./index.ts", "!./index.ts"] }]],
130
+ ["on", ["npm:gentle-pi", { source: "npm:pi-subagents-j0k3r", extensions: ["./index.ts", "!*.ts"] }]],
131
+ ] as const) {
132
+ await writeFile(f.settingsPath, JSON.stringify({ packages }));
133
+ const before = await readFile(f.settingsPath, "utf8");
134
+ assert.notEqual((await inspectJokerAgents(base(f))).mode, mode === "off" ? "gentle" : "joker");
135
+ await assert.rejects(switchAgentMode({ ...base(f), mode }), /autoload|allowlist/i);
136
+ assert.equal(await readFile(f.settingsPath, "utf8"), before);
137
+ }
138
+ });
139
+
140
+ void test("prevalidates known failures before install and reports partial installation on later failure", async () => {
141
+ const f = await fixture();
142
+ let calls = 0;
143
+ const install = async () => { calls++; await writeFile(f.settingsPath, '{"packages":["npm:pi-subagents-j0k3r"]}'); };
144
+ await writeFile(f.settingsPath, '{"packages":["npm:gentle-pi"],"osdyPiJokerExclusionOwned":false}');
145
+ await assert.rejects(switchAgentMode({ ...base(f), mode: "on", install }), /ownership|marker/i);
146
+ assert.equal(calls, 0);
147
+ await writeFile(f.settingsPath, '{"packages":["npm:gentle-pi"]}');
148
+ await assert.rejects(switchAgentMode({ ...base(f), mode: "on", install: async () => {
149
+ await install();
150
+ await writeFile(f.settingsPath, "{bad");
151
+ } }), /Joker may have been installed.*agents status/is);
152
+ assert.equal(calls, 1);
153
+ });
154
+
155
+ void test("off retains remaining positive and negative Gentle filters", async () => {
156
+ const f = await fixture();
157
+ await writeFile(f.settingsPath, JSON.stringify({ packages: [
158
+ { source: "npm:gentle-pi", skills: ["skills/*"], extensions: ["extensions/gentle-agents.ts", "-extensions/gentle-todo.ts", "-extensions/gentle-agents.ts"] },
159
+ "npm:pi-subagents-j0k3r",
160
+ ] }));
161
+ await switchAgentMode({ ...base(f), mode: "off" });
162
+ const settings = JSON.parse(await readFile(f.settingsPath, "utf8")) as { packages: unknown[] };
163
+ assert.deepEqual(settings.packages[0], {
164
+ source: "npm:gentle-pi", skills: ["skills/*"], extensions: ["extensions/gentle-agents.ts", "-extensions/gentle-todo.ts"],
165
+ });
166
+ assert.equal((await inspectJokerAgents(base(f))).mode, "gentle");
167
+ });
168
+
169
+ void test("off validates project override and malformed filters before touching settings", async () => {
170
+ const f = await fixture();
171
+ const project = join(f.root, "project");
172
+ await mkdir(join(project, ".pi"), { recursive: true });
173
+ await writeFile(join(project, ".pi", "settings.json"), '{"packages":["npm:gentle-pi"]}');
174
+ await assert.rejects(switchAgentMode({ ...base(f), cwd: project, mode: "off" }), /project-local Gentle/i);
175
+ await writeFile(f.settingsPath, '{"packages":["npm:gentle-pi",{"source":"npm:pi-subagents-j0k3r","extensions":false}]}');
176
+ const previous = await readFile(f.settingsPath, "utf8");
177
+ await assert.rejects(switchAgentMode({ ...base(f), mode: "off" }), /extensions filter/i);
178
+ assert.equal(await readFile(f.settingsPath, "utf8"), previous);
179
+ });
180
+
181
+ void test("positive extension allowlists that omit selected agents fail closed", async () => {
182
+ const f = await fixture();
183
+ for (const [mode, packages] of [
184
+ ["on", ["npm:gentle-pi", { source: "npm:pi-subagents-j0k3r", extensions: ["./other.ts"] }]],
185
+ ["off", [{ source: "npm:gentle-pi", extensions: ["extensions/gentle-todo.ts"] }, "npm:pi-subagents-j0k3r"]],
186
+ ] as const) {
187
+ await writeFile(f.settingsPath, JSON.stringify({ packages }));
188
+ const previous = await readFile(f.settingsPath, "utf8");
189
+ await assert.rejects(switchAgentMode({ ...base(f), mode }), /allowlist/i);
190
+ assert.equal(await readFile(f.settingsPath, "utf8"), previous);
191
+ }
192
+ });
193
+
33
194
  void test("installs Joker then narrowly reconciles every Gentle entry using latest settings", async () => {
34
195
  const f = await fixture();
35
196
  let calls = 0;
@@ -9,6 +9,8 @@ import { fileURLToPath } from "node:url";
9
9
  const runFile = promisify(execFile);
10
10
  const JOKER = "npm:pi-subagents-j0k3r";
11
11
  const GENTLE_EXCLUSION = "-extensions/gentle-agents.ts";
12
+ const JOKER_EXCLUSION = "-./index.ts";
13
+ const OWNED = "osdyPiJokerExclusionOwned";
12
14
 
13
15
  type Settings = Record<string, unknown> & { packages?: unknown[] };
14
16
 
@@ -65,12 +67,6 @@ async function gentle(entry: unknown, settingsDir: string, home: string): Promis
65
67
  }
66
68
  }
67
69
 
68
- function validateGentleFilters(entry: unknown): void {
69
- if (!object(entry) || entry.extensions === undefined) return;
70
- if (!Array.isArray(entry.extensions) || !entry.extensions.every((value: unknown) => typeof value === "string"))
71
- throw new Error("Gentle extensions filter must be an array of strings.");
72
- }
73
-
74
70
  async function regularSettings(path: string): Promise<Settings> {
75
71
  if (!(await lstat(path)).isFile()) throw new Error("Personal settings.json must be a regular file, not a link.");
76
72
  let value: unknown;
@@ -118,19 +114,87 @@ async function installJoker(): Promise<void> {
118
114
  await runFile("pi", ["install", JOKER], { cwd: homedir(), maxBuffer: 4096 });
119
115
  }
120
116
 
121
- export async function inspectJokerAgents(options: Omit<SetupOptions, "install">): Promise<{ jokerInstalled: boolean; gentleCount: number; filtered: boolean }> {
117
+ export async function inspectJokerAgents(options: Omit<SetupOptions, "install">): Promise<{ jokerInstalled: boolean; gentleCount: number; filtered: boolean; mode: "joker" | "gentle" | "mixed" | "unavailable" }> {
122
118
  const target = await validateTarget(options);
123
119
  const settings = await regularSettings(join(target, "settings.json"));
124
120
  const packages = settings.packages ?? [];
121
+ validatePackages(packages);
125
122
  const flags = await Promise.all(packages.map((entry) => gentle(entry, target, options.home ?? homedir())));
123
+ const filtered = flags.every((isGentle, index) => !isGentle || hasFilter(packages[index], GENTLE_EXCLUSION));
124
+ const jokerInstalled = packages.some(joker);
125
+ const jokerDisabled = packages.some((entry) => joker(entry) && hasFilter(entry, JOKER_EXCLUSION));
126
+ const jokerAllowed = packages.some((entry) => joker(entry) && allowsAgent(entry, "./index.ts"));
127
+ const gentleAllowed = flags.every((flag, i) => !flag || allowsAgent(packages[i], "extensions/gentle-agents.ts"));
126
128
  return {
127
- jokerInstalled: packages.some(joker),
128
- gentleCount: flags.filter(Boolean).length,
129
- filtered: flags.every((isGentle, index) => !isGentle ||
130
- (object(packages[index]) && Array.isArray(packages[index].extensions) && packages[index].extensions.includes(GENTLE_EXCLUSION))),
129
+ jokerInstalled, gentleCount: flags.filter(Boolean).length, filtered,
130
+ mode: jokerInstalled && !jokerDisabled && jokerAllowed && filtered ? "joker" :
131
+ (!jokerInstalled || jokerDisabled) && flags.some(Boolean) && gentleAllowed && flags.every((flag, i) => !flag || !hasFilter(packages[i], GENTLE_EXCLUSION)) ? "gentle" :
132
+ jokerInstalled || flags.some(Boolean) ? "mixed" : "unavailable",
131
133
  };
132
134
  }
133
135
 
136
+ function hasFilter(entry: unknown, filter: string): boolean {
137
+ return object(entry) && Array.isArray(entry.extensions) && entry.extensions.includes(filter);
138
+ }
139
+
140
+ function validatePackages(packages: unknown[]): void {
141
+ if (packages.some((entry) => typeof entry !== "string" && (!object(entry) || typeof entry.source !== "string")))
142
+ throw new Error("Personal settings packages contain an invalid entry.");
143
+ if (packages.filter(joker).length > 1) throw new Error("Personal settings contain duplicate Joker entries.");
144
+ for (const entry of packages) {
145
+ if (object(entry) && entry.extensions !== undefined &&
146
+ (!Array.isArray(entry.extensions) || !entry.extensions.every((value: unknown) => typeof value === "string")))
147
+ throw new Error("Gentle extensions or Joker extensions filter must be an array of strings.");
148
+ }
149
+ }
150
+
151
+ function allowsAgent(entry: unknown, expected: string): boolean {
152
+ if (object(entry) && entry.autoload === false) return false;
153
+ if (!object(entry) || !Array.isArray(entry.extensions)) return true;
154
+ const filters = entry.extensions as string[];
155
+ if (!filters.length) return false;
156
+ // Pi applies plain includes, then ! globs, + exact paths, and finally - exact paths.
157
+ // Refuse uncertain glob matches rather than reporting a provider ready incorrectly.
158
+ const exact = (pattern: string) => pattern.replace(/^\.\//, "") === expected.replace(/^\.\//, "");
159
+ const exclusions = filters.filter((filter) => filter.startsWith("!") || filter.startsWith("-"));
160
+ if (exclusions.some((filter) => filter.slice(1).includes("*") || filter.slice(1).includes("?") || exact(filter.slice(1)))) return false;
161
+ const positives = filters.filter((filter) => !/^[!+-]/.test(filter));
162
+ return !positives.length || positives.some(exact) || filters.some((filter) => filter.startsWith("+") && exact(filter.slice(1)));
163
+ }
164
+
165
+ function assertAgentAllowlists(packages: unknown[], gentleFlags: boolean[], mode: "on" | "off"): void {
166
+ for (const [index, entry] of packages.entries()) {
167
+ // Adding a negative filter to [] makes Pi load every other extension.
168
+ if (((gentleFlags[index] && mode === "on") || (joker(entry) && mode === "off")) &&
169
+ object(entry) && Array.isArray(entry.extensions) && entry.extensions.length === 0)
170
+ throw new Error("Cannot exclude an agent from disabled extensions []; nothing changed.");
171
+ if (!(joker(entry) && mode === "on") && !(gentleFlags[index] && mode === "off")) continue;
172
+ const expected = joker(entry) ? "./index.ts" : "extensions/gentle-agents.ts";
173
+ // The mode switch removes only its own known exclusion before selecting this agent.
174
+ const ownedFilter = joker(entry) ? JOKER_EXCLUSION : GENTLE_EXCLUSION;
175
+ let selected = entry;
176
+ if (object(entry) && Array.isArray(entry.extensions) && entry.extensions.includes(ownedFilter)) {
177
+ const remaining = entry.extensions.filter((value: unknown) => value !== ownedFilter);
178
+ const updated = { ...entry };
179
+ if (remaining.length) updated.extensions = remaining;
180
+ else delete updated.extensions;
181
+ selected = updated;
182
+ }
183
+ if (!allowsAgent(selected, expected))
184
+ throw new Error(`Package autoload or extension allowlist excludes ${expected}; cannot guarantee selected agent mode.`);
185
+ }
186
+ }
187
+
188
+ async function saveSettings(path: string, settings: Settings): Promise<void> {
189
+ const temporary = join(dirname(path), `.settings.${randomUUID()}.tmp`);
190
+ try {
191
+ await writeFile(temporary, `${JSON.stringify(settings, null, 2)}\n`, { flag: "wx", mode: 0o600 });
192
+ await rename(temporary, path);
193
+ } finally {
194
+ await unlink(temporary).catch(() => {});
195
+ }
196
+ }
197
+
134
198
  async function validateTarget(options: Omit<SetupOptions, "install">): Promise<string> {
135
199
  const home = resolve(options.home ?? homedir());
136
200
  const target = resolve(options.agentDir);
@@ -149,48 +213,76 @@ async function validateTarget(options: Omit<SetupOptions, "install">): Promise<s
149
213
  }
150
214
 
151
215
  export async function setupJokerAgents(options: SetupOptions): Promise<{ installed: boolean; changed: boolean; gentleCount: number }> {
216
+ return switchAgentMode({ ...options, mode: "on" });
217
+ }
218
+
219
+ export async function switchAgentMode(options: SetupOptions & { mode: "on" | "off" }): Promise<{ installed: boolean; changed: boolean; gentleCount: number }> {
152
220
  const target = await validateTarget(options);
153
221
  const path = join(target, "settings.json");
154
222
  const before = await regularSettings(path);
155
223
  const original = before.packages ?? [];
156
- if (original.some((entry) => entry !== null && typeof entry !== "string" && !object(entry)))
157
- throw new Error("Personal settings packages contain an invalid entry.");
224
+ validatePackages(original);
158
225
  const initialGentle = await Promise.all(original.map((entry) => gentle(entry, target, options.home ?? homedir())));
159
- if (original.filter(joker).length > 1) throw new Error("Personal settings contain duplicate Joker entries.");
160
226
  const alreadyJoker = original.some(joker);
161
- for (const [index, isGentle] of initialGentle.entries()) {
162
- if (isGentle) validateGentleFilters(original[index]);
163
- }
164
- const filtered = initialGentle.every((isGentle, index) => !isGentle ||
165
- (object(original[index]) && Array.isArray(original[index].extensions) && original[index].extensions.includes(GENTLE_EXCLUSION)));
166
- if (alreadyJoker && filtered) return { installed: false, changed: false, gentleCount: initialGentle.filter(Boolean).length };
167
- if (!alreadyJoker) await (options.install ?? installJoker)();
227
+ if (options.mode === "off" && !initialGentle.some(Boolean))
228
+ throw new Error("Gentle agents require an eligible personal Gentle package; nothing changed.");
229
+ if (before[OWNED] !== undefined && before[OWNED] !== true)
230
+ throw new Error("Invalid Joker filter ownership marker; nothing changed.");
231
+ if (before[OWNED] === true && !original.some((entry) => joker(entry) && hasFilter(entry, JOKER_EXCLUSION)))
232
+ throw new Error("Joker filter ownership marker does not match settings; nothing changed.");
233
+ if (original.some((entry) => joker(entry) && hasFilter(entry, JOKER_EXCLUSION)) && before[OWNED] !== true)
234
+ throw new Error("Joker extension has an unowned exclusion; cannot change agent mode.");
235
+ assertAgentAllowlists(original, initialGentle, options.mode);
236
+ // All failures known from the original settings must be checked before Pi install can mutate them.
237
+ let installAttempted = false;
238
+ try {
239
+ if (options.mode === "on" && !alreadyJoker) {
240
+ installAttempted = true;
241
+ await (options.install ?? installJoker)();
242
+ }
168
243
  // Install owns only Joker. Re-read the latest settings instead of replaying a stale snapshot.
169
244
  await assertNoProjectGentle(options.cwd, options.home ?? homedir());
170
245
  const latest = await regularSettings(path);
171
246
  const packages = latest.packages ?? [];
172
- if (!packages.some(joker)) throw new Error("Pi install did not register Joker in personal settings; no Gentle filters were changed.");
173
- if (packages.filter(joker).length > 1) throw new Error("Personal settings contain duplicate Joker entries.");
247
+ validatePackages(packages);
248
+ if (options.mode === "on" && !packages.some(joker)) throw new Error("Joker is not registered in personal settings; no filters were changed.");
249
+ if (latest[OWNED] !== undefined && latest[OWNED] !== true)
250
+ throw new Error("Invalid Joker filter ownership marker; nothing changed.");
251
+ if (packages.some((entry) => joker(entry) && hasFilter(entry, JOKER_EXCLUSION)) && latest[OWNED] !== true)
252
+ throw new Error("Joker extension has an unowned exclusion; cannot change agent mode.");
253
+ if (latest[OWNED] === true && !packages.some((entry) => joker(entry) && hasFilter(entry, JOKER_EXCLUSION)))
254
+ throw new Error("Joker filter ownership marker does not match settings; nothing changed.");
174
255
  const gentleFlags = await Promise.all(packages.map((entry) => gentle(entry, target, options.home ?? homedir())));
256
+ assertAgentAllowlists(packages, gentleFlags, options.mode);
257
+ if (options.mode === "off" && !gentleFlags.some(Boolean)) throw new Error("No eligible Gentle package; nothing changed.");
175
258
  let changed = false;
176
259
  const updated = packages.map((entry, index) => {
177
- if (!gentleFlags[index]) return entry;
178
- if (typeof entry !== "string" && !object(entry)) throw new Error("Gentle package entry is invalid.");
179
- const item = typeof entry === "string" ? { source: entry } : entry;
180
- validateGentleFilters(item);
260
+ const isGentle = gentleFlags[index];
261
+ if (!isGentle && !joker(entry)) return entry;
262
+ const filter = isGentle ? GENTLE_EXCLUSION : JOKER_EXCLUSION;
263
+ const shouldExclude = isGentle ? options.mode === "on" : options.mode === "off";
264
+ const item = typeof entry === "string" ? { source: entry } : entry as Record<string, unknown>;
181
265
  const extensions = item.extensions as string[] | undefined;
182
- if (extensions?.includes(GENTLE_EXCLUSION)) return entry;
266
+ if (shouldExclude === (extensions?.includes(filter) ?? false)) return entry;
183
267
  changed = true;
184
- return { ...item, extensions: [...(extensions ?? []), GENTLE_EXCLUSION] };
185
- });
186
- if (changed) {
187
- const temporary = join(target, `.settings.${randomUUID()}.tmp`);
188
- try {
189
- await writeFile(temporary, `${JSON.stringify({ ...latest, packages: updated }, null, 2)}\n`, { flag: "wx", mode: 0o600 });
190
- await rename(temporary, path);
191
- } finally {
192
- await unlink(temporary).catch(() => {});
268
+ const next = shouldExclude ? [...(extensions ?? []), filter] : extensions!.filter((value) => value !== filter);
269
+ if (!next.length) {
270
+ const rest = { ...item };
271
+ delete rest.extensions;
272
+ return Object.keys(rest).length === 1 ? rest.source : rest;
193
273
  }
274
+ return { ...item, extensions: next };
275
+ });
276
+ const ownershipChanged = (latest[OWNED] === true) !== (options.mode === "off" && packages.some(joker));
277
+ if (changed || ownershipChanged) {
278
+ const saved: Settings = { ...latest, packages: updated };
279
+ if (options.mode === "off" && packages.some(joker)) saved[OWNED] = true;
280
+ else delete saved[OWNED];
281
+ await saveSettings(path, saved);
282
+ }
283
+ return { installed: options.mode === "on" && !alreadyJoker, changed: changed || ownershipChanged, gentleCount: gentleFlags.filter(Boolean).length };
284
+ } catch (error) {
285
+ if (!installAttempted) throw error;
286
+ throw new Error(`Joker may have been installed even though agent setup failed: ${error instanceof Error ? error.message : String(error)} Run /osdy-pi agents status, inspect personal Pi settings.json, and retry /osdy-pi agents on after resolving the issue.`, { cause: error });
194
287
  }
195
- return { installed: !alreadyJoker, changed, gentleCount: gentleFlags.filter(Boolean).length };
196
288
  }
@@ -117,15 +117,30 @@ void test("agents setup requires interactive confirmation and reloads only after
117
117
  await handleAgentsSetupCommand("setup", ctx, setup);
118
118
  assert.equal(installs, 1);
119
119
  assert.equal(reloads, 1);
120
- await handleAgentsSetupCommand("status", ctx, setup, () => Promise.resolve({ jokerInstalled: true, gentleCount: 1, filtered: true }));
120
+ await handleAgentsSetupCommand("status", ctx, setup, () => Promise.resolve({ jokerInstalled: true, gentleCount: 1, filtered: true, mode: "joker" as const }));
121
121
  assert.ok(notices.some((notice) => notice.includes("agent exclusion complete")));
122
122
  await handleAgentsSetupCommand("invalid", ctx, setup);
123
123
  assert.equal(installs, 1);
124
124
  assert.ok(notices.some((notice) => notice.includes("Usage:")));
125
- assert.deepEqual(getOsdyCommandCompletions("agents"), [
126
- { value: "agents setup", label: "agents setup" },
127
- { value: "agents status", label: "agents status" },
128
- ]);
125
+ assert.deepEqual(getOsdyCommandCompletions("agents"), ["setup", "on", "off", "status"].map((action) => ({ value: `agents ${action}`, label: `agents ${action}` })));
126
+ });
127
+
128
+ void test("agents off confirms and switches, reports actual mode, and guides restart on failed reload", async () => {
129
+ const notices: string[] = [];
130
+ const confirmations: string[] = [];
131
+ const modes: string[] = [];
132
+ const ctx = { hasUI: true, cwd: "/tmp", ui: {
133
+ confirm: (_title: string, message: string) => { confirmations.push(message); return Promise.resolve(true); },
134
+ notify: (message: string) => { notices.push(message); },
135
+ }, reload: () => Promise.reject(new Error("reload failed")) } as unknown as ExtensionCommandContext;
136
+ await handleAgentsSetupCommand("off", ctx, undefined, () => Promise.resolve({ jokerInstalled: true, gentleCount: 1, filtered: false, mode: "gentle" }),
137
+ (options) => { modes.push(options.mode); return Promise.resolve({ installed: false, changed: true, gentleCount: 1 }); });
138
+ assert.deepEqual(modes, ["off"]);
139
+ assert.match(confirmations[0] ?? "", /Joker.*Gentle|Gentle.*Joker/i);
140
+ assert.ok(notices.some((notice) => /restart Pi/i.test(notice)));
141
+ await handleAgentsSetupCommand("status", ctx, undefined, () => Promise.resolve({ jokerInstalled: true, gentleCount: 1, filtered: false, mode: "gentle" }));
142
+ assert.ok(notices.some((notice) => /Gentle/.test(notice)));
143
+ assert.deepEqual(getOsdyCommandCompletions("agents"), ["setup", "on", "off", "status"].map((action) => ({ value: `agents ${action}`, label: `agents ${action}` })));
129
144
  });
130
145
 
131
146
  void test("header command completes only catalog choices and is persisted through session wiring", () => {
@@ -151,6 +166,17 @@ void test("header command completes only catalog choices and is persisted throug
151
166
  assert.doesNotMatch(source, /if \(isHeaderVariant\(action\)\)/);
152
167
  });
153
168
 
169
+ void test("uninstall command is offered and wired through guarded Pi CLI removal", () => {
170
+ assert.deepEqual(getOsdyCommandCompletions("uninstall"), [
171
+ { value: "uninstall", label: "uninstall" },
172
+ ]);
173
+ const source = readFileSync(new URL("./runtime.ts", import.meta.url), "utf8");
174
+ assert.match(source, /action === "uninstall"/);
175
+ assert.match(source, /!ctx\.hasUI \|\| !ctx\.isProjectTrusted\(\)/);
176
+ assert.match(source, /runOsdyUninstall\(/);
177
+ assert.match(source, /pi\.exec\("pi", \["remove", source/);
178
+ });
179
+
154
180
  void test("mascot command completes current, Bts, and status while persisting with immediate refresh", () => {
155
181
  assert.deepEqual(getOsdyCommandCompletions("mascot"), [
156
182
  { value: "mascot current", label: "mascot current" },
@@ -4,8 +4,9 @@ import type {
4
4
  ExtensionContext,
5
5
  } from "@earendil-works/pi-coding-agent";
6
6
  import { homedir } from "node:os";
7
+ import { identifyLocalPackage, runOsdyUninstall } from "./uninstall.js";
7
8
  import { join } from "node:path";
8
- import { inspectJokerAgents, setupJokerAgents } from "./agent-coexistence-setup.js";
9
+ import { inspectJokerAgents, setupJokerAgents, switchAgentMode } from "./agent-coexistence-setup.js";
9
10
  import { createAudioEventRouter } from "./audio-event-router.js";
10
11
  import { registerAudioNotificationFlags } from "./audio-notification-config.js";
11
12
  import { createAudioNotificationService } from "./audio-notification-service.js";
@@ -266,10 +267,11 @@ export function getOsdyCommandCompletions(prefix: string) {
266
267
  "mascot",
267
268
  "header",
268
269
  "diff",
270
+ "uninstall",
269
271
  ].map((value) => ({ value, label: value }));
270
272
  }
271
273
  if (trimmed === "agents") {
272
- return ["agents setup", "agents status"].map((value) => ({ value, label: value }));
274
+ return ["agents setup", "agents on", "agents off", "agents status"].map((value) => ({ value, label: value }));
273
275
  }
274
276
  if (trimmed === "sound") {
275
277
  return [{ value: "sound setup", label: "sound setup" }];
@@ -322,6 +324,7 @@ export function getOsdyCommandCompletions(prefix: string) {
322
324
  "mascot",
323
325
  "header",
324
326
  "diff",
327
+ "uninstall",
325
328
  ]
326
329
  .filter((value) => value.startsWith(valuePrefix))
327
330
  .map((value) => ({ value, label: value }));
@@ -717,9 +720,10 @@ export async function handleAgentsSetupCommand(
717
720
  ctx: ExtensionCommandContext,
718
721
  setup: typeof setupJokerAgents = setupJokerAgents,
719
722
  inspect: typeof inspectJokerAgents = inspectJokerAgents,
723
+ switchMode: typeof switchAgentMode = switchAgentMode,
720
724
  ): Promise<void> {
721
- if (action !== "setup" && action !== "status") {
722
- ctx.ui.notify("Usage: /osdy-pi agents setup|status", "warning");
725
+ if (action !== "setup" && action !== "on" && action !== "off" && action !== "status") {
726
+ ctx.ui.notify("Usage: /osdy-pi agents setup|on|off|status", "warning");
723
727
  return;
724
728
  }
725
729
  if (!ctx.hasUI) {
@@ -730,20 +734,21 @@ export async function handleAgentsSetupCommand(
730
734
  if (action === "status") {
731
735
  try {
732
736
  const status = await inspect({ agentDir: target, cwd: ctx.cwd, env: process.env });
733
- ctx.ui.notify(`Joker ${status.jokerInstalled ? "installed" : "not installed"}; ${status.gentleCount} Gentle entries; agent exclusion ${status.filtered ? "complete" : "incomplete"}.`, "info");
737
+ ctx.ui.notify(`Agent mode: ${status.mode}; Joker ${status.jokerInstalled ? "installed" : "not installed"}; ${status.gentleCount} Gentle entries; agent exclusion ${status.filtered ? "complete" : "incomplete"}.`, "info");
734
738
  } catch (error) {
735
739
  ctx.ui.notify(`Agents status unavailable: ${error instanceof Error ? error.message : "unknown error"}`, "warning");
736
740
  }
737
741
  return;
738
742
  }
739
743
  const confirmed = await ctx.ui.confirm(
740
- "Set up Joker agents in normal Pi?",
741
- `Target: ${join(target, "settings.json")}\nInstall npm:pi-subagents-j0k3r with Pi and exclude only -extensions/gentle-agents.ts from eligible Gentle packages. No isolated profile or credentials are changed.`,
744
+ `Switch agents to ${action === "off" ? "Gentle" : "Joker"} in normal Pi?`,
745
+ `Target: ${join(target, "settings.json")}\n${action === "off" ? "Disable Joker's ./index.ts extension and enable Gentle agents (requires eligible Gentle)." : "Install npm:pi-subagents-j0k3r if absent, enable Joker's ./index.ts extension and exclude only -extensions/gentle-agents.ts from eligible Gentle packages."} Packages and unrelated resources remain installed. No isolated profile or credentials are changed.`,
742
746
  );
743
747
  if (!confirmed) return;
744
748
  try {
745
- const result = await setup({ agentDir: target, cwd: ctx.cwd, env: process.env });
746
- ctx.ui.notify(`Joker agents ready (${result.gentleCount} Gentle entries checked). Reloading resources.`, "info");
749
+ const result = action === "setup" ? await setup({ agentDir: target, cwd: ctx.cwd, env: process.env }) :
750
+ await switchMode({ agentDir: target, cwd: ctx.cwd, env: process.env, mode: action });
751
+ ctx.ui.notify(`${action === "off" ? "Gentle" : "Joker"} agents ready (${result.gentleCount} Gentle entries checked). ${result.changed || result.installed ? "Reloading resources." : "Settings already in this mode; reloading resources."}`, "info");
747
752
  } catch (error) {
748
753
  ctx.ui.notify(`Agents setup failed: ${error instanceof Error ? error.message : "unknown error"}. Check settings and retry after resolving the error.`, "error");
749
754
  return;
@@ -751,7 +756,7 @@ export async function handleAgentsSetupCommand(
751
756
  try {
752
757
  await ctx.reload();
753
758
  } catch {
754
- ctx.ui.notify("Agents setup saved, but reload failed; restart Pi to apply the changes.", "warning");
759
+ ctx.ui.notify("Agent settings saved, but reload failed; restart Pi to apply the changes.", "warning");
755
760
  }
756
761
  }
757
762
 
@@ -772,6 +777,27 @@ function registerCommand(
772
777
  getArgumentCompletions: getOsdyCommandCompletions,
773
778
  handler: async (args, ctx) => {
774
779
  const [action = "status", ...rest] = parseCommandArgs(args);
780
+ if (action === "uninstall") {
781
+ if (rest.length || !ctx.hasUI || !ctx.isProjectTrusted()) {
782
+ ctx.ui.notify("Uninstall requires an interactive, trusted project and no extra arguments; nothing was removed.", "warning");
783
+ return;
784
+ }
785
+ await runOsdyUninstall({
786
+ list: async () => {
787
+ const result = await pi.exec("pi", ["list", "--approve"], { cwd: ctx.cwd });
788
+ if (result.code !== 0 || result.stderr.trim()) throw new Error("pi list failed");
789
+ return result.stdout;
790
+ },
791
+ remove: async (source, local) => {
792
+ const result = await pi.exec("pi", ["remove", source, ...(local ? ["--local", "--approve"] : [])], { cwd: ctx.cwd });
793
+ if (result.code !== 0) throw new Error("pi remove failed");
794
+ },
795
+ identifyLocal: (path) => identifyLocalPackage(path, import.meta.url),
796
+ confirm: (source, scope) => ctx.ui.confirm("Uninstall Osdy Pi?", `Remove only the Pi package registration:\nSource: ${source}\nScope: ${scope}\nProfiles, accounts and global CLI stay intact. Continue?`),
797
+ notify: (message, level = "info") => ctx.ui.notify(message, level),
798
+ });
799
+ return;
800
+ }
775
801
  if (action === "agents") {
776
802
  await handleAgentsSetupCommand(rest.length === 1 ? rest[0] : undefined, ctx);
777
803
  return;
@@ -864,7 +890,7 @@ function registerCommand(
864
890
  return;
865
891
  }
866
892
  ctx.ui.notify(
867
- `Usage: /osdy-pi agents setup|status | enable|disable|on|off|status | mascot ${MASCOT_CHOICES.join("|")}|status | header ${HEADER_VARIANT_CHOICES.join("|")}|status | editor auto|extended|simple|on|off|toggle|status | sound setup | working-tree ... | diff`,
893
+ `Usage: /osdy-pi agents setup|on|off|status | enable|disable|on|off|status | mascot ${MASCOT_CHOICES.join("|")}|status | header ${HEADER_VARIANT_CHOICES.join("|")}|status | editor auto|extended|simple|on|off|toggle|status | sound setup | working-tree ... | diff | uninstall`,
868
894
  "warning",
869
895
  );
870
896
  },
@@ -0,0 +1,142 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import { existsSync, readFileSync } from "node:fs";
4
+ import { registerHooks } from "node:module";
5
+ import { fileURLToPath } from "node:url";
6
+ import ts from "typescript";
7
+
8
+ registerHooks({
9
+ resolve(specifier, context, nextResolve) {
10
+ if (specifier.startsWith(".") && specifier.endsWith(".js") && context.parentURL?.endsWith(".ts")) {
11
+ const url = new URL(`${specifier.slice(0, -3)}.ts`, context.parentURL);
12
+ if (existsSync(fileURLToPath(url))) return { shortCircuit: true, url: url.href };
13
+ }
14
+ return nextResolve(specifier, context);
15
+ },
16
+ load(url, context, nextLoad) {
17
+ if (url.endsWith(".ts")) return { format: "module", shortCircuit: true, source: ts.transpileModule(readFileSync(fileURLToPath(url), "utf8"), { compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 } }).outputText };
18
+ return nextLoad(url, context);
19
+ },
20
+ });
21
+ const { runOsdyUninstall, identifyLocalPackage } = await import("./uninstall.js");
22
+
23
+ const listing = "User packages:\n npm:osdy-pi@1.5.0\n npm:other\n";
24
+ function harness(output = listing) {
25
+ const calls: Array<{ command: string; args: string[] }> = [];
26
+ const notices: string[] = [];
27
+ let confirmed = true;
28
+ const api = {
29
+ list: () => Promise.resolve(output),
30
+ remove: (source: string, local: boolean) => { calls.push({ command: source, args: local ? ["--local"] : [] }); return Promise.resolve(); },
31
+ confirm: () => Promise.resolve(confirmed),
32
+ notify: (message: string) => notices.push(message),
33
+ };
34
+ return { api, calls, notices, cancel: () => { confirmed = false; } };
35
+ }
36
+
37
+ void test("removes exactly one confirmed registration without touching unrelated packages", async () => {
38
+ const h = harness();
39
+ await runOsdyUninstall(h.api);
40
+ assert.deepEqual(h.calls, [{ command: "npm:osdy-pi@1.5.0", args: [] }]);
41
+ assert.match(h.notices.at(-1) ?? "", /restart Pi/i);
42
+ });
43
+
44
+ void test("cancellation and ambiguous scopes never remove", async () => {
45
+ const cancelled = harness(); cancelled.cancel();
46
+ await runOsdyUninstall(cancelled.api);
47
+ assert.equal(cancelled.calls.length, 0);
48
+ const ambiguous = harness("User packages:\n npm:osdy-pi\nProject packages:\n npm:osdy-pi\n");
49
+ await runOsdyUninstall(ambiguous.api);
50
+ assert.equal(ambiguous.calls.length, 0);
51
+ });
52
+
53
+ void test("unrecognized list output fails closed", async () => {
54
+ const h = harness("User packages:\n npm:osdy-pi\nmalformed\n");
55
+ await runOsdyUninstall(h.api);
56
+ assert.equal(h.calls.length, 0);
57
+ });
58
+
59
+ void test("git project registration uses local scope and exact ref", async () => {
60
+ const h = harness("Project packages:\n git:github.com/OsdyOrtiz/Osdy-Pi@v1\n npm:unrelated\n");
61
+ await runOsdyUninstall(h.api);
62
+ assert.deepEqual(h.calls, [{ command: "git:github.com/OsdyOrtiz/Osdy-Pi@v1", args: ["--local"] }]);
63
+ });
64
+
65
+ void test("Pi-prefixed HTTPS Git source is removable when unique", async () => {
66
+ const h = harness("User packages:\n git:https://github.com/OsdyOrtiz/Osdy-Pi@main\n");
67
+ await runOsdyUninstall(h.api);
68
+ assert.deepEqual(h.calls, [{ command: "git:https://github.com/OsdyOrtiz/Osdy-Pi@main", args: [] }]);
69
+ });
70
+
71
+ void test("equivalent Git URLs in one scope are ambiguous", async () => {
72
+ for (const duplicate of [
73
+ "https://github.com/OsdyOrtiz/Osdy-Pi.git",
74
+ "git:https://github.com/OsdyOrtiz/Osdy-Pi@main",
75
+ "git:ssh://git@github.com/OsdyOrtiz/Osdy-Pi",
76
+ "git:git@github.com:OsdyOrtiz/Osdy-Pi.git",
77
+ "git:git+https://github.com/OsdyOrtiz/Osdy-Pi",
78
+ "git:github.com/OsdyOrtiz/Osdy-Pi",
79
+ ]) {
80
+ const h = harness(`User packages:\n git:github.com/OsdyOrtiz/Osdy-Pi\n ${duplicate}\n`);
81
+ await runOsdyUninstall(h.api);
82
+ assert.equal(h.calls.length, 0, duplicate);
83
+ }
84
+ });
85
+
86
+ void test("unsupported Git spelling is never selected", async () => {
87
+ const h = harness("User packages:\n git@github.com:OsdyOrtiz/Osdy-Pi\n");
88
+ await runOsdyUninstall(h.api);
89
+ assert.equal(h.calls.length, 0);
90
+ });
91
+
92
+ void test("confirmation rechecks source, scope, and duplicate identities", async () => {
93
+ for (const changed of [
94
+ "User packages:\n npm:osdy-pi@2.0.0\n",
95
+ "Project packages:\n npm:osdy-pi@1.5.0\n",
96
+ "User packages:\n npm:osdy-pi@1.5.0\n npm:osdy-pi@2.0.0\n",
97
+ "User packages:\n git:github.com/OsdyOrtiz/Osdy-Pi\n",
98
+ "User packages:\n npm:osdy-pi@1.5.0\n npm:other\nProject packages:\n npm:unrelated\n",
99
+ ]) {
100
+ const h = harness();
101
+ let current = listing;
102
+ await runOsdyUninstall({ ...h.api, list: () => Promise.resolve(current), confirm: () => { current = changed; return Promise.resolve(true); } });
103
+ assert.equal(h.calls.length, 0, changed);
104
+ }
105
+ });
106
+
107
+ void test("local provenance requires the executing package root, not only a matching manifest", async () => {
108
+ const manifest = JSON.stringify({ name: "osdy-pi", pi: { extensions: ["./extensions/osdy-pi.ts"] } });
109
+ const io = {
110
+ readFile: () => Promise.resolve(manifest),
111
+ realpath: (path: string) => Promise.resolve(path === "/installed/copy" ? "/installed/copy" : "/running/package"),
112
+ };
113
+ assert.equal(await identifyLocalPackage("/installed/copy", import.meta.url, io), false);
114
+ assert.equal(await identifyLocalPackage("/running/package", import.meta.url, io), true);
115
+ assert.equal(await identifyLocalPackage("/running/package", import.meta.url, { ...io, readFile: () => Promise.resolve("{}") }), false);
116
+ });
117
+
118
+ void test("local provenance is rechecked after confirmation", async () => {
119
+ const h = harness("User packages:\n ./Osdy-Pi\n /running/package\n");
120
+ let trusted = true;
121
+ await runOsdyUninstall({
122
+ ...h.api,
123
+ identifyLocal: () => Promise.resolve(trusted),
124
+ confirm: () => { trusted = false; return Promise.resolve(true); },
125
+ });
126
+ assert.equal(h.calls.length, 0);
127
+ });
128
+
129
+ void test("local registrations require identified package path and do not remove other local packages", async () => {
130
+ const h = harness("User packages:\n ../Osdy-Pi\n /home/user/Osdy-Pi\n ./other\n /home/user/other\n");
131
+ await runOsdyUninstall({ ...h.api, identifyLocal: (path) => Promise.resolve(path === "/home/user/Osdy-Pi") });
132
+ assert.deepEqual(h.calls, [{ command: "/home/user/Osdy-Pi", args: [] }]);
133
+ const unknown = harness("User packages:\n ../Osdy-Pi\n /home/user/Osdy-Pi\n");
134
+ await runOsdyUninstall(unknown.api);
135
+ assert.equal(unknown.calls.length, 0);
136
+ });
137
+
138
+ void test("remove errors do not report success", async () => {
139
+ const h = harness();
140
+ await runOsdyUninstall({ ...h.api, remove: () => Promise.reject(new Error("remove failed")) });
141
+ assert.equal(h.notices.some((message) => message.startsWith("Removed")), false);
142
+ });
@@ -0,0 +1,141 @@
1
+ import { readFile, realpath } from "node:fs/promises";
2
+ import { isAbsolute, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ export interface UninstallPort {
6
+ list(): Promise<string>;
7
+ remove(source: string, local: boolean): Promise<void>;
8
+ confirm(source: string, scope: "user" | "project"): Promise<boolean>;
9
+ notify(message: string, level?: "info" | "warning"): void;
10
+ identifyLocal?(path: string): Promise<boolean>;
11
+ }
12
+
13
+ type Registration = { source: string; scope: "user" | "project"; path?: string };
14
+ type GitSource = { type: "git"; host: string; path: string };
15
+ type GitParser = { parseGitUrl(source: string): GitSource | null };
16
+
17
+ // Use the parser shipped with the installed Pi CLI: its host/path pair is
18
+ // also the key Pi uses when removing declarations from a scope's settings.
19
+ async function gitParser(): Promise<GitParser> {
20
+ const packageEntry = import.meta.resolve("@earendil-works/pi-coding-agent");
21
+ const parserUrl = new URL("./utils/git.js", packageEntry);
22
+ const module: unknown = await import(parserUrl.href);
23
+ if (typeof module !== "object" || module === null || !("parseGitUrl" in module) || typeof module.parseGitUrl !== "function") {
24
+ throw new Error("Pi Git parser unavailable");
25
+ }
26
+ return module as GitParser;
27
+ }
28
+
29
+ // `pi list` has no machine-readable mode. Reject unknown output instead of guessing
30
+ // which section owns a source or passing a display label to `pi remove`.
31
+ function parseList(output: string): Registration[] {
32
+ const entries: Registration[] = [];
33
+ let scope: Registration["scope"] | undefined;
34
+ let last: Registration | undefined;
35
+ for (const line of output.trimEnd().split("\n")) {
36
+ if (line === "No packages installed.") return [];
37
+ if (line === "User packages:") { scope = "user"; last = undefined; continue; }
38
+ if (line === "Project packages:") { scope = "project"; last = undefined; continue; }
39
+ if (line === "") continue;
40
+ if (line.startsWith(" ") && last && isAbsolute(line.trim())) {
41
+ last.path = line.trim(); continue;
42
+ }
43
+ const match = /^ {2}(\S+?)(?: \(filtered\))?$/.exec(line);
44
+ if (!match?.[1] || !scope) throw new Error("Unrecognized pi list output");
45
+ last = { source: match[1], scope };
46
+ entries.push(last);
47
+ }
48
+ return entries;
49
+ }
50
+
51
+ // Limit the source we submit to remove to protocol forms Pi actually parses.
52
+ // The installed parser, rather than this allowlist, decides identity.
53
+ function supportedGitSource(source: string): boolean {
54
+ return /^(?:git:(?:github\.com\/|https?:\/\/|ssh:\/\/|git:\/\/|git@github\.com:)|https?:\/\/|ssh:\/\/|git:\/\/)/.test(source);
55
+ }
56
+
57
+ function isLocalSource(source: string): boolean {
58
+ return source.startsWith("/") || source.startsWith(".") || source.startsWith("~");
59
+ }
60
+
61
+ /** Only a registration pointing to the package executing this code is trusted. */
62
+ export async function identifyLocalPackage(
63
+ path: string,
64
+ extensionUrl: string,
65
+ io: { realpath(path: string): Promise<string>; readFile(path: string, encoding: "utf8"): Promise<string> } = { realpath, readFile },
66
+ ): Promise<boolean> {
67
+ try {
68
+ if (!isAbsolute(path)) return false;
69
+ const root = fileURLToPath(new URL("../../", extensionUrl));
70
+ if (await io.realpath(path) !== await io.realpath(root)) return false;
71
+ const manifest: unknown = JSON.parse(await io.readFile(join(path, "package.json"), "utf8"));
72
+ return typeof manifest === "object" && manifest !== null && "name" in manifest && manifest.name === "osdy-pi" &&
73
+ "pi" in manifest && typeof manifest.pi === "object" && manifest.pi !== null && "extensions" in manifest.pi &&
74
+ Array.isArray(manifest.pi.extensions) && manifest.pi.extensions.includes("./extensions/osdy-pi.ts");
75
+ } catch { return false; }
76
+ }
77
+
78
+ async function select(entries: Registration[], port: UninstallPort, parseGit: GitParser["parseGitUrl"]): Promise<(Registration & { removeSource: string }) | undefined> {
79
+ const candidates: Array<Registration & { removeSource: string }> = [];
80
+ for (const entry of entries) {
81
+ if (/^npm:osdy-pi(?:@[^\s]+)?$/.test(entry.source)) {
82
+ candidates.push({ ...entry, removeSource: entry.source });
83
+ } else if (supportedGitSource(entry.source)) {
84
+ const git = parseGit(entry.source);
85
+ if (git?.type === "git" && git.host === "github.com" && git.path === "OsdyOrtiz/Osdy-Pi") {
86
+ candidates.push({ ...entry, removeSource: entry.source });
87
+ }
88
+ } else if (isLocalSource(entry.source) && entry.path && port.identifyLocal && await port.identifyLocal(entry.path)) {
89
+ candidates.push({ ...entry, removeSource: entry.path });
90
+ }
91
+ }
92
+ if (candidates.length !== 1) {
93
+ port.notify(candidates.length ? "Multiple Osdy Pi registrations found; remove one manually with pi remove <source> [-l]." : "No unique Osdy Pi package registration found; nothing was removed.", "warning");
94
+ return undefined;
95
+ }
96
+ const selected = candidates[0];
97
+ if (!selected) return undefined;
98
+ const selectedGit = parseGit(selected.source);
99
+ const selectedEntry = entries.find((entry) => entry.scope === selected.scope && entry.source === selected.source && entry.path === selected.path);
100
+ const duplicates = entries.filter((entry) => entry.scope === selected.scope && entry !== selectedEntry);
101
+ for (const entry of duplicates) {
102
+ if (selected.source.startsWith("npm:")) {
103
+ if (/^npm:osdy-pi(?:@|$)/.test(entry.source)) {
104
+ port.notify("Ambiguous package identity; nothing was removed.", "warning"); return undefined;
105
+ }
106
+ } else if (selectedGit?.type === "git") {
107
+ const git = parseGit(entry.source);
108
+ if (git?.type === "git" && git.host === selectedGit.host && git.path === selectedGit.path) {
109
+ port.notify("Ambiguous package identity; nothing was removed.", "warning"); return undefined;
110
+ }
111
+ } else if (entry.path === selected.path) {
112
+ port.notify("Ambiguous package identity; nothing was removed.", "warning"); return undefined;
113
+ }
114
+ }
115
+ return selected;
116
+ }
117
+
118
+ export async function runOsdyUninstall(port: UninstallPort): Promise<void> {
119
+ try {
120
+ const parser = await gitParser();
121
+ const parseGit = (source: string) => parser.parseGitUrl(source);
122
+ const entries = parseList(await port.list());
123
+ const selected = await select(entries, port, parseGit);
124
+ if (!selected) return;
125
+ if (!await port.confirm(selected.source, selected.scope)) {
126
+ port.notify("Uninstall cancelled; nothing was removed.", "info"); return;
127
+ }
128
+ // The confirmation applies only to the exact observed registration set.
129
+ // Re-list and re-identify local paths before invoking the separate Pi process.
130
+ const current = parseList(await port.list());
131
+ if (JSON.stringify(current) !== JSON.stringify(entries)) {
132
+ port.notify("Pi registrations changed after confirmation; nothing was removed.", "warning"); return;
133
+ }
134
+ const verified = await select(current, port, parseGit);
135
+ if (!verified || JSON.stringify(verified) !== JSON.stringify(selected)) return;
136
+ await port.remove(selected.removeSource, selected.scope === "project");
137
+ port.notify(`Removed ${selected.source} (${selected.scope}) from Pi. Restart Pi to unload the extension. Profiles, accounts, global CLI and other packages were left untouched.`, "info");
138
+ } catch {
139
+ port.notify("Could not safely identify or remove the Osdy Pi registration. Check pi list before retrying; no other packages were requested for removal.", "warning");
140
+ }
141
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "osdy-pi",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
4
  "description": "Osdy Pi package: themes, header, and custom editor for Pi.",
5
5
  "license": "MIT",
6
6
  "type": "module",