pi-system-theme 0.1.1 → 0.2.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.
@@ -0,0 +1,36 @@
1
+ name: Publish package to npm
2
+
3
+ on:
4
+ release:
5
+ types:
6
+ - published
7
+
8
+ jobs:
9
+ publish:
10
+ runs-on: ubuntu-latest
11
+ permissions:
12
+ contents: read
13
+ id-token: write
14
+ steps:
15
+ - name: Checkout release tag
16
+ uses: actions/checkout@v4
17
+ with:
18
+ ref: ${{ github.event.release.tag_name }}
19
+
20
+ - name: Setup Node.js
21
+ uses: actions/setup-node@v4
22
+ with:
23
+ node-version: "22"
24
+ registry-url: "https://registry.npmjs.org"
25
+
26
+ - name: Verify tag matches package version
27
+ run: |
28
+ TAG="${{ github.event.release.tag_name }}"
29
+ VERSION="$(node -e "const fs=require('fs'); const pkg=JSON.parse(fs.readFileSync('package.json','utf8')); process.stdout.write(pkg.version)")"
30
+ if [ "v$VERSION" != "$TAG" ]; then
31
+ echo "Tag $TAG does not match package.json version $VERSION"
32
+ exit 1
33
+ fi
34
+
35
+ - name: Publish to npm (trusted publisher / OIDC)
36
+ run: npm publish --provenance
package/README.md CHANGED
@@ -41,31 +41,31 @@ Example:
41
41
 
42
42
  ## Interactive command
43
43
 
44
- Use `/system-theme` to edit all knobs directly:
44
+ Use `/system-theme` to open a small settings menu and edit:
45
45
 
46
46
  1. dark theme name
47
47
  2. light theme name
48
48
  3. poll interval (ms)
49
49
 
50
- After saving, the extension writes global overrides and applies changes immediately.
50
+ Choose **Save and apply** to persist overrides and apply immediately.
51
51
 
52
52
  ## Notes
53
53
 
54
54
  - This extension currently only acts on macOS (`process.platform === "darwin"`).
55
- - If a configured theme name is missing, it falls back to Pi built-ins (`dark`/`light`).
55
+ - If a configured theme name does not exist, Pi keeps the current theme and logs a warning.
56
56
 
57
57
  ## Install
58
58
 
59
- From git:
59
+ From npm (standalone package):
60
60
 
61
61
  ```bash
62
- pi install git:github.com/ferologics/pi-system-theme
62
+ pi install npm:pi-system-theme
63
63
  ```
64
64
 
65
- As part of `pi-shit` package:
65
+ From git:
66
66
 
67
67
  ```bash
68
- pi install npm:pi-shit
68
+ pi install git:github.com/ferologics/pi-system-theme
69
69
  ```
70
70
 
71
71
  Or from local source while developing:
