osdy-pi 1.5.0 → 1.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -6
- package/extensions/osdy-pi/agent-coexistence-setup.test.ts +162 -1
- package/extensions/osdy-pi/agent-coexistence-setup.ts +130 -38
- package/extensions/osdy-pi/runtime.test.ts +31 -5
- package/extensions/osdy-pi/runtime.ts +37 -11
- package/extensions/osdy-pi/uninstall.test.ts +142 -0
- package/extensions/osdy-pi/uninstall.ts +141 -0
- package/package.json +4 -5
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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`
|
|
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
|
|
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:
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
if (
|
|
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
|
-
|
|
173
|
-
if (packages.
|
|
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
|
-
|
|
178
|
-
if (
|
|
179
|
-
const
|
|
180
|
-
|
|
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(
|
|
266
|
+
if (shouldExclude === (extensions?.includes(filter) ?? false)) return entry;
|
|
183
267
|
changed = true;
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
127
|
-
|
|
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
|
-
"
|
|
741
|
-
`Target: ${join(target, "settings.json")}\
|
|
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
|
-
|
|
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("
|
|
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.
|
|
3
|
+
"version": "1.6.1",
|
|
4
4
|
"description": "Osdy Pi package: themes, header, and custom editor for Pi.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -56,7 +56,8 @@
|
|
|
56
56
|
"peerDependencies": {
|
|
57
57
|
"@earendil-works/pi-coding-agent": "*",
|
|
58
58
|
"@earendil-works/pi-tui": "*",
|
|
59
|
-
"@juicesharp/rpiv-i18n": "*"
|
|
59
|
+
"@juicesharp/rpiv-i18n": "*",
|
|
60
|
+
"typebox": "*"
|
|
60
61
|
},
|
|
61
62
|
"peerDependenciesMeta": {
|
|
62
63
|
"@juicesharp/rpiv-i18n": {
|
|
@@ -69,10 +70,8 @@
|
|
|
69
70
|
"@eslint/js": "^9.39.1",
|
|
70
71
|
"@types/node": "^25.8.0",
|
|
71
72
|
"eslint": "^9.39.1",
|
|
73
|
+
"typebox": "^1.3.7",
|
|
72
74
|
"typescript": "^5.9.3",
|
|
73
75
|
"typescript-eslint": "^8.46.3"
|
|
74
|
-
},
|
|
75
|
-
"dependencies": {
|
|
76
|
-
"typebox": "^1.3.7"
|
|
77
76
|
}
|
|
78
77
|
}
|