pi-system-prompt-switcher 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,64 @@
1
+ # pi-system-prompt-switcher
2
+
3
+ [![npm version](https://img.shields.io/npm/v/pi-system-prompt-switcher.svg)](https://www.npmjs.com/package/pi-system-prompt-switcher)
4
+
5
+ Switch Pi's active system prompt from files in `~/.pi/agent/systems/`.
6
+
7
+ This package does one thing: `/system-prompt` picks a prompt file, persists that choice, then reloads Pi so the next turn uses it. There is no file watcher. Editing a prompt file takes effect after a reload or after selecting it again.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ pi install npm:pi-system-prompt-switcher
13
+ ```
14
+
15
+ ## Prompt files
16
+
17
+ Put prompt files directly in:
18
+
19
+ ```text
20
+ ~/.pi/agent/systems/
21
+ ```
22
+
23
+ Example:
24
+
25
+ ```text
26
+ ~/.pi/agent/systems/default.md
27
+ ~/.pi/agent/systems/reviewer.md
28
+ ~/.pi/agent/systems/prototype.md
29
+ ```
30
+
31
+ Hidden files and subdirectories are ignored. Symlinks are ignored so selections cannot escape the systems directory.
32
+
33
+ ## Usage
34
+
35
+ Interactive picker:
36
+
37
+ ```text
38
+ /system-prompt
39
+ ```
40
+
41
+ Direct switch:
42
+
43
+ ```text
44
+ /system-prompt reviewer.md
45
+ ```
46
+
47
+ Selection is stored in:
48
+
49
+ ```text
50
+ ~/.pi/agent/system-prompt-switcher.json
51
+ ```
52
+
53
+ After a successful switch, the extension calls Pi's reload flow. The selected prompt is appended to Pi's effective system prompt under an `Active system prompt` heading, preserving Pi's generated tool guidance, skills, project context, date, and cwd.
54
+
55
+ ## Development
56
+
57
+ ```bash
58
+ bun install
59
+ bun test
60
+ bun run typecheck
61
+ bun run check
62
+ bun pm pack --dry-run
63
+ PI_OFFLINE=1 bunx --bun pi --no-extensions -e . --list-models >/tmp/pi-system-prompt-switcher-pi-load.out
64
+ ```
@@ -0,0 +1,241 @@
1
+ import { lstat, mkdir, readdir, readFile, writeFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join, resolve, sep } from "node:path";
4
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
5
+
6
+ export const DEFAULT_SYSTEMS_DIR = join(homedir(), ".pi", "agent", "systems");
7
+ export const DEFAULT_CONFIG_PATH = join(
8
+ homedir(),
9
+ ".pi",
10
+ "agent",
11
+ "system-prompt-switcher.json",
12
+ );
13
+
14
+ const NO_SELECTION = "No system prompt selected.";
15
+
16
+ export interface SelectedPrompt {
17
+ ok: true;
18
+ name: string;
19
+ path: string;
20
+ content: string;
21
+ }
22
+
23
+ export interface PromptError {
24
+ ok: false;
25
+ error: string;
26
+ }
27
+
28
+ interface StoredConfig {
29
+ active?: unknown;
30
+ }
31
+
32
+ function promptPath(systemsDir: string, name: string): string | undefined {
33
+ if (!name || name.includes("/") || name.includes("\\")) return undefined;
34
+ const base = resolve(systemsDir);
35
+ const candidate = resolve(base, name);
36
+ if (candidate !== base && candidate.startsWith(`${base}${sep}`))
37
+ return candidate;
38
+ return undefined;
39
+ }
40
+
41
+ export async function discoverSystemPrompts(
42
+ systemsDir: string,
43
+ ): Promise<string[]> {
44
+ try {
45
+ const entries = await readdir(systemsDir, { withFileTypes: true });
46
+ return entries
47
+ .filter((entry) => entry.isFile() && !entry.name.startsWith("."))
48
+ .map((entry) => entry.name)
49
+ .sort((left, right) => left.localeCompare(right));
50
+ } catch (error) {
51
+ if (error && typeof error === "object" && "code" in error) {
52
+ const code = String(error.code);
53
+ if (code === "ENOENT" || code === "ENOTDIR") return [];
54
+ }
55
+ throw error;
56
+ }
57
+ }
58
+
59
+ export async function saveSelectedPrompt(
60
+ configPath: string,
61
+ name: string,
62
+ ): Promise<void> {
63
+ await mkdir(dirname(configPath), { recursive: true });
64
+ await writeFile(
65
+ configPath,
66
+ `${JSON.stringify({ active: name }, null, "\t")}\n`,
67
+ "utf8",
68
+ );
69
+ }
70
+
71
+ export async function selectSystemPrompt(options: {
72
+ configPath: string;
73
+ systemsDir: string;
74
+ name: string;
75
+ }): Promise<{ ok: true; name: string; path: string } | PromptError> {
76
+ const name = options.name.trim();
77
+ const path = promptPath(options.systemsDir, name);
78
+ if (!path) return { ok: false, error: `Invalid system prompt name: ${name}` };
79
+
80
+ try {
81
+ const info = await lstat(path);
82
+ if (!info.isFile()) {
83
+ return { ok: false, error: `System prompt not found: ${name}` };
84
+ }
85
+ } catch (error) {
86
+ if (error && typeof error === "object" && "code" in error) {
87
+ const code = String(error.code);
88
+ if (code === "ENOENT" || code === "ENOTDIR") {
89
+ return { ok: false, error: `System prompt not found: ${name}` };
90
+ }
91
+ }
92
+ const message = error instanceof Error ? error.message : String(error);
93
+ return {
94
+ ok: false,
95
+ error: `Could not inspect system prompt ${name}: ${message}`,
96
+ };
97
+ }
98
+
99
+ await saveSelectedPrompt(options.configPath, name);
100
+ return { ok: true, name, path };
101
+ }
102
+
103
+ export async function loadSelectedPrompt(options: {
104
+ configPath: string;
105
+ systemsDir: string;
106
+ }): Promise<SelectedPrompt | PromptError> {
107
+ let rawConfig: StoredConfig;
108
+ try {
109
+ rawConfig = JSON.parse(
110
+ await readFile(options.configPath, "utf8"),
111
+ ) as StoredConfig;
112
+ } catch (error) {
113
+ if (error && typeof error === "object" && "code" in error) {
114
+ const code = String(error.code);
115
+ if (code === "ENOENT") return { ok: false, error: NO_SELECTION };
116
+ }
117
+ const message = error instanceof Error ? error.message : String(error);
118
+ return {
119
+ ok: false,
120
+ error: `Could not read system prompt switcher config: ${message}`,
121
+ };
122
+ }
123
+
124
+ if (typeof rawConfig.active !== "string" || !rawConfig.active.trim()) {
125
+ return { ok: false, error: NO_SELECTION };
126
+ }
127
+
128
+ const name = rawConfig.active.trim();
129
+ const path = promptPath(options.systemsDir, name);
130
+ if (!path) return { ok: false, error: `Invalid system prompt name: ${name}` };
131
+
132
+ try {
133
+ const systemsInfo = await lstat(options.systemsDir);
134
+ if (!systemsInfo.isDirectory()) return { ok: false, error: NO_SELECTION };
135
+ } catch (error) {
136
+ if (error && typeof error === "object" && "code" in error) {
137
+ const code = String(error.code);
138
+ if (code === "ENOENT" || code === "ENOTDIR") {
139
+ return { ok: false, error: NO_SELECTION };
140
+ }
141
+ }
142
+ const message = error instanceof Error ? error.message : String(error);
143
+ return {
144
+ ok: false,
145
+ error: `Could not inspect systems directory ${options.systemsDir}: ${message}`,
146
+ };
147
+ }
148
+
149
+ try {
150
+ const info = await lstat(path);
151
+ if (!info.isFile()) {
152
+ return { ok: false, error: `System prompt not found: ${name}` };
153
+ }
154
+ return {
155
+ ok: true,
156
+ name,
157
+ path,
158
+ content: await readFile(path, "utf8"),
159
+ };
160
+ } catch (error) {
161
+ const message = error instanceof Error ? error.message : String(error);
162
+ return {
163
+ ok: false,
164
+ error: `Could not read selected system prompt ${name}: ${message}`,
165
+ };
166
+ }
167
+ }
168
+
169
+ export function appendActiveSystemPrompt(
170
+ basePrompt: string,
171
+ prompt: Pick<SelectedPrompt, "name" | "content">,
172
+ ): string {
173
+ return `${basePrompt}\n\n## Active system prompt: ${prompt.name}\n\n${prompt.content.trimEnd()}`;
174
+ }
175
+
176
+ export interface SystemPromptSwitcherOptions {
177
+ configPath?: string;
178
+ systemsDir?: string;
179
+ }
180
+
181
+ export default async function systemPromptSwitcherExtension(
182
+ pi: ExtensionAPI,
183
+ options: SystemPromptSwitcherOptions = {},
184
+ ): Promise<void> {
185
+ const configPath = options.configPath ?? DEFAULT_CONFIG_PATH;
186
+ const systemsDir = options.systemsDir ?? DEFAULT_SYSTEMS_DIR;
187
+ const activePrompt = await loadSelectedPrompt({ configPath, systemsDir });
188
+ let warningShown = false;
189
+
190
+ pi.on("before_agent_start", async (event, ctx) => {
191
+ if (!activePrompt.ok) {
192
+ if (activePrompt.error !== NO_SELECTION && !warningShown) {
193
+ warningShown = true;
194
+ ctx.ui.notify(activePrompt.error, "warning");
195
+ }
196
+ return undefined;
197
+ }
198
+ return {
199
+ systemPrompt: appendActiveSystemPrompt(event.systemPrompt, activePrompt),
200
+ };
201
+ });
202
+
203
+ pi.registerCommand("system-prompt", {
204
+ description:
205
+ "Switch active system prompt from ~/.pi/agent/systems/ and reload Pi",
206
+ handler: async (args, ctx) => {
207
+ const prompts = await discoverSystemPrompts(systemsDir);
208
+ if (prompts.length === 0) {
209
+ ctx.ui.notify(`No system prompts found in ${systemsDir}.`, "warning");
210
+ return;
211
+ }
212
+
213
+ let name = args.trim();
214
+ if (!name) {
215
+ if (!ctx.hasUI) {
216
+ ctx.ui.notify(`Pass one of: ${prompts.join(", ")}`, "warning");
217
+ return;
218
+ }
219
+ const choice = await ctx.ui.select(
220
+ "Select active system prompt:",
221
+ prompts,
222
+ );
223
+ if (!choice) return;
224
+ name = choice;
225
+ }
226
+
227
+ const result = await selectSystemPrompt({ configPath, systemsDir, name });
228
+ if (!result.ok) {
229
+ ctx.ui.notify(result.error, "error");
230
+ return;
231
+ }
232
+
233
+ ctx.ui.notify(
234
+ `Switched active system prompt to ${result.name}. Reloading Pi...`,
235
+ "info",
236
+ );
237
+ await ctx.reload();
238
+ return;
239
+ },
240
+ });
241
+ }
package/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export * from "./extensions/system-prompt-switcher.ts";
2
+ export { default } from "./extensions/system-prompt-switcher.ts";
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "pi-system-prompt-switcher",
3
+ "version": "0.1.0",
4
+ "description": "Switch active Pi system prompt from ~/.pi/agent/systems and reload on swap",
5
+ "type": "module",
6
+ "exports": "./index.ts",
7
+ "author": "drsh4dow <daniel.morettiv@gmail.com> (https://codeberg.org/drsh4dow)",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://codeberg.org/drsh4dow/pi-system-prompt-switcher.git"
11
+ },
12
+ "bugs": {
13
+ "url": "https://codeberg.org/drsh4dow/pi-system-prompt-switcher/issues"
14
+ },
15
+ "homepage": "https://codeberg.org/drsh4dow/pi-system-prompt-switcher#readme",
16
+ "scripts": {
17
+ "check": "biome check .",
18
+ "check:fix": "biome check --write .",
19
+ "test": "bun test",
20
+ "typecheck": "bunx tsc --noEmit"
21
+ },
22
+ "keywords": [
23
+ "pi-package",
24
+ "pi",
25
+ "system-prompt",
26
+ "prompt",
27
+ "reload",
28
+ "extension"
29
+ ],
30
+ "files": [
31
+ "extensions",
32
+ "index.ts",
33
+ "README.md"
34
+ ],
35
+ "pi": {
36
+ "extensions": [
37
+ "./extensions/system-prompt-switcher.ts"
38
+ ]
39
+ },
40
+ "devDependencies": {
41
+ "@biomejs/biome": "^2.4.14",
42
+ "@mariozechner/pi-ai": "^0.72.1",
43
+ "@mariozechner/pi-coding-agent": "^0.72.1",
44
+ "@mariozechner/pi-tui": "^0.72.1",
45
+ "@types/bun": "^1.3.13",
46
+ "typescript": "^6.0.3"
47
+ },
48
+ "peerDependencies": {
49
+ "@mariozechner/pi-ai": "*",
50
+ "@mariozechner/pi-coding-agent": "*",
51
+ "@mariozechner/pi-tui": "*"
52
+ },
53
+ "trustedDependencies": [
54
+ "koffi",
55
+ "protobufjs"
56
+ ]
57
+ }