package/index.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { execFile } from "node:child_process";
2
- import { access, mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { promisify } from "node:util";
@@ -7,20 +7,14 @@ import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@m
7
7
 
8
8
  const execFileAsync = promisify(execFile);
9
9
 
10
- type Appearance = "dark" | "light" | "unknown";
11
-
12
- type RawConfig = {
13
- darkTheme?: unknown;
14
- lightTheme?: unknown;
15
- pollMs?: unknown;
16
- };
17
-
18
10
  type Config = {
19
11
  darkTheme: string;
20
12
  lightTheme: string;
21
13
  pollMs: number;
22
14
  };
23
15
 
16
+ type Appearance = "dark" | "light";
17
+
24
18
  const DEFAULT_CONFIG: Config = {
25
19
  darkTheme: "dark",
26
20
  lightTheme: "light",
@@ -28,74 +22,32 @@ const DEFAULT_CONFIG: Config = {
28
22
  };
29
23
 
30
24
  const GLOBAL_CONFIG_PATH = path.join(os.homedir(), ".pi", "agent", "system-theme.json");
31
- const MIN_POLL_MS = 500;
32
25
  const DETECTION_TIMEOUT_MS = 1200;
33
-
34
- function toNonEmptyString(value: unknown): string | undefined {
35
- if (typeof value !== "string") return undefined;
36
-
37
- const trimmed = value.trim();
38
- return trimmed.length > 0 ? trimmed : undefined;
39
- }
40
-
41
- function toPositiveInteger(value: unknown): number | undefined {
42
- if (typeof value !== "number" || !Number.isFinite(value)) return undefined;
43
-
44
- const rounded = Math.round(value);
45
- return rounded > 0 ? rounded : undefined;
46
- }
47
-
48
- function mergeConfig(base: Config, rawConfig: RawConfig | undefined): Config {
49
- if (!rawConfig) return base;
50
-
51
- const darkTheme = toNonEmptyString(rawConfig.darkTheme) ?? base.darkTheme;
52
- const lightTheme = toNonEmptyString(rawConfig.lightTheme) ?? base.lightTheme;
53
-
54
- const pollMsValue = toPositiveInteger(rawConfig.pollMs);
55
- const pollMs = pollMsValue ? Math.max(pollMsValue, MIN_POLL_MS) : base.pollMs;
56
-
57
- return {
58
- darkTheme,
59
- lightTheme,
60
- pollMs,
61
- };
62
- }
26
+ const MIN_POLL_MS = 500;
63
27
 
64
28
  function isObject(value: unknown): value is Record<string, unknown> {
65
29
  return typeof value === "object" && value !== null && !Array.isArray(value);
66
30
  }
67
31
 
68
- async function readConfig(pathToConfig: string): Promise<RawConfig | undefined> {
69
- try {
70
- await access(pathToConfig);
71
- } catch {
72
- return undefined;
32
+ function toThemeName(value: unknown, fallback: string): string {
33
+ if (typeof value !== "string") {
34
+ return fallback;
73
35
  }
74
36
 
75
- try {
76
- const rawContent = await readFile(pathToConfig, "utf8");
77
- const parsed = JSON.parse(rawContent) as unknown;
78
-
79
- if (!isObject(parsed)) {
80
- console.warn(`[pi-system-theme] Ignoring ${pathToConfig}: expected a JSON object.`);
81
- return undefined;
82
- }
37
+ const trimmed = value.trim();
38
+ return trimmed.length > 0 ? trimmed : fallback;
39
+ }
83
40
 
84
- return parsed;
85
- } catch (error) {
86
- const message = error instanceof Error ? error.message : String(error);
87
- console.warn(`[pi-system-theme] Failed to load ${pathToConfig}: ${message}`);
88
- return undefined;
41
+ function toPollMs(value: unknown, fallback: number): number {
42
+ if (typeof value !== "number" || !Number.isFinite(value)) {
43
+ return fallback;
89
44
  }
90
- }
91
45
 
92
- async function loadConfig(): Promise<Config> {
93
- const globalConfig = await readConfig(GLOBAL_CONFIG_PATH);
94
- return mergeConfig(DEFAULT_CONFIG, globalConfig);
46
+ return Math.max(MIN_POLL_MS, Math.round(value));
95
47
  }
96
48
 
97
- function getOverrides(config: Config): Record<string, string | number> {
98
- const overrides: Record<string, string | number> = {};
49
+ function getOverrides(config: Config): Partial<Config> {
50
+ const overrides: Partial<Config> = {};
99
51
 
100
52
  if (config.darkTheme !== DEFAULT_CONFIG.darkTheme) {
101
53
  overrides.darkTheme = config.darkTheme;
@@ -112,29 +64,65 @@ function getOverrides(config: Config): Record<string, string | number> {
112
64
  return overrides;
113
65
  }
114
66
 
115
- async function writeGlobalOverrides(config: Config): Promise<{ wroteFile: boolean; overrideCount: number }> {
67
+ async function loadConfig(): Promise<Config> {
68
+ const config = { ...DEFAULT_CONFIG };
69
+
70
+ try {
71
+ const rawContent = await readFile(GLOBAL_CONFIG_PATH, "utf8");
72
+ const parsed = JSON.parse(rawContent) as unknown;
73
+
74
+ if (!isObject(parsed)) {
75
+ console.warn(`[pi-system-theme] Ignoring ${GLOBAL_CONFIG_PATH}: expected JSON object.`);
76
+ return config;
77
+ }
78
+
79
+ config.darkTheme = toThemeName(parsed.darkTheme, config.darkTheme);
80
+ config.lightTheme = toThemeName(parsed.lightTheme, config.lightTheme);
81
+ config.pollMs = toPollMs(parsed.pollMs, config.pollMs);
82
+
83
+ return config;
84
+ } catch (error) {
85
+ if ((error as { code?: string })?.code === "ENOENT") {
86
+ return config;
87
+ }
88
+
89
+ const message = error instanceof Error ? error.message : String(error);
90
+ console.warn(`[pi-system-theme] Failed to read ${GLOBAL_CONFIG_PATH}: ${message}`);
91
+ return config;
92
+ }
93
+ }
94
+
95
+ async function saveConfig(config: Config): Promise<{ wroteFile: boolean; overrideCount: number }> {
116
96
  const overrides = getOverrides(config);
117
97
  const overrideCount = Object.keys(overrides).length;
118
98
 
119
99
  if (overrideCount === 0) {
120
100
  await rm(GLOBAL_CONFIG_PATH, { force: true });
121
- return { wroteFile: false, overrideCount };
101
+ return {
102
+ wroteFile: false,
103
+ overrideCount,
104
+ };
122
105
  }
123
106
 
124
107
  await mkdir(path.dirname(GLOBAL_CONFIG_PATH), { recursive: true });
125
108
  await writeFile(GLOBAL_CONFIG_PATH, `${JSON.stringify(overrides, null, 4)}\n`, "utf8");
126
109
 
127
- return { wroteFile: true, overrideCount };
110
+ return {
111
+ wroteFile: true,
112
+ overrideCount,
113
+ };
128
114
  }
129
115
 
130
116
  function extractStderr(error: unknown): string {
131
- if (typeof error !== "object" || error === null) return "";
117
+ if (!error || typeof error !== "object") {
118
+ return "";
119
+ }
132
120
 
133
- const maybeStderr = (error as { stderr?: unknown }).stderr;
134
- return typeof maybeStderr === "string" ? maybeStderr : "";
121
+ const stderr = (error as { stderr?: unknown }).stderr;
122
+ return typeof stderr === "string" ? stderr : "";
135
123
  }
136
124
 
137
- async function detectAppearance(): Promise<Appearance> {
125
+ async function detectAppearance(): Promise<Appearance | null> {
138
126
  try {
139
127
  const { stdout } = await execFileAsync("/usr/bin/defaults", ["read", "-g", "AppleInterfaceStyle"], {
140
128
  timeout: DETECTION_TIMEOUT_MS,
@@ -142,153 +130,96 @@ async function detectAppearance(): Promise<Appearance> {
142
130
  });
143
131
 
144
132
  const normalized = stdout.trim().toLowerCase();
145
- if (normalized === "dark") return "dark";
146
- if (normalized === "light") return "light";
133
+ if (normalized === "dark") {
134
+ return "dark";
135
+ }
147
136
 
148
- return "unknown";
137
+ if (normalized === "light") {
138
+ return "light";
139
+ }
140
+
141
+ return null;
149
142
  } catch (error) {
150
143
  const stderr = extractStderr(error).toLowerCase();
151
144
  if (stderr.includes("does not exist")) {
152
145
  return "light";
153
146
  }
154
147
 
155
- return "unknown";
148
+ return null;
156
149
  }
157
150
  }
158
151
 
159
- function getRequestedTheme(config: Config, appearance: Exclude<Appearance, "unknown">): string {
160
- return appearance === "dark" ? config.darkTheme : config.lightTheme;
161
- }
162
-
163
- function getBuiltinFallbackTheme(appearance: Exclude<Appearance, "unknown">): string {
164
- return appearance === "dark" ? "dark" : "light";
165
- }
166
-
167
- function resolveThemeName(
168
- ctx: ExtensionContext,
169
- requestedTheme: string,
170
- fallbackTheme: string,
171
- warnedMissingThemes: Set<string>,
172
- ): string {
173
- if (ctx.ui.getTheme(requestedTheme)) {
174
- return requestedTheme;
175
- }
176
-
177
- const warningKey = `${requestedTheme}=>${fallbackTheme}`;
178
- if (!warnedMissingThemes.has(warningKey)) {
179
- warnedMissingThemes.add(warningKey);
180
- console.warn(
181
- `[pi-system-theme] Theme "${requestedTheme}" is not available. Falling back to "${fallbackTheme}".`,
182
- );
183
- }
184
-
185
- if (ctx.ui.getTheme(fallbackTheme)) {
186
- return fallbackTheme;
187
- }
188
-
189
- return requestedTheme;
190
- }
191
-
192
- function warnSetThemeFailureOnce(
193
- warningKey: string,
194
- warnings: Set<string>,
195
- themeName: string,
196
- errorMessage: string,
197
- ): void {
198
- if (warnings.has(warningKey)) return;
199
-
200
- warnings.add(warningKey);
201
- console.warn(`[pi-system-theme] Failed to set theme "${themeName}": ${errorMessage}`);
202
- }
203
-
204
- async function promptStringSetting(
152
+ async function promptTheme(
205
153
  ctx: ExtensionCommandContext,
206
154
  label: string,
207
155
  currentValue: string,
208
156
  ): Promise<string | undefined> {
209
- const value = await ctx.ui.input(`${label} (current: ${currentValue})`, currentValue);
210
- if (value === undefined) return undefined;
157
+ const next = await ctx.ui.input(label, currentValue);
158
+ if (next === undefined) {
159
+ return undefined;
160
+ }
211
161
 
212
- const trimmed = value.trim();
162
+ const trimmed = next.trim();
213
163
  return trimmed.length > 0 ? trimmed : currentValue;
214
164
  }
215
165
 
216
- async function promptIntegerSetting(
217
- ctx: ExtensionCommandContext,
218
- label: string,
219
- currentValue: number,
220
- minimum: number,
221
- ): Promise<number | undefined> {
166
+ async function promptPollMs(ctx: ExtensionCommandContext, currentValue: number): Promise<number | undefined> {
222
167
  while (true) {
223
- const value = await ctx.ui.input(`${label} (current: ${currentValue})`, String(currentValue));
224
- if (value === undefined) return undefined;
168
+ const next = await ctx.ui.input("Poll interval (ms)", String(currentValue));
169
+ if (next === undefined) {
170
+ return undefined;
171
+ }
225
172
 
226
- const trimmed = value.trim();
227
- if (trimmed.length === 0) return currentValue;
173
+ const trimmed = next.trim();
174
+ if (trimmed.length === 0) {
175
+ return currentValue;
176
+ }
228
177
 
229
178
  const parsed = Number.parseInt(trimmed, 10);
230
- if (Number.isFinite(parsed) && parsed >= minimum) {
179
+ if (Number.isFinite(parsed) && parsed >= MIN_POLL_MS) {
231
180
  return parsed;
232
181
  }
233
182
 
234
- ctx.ui.notify(`Enter a whole number >= ${minimum}, or leave blank to keep current value.`, "warning");
183
+ ctx.ui.notify(`Enter a whole number >= ${MIN_POLL_MS}.`, "warning");
235
184
  }
236
185
  }
237
186
 
238
187
  export default function systemThemeExtension(pi: ExtensionAPI): void {
188
+ let activeConfig: Config = { ...DEFAULT_CONFIG };
239
189
  let intervalId: ReturnType<typeof setInterval> | null = null;
240
190
  let syncInProgress = false;
241
- let lastAppliedThemeName: string | undefined;
242
- let activeConfig: Config = DEFAULT_CONFIG;
243
-
244
- const missingThemeWarnings = new Set<string>();
245
- const setThemeWarnings = new Set<string>();
191
+ let lastSetThemeError: string | null = null;
246
192
 
247
193
  async function syncTheme(ctx: ExtensionContext): Promise<void> {
248
- if (syncInProgress) return;
194
+ if (syncInProgress) {
195
+ return;
196
+ }
197
+
249
198
  syncInProgress = true;
250
199
 
251
200
  try {
252
201
  const appearance = await detectAppearance();
253
- if (appearance === "unknown") return;
254
-
255
- const requestedTheme = getRequestedTheme(activeConfig, appearance);
256
- const fallbackTheme = getBuiltinFallbackTheme(appearance);
257
- const targetTheme = resolveThemeName(ctx, requestedTheme, fallbackTheme, missingThemeWarnings);
258
-
259
- const activeThemeName = ctx.ui.theme.name ?? lastAppliedThemeName;
260
- if (activeThemeName === targetTheme) {
261
- lastAppliedThemeName = activeThemeName;
202
+ if (!appearance) {
262
203
  return;
263
204
  }
264
205
 
265
- const setResult = ctx.ui.setTheme(targetTheme);
266
- if (setResult.success) {
267
- lastAppliedThemeName = targetTheme;
206
+ const targetTheme = appearance === "dark" ? activeConfig.darkTheme : activeConfig.lightTheme;
207
+ if (ctx.ui.theme.name === targetTheme) {
268
208
  return;
269
209
  }
270
210
 
271
- warnSetThemeFailureOnce(
272
- `set:${targetTheme}`,
273
- setThemeWarnings,
274
- targetTheme,
275
- setResult.error ?? "unknown error",
276
- );
277
-
278
- if (targetTheme === fallbackTheme) return;
279
-
280
- const fallbackResult = ctx.ui.setTheme(fallbackTheme);
281
- if (fallbackResult.success) {
282
- lastAppliedThemeName = fallbackTheme;
211
+ const result = ctx.ui.setTheme(targetTheme);
212
+ if (result.success) {
213
+ lastSetThemeError = null;
283
214
  return;
284
215
  }
285
216
 
286
- warnSetThemeFailureOnce(
287
- `fallback:${fallbackTheme}`,
288
- setThemeWarnings,
289
- fallbackTheme,
290
- fallbackResult.error ?? "unknown error",
291
- );
217
+ const message = result.error ?? "unknown error";
218
+ const errorKey = `${targetTheme}:${message}`;
219
+ if (errorKey !== lastSetThemeError) {
220
+ lastSetThemeError = errorKey;
221
+ console.warn(`[pi-system-theme] Failed to set theme "${targetTheme}": ${message}`);
222
+ }
292
223
  } finally {
293
224
  syncInProgress = false;
294
225
  }
@@ -305,58 +236,99 @@ export default function systemThemeExtension(pi: ExtensionAPI): void {
305
236
  }
306
237
 
307
238
  pi.registerCommand("system-theme", {
308
- description: "Configure global pi-system-theme settings",
239
+ description: "Configure pi-system-theme",
309
240
  handler: async (_args, ctx) => {
310
241
  if (process.platform !== "darwin") {
311
242
  ctx.ui.notify("pi-system-theme currently supports macOS only.", "info");
312
243
  return;
313
244
  }
314
245
 
315
- const darkTheme = await promptStringSetting(ctx, "Dark theme", activeConfig.darkTheme);
316
- if (darkTheme === undefined) return;
246
+ const draft: Config = { ...activeConfig };
247
+
248
+ while (true) {
249
+ const darkOption = `Dark theme: ${draft.darkTheme}`;
250
+ const lightOption = `Light theme: ${draft.lightTheme}`;
251
+ const pollOption = `Poll interval (ms): ${draft.pollMs}`;
252
+ const saveOption = "Save and apply";
253
+ const cancelOption = "Cancel";
254
+
255
+ const choice = await ctx.ui.select("pi-system-theme", [
256
+ darkOption,
257
+ lightOption,
258
+ pollOption,
259
+ saveOption,
260
+ cancelOption,
261
+ ]);
262
+
263
+ if (choice === undefined || choice === cancelOption) {
264
+ return;
265
+ }
317
266
 
318
- const lightTheme = await promptStringSetting(ctx, "Light theme", activeConfig.lightTheme);
319
- if (lightTheme === undefined) return;
267
+ if (choice === darkOption) {
268
+ const next = await promptTheme(ctx, "Dark theme", draft.darkTheme);
269
+ if (next !== undefined) {
270
+ draft.darkTheme = next;
271
+ }
272
+ continue;
273
+ }
320
274
 
321
- const pollMs = await promptIntegerSetting(ctx, "Poll interval (ms)", activeConfig.pollMs, MIN_POLL_MS);
322
- if (pollMs === undefined) return;
275
+ if (choice === lightOption) {
276
+ const next = await promptTheme(ctx, "Light theme", draft.lightTheme);
277
+ if (next !== undefined) {
278
+ draft.lightTheme = next;
279
+ }
280
+ continue;
281
+ }
323
282
 
324
- activeConfig = mergeConfig(DEFAULT_CONFIG, {
325
- darkTheme,
326
- lightTheme,
327
- pollMs,
328
- });
283
+ if (choice === pollOption) {
284
+ const next = await promptPollMs(ctx, draft.pollMs);
285
+ if (next !== undefined) {
286
+ draft.pollMs = next;
287
+ }
288
+ continue;
289
+ }
329
290
 
330
- try {
331
- const result = await writeGlobalOverrides(activeConfig);
332
- if (result.wroteFile) {
333
- ctx.ui.notify(`Saved ${result.overrideCount} override(s) to ${GLOBAL_CONFIG_PATH}.`, "info");
334
- } else {
335
- ctx.ui.notify("No overrides left. Removed global config file; defaults are active.", "info");
291
+ if (choice === saveOption) {
292
+ activeConfig = draft;
293
+
294
+ try {
295
+ const result = await saveConfig(activeConfig);
296
+ if (result.wroteFile) {
297
+ ctx.ui.notify(
298
+ `Saved ${result.overrideCount} override(s) to ${GLOBAL_CONFIG_PATH}.`,
299
+ "info",
300
+ );
301
+ } else {
302
+ ctx.ui.notify("No overrides left. Using defaults.", "info");
303
+ }
304
+ } catch (error) {
305
+ const message = error instanceof Error ? error.message : String(error);
306
+ ctx.ui.notify(`Failed to save config: ${message}`, "error");
307
+ return;
308
+ }
309
+
310
+ await syncTheme(ctx);
311
+ restartPolling(ctx);
312
+ return;
336
313
  }
337
- } catch (error) {
338
- const message = error instanceof Error ? error.message : String(error);
339
- ctx.ui.notify(`Failed to save config: ${message}`, "error");
340
- return;
341
314
  }
342
-
343
- await syncTheme(ctx);
344
- restartPolling(ctx);
345
315
  },
346
316
  });
347
317
 
348
318
  pi.on("session_start", async (_event, ctx) => {
349
- if (process.platform !== "darwin") return;
319
+ if (process.platform !== "darwin") {
320
+ return;
321
+ }
350
322
 
351
323
  activeConfig = await loadConfig();
352
- lastAppliedThemeName = ctx.ui.theme.name;
353
-
354
324
  await syncTheme(ctx);
355
325
  restartPolling(ctx);
356
326
  });
357
327
 
358
328
  pi.on("session_shutdown", () => {
359
- if (!intervalId) return;
329
+ if (!intervalId) {
330
+ return;
331
+ }
360
332
 
361
333
  clearInterval(intervalId);
362
334
  intervalId = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-system-theme",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Sync Pi theme with macOS light/dark appearance",
5
5
  "keywords": [
6
6
  "pi-package",