pi-archimedes 2.3.0 → 2.4.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/package.json +12 -11
- package/src/config.ts +0 -1
- package/src/factory-lifecycle.test.ts +196 -0
- package/src/index.ts +37 -9
- package/src/plugin-manager.ts +94 -0
- package/src/plugins.test.ts +417 -0
- package/src/plugins.ts +66 -0
- package/src/settings.ts +41 -27
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-archimedes",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package"
|
|
@@ -11,16 +11,17 @@
|
|
|
11
11
|
],
|
|
12
12
|
"main": "./src/index.ts",
|
|
13
13
|
"dependencies": {
|
|
14
|
-
"@pi-archimedes/core": "2.
|
|
15
|
-
"@pi-archimedes/
|
|
16
|
-
"@pi-archimedes/
|
|
17
|
-
"@pi-archimedes/
|
|
18
|
-
"@pi-archimedes/
|
|
19
|
-
"@pi-archimedes/subagent": "2.
|
|
20
|
-
"@pi-archimedes/
|
|
21
|
-
"@pi-archimedes/
|
|
22
|
-
"@pi-archimedes/
|
|
23
|
-
"@pi-archimedes/
|
|
14
|
+
"@pi-archimedes/core": "2.4.0",
|
|
15
|
+
"@pi-archimedes/footer": "2.4.0",
|
|
16
|
+
"@pi-archimedes/diff": "2.4.0",
|
|
17
|
+
"@pi-archimedes/ask": "2.4.0",
|
|
18
|
+
"@pi-archimedes/image-paste": "2.4.0",
|
|
19
|
+
"@pi-archimedes/subagent": "2.4.0",
|
|
20
|
+
"@pi-archimedes/notify": "2.4.0",
|
|
21
|
+
"@pi-archimedes/mcp": "2.4.0",
|
|
22
|
+
"@pi-archimedes/sudo": "2.4.0",
|
|
23
|
+
"@pi-archimedes/session-name": "2.4.0",
|
|
24
|
+
"@pi-archimedes/todo": "2.4.0"
|
|
24
25
|
},
|
|
25
26
|
"peerDependencies": {
|
|
26
27
|
"@earendil-works/pi-coding-agent": ">=0.1.0",
|
package/src/config.ts
CHANGED
|
@@ -92,7 +92,6 @@ import { loadSessionNameConfig } from "@pi-archimedes/session-name";
|
|
|
92
92
|
export type { SessionNameSettings } from "@pi-archimedes/session-name";
|
|
93
93
|
|
|
94
94
|
export const DEFAULT_SESSION_NAME_CONFIG: SessionNameSettings = {
|
|
95
|
-
enabled: true,
|
|
96
95
|
model: undefined,
|
|
97
96
|
};
|
|
98
97
|
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
// ── Factory lifecycle: image-paste registration (session_start) vs ─────────
|
|
4
|
+
// teardown (session_shutdown), gated by a *mutable* config that can be
|
|
5
|
+
// toggled mid-session via /plugins. Teardown must be liveness-based (the
|
|
6
|
+
// module-level ref itself records "this session registered image-paste"),
|
|
7
|
+
// not config-based.
|
|
8
|
+
|
|
9
|
+
// ── Mock settings-io with an in-memory store (mirrors plugins.test.ts) ────
|
|
10
|
+
|
|
11
|
+
vi.mock("@pi-archimedes/core/settings-io", () => {
|
|
12
|
+
const store: Record<string, unknown> = {};
|
|
13
|
+
return {
|
|
14
|
+
loadConfig: vi.fn(
|
|
15
|
+
(ns: string, defaults: object) =>
|
|
16
|
+
({ ...defaults, ...((store[ns] as object) ?? {}) }),
|
|
17
|
+
),
|
|
18
|
+
saveConfig: vi.fn((ns: string, config: object) => {
|
|
19
|
+
store[ns] = config;
|
|
20
|
+
}),
|
|
21
|
+
removeConfig: vi.fn((ns: string) => {
|
|
22
|
+
delete store[ns];
|
|
23
|
+
}),
|
|
24
|
+
isConfigEnabled: vi.fn((ns: string) => {
|
|
25
|
+
const cfg = (store[ns] ?? {}) as Record<string, unknown>;
|
|
26
|
+
return cfg.enabled !== false;
|
|
27
|
+
}),
|
|
28
|
+
setConfigEnabled: vi.fn((ns: string, enabled: boolean) => {
|
|
29
|
+
if (enabled) {
|
|
30
|
+
const cfg = { ...((store[ns] as object) ?? {}) } as Record<string, unknown>;
|
|
31
|
+
delete cfg.enabled;
|
|
32
|
+
if (Object.keys(cfg).length === 0) delete store[ns];
|
|
33
|
+
else store[ns] = cfg;
|
|
34
|
+
} else {
|
|
35
|
+
store[ns] = { ...((store[ns] as object) ?? {}), enabled: false };
|
|
36
|
+
}
|
|
37
|
+
}),
|
|
38
|
+
// Exposed for test setup/teardown only
|
|
39
|
+
__store: store,
|
|
40
|
+
};
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const settingsIo = await import("@pi-archimedes/core/settings-io");
|
|
44
|
+
const mockStore = (settingsIo as unknown as { __store: Record<string, unknown> }).__store;
|
|
45
|
+
|
|
46
|
+
// ── Mock every package the factory touches so the factory test is hermetic ─
|
|
47
|
+
|
|
48
|
+
// Static top-level imports of the factory
|
|
49
|
+
vi.mock("@pi-archimedes/core/profiler", () => ({
|
|
50
|
+
time: vi.fn(),
|
|
51
|
+
print: vi.fn(),
|
|
52
|
+
reset: vi.fn(),
|
|
53
|
+
}));
|
|
54
|
+
vi.mock("@pi-archimedes/core", () => ({
|
|
55
|
+
registerCore: vi.fn(),
|
|
56
|
+
unpatchConsoleLog: vi.fn(),
|
|
57
|
+
}));
|
|
58
|
+
vi.mock("@pi-archimedes/footer", () => ({ registerFooter: vi.fn() }));
|
|
59
|
+
vi.mock("@pi-archimedes/todo", () => ({ registerTodo: vi.fn() }));
|
|
60
|
+
vi.mock("@pi-archimedes/ask", () => ({ registerAsk: vi.fn() }));
|
|
61
|
+
vi.mock("@pi-archimedes/notify", () => ({ registerNotify: vi.fn() }));
|
|
62
|
+
vi.mock("@pi-archimedes/session-name", () => ({ registerSessionName: vi.fn() }));
|
|
63
|
+
|
|
64
|
+
// Dynamic imports done in the session_start handler — mock EXACTLY the
|
|
65
|
+
// properties index.ts uses via destructured `ipMod.*` / `diffMod` / `saMod` /
|
|
66
|
+
// `mcpMod` access (import result objects, not named destructure).
|
|
67
|
+
vi.mock("@pi-archimedes/diff", () => ({
|
|
68
|
+
registerDiffTools: vi.fn(),
|
|
69
|
+
}));
|
|
70
|
+
vi.mock("@pi-archimedes/image-paste", () => ({
|
|
71
|
+
registerImagePaste: vi.fn(),
|
|
72
|
+
shutdownImagePaste: vi.fn(),
|
|
73
|
+
initImagePasteSession: vi.fn(),
|
|
74
|
+
}));
|
|
75
|
+
vi.mock("@pi-archimedes/subagent", () => ({
|
|
76
|
+
registerSubagent: vi.fn(),
|
|
77
|
+
registerAgentsCommand: vi.fn(),
|
|
78
|
+
}));
|
|
79
|
+
vi.mock("@pi-archimedes/mcp", () => ({
|
|
80
|
+
registerMcp: vi.fn(),
|
|
81
|
+
}));
|
|
82
|
+
|
|
83
|
+
// Meta-local modules the factory imports — not under test here
|
|
84
|
+
vi.mock("./config.js", () => ({ loadDiffConfig: vi.fn(() => ({})) }));
|
|
85
|
+
vi.mock("./settings.js", () => ({ openSettings: vi.fn() }));
|
|
86
|
+
vi.mock("./plugin-manager.js", () => ({ registerPluginsCommand: vi.fn() }));
|
|
87
|
+
|
|
88
|
+
// The real plugins.ts gate semantics are what these tests exercise
|
|
89
|
+
// (read via the mocked settings-io on each call → mutable mid-session).
|
|
90
|
+
const { default: metaFactory } = await import("./index.js");
|
|
91
|
+
|
|
92
|
+
const { registerImagePaste, shutdownImagePaste, initImagePasteSession } =
|
|
93
|
+
await import("@pi-archimedes/image-paste");
|
|
94
|
+
|
|
95
|
+
// ── Stub pi: record pi.on() registrations and command/tool registrations ───
|
|
96
|
+
|
|
97
|
+
interface PiHarness {
|
|
98
|
+
pi: never;
|
|
99
|
+
handlers: Record<string, Array<(...args: unknown[]) => unknown>>;
|
|
100
|
+
commands: Map<string, unknown>;
|
|
101
|
+
tools: string[];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function makePi(): PiHarness {
|
|
105
|
+
const handlers: Record<string, Array<(...args: unknown[]) => unknown>> = {};
|
|
106
|
+
const commands = new Map<string, unknown>();
|
|
107
|
+
const tools: string[] = [];
|
|
108
|
+
const pi = {
|
|
109
|
+
on(event: string, handler: (...args: unknown[]) => unknown) {
|
|
110
|
+
(handlers[event] ??= []).push(handler);
|
|
111
|
+
},
|
|
112
|
+
registerCommand: (name: string, def: unknown) => {
|
|
113
|
+
commands.set(name, def);
|
|
114
|
+
},
|
|
115
|
+
registerTool: (name: string) => {
|
|
116
|
+
tools.push(name);
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
return { pi: pi as never, handlers, commands, tools };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Run the meta factory against a fresh stub pi and expose the latest
|
|
123
|
+
* session_start / session_shutdown handlers captured by pi.on(). */
|
|
124
|
+
function freshFactory(): {
|
|
125
|
+
harness: PiHarness;
|
|
126
|
+
startSession: (ctx: unknown) => Promise<unknown>;
|
|
127
|
+
shutdownSession: () => unknown;
|
|
128
|
+
} {
|
|
129
|
+
const harness = makePi();
|
|
130
|
+
metaFactory(harness.pi);
|
|
131
|
+
|
|
132
|
+
const startRcs = harness.handlers["session_start"] ?? [];
|
|
133
|
+
const shutdownRcs = harness.handlers["session_shutdown"] ?? [];
|
|
134
|
+
expect(startRcs.length).toBeGreaterThan(0);
|
|
135
|
+
expect(shutdownRcs.length).toBeGreaterThan(0);
|
|
136
|
+
const startRc = (startRcs[startRcs.length - 1] ?? expect.fail("no session_start handler")) as (...args: unknown[]) => unknown;
|
|
137
|
+
const shutdownRc = (shutdownRcs[shutdownRcs.length - 1] ?? expect.fail("no session_shutdown handler")) as (...args: unknown[]) => unknown;
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
harness,
|
|
141
|
+
startSession: (ctx: unknown) => startRc(undefined, ctx) as Promise<unknown>,
|
|
142
|
+
shutdownSession: () => shutdownRc(undefined, {}),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
beforeEach(() => {
|
|
147
|
+
for (const key of Object.keys(mockStore)) delete mockStore[key];
|
|
148
|
+
vi.clearAllMocks();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe("image-paste factory lifecycle (registration is config-gated, teardown is liveness-gated)", () => {
|
|
152
|
+
it("still runs shutdownImagePaste when the plugin is toggled OFF mid-session (via /plugins)", async () => {
|
|
153
|
+
// Store empty → image-paste enabled by default at session start
|
|
154
|
+
const { startSession, shutdownSession } = freshFactory();
|
|
155
|
+
await startSession({});
|
|
156
|
+
expect(vi.mocked(registerImagePaste)).toHaveBeenCalledTimes(1);
|
|
157
|
+
expect(vi.mocked(initImagePasteSession)).toHaveBeenCalledTimes(1);
|
|
158
|
+
|
|
159
|
+
// Mid-session: user toggles image-paste OFF via /plugins (persists
|
|
160
|
+
// immediately to its own namespace).
|
|
161
|
+
mockStore["archimedes.imagePaste"] = { enabled: false };
|
|
162
|
+
|
|
163
|
+
// Session ends → cleanup MUST still run for this session's registration.
|
|
164
|
+
shutdownSession();
|
|
165
|
+
expect(vi.mocked(shutdownImagePaste)).toHaveBeenCalledTimes(1);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("registers nothing and tears down nothing when config is off at session start", async () => {
|
|
169
|
+
mockStore["archimedes.imagePaste"] = { enabled: false };
|
|
170
|
+
const { startSession, shutdownSession } = freshFactory();
|
|
171
|
+
await startSession({});
|
|
172
|
+
expect(vi.mocked(registerImagePaste)).not.toHaveBeenCalled();
|
|
173
|
+
expect(vi.mocked(initImagePasteSession)).not.toHaveBeenCalled();
|
|
174
|
+
|
|
175
|
+
shutdownSession();
|
|
176
|
+
expect(vi.mocked(shutdownImagePaste)).not.toHaveBeenCalled();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("does not re-execute a stale shutdown ref from a previous session", async () => {
|
|
180
|
+
// Session A: enabled → registered → shutdown (cleanup runs, once).
|
|
181
|
+
const { startSession, shutdownSession } = freshFactory();
|
|
182
|
+
await startSession({});
|
|
183
|
+
expect(vi.mocked(registerImagePaste)).toHaveBeenCalledTimes(1);
|
|
184
|
+
shutdownSession();
|
|
185
|
+
expect(vi.mocked(shutdownImagePaste)).toHaveBeenCalledTimes(1);
|
|
186
|
+
|
|
187
|
+
// Session B: toggled off before start → NOT registered.
|
|
188
|
+
mockStore["archimedes.imagePaste"] = { enabled: false };
|
|
189
|
+
await startSession({});
|
|
190
|
+
expect(vi.mocked(registerImagePaste)).toHaveBeenCalledTimes(1); // still 1
|
|
191
|
+
|
|
192
|
+
// Session B shutdown must not re-run the (stale) session-A ref.
|
|
193
|
+
shutdownSession();
|
|
194
|
+
expect(vi.mocked(shutdownImagePaste)).toHaveBeenCalledTimes(1); // exactly once total
|
|
195
|
+
});
|
|
196
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -10,10 +10,13 @@ const _moduleEvalAt = Date.now();
|
|
|
10
10
|
// image-paste & subagent — also lazy-loaded below (heavy deps, only needed on use)
|
|
11
11
|
import { registerTodo } from "@pi-archimedes/todo";
|
|
12
12
|
import { registerAsk } from "@pi-archimedes/ask";
|
|
13
|
+
import { isPluginEnabled, migrateLegacyPluginsMap } from "./plugins.js";
|
|
13
14
|
import { registerNotify } from "@pi-archimedes/notify";
|
|
14
15
|
import { registerSessionName } from "@pi-archimedes/session-name";
|
|
16
|
+
import { registerSudo } from "@pi-archimedes/sudo";
|
|
15
17
|
import { loadDiffConfig } from "./config.js";
|
|
16
18
|
import { openSettings } from "./settings.js"
|
|
19
|
+
import { registerPluginsCommand } from "./plugin-manager.js"
|
|
17
20
|
|
|
18
21
|
// Module-level ref for shutdown (survives session replacements)
|
|
19
22
|
let imagePasteShutdown: (() => void) | undefined;
|
|
@@ -23,38 +26,51 @@ let imagePasteShutdown: (() => void) | undefined;
|
|
|
23
26
|
let currentCtx: ExtensionContext | undefined;
|
|
24
27
|
|
|
25
28
|
export default function (pi: ExtensionAPI): void {
|
|
29
|
+
// Must run before any isPluginEnabled gate evaluation: re-points the
|
|
30
|
+
// legacy archimedes.plugins map onto per-package namespaces, once.
|
|
31
|
+
migrateLegacyPluginsMap();
|
|
26
32
|
archResetTimings();
|
|
27
33
|
archTime(`factory start (module eval was ${Date.now() - _moduleEvalAt}ms ago)`);
|
|
28
34
|
|
|
29
35
|
// Register all component extensions (static imports already compiled by jiti above)
|
|
30
36
|
registerCore(pi);
|
|
31
37
|
archTime("registerCore");
|
|
32
|
-
registerFooter(pi);
|
|
38
|
+
if (isPluginEnabled("footer")) registerFooter(pi);
|
|
33
39
|
archTime("registerFooter");
|
|
34
40
|
|
|
35
41
|
// image-paste & subagent lazy-loaded in session_start below — not here
|
|
36
42
|
|
|
37
43
|
// Register todo (lightweight, registers tool + bus listener)
|
|
38
|
-
registerTodo(pi);
|
|
44
|
+
if (isPluginEnabled("todo")) registerTodo(pi);
|
|
39
45
|
archTime("registerTodo");
|
|
40
46
|
|
|
41
47
|
// Register ask tool
|
|
42
|
-
registerAsk(pi);
|
|
48
|
+
if (isPluginEnabled("ask")) registerAsk(pi);
|
|
43
49
|
archTime("registerAsk");
|
|
44
50
|
|
|
45
51
|
// Register notify
|
|
46
|
-
registerNotify(pi);
|
|
52
|
+
if (isPluginEnabled("notify")) registerNotify(pi);
|
|
47
53
|
archTime("registerNotify");
|
|
48
54
|
|
|
49
55
|
// Register session-name
|
|
50
|
-
registerSessionName(pi);
|
|
56
|
+
if (isPluginEnabled("session-name")) registerSessionName(pi);
|
|
51
57
|
archTime("registerSessionName");
|
|
52
58
|
|
|
59
|
+
// Register sudo (lightweight: tool + guard + /sudo command + lifecycle)
|
|
60
|
+
if (isPluginEnabled("sudo")) registerSudo(pi);
|
|
61
|
+
archTime("registerSudo");
|
|
62
|
+
|
|
53
63
|
archTime("factory end");
|
|
54
64
|
|
|
55
65
|
// session_shutdown handler (top-level to prevent accumulation on /reload)
|
|
56
66
|
pi.on("session_shutdown", (_event, _ctx) => {
|
|
67
|
+
// Liveness-gated on the ref itself: it is set only when image-paste was
|
|
68
|
+
// registered during THIS session (session_start, gated by config at that
|
|
69
|
+
// moment). Config may be toggled mid-session via /plugins — cleanup must
|
|
70
|
+
// still run. Nulled afterwards so a later never-registered session cannot
|
|
71
|
+
// re-execute a stale ref.
|
|
57
72
|
imagePasteShutdown?.();
|
|
73
|
+
imagePasteShutdown = undefined;
|
|
58
74
|
unpatchConsoleLog();
|
|
59
75
|
archPrintTimings();
|
|
60
76
|
});
|
|
@@ -67,10 +83,18 @@ export default function (pi: ExtensionAPI): void {
|
|
|
67
83
|
|
|
68
84
|
// ── Parallel lazy-load all three packages (saves ~100ms vs sequential) ──
|
|
69
85
|
const [diffMod, ipMod, saMod, mcpMod] = await Promise.all([
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
86
|
+
isPluginEnabled("diff")
|
|
87
|
+
? import("@pi-archimedes/diff").catch((e) => { console.error("[archimedes] diff load failed:", e); return null; })
|
|
88
|
+
: Promise.resolve(null),
|
|
89
|
+
isPluginEnabled("image-paste")
|
|
90
|
+
? import("@pi-archimedes/image-paste").catch((e) => { console.error("[archimedes] image-paste load failed:", e); return null; })
|
|
91
|
+
: Promise.resolve(null),
|
|
92
|
+
isPluginEnabled("subagent")
|
|
93
|
+
? import("@pi-archimedes/subagent").catch((e) => { console.error("[archimedes] subagent load failed:", e); return null; })
|
|
94
|
+
: Promise.resolve(null),
|
|
95
|
+
isPluginEnabled("mcp")
|
|
96
|
+
? import("@pi-archimedes/mcp").catch((e) => { console.error("[archimedes] mcp load failed:", e); return null; })
|
|
97
|
+
: Promise.resolve(null),
|
|
74
98
|
]);
|
|
75
99
|
archTime("4 packages loaded in parallel");
|
|
76
100
|
|
|
@@ -109,4 +133,8 @@ export default function (pi: ExtensionAPI): void {
|
|
|
109
133
|
await openSettings(pi, ctx);
|
|
110
134
|
},
|
|
111
135
|
});
|
|
136
|
+
|
|
137
|
+
// Register /plugins command (plugin manager — single home in plugin-manager.ts)
|
|
138
|
+
registerPluginsCommand(pi);
|
|
139
|
+
archTime("registerCommands");
|
|
112
140
|
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// ── /plugins — plugin manager ─────────────────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// Single registration path for the /plugins command (mirrors the subagent
|
|
4
|
+
// package's registerAgentsCommand pattern). Reuses the settings-manager
|
|
5
|
+
// chrome for a minimal per-plugin list: each row has `values: ["On", "Off"]`
|
|
6
|
+
// cycled with ←/→, and every change persists immediately to the
|
|
7
|
+
// package's own archimedes.* namespace (its `enabled` key). There are
|
|
8
|
+
// deliberately NO prompt descriptors — Enter /
|
|
9
|
+
// Space stay inert in list mode so the rows toggle instead of opening a
|
|
10
|
+
// text prompt.
|
|
11
|
+
|
|
12
|
+
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
13
|
+
import type { SettingItem } from "@earendil-works/pi-tui";
|
|
14
|
+
import { OVERLAY_CHROME } from "@pi-archimedes/core/overlay";
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
PLUGINS,
|
|
18
|
+
isPluginEnabled,
|
|
19
|
+
setPluginEnabled,
|
|
20
|
+
type PluginDef,
|
|
21
|
+
} from "./plugins.js";
|
|
22
|
+
import { createSettingsManager } from "./settings-manager.js";
|
|
23
|
+
|
|
24
|
+
// Free-input descriptors are intentionally empty — no prompt mode.
|
|
25
|
+
const PLUGIN_PROMPTS: Record<string, never> = {};
|
|
26
|
+
|
|
27
|
+
export function registerPluginsCommand(pi: ExtensionAPI): void {
|
|
28
|
+
pi.registerCommand("plugins", {
|
|
29
|
+
description: "Enable or disable optional archimedes plugins",
|
|
30
|
+
handler: async (_args: string, ctx: ExtensionCommandContext) => {
|
|
31
|
+
await buildPluginManager(ctx);
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Open the plugin manager overlay. `plugins` defaults to the global
|
|
38
|
+
* PLUGINS manifest; tests may inject stubbed entries.
|
|
39
|
+
*
|
|
40
|
+
* "Installed" probe: a plugin with a rejecting `load()` import is not
|
|
41
|
+
* installed and is hidden from the menu (same semantics as the /plugins
|
|
42
|
+
* registration gate — a missing package is simply absent).
|
|
43
|
+
*/
|
|
44
|
+
export async function buildPluginManager(
|
|
45
|
+
ctx: ExtensionContext,
|
|
46
|
+
plugins: PluginDef[] = PLUGINS,
|
|
47
|
+
): Promise<void> {
|
|
48
|
+
const installed = (
|
|
49
|
+
await Promise.all(
|
|
50
|
+
plugins.map(async (p): Promise<PluginDef | null> => {
|
|
51
|
+
try {
|
|
52
|
+
await p.load();
|
|
53
|
+
return p;
|
|
54
|
+
} catch {
|
|
55
|
+
return null; // not installed (or current broken) — hidden
|
|
56
|
+
}
|
|
57
|
+
}),
|
|
58
|
+
)
|
|
59
|
+
).filter((p): p is PluginDef => p !== null);
|
|
60
|
+
|
|
61
|
+
if (installed.length === 0) {
|
|
62
|
+
ctx.ui.notify("No optional plugins installed.", "info");
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const items: SettingItem[] = installed.map((p) => ({
|
|
67
|
+
id: `plugin:${p.id}`,
|
|
68
|
+
label: p.label,
|
|
69
|
+
description: p.description,
|
|
70
|
+
currentValue: isPluginEnabled(p.id) ? "On" : "Off",
|
|
71
|
+
values: ["On", "Off"],
|
|
72
|
+
}));
|
|
73
|
+
|
|
74
|
+
await ctx.ui.custom(
|
|
75
|
+
(_tui, theme, _keybindings, done) => {
|
|
76
|
+
return createSettingsManager({
|
|
77
|
+
items,
|
|
78
|
+
prompts: PLUGIN_PROMPTS,
|
|
79
|
+
theme,
|
|
80
|
+
onChange: (id: string, newValue: string) => {
|
|
81
|
+
const pluginId = id.startsWith("plugin:") ? id.slice("plugin:".length) : id;
|
|
82
|
+
setPluginEnabled(pluginId, newValue === "On");
|
|
83
|
+
},
|
|
84
|
+
onSave: () => {
|
|
85
|
+
// Toggles already persist on change — nothing to save on exit.
|
|
86
|
+
},
|
|
87
|
+
onClose: () => {
|
|
88
|
+
done(undefined);
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
},
|
|
92
|
+
{ overlay: true, overlayOptions: OVERLAY_CHROME },
|
|
93
|
+
);
|
|
94
|
+
}
|
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import type { PluginDef } from "./plugins.js";
|
|
4
|
+
|
|
5
|
+
// ── Mock settings-io with an in-memory store (mirrors core config.test.ts) ──
|
|
6
|
+
|
|
7
|
+
vi.mock("@pi-archimedes/core/settings-io", () => {
|
|
8
|
+
const store: Record<string, unknown> = {};
|
|
9
|
+
return {
|
|
10
|
+
loadConfig: vi.fn(
|
|
11
|
+
(ns: string, defaults: object) =>
|
|
12
|
+
({ ...defaults, ...((store[ns] as object) ?? {}) }),
|
|
13
|
+
),
|
|
14
|
+
saveConfig: vi.fn((ns: string, config: object) => {
|
|
15
|
+
store[ns] = config;
|
|
16
|
+
}),
|
|
17
|
+
// Core-exact semantics: strict `enabled !== false`; delete-on-On with
|
|
18
|
+
// empty-namespace removal — so meta's flow is verified through the same
|
|
19
|
+
// primitives core uses.
|
|
20
|
+
removeConfig: vi.fn((ns: string) => {
|
|
21
|
+
delete store[ns];
|
|
22
|
+
}),
|
|
23
|
+
isConfigEnabled: vi.fn((ns: string) => {
|
|
24
|
+
const cfg = (store[ns] ?? {}) as Record<string, unknown>;
|
|
25
|
+
return cfg.enabled !== false;
|
|
26
|
+
}),
|
|
27
|
+
setConfigEnabled: vi.fn((ns: string, enabled: boolean) => {
|
|
28
|
+
if (enabled) {
|
|
29
|
+
const cfg = { ...((store[ns] as object) ?? {}) } as Record<string, unknown>;
|
|
30
|
+
delete cfg.enabled;
|
|
31
|
+
if (Object.keys(cfg).length === 0) delete store[ns];
|
|
32
|
+
else store[ns] = cfg;
|
|
33
|
+
} else {
|
|
34
|
+
store[ns] = { ...((store[ns] as object) ?? {}), enabled: false };
|
|
35
|
+
}
|
|
36
|
+
}),
|
|
37
|
+
// Exposed for test setup/teardown only
|
|
38
|
+
__store: store,
|
|
39
|
+
};
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const settingsIo = await import("@pi-archimedes/core/settings-io");
|
|
43
|
+
const mockStore = (settingsIo as unknown as { __store: Record<string, unknown> }).__store;
|
|
44
|
+
|
|
45
|
+
// ── Mock every package's settings-items provider so meta/settings.ts can be
|
|
46
|
+
// exercised without pulling the real extensions (and shiki) into the test ──
|
|
47
|
+
|
|
48
|
+
vi.mock("@pi-archimedes/core", () => ({
|
|
49
|
+
getCoreSettingsItems: vi.fn(() => [
|
|
50
|
+
{ id: "mutedTheme", label: "Muted theme", currentValue: "Off", values: ["On", "Off"] },
|
|
51
|
+
]),
|
|
52
|
+
}));
|
|
53
|
+
|
|
54
|
+
vi.mock("@pi-archimedes/footer/config", () => ({
|
|
55
|
+
getFooterSettingsItems: vi.fn(() => [
|
|
56
|
+
{ id: "splitThreshold", label: "Footer split threshold", currentValue: "120" },
|
|
57
|
+
]),
|
|
58
|
+
}));
|
|
59
|
+
|
|
60
|
+
vi.mock("@pi-archimedes/notify", () => ({
|
|
61
|
+
getNotifySettingsItems: vi.fn(() => [
|
|
62
|
+
{ id: "delayMs", label: "Notify delay (seconds)", currentValue: "30s" },
|
|
63
|
+
]),
|
|
64
|
+
}));
|
|
65
|
+
|
|
66
|
+
vi.mock("@pi-archimedes/session-name", () => ({
|
|
67
|
+
getSessionNameSettingsItems: vi.fn(() => [
|
|
68
|
+
{ id: "sessionNameModel", label: "Session name model", currentValue: "(current model)" },
|
|
69
|
+
]),
|
|
70
|
+
}));
|
|
71
|
+
|
|
72
|
+
// Spy on the diff items so tests can assert the lazy import is skipped when
|
|
73
|
+
// the diff plugin is disabled (shiki must never be pulled in).
|
|
74
|
+
vi.mock("@pi-archimedes/diff", () => ({
|
|
75
|
+
getDiffSettingsItems: vi.fn(() => [
|
|
76
|
+
{ id: "diffTheme", label: "Diff theme", currentValue: "github-dark" },
|
|
77
|
+
]),
|
|
78
|
+
}));
|
|
79
|
+
const {
|
|
80
|
+
PLUGINS,
|
|
81
|
+
isPluginEnabled,
|
|
82
|
+
setPluginEnabled,
|
|
83
|
+
migrateLegacyPluginsMap,
|
|
84
|
+
} = await import("./plugins.js");
|
|
85
|
+
|
|
86
|
+
describe("isPluginEnabled (per-namespace gate)", () => {
|
|
87
|
+
beforeEach(() => {
|
|
88
|
+
for (const key of Object.keys(mockStore)) delete mockStore[key];
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("defaults to enabled when config is empty", () => {
|
|
92
|
+
expect(isPluginEnabled("mcp")).toBe(true);
|
|
93
|
+
expect(isPluginEnabled("footer")).toBe(true);
|
|
94
|
+
expect(isPluginEnabled("diff")).toBe(true);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("returns false only for the plugin disabled in its own namespace", () => {
|
|
98
|
+
mockStore["archimedes.mcp"] = { enabled: false };
|
|
99
|
+
expect(isPluginEnabled("mcp")).toBe(false);
|
|
100
|
+
expect(isPluginEnabled("footer")).toBe(true);
|
|
101
|
+
expect(isPluginEnabled("todo")).toBe(true);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("treats explicit enabled: true as enabled", () => {
|
|
105
|
+
mockStore["archimedes.footer"] = { enabled: true };
|
|
106
|
+
expect(isPluginEnabled("footer")).toBe(true);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("defaults to enabled for unknown ids", () => {
|
|
110
|
+
expect(isPluginEnabled("does-not-exist")).toBe(true);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe("migrateLegacyPluginsMap", () => {
|
|
115
|
+
beforeEach(() => {
|
|
116
|
+
for (const key of Object.keys(mockStore)) delete mockStore[key];
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("moves explicit false entries to package namespaces, drops true/unknown, removes the map", () => {
|
|
120
|
+
mockStore["archimedes.plugins"] = { footer: false, ask: true, junk: false };
|
|
121
|
+
migrateLegacyPluginsMap();
|
|
122
|
+
expect(mockStore["archimedes.footer"]).toEqual({ enabled: false });
|
|
123
|
+
// explicit true = default-on: nothing written
|
|
124
|
+
expect(mockStore["archimedes.ask"]).toBeUndefined();
|
|
125
|
+
// unknown id: dropped
|
|
126
|
+
expect(Object.keys(mockStore).some((k) => k.includes("junk"))).toBe(false);
|
|
127
|
+
// legacy map removed
|
|
128
|
+
expect("archimedes.plugins" in mockStore).toBe(false);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("is idempotent — a second run changes nothing", () => {
|
|
132
|
+
mockStore["archimedes.plugins"] = { footer: false, ask: true, junk: false };
|
|
133
|
+
migrateLegacyPluginsMap();
|
|
134
|
+
const afterFirst: Record<string, unknown> = JSON.parse(JSON.stringify(mockStore));
|
|
135
|
+
migrateLegacyPluginsMap();
|
|
136
|
+
expect(mockStore).toEqual(afterFirst);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("is a no-op when there is no legacy map", () => {
|
|
140
|
+
mockStore["archimedes.footer"] = { enabled: false };
|
|
141
|
+
migrateLegacyPluginsMap();
|
|
142
|
+
expect(mockStore).toEqual({ "archimedes.footer": { enabled: false } });
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("merges into a namespace that already carries real settings", () => {
|
|
146
|
+
mockStore["archimedes.notify"] = { delayMs: 30000 };
|
|
147
|
+
mockStore["archimedes.plugins"] = { notify: false };
|
|
148
|
+
migrateLegacyPluginsMap();
|
|
149
|
+
expect(mockStore["archimedes.notify"]).toEqual({ delayMs: 30000, enabled: false });
|
|
150
|
+
expect("archimedes.plugins" in mockStore).toBe(false);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe("setPluginEnabled (save path)", () => {
|
|
155
|
+
beforeEach(() => {
|
|
156
|
+
for (const key of Object.keys(mockStore)) delete mockStore[key];
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("off: writes enabled: false to the package's own namespace only", () => {
|
|
160
|
+
expect(setPluginEnabled("footer", false)).toBe(true);
|
|
161
|
+
expect(mockStore["archimedes.footer"]).toEqual({ enabled: false });
|
|
162
|
+
expect("archimedes.plugins" in mockStore).toBe(false);
|
|
163
|
+
expect(isPluginEnabled("footer")).toBe(false);
|
|
164
|
+
expect(isPluginEnabled("todo")).toBe(true);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("on: deletes the enabled key and removes a namespace that held nothing else", () => {
|
|
168
|
+
setPluginEnabled("footer", false);
|
|
169
|
+
expect(setPluginEnabled("footer", true)).toBe(true);
|
|
170
|
+
expect("archimedes.footer" in mockStore).toBe(false);
|
|
171
|
+
expect(isPluginEnabled("footer")).toBe(true);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("on: other keys in the namespace survive the delete", () => {
|
|
175
|
+
mockStore["archimedes.notify"] = { delayMs: 30000, enabled: false };
|
|
176
|
+
expect(setPluginEnabled("notify", true)).toBe(true);
|
|
177
|
+
expect(mockStore["archimedes.notify"]).toEqual({ delayMs: 30000 });
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("returns false and writes nothing for an unknown id", () => {
|
|
181
|
+
expect(setPluginEnabled("does-not-exist", true)).toBe(false);
|
|
182
|
+
expect(setPluginEnabled("does-not-exist", false)).toBe(false);
|
|
183
|
+
expect(mockStore).toEqual({});
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe("PLUGINS manifest integrity", () => {
|
|
188
|
+
const EXPECTED_IDS = [
|
|
189
|
+
"footer",
|
|
190
|
+
"todo",
|
|
191
|
+
"ask",
|
|
192
|
+
"notify",
|
|
193
|
+
"session-name",
|
|
194
|
+
"diff",
|
|
195
|
+
"image-paste",
|
|
196
|
+
"subagent",
|
|
197
|
+
"mcp",
|
|
198
|
+
"sudo",
|
|
199
|
+
];
|
|
200
|
+
|
|
201
|
+
it("lists exactly the 10 non-core packages (no drift)", () => {
|
|
202
|
+
expect([...PLUGINS.map((p) => p.id)].sort()).toEqual([...EXPECTED_IDS].sort());
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("has no duplicate ids", () => {
|
|
206
|
+
const ids = PLUGINS.map((p) => p.id);
|
|
207
|
+
expect(new Set(ids).size).toBe(ids.length);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("gives every entry a unique, non-empty namespace", () => {
|
|
211
|
+
const namespaces = PLUGINS.map((p) => p.namespace);
|
|
212
|
+
for (const ns of namespaces) {
|
|
213
|
+
expect(ns.length).toBeGreaterThan(0);
|
|
214
|
+
expect(ns).toMatch(/^archimedes\./);
|
|
215
|
+
}
|
|
216
|
+
expect(new Set(namespaces).size).toBe(namespaces.length);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("gives every entry a label, description, and load function", () => {
|
|
220
|
+
for (const plugin of PLUGINS) {
|
|
221
|
+
expect(plugin.label.length).toBeGreaterThan(0);
|
|
222
|
+
expect(plugin.description.length).toBeGreaterThan(0);
|
|
223
|
+
expect(typeof plugin.load).toBe("function");
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("load() resolves for a real installed package (footer probe)", async () => {
|
|
228
|
+
const footer = PLUGINS.find((p): p is PluginDef => p.id === "footer");
|
|
229
|
+
expect(footer).toBeDefined();
|
|
230
|
+
const mod = await footer!.load();
|
|
231
|
+
expect(mod).toBeTruthy();
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
// ── Task 2: settings-item gate, /plugins command, plugin manager ──────────
|
|
236
|
+
|
|
237
|
+
const { buildSettingsItems } = await import("./settings.js");
|
|
238
|
+
const { getDiffSettingsItems } = await import("@pi-archimedes/diff");
|
|
239
|
+
const { registerPluginsCommand, buildPluginManager } = await import("./plugin-manager.js");
|
|
240
|
+
|
|
241
|
+
// Raw terminal input for the right-arrow key (legacy sequence)
|
|
242
|
+
const ARROW_RIGHT = "\x1b[C";
|
|
243
|
+
|
|
244
|
+
/** All enabled — no `enabled` keys persisted anywhere. */
|
|
245
|
+
function fakeAllConfig(): Parameters<typeof buildSettingsItems>[0] {
|
|
246
|
+
// Minimal shape — buildSettingsItems only reads core/notify/sessionName
|
|
247
|
+
// through the (mocked) item builders, so missing fields never surface.
|
|
248
|
+
return {
|
|
249
|
+
core: { mutedTheme: false },
|
|
250
|
+
footer: { splitThreshold: 120 },
|
|
251
|
+
diff: { diffTheme: "github-dark", diffSplitMinWidth: 150, diffSplitMinCodeWidth: 60 },
|
|
252
|
+
notify: { delayMs: 30000, notifyOnAgentEnd: true, notifyOnQuestion: true },
|
|
253
|
+
sessionName: {},
|
|
254
|
+
} as unknown as Parameters<typeof buildSettingsItems>[0];
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
describe("buildSettingsItems (settings gate)", () => {
|
|
258
|
+
beforeEach(() => {
|
|
259
|
+
for (const key of Object.keys(mockStore)) delete mockStore[key];
|
|
260
|
+
vi.mocked(getDiffSettingsItems).mockClear();
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("includes all packages' items when everything is enabled, and lazy-imports diff", async () => {
|
|
264
|
+
const items = await buildSettingsItems(fakeAllConfig());
|
|
265
|
+
const ids = items.map((i) => i.id);
|
|
266
|
+
expect(ids).toEqual(expect.arrayContaining(["mutedTheme", "splitThreshold", "diffTheme", "delayMs", "sessionNameModel"]));
|
|
267
|
+
expect(vi.mocked(getDiffSettingsItems)).toHaveBeenCalledTimes(1);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("excludes disabled packages' items and never lazy-imports diff when disabled", async () => {
|
|
271
|
+
mockStore["archimedes.footer"] = { enabled: false };
|
|
272
|
+
mockStore["archimedes.diff"] = { enabled: false };
|
|
273
|
+
mockStore["archimedes.sessionName"] = { enabled: false };
|
|
274
|
+
mockStore["archimedes.notify"] = { enabled: false };
|
|
275
|
+
const items = await buildSettingsItems(fakeAllConfig());
|
|
276
|
+
// Core items are always present
|
|
277
|
+
expect(items.map((i) => i.id)).toContain("mutedTheme");
|
|
278
|
+
// No item from a disabled package
|
|
279
|
+
for (const id of ["splitThreshold", "diffTheme", "delayMs", "sessionNameModel"]) {
|
|
280
|
+
expect(items.map((i) => i.id)).not.toContain(id);
|
|
281
|
+
}
|
|
282
|
+
// Diff must never be lazy-imported (shiki stays out of /archimedes)
|
|
283
|
+
expect(vi.mocked(getDiffSettingsItems)).not.toHaveBeenCalled();
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it("keeps enabled packages when others are disabled", async () => {
|
|
287
|
+
mockStore["archimedes.footer"] = { enabled: false };
|
|
288
|
+
mockStore["archimedes.sessionName"] = { enabled: false };
|
|
289
|
+
const items = await buildSettingsItems(fakeAllConfig());
|
|
290
|
+
const ids = items.map((i) => i.id);
|
|
291
|
+
expect(ids).toEqual(expect.arrayContaining(["mutedTheme", "diffTheme", "delayMs"]));
|
|
292
|
+
expect(ids).not.toContain("splitThreshold");
|
|
293
|
+
expect(ids).not.toContain("sessionNameModel");
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
// ── /plugins command registration ────────────────────────────────────────
|
|
298
|
+
|
|
299
|
+
describe("registerPluginsCommand", () => {
|
|
300
|
+
it("registers a 'plugins' command with a description and handler", () => {
|
|
301
|
+
const commands = new Map<string, { description?: string; handler?: unknown }>();
|
|
302
|
+
const fakePi = { registerCommand: (name: string, def: unknown) => commands.set(name, def as never) };
|
|
303
|
+
registerPluginsCommand(fakePi as never);
|
|
304
|
+
expect(commands.has("plugins")).toBe(true);
|
|
305
|
+
expect(typeof commands.get("plugins")?.description).toBe("string");
|
|
306
|
+
expect(commands.get("plugins")?.description?.length).toBeGreaterThan(0);
|
|
307
|
+
expect(typeof commands.get("plugins")?.handler).toBe("function");
|
|
308
|
+
});
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
// ── Plugin manager (buildPluginManager) ───────────────────────────────────
|
|
312
|
+
|
|
313
|
+
interface CustomCapture {
|
|
314
|
+
factory: ((tui: unknown, theme: unknown, kb: unknown, done: () => void) => unknown) | null;
|
|
315
|
+
options: unknown | null;
|
|
316
|
+
customCalls: number;
|
|
317
|
+
notifyMessage: string | null;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function makeCtx(): { ctx: unknown; captured: CustomCapture } {
|
|
321
|
+
const captured: CustomCapture = { factory: null, options: null, customCalls: 0, notifyMessage: null };
|
|
322
|
+
const ctx = {
|
|
323
|
+
ui: {
|
|
324
|
+
custom: (factory: CustomCapture["factory"], options: unknown) => {
|
|
325
|
+
captured.factory = factory as never;
|
|
326
|
+
captured.options = options;
|
|
327
|
+
captured.customCalls++;
|
|
328
|
+
return Promise.resolve();
|
|
329
|
+
},
|
|
330
|
+
notify: (msg: string) => {
|
|
331
|
+
captured.notifyMessage = msg;
|
|
332
|
+
},
|
|
333
|
+
},
|
|
334
|
+
};
|
|
335
|
+
return { ctx, captured };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Minimal theme stub — render()/handleInput() only ever call theme.fg/error
|
|
339
|
+
const stubTheme = {
|
|
340
|
+
fg: (_color: string, text?: string) => text ?? "",
|
|
341
|
+
error: (text?: string) => text ?? "",
|
|
342
|
+
} as never;
|
|
343
|
+
|
|
344
|
+
const footerPlugin: PluginDef = {
|
|
345
|
+
id: "footer", label: "Footer status bar", description: "Status bar with cost/timer", namespace: "archimedes.footer",
|
|
346
|
+
load: () => Promise.resolve({}),
|
|
347
|
+
};
|
|
348
|
+
const ghostPlugin: PluginDef = {
|
|
349
|
+
id: "ghost", label: "Ghost Plugin", description: "Not installed", namespace: "archimedes.ghost",
|
|
350
|
+
load: () => Promise.reject(new Error("module not found")),
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
describe("buildPluginManager", () => {
|
|
354
|
+
beforeEach(() => {
|
|
355
|
+
for (const key of Object.keys(mockStore)) delete mockStore[key];
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it("opens the settings overlay with rows only for installed plugins", async () => {
|
|
359
|
+
const { ctx, captured } = makeCtx();
|
|
360
|
+
await buildPluginManager(ctx as never, [footerPlugin, ghostPlugin]);
|
|
361
|
+
|
|
362
|
+
expect(captured.customCalls).toBe(1);
|
|
363
|
+
expect(captured.options).toEqual({ overlay: true, overlayOptions: expect.anything() });
|
|
364
|
+
expect(captured.factory).not.toBeNull();
|
|
365
|
+
|
|
366
|
+
const component = captured.factory!(null, stubTheme, null, () => {});
|
|
367
|
+
const lines = (component as { render(w: number): string[] }).render(80);
|
|
368
|
+
const text = lines.join("\n");
|
|
369
|
+
// Installed plugin visible, load-failing plugin absent
|
|
370
|
+
expect(text).toContain("Footer status bar");
|
|
371
|
+
expect(text).not.toContain("Ghost Plugin");
|
|
372
|
+
// Current state shown as On for a default-enabled plugin
|
|
373
|
+
expect(text).toContain("On");
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it("toggling a row On → Off → On persists to the package's own namespace", async () => {
|
|
377
|
+
mockStore["archimedes.footer"] = { enabled: true };
|
|
378
|
+
const { ctx, captured } = makeCtx();
|
|
379
|
+
await buildPluginManager(ctx as never, [footerPlugin]);
|
|
380
|
+
|
|
381
|
+
const component = captured.factory!(null, stubTheme, null, () => {});
|
|
382
|
+
// ←/→ cycle the value on the selected row (single row, pre-selected)
|
|
383
|
+
(component as { handleInput(d: string): void }).handleInput(ARROW_RIGHT);
|
|
384
|
+
expect(mockStore["archimedes.footer"]).toEqual({ enabled: false });
|
|
385
|
+
|
|
386
|
+
// Cyclic: one more right press goes Off → On — the namespace held only
|
|
387
|
+
// `enabled`, so the whole namespace key is removed again.
|
|
388
|
+
(component as { handleInput(d: string): void }).handleInput(ARROW_RIGHT);
|
|
389
|
+
expect("archimedes.footer" in mockStore).toBe(false);
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
it("starts from Off when the plugin is already disabled", async () => {
|
|
393
|
+
mockStore["archimedes.footer"] = { enabled: false };
|
|
394
|
+
const { ctx, captured } = makeCtx();
|
|
395
|
+
await buildPluginManager(ctx as never, [footerPlugin]);
|
|
396
|
+
const component = captured.factory!(null, stubTheme, null, () => {});
|
|
397
|
+
const text = (component as { render(w: number): string[] }).render(80).join("\n");
|
|
398
|
+
expect(text).toContain("Footer status bar");
|
|
399
|
+
expect(text).toContain("Off");
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it("notifies instead of opening the overlay when no optional plugins are installed", async () => {
|
|
403
|
+
const { ctx, captured } = makeCtx();
|
|
404
|
+
await buildPluginManager(ctx as never, [ghostPlugin]);
|
|
405
|
+
expect(captured.customCalls).toBe(0);
|
|
406
|
+
expect(captured.notifyMessage).toContain("No optional plugins installed");
|
|
407
|
+
});
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
// ── Docs ──────────────────────────────────────────────────────────────────
|
|
411
|
+
|
|
412
|
+
describe("docs", () => {
|
|
413
|
+
it("README documents the /plugins command", () => {
|
|
414
|
+
const readme = readFileSync(new URL("../../README.md", import.meta.url), "utf8");
|
|
415
|
+
expect(readme).toContain("/plugins");
|
|
416
|
+
});
|
|
417
|
+
});
|
package/src/plugins.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// ── Plugin manager: manifest + per-namespace `enabled` gate ──────────────
|
|
2
|
+
//
|
|
3
|
+
// Core is intentionally NOT in the manifest — it is always registered.
|
|
4
|
+
// Every other package meta composes is listed here. A plugin is "enabled"
|
|
5
|
+
// unless settings.json carries `archimedes.<pkg>.enabled === false` (the
|
|
6
|
+
// uniform default-on now lives in isConfigEnabled's strict `!== false`).
|
|
7
|
+
// A plugin is "installed" when its `load()` import resolves; a missing
|
|
8
|
+
// package is simply never registered (and hidden from the /plugins menu).
|
|
9
|
+
|
|
10
|
+
import { isConfigEnabled, setConfigEnabled, loadConfig, removeConfig } from "@pi-archimedes/core/settings-io";
|
|
11
|
+
|
|
12
|
+
export interface PluginDef {
|
|
13
|
+
id: string; // matches package npm name suffix, e.g. "mcp"
|
|
14
|
+
label: string; // human label, e.g. "MCP"
|
|
15
|
+
description: string; // one-liner shown in the menu
|
|
16
|
+
namespace: string; // "archimedes.mcp" — the settings.json key holding this plugin's { enabled, ...settings }
|
|
17
|
+
load: () => Promise<unknown>; // lazy import for instance probing + future lazy mount
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Array order = menu display order. There is no separate order constant.
|
|
21
|
+
export const PLUGINS: PluginDef[] = [
|
|
22
|
+
{ id: "footer", label: "Footer status bar", description: "Status bar with cost/timer", namespace: "archimedes.footer", load: () => import("@pi-archimedes/footer") },
|
|
23
|
+
{ id: "todo", label: "Todo list", description: "manage_todo_list tool + widget", namespace: "archimedes.todo", load: () => import("@pi-archimedes/todo") },
|
|
24
|
+
{ id: "ask", label: "Ask tool", description: "Structured in-conversation questions", namespace: "archimedes.ask", load: () => import("@pi-archimedes/ask") },
|
|
25
|
+
{ id: "notify", label: "Notifications", description: "Delayed desktop notifications", namespace: "archimedes.notify", load: () => import("@pi-archimedes/notify") },
|
|
26
|
+
{ id: "session-name", label: "Session naming", description: "Auto session name via git diff", namespace: "archimedes.sessionName", load: () => import("@pi-archimedes/session-name") },
|
|
27
|
+
{ id: "diff", label: "Diff rendering", description: "Shiki-powered diff display", namespace: "archimedes.diff", load: () => import("@pi-archimedes/diff") },
|
|
28
|
+
{ id: "image-paste", label: "Image paste", description: "Clipboard image paste", namespace: "archimedes.imagePaste", load: () => import("@pi-archimedes/image-paste") },
|
|
29
|
+
{ id: "subagent", label: "Subagents", description: "Live subagent dispatch (general, reviewer, …)", namespace: "archimedes.subagent", load: () => import("@pi-archimedes/subagent") },
|
|
30
|
+
{ id: "mcp", label: "MCP", description: "MCP client adapter + /mcp commands", namespace: "archimedes.mcp", load: () => import("@pi-archimedes/mcp") },
|
|
31
|
+
{ id: "sudo", label: "Sudo", description: "Safe privileged execution", namespace: "archimedes.sudo", load: () => import("@pi-archimedes/sudo") },
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
// ── Gate: settings[archimedes.<pkg>].enabled, strict `!== false` ─────────
|
|
35
|
+
// Absent file/namespace/key = enabled; explicit false = disabled.
|
|
36
|
+
|
|
37
|
+
export function isPluginEnabled(id: string): boolean {
|
|
38
|
+
const def = PLUGINS.find((p) => p.id === id);
|
|
39
|
+
if (!def) return true; // unknown id defaults on (unchanged plan-031 semantics)
|
|
40
|
+
return isConfigEnabled(def.namespace);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function setPluginEnabled(id: string, enabled: boolean): boolean {
|
|
44
|
+
const def = PLUGINS.find((p) => p.id === id);
|
|
45
|
+
if (!def) return false;
|
|
46
|
+
setConfigEnabled(def.namespace, enabled);
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ── One-time migration: legacy archimedes.plugins { id → boolean } map ───
|
|
51
|
+
// Plan 031 stored the gate in a single standalone namespace. Only explicit
|
|
52
|
+
// false entries need moving (true / non-boolean = default-on = nothing to
|
|
53
|
+
// write); unknown ids are dropped and the legacy key is removed afterwards,
|
|
54
|
+
// so a second run is a no-op.
|
|
55
|
+
|
|
56
|
+
export function migrateLegacyPluginsMap(): void {
|
|
57
|
+
const legacy = loadConfig("archimedes.plugins", {}) as Record<string, unknown>;
|
|
58
|
+
if (Object.keys(legacy).length === 0) return; // nothing to do — idempotent on re-run
|
|
59
|
+
for (const [id, value] of Object.entries(legacy)) {
|
|
60
|
+
const def = PLUGINS.find((p) => p.id === id);
|
|
61
|
+
if (!def) continue; // unknown id: drop
|
|
62
|
+
if (value === false) setConfigEnabled(def.namespace, false);
|
|
63
|
+
// explicit true / non-boolean: equals default-on → nothing to write
|
|
64
|
+
}
|
|
65
|
+
removeConfig("archimedes.plugins");
|
|
66
|
+
}
|
package/src/settings.ts
CHANGED
|
@@ -4,7 +4,9 @@ import type { SettingItem } from "@earendil-works/pi-tui";
|
|
|
4
4
|
import { getCoreSettingsItems } from "@pi-archimedes/core";
|
|
5
5
|
import { OVERLAY_CHROME } from "@pi-archimedes/core/overlay";
|
|
6
6
|
import { getFooterSettingsItems } from "@pi-archimedes/footer/config";
|
|
7
|
-
// diff (shiki) is lazy-loaded
|
|
7
|
+
// diff (shiki) is lazy-loaded inside buildSettingsItems AND gated by the
|
|
8
|
+
// per-namespace plugin gate (archimedes.diff.enabled — see ADR 0012) —
|
|
9
|
+
// disabled means shiki is never imported
|
|
8
10
|
import { getNotifySettingsItems } from "@pi-archimedes/notify";
|
|
9
11
|
import { getSessionNameSettingsItems } from "@pi-archimedes/session-name";
|
|
10
12
|
import {
|
|
@@ -17,6 +19,7 @@ import {
|
|
|
17
19
|
type CoreConfig,
|
|
18
20
|
type NotifyConfig,
|
|
19
21
|
} from "./config.js";
|
|
22
|
+
import { isPluginEnabled } from "./plugins.js";
|
|
20
23
|
import { createSettingsManager, type PromptDescriptor } from "./settings-manager.js";
|
|
21
24
|
|
|
22
25
|
// ── Free-input prompt descriptors (keyed by item.id) ───────────────────────
|
|
@@ -33,9 +36,43 @@ const PROMPTS: Record<string, PromptDescriptor> = {
|
|
|
33
36
|
|
|
34
37
|
// ── Settings UI ─────────────────────────────────────────────────────────────
|
|
35
38
|
|
|
39
|
+
// Compose the /archimedes item list. Core is always included; every other
|
|
40
|
+
// package's items are gated by the per-namespace plugin gate
|
|
41
|
+
// (archimedes.<pkg>.enabled — see ADR 0012) so a disabled plugin can
|
|
42
|
+
// not leak back in through the settings overlay. The diff import (heavy —
|
|
43
|
+
// pulls in shiki) is lazy AND inside the gate: disabled diff is never loaded.
|
|
44
|
+
export async function buildSettingsItems(allConfig: ReturnType<typeof loadAllConfig>): Promise<SettingItem[]> {
|
|
45
|
+
const items: SettingItem[] = [...getCoreSettingsItems({ ...allConfig.core })];
|
|
46
|
+
|
|
47
|
+
if (isPluginEnabled("footer")) {
|
|
48
|
+
items.push(...getFooterSettingsItems());
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (isPluginEnabled("diff")) {
|
|
52
|
+
const { getDiffSettingsItems } = await import("@pi-archimedes/diff");
|
|
53
|
+
items.push(...getDiffSettingsItems());
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (isPluginEnabled("notify")) {
|
|
57
|
+
const notifyItems = getNotifySettingsItems({ ...allConfig.notify });
|
|
58
|
+
// The notify package seeds delayMs as "30s" — strip the suffix so the
|
|
59
|
+
// number prompt can be edited in place (typed digits would otherwise
|
|
60
|
+
// append to "30s" and parseInt would discard the edit).
|
|
61
|
+
const delayItem = notifyItems.find((i) => i.id === "delayMs");
|
|
62
|
+
if (delayItem) {
|
|
63
|
+
delayItem.currentValue = String(allConfig.notify.delayMs / 1000);
|
|
64
|
+
}
|
|
65
|
+
items.push(...notifyItems);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (isPluginEnabled("session-name")) {
|
|
69
|
+
items.push(...getSessionNameSettingsItems({ ...allConfig.sessionName }));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return items;
|
|
73
|
+
}
|
|
74
|
+
|
|
36
75
|
export async function openSettings(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
|
|
37
|
-
// Lazy-load diff (pulls in shiki) — only needed when /archimedes is opened
|
|
38
|
-
const { getDiffSettingsItems } = await import("@pi-archimedes/diff");
|
|
39
76
|
const allConfig = loadAllConfig();
|
|
40
77
|
|
|
41
78
|
const coreConfig: CoreConfig = { ...allConfig.core };
|
|
@@ -44,28 +81,7 @@ export async function openSettings(pi: ExtensionAPI, ctx: ExtensionContext): Pro
|
|
|
44
81
|
const diffConfig = { ...allConfig.diff };
|
|
45
82
|
const sessionNameConfig = { ...allConfig.sessionName };
|
|
46
83
|
|
|
47
|
-
|
|
48
|
-
const coreItems = getCoreSettingsItems(coreConfig);
|
|
49
|
-
const footerItems = getFooterSettingsItems();
|
|
50
|
-
const diffItems = getDiffSettingsItems();
|
|
51
|
-
const notifyItems = getNotifySettingsItems(notifyConfig);
|
|
52
|
-
const sessionNameItems = getSessionNameSettingsItems(sessionNameConfig);
|
|
53
|
-
|
|
54
|
-
// The notify package seeds delayMs as "30s" — strip the suffix so the
|
|
55
|
-
// number prompt can be edited in place (typed digits would otherwise
|
|
56
|
-
// append to "30s" and parseInt would discard the edit).
|
|
57
|
-
const delayItem = notifyItems.find((i) => i.id === "delayMs");
|
|
58
|
-
if (delayItem) {
|
|
59
|
-
delayItem.currentValue = String(notifyConfig.delayMs / 1000);
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
const items: SettingItem[] = [
|
|
63
|
-
...coreItems,
|
|
64
|
-
...footerItems,
|
|
65
|
-
...diffItems,
|
|
66
|
-
...notifyItems,
|
|
67
|
-
...sessionNameItems,
|
|
68
|
-
];
|
|
84
|
+
const items = await buildSettingsItems(allConfig);
|
|
69
85
|
|
|
70
86
|
ctx.ui.custom((_tui, theme, _keybindings, done) => {
|
|
71
87
|
const settingsManager = createSettingsManager({
|
|
@@ -102,7 +118,6 @@ export async function openSettings(pi: ExtensionAPI, ctx: ExtensionContext): Pro
|
|
|
102
118
|
}
|
|
103
119
|
|
|
104
120
|
// ── Notify settings ──
|
|
105
|
-
case "enabled": notifyConfig.enabled = newValue === "On"; break;
|
|
106
121
|
case "notifyOnAgentEnd": notifyConfig.notifyOnAgentEnd = newValue === "On"; break;
|
|
107
122
|
case "notifyOnQuestion": notifyConfig.notifyOnQuestion = newValue === "On"; break;
|
|
108
123
|
case "delayMs": {
|
|
@@ -112,7 +127,6 @@ export async function openSettings(pi: ExtensionAPI, ctx: ExtensionContext): Pro
|
|
|
112
127
|
}
|
|
113
128
|
|
|
114
129
|
// ── Session name settings ──
|
|
115
|
-
case "sessionNameEnabled": sessionNameConfig.enabled = newValue === "On"; break;
|
|
116
130
|
case "sessionNameModel": sessionNameConfig.model = newValue === "(current model)" ? undefined : newValue; break;
|
|
117
131
|
}
|
|
118
132
|
},
|