@aliou/pi-utils-settings 0.0.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.
package/config-loader.ts CHANGED
@@ -1,17 +1,30 @@
1
1
  /**
2
2
  * Generic JSON config loader for pi extensions.
3
3
  *
4
- * Loads config from two files (global + project), deep-merges with defaults,
5
- * and optionally applies versioned migrations.
4
+ * Loads config from configurable scopes (global, local, memory),
5
+ * deep-merges with defaults, and optionally applies versioned migrations.
6
6
  *
7
7
  * Global: ~/.pi/agent/extensions/{name}.json
8
- * Project: .pi/extensions/{name}.json
8
+ * Local: {project}/.pi/extensions/{name}.json (walks up to find .pi)
9
+ * Memory: In-memory only, not persisted, resets on reload
10
+ *
11
+ * Merge priority (lowest to highest): defaults -> global -> local -> memory
9
12
  */
10
13
 
14
+ import { existsSync, statSync } from "node:fs";
11
15
  import { mkdir, readFile, writeFile } from "node:fs/promises";
16
+ import { homedir } from "node:os";
12
17
  import { dirname, resolve } from "node:path";
13
18
  import { getAgentDir } from "@mariozechner/pi-coding-agent";
14
19
 
20
+ /**
21
+ * Available configuration scopes.
22
+ * - global: User-wide settings in ~/.pi/agent/extensions/
23
+ * - local: Project-specific settings in {project}/.pi/extensions/
24
+ * - memory: Ephemeral settings, not persisted, reset on reload
25
+ */
26
+ export type Scope = "global" | "local" | "memory";
27
+
15
28
  /**
16
29
  * A migration that transforms a config from one version to another.
17
30
  * Migrations are applied in order during load(). If any migration
@@ -36,76 +49,127 @@ export interface Migration<TConfig> {
36
49
  */
37
50
  export interface ConfigStore<TConfig extends object, TResolved extends object> {
38
51
  getConfig(): TResolved;
39
- getRawConfig(scope: "global" | "project"): TConfig | null;
40
- hasConfig(scope: "global" | "project"): boolean;
41
- save(scope: "global" | "project", config: TConfig): Promise<void>;
52
+ getRawConfig(scope: Scope): TConfig | null;
53
+ hasScope(scope: Scope): boolean;
54
+ hasConfig(scope: Scope): boolean;
55
+ getEnabledScopes(): Scope[];
56
+ save(scope: Scope, config: TConfig): Promise<void>;
57
+ }
58
+
59
+ /**
60
+ * Walk up from cwd to find the project root (.pi directory).
61
+ * Stops at home directory.
62
+ * Returns the path to .pi/extensions/{name}.json, or null if no .pi found.
63
+ */
64
+ function findLocalConfigPath(extensionName: string): string | null {
65
+ let dir = process.cwd();
66
+ const home = homedir();
67
+
68
+ while (true) {
69
+ const piDir = resolve(dir, ".pi");
70
+ if (existsSync(piDir) && statSync(piDir).isDirectory()) {
71
+ return resolve(piDir, `extensions/${extensionName}.json`);
72
+ }
73
+
74
+ // Stop at home directory
75
+ if (dir === home) break;
76
+
77
+ const parent = resolve(dir, "..");
78
+ // Stop if we can't go higher
79
+ if (parent === dir) break;
80
+ dir = parent;
81
+ }
82
+
83
+ return null;
42
84
  }
43
85
 
44
86
  export class ConfigLoader<TConfig extends object, TResolved extends object>
45
87
  implements ConfigStore<TConfig, TResolved>
46
88
  {
47
89
  private globalConfig: TConfig | null = null;
48
- private projectConfig: TConfig | null = null;
90
+ private localConfig: TConfig | null = null;
91
+ private memoryConfig: TConfig | null = null;
49
92
  private resolved: TResolved | null = null;
50
93
 
51
- private readonly globalPath: string;
52
- private readonly projectPath: string;
94
+ private readonly scopes: Scope[];
95
+ private readonly globalPath: string | null;
96
+ private readonly localPath: string | null;
53
97
  private readonly defaults: TResolved;
54
98
  private readonly migrations: Migration<TConfig>[];
55
99
  private readonly afterMerge?: (
56
100
  resolved: TResolved,
57
101
  global: TConfig | null,
58
- project: TConfig | null,
102
+ local: TConfig | null,
103
+ memory: TConfig | null,
59
104
  ) => TResolved;
60
105
 
61
106
  constructor(
62
107
  extensionName: string,
63
108
  defaults: TResolved,
64
109
  options?: {
110
+ /**
111
+ * Enabled scopes. Default: ["global", "local"]
112
+ * Merge priority (lowest to highest): defaults -> global -> local -> memory
113
+ */
114
+ scopes?: Scope[];
65
115
  migrations?: Migration<TConfig>[];
66
116
  /**
67
- * Post-merge hook. Called after deep merge with both raw configs.
117
+ * Post-merge hook. Called after deep merge with all raw configs.
68
118
  * Use for logic that can't be expressed as a simple merge
69
119
  * (e.g., one field replacing another).
70
120
  */
71
121
  afterMerge?: (
72
122
  resolved: TResolved,
73
123
  global: TConfig | null,
74
- project: TConfig | null,
124
+ local: TConfig | null,
125
+ memory: TConfig | null,
75
126
  ) => TResolved;
76
127
  },
77
128
  ) {
78
- this.globalPath = resolve(
79
- getAgentDir(),
80
- `extensions/${extensionName}.json`,
81
- );
82
- this.projectPath = resolve(
83
- process.cwd(),
84
- `.pi/extensions/${extensionName}.json`,
85
- );
129
+ this.scopes = options?.scopes ?? ["global", "local"];
86
130
  this.defaults = defaults;
87
131
  this.migrations = options?.migrations ?? [];
88
132
  this.afterMerge = options?.afterMerge;
133
+
134
+ // Set up paths based on enabled scopes
135
+ this.globalPath = this.scopes.includes("global")
136
+ ? resolve(getAgentDir(), `extensions/${extensionName}.json`)
137
+ : null;
138
+
139
+ this.localPath = this.scopes.includes("local")
140
+ ? findLocalConfigPath(extensionName)
141
+ : null;
89
142
  }
90
143
 
91
144
  /**
92
145
  * Load (or reload) config from disk. Applies migrations if needed.
93
146
  * Must be called before getConfig() or getRawConfig().
147
+ *
148
+ * Note: Memory config is reset to null on reload (ephemeral).
94
149
  */
95
150
  async load(): Promise<void> {
96
- this.globalConfig = await this.readFile(this.globalPath);
97
- this.projectConfig = await this.readFile(this.projectPath);
151
+ // Load from disk
152
+ this.globalConfig = this.globalPath
153
+ ? await this.readFile(this.globalPath)
154
+ : null;
155
+ this.localConfig = this.localPath
156
+ ? await this.readFile(this.localPath)
157
+ : null;
98
158
 
99
- if (this.globalConfig) {
159
+ // Reset memory on reload (ephemeral)
160
+ this.memoryConfig = null;
161
+
162
+ // Apply migrations to disk configs
163
+ if (this.globalConfig && this.globalPath) {
100
164
  this.globalConfig = await this.applyMigrations(
101
165
  this.globalConfig,
102
166
  this.globalPath,
103
167
  );
104
168
  }
105
- if (this.projectConfig) {
106
- this.projectConfig = await this.applyMigrations(
107
- this.projectConfig,
108
- this.projectPath,
169
+ if (this.localConfig && this.localPath) {
170
+ this.localConfig = await this.applyMigrations(
171
+ this.localConfig,
172
+ this.localPath,
109
173
  );
110
174
  }
111
175
 
@@ -119,21 +183,60 @@ export class ConfigLoader<TConfig extends object, TResolved extends object>
119
183
  return this.resolved;
120
184
  }
121
185
 
122
- getRawConfig(scope: "global" | "project"): TConfig | null {
123
- return scope === "global" ? this.globalConfig : this.projectConfig;
186
+ getRawConfig(scope: Scope): TConfig | null {
187
+ switch (scope) {
188
+ case "global":
189
+ return this.globalConfig;
190
+ case "local":
191
+ return this.localConfig;
192
+ case "memory":
193
+ return this.memoryConfig;
194
+ }
124
195
  }
125
196
 
126
- hasConfig(scope: "global" | "project"): boolean {
127
- return scope === "global"
128
- ? this.globalConfig !== null
129
- : this.projectConfig !== null;
197
+ hasScope(scope: Scope): boolean {
198
+ return this.scopes.includes(scope);
130
199
  }
131
200
 
132
- /** Save config and reload all state. */
133
- async save(scope: "global" | "project", config: TConfig): Promise<void> {
134
- const path = scope === "global" ? this.globalPath : this.projectPath;
201
+ hasConfig(scope: Scope): boolean {
202
+ if (!this.hasScope(scope)) return false;
203
+ return this.getRawConfig(scope) !== null;
204
+ }
205
+
206
+ getEnabledScopes(): Scope[] {
207
+ return [...this.scopes];
208
+ }
209
+
210
+ /** Save config and reload state (except memory which just updates in place). */
211
+ async save(scope: Scope, config: TConfig): Promise<void> {
212
+ if (!this.hasScope(scope)) {
213
+ throw new Error(`Scope "${scope}" is not enabled`);
214
+ }
215
+
216
+ if (scope === "memory") {
217
+ // Memory is ephemeral, just store in place and re-merge
218
+ this.memoryConfig = config;
219
+ this.resolved = this.merge();
220
+ return;
221
+ }
222
+
223
+ const path = scope === "global" ? this.globalPath : this.localPath;
224
+ if (!path) {
225
+ throw new Error(`No path configured for scope "${scope}"`);
226
+ }
227
+
135
228
  await this.writeFile(path, config);
136
- await this.load();
229
+
230
+ // Reload disk configs but preserve memory
231
+ const savedMemory = this.memoryConfig;
232
+ this.globalConfig = this.globalPath
233
+ ? await this.readFile(this.globalPath)
234
+ : null;
235
+ this.localConfig = this.localPath
236
+ ? await this.readFile(this.localPath)
237
+ : null;
238
+ this.memoryConfig = savedMemory;
239
+ this.resolved = this.merge();
137
240
  }
138
241
 
139
242
  // --- Internal ---
@@ -161,7 +264,7 @@ export class ConfigLoader<TConfig extends object, TResolved extends object>
161
264
  try {
162
265
  await this.writeFile(filePath, current);
163
266
  } catch {
164
- // Save failed use migrated version in memory only.
267
+ // Save failed - use migrated version in memory only.
165
268
  }
166
269
  }
167
270
 
@@ -170,10 +273,19 @@ export class ConfigLoader<TConfig extends object, TResolved extends object>
170
273
 
171
274
  private merge(): TResolved {
172
275
  const merged = structuredClone(this.defaults);
276
+
277
+ // Merge in priority order: global -> local -> memory
173
278
  if (this.globalConfig) this.deepMerge(merged, this.globalConfig);
174
- if (this.projectConfig) this.deepMerge(merged, this.projectConfig);
279
+ if (this.localConfig) this.deepMerge(merged, this.localConfig);
280
+ if (this.memoryConfig) this.deepMerge(merged, this.memoryConfig);
281
+
175
282
  if (this.afterMerge) {
176
- return this.afterMerge(merged, this.globalConfig, this.projectConfig);
283
+ return this.afterMerge(
284
+ merged,
285
+ this.globalConfig,
286
+ this.localConfig,
287
+ this.memoryConfig,
288
+ );
177
289
  }
178
290
  return merged;
179
291
  }
package/index.ts CHANGED
@@ -22,6 +22,7 @@ export {
22
22
  ConfigLoader,
23
23
  type ConfigStore,
24
24
  type Migration,
25
+ type Scope,
25
26
  } from "./config-loader";
26
27
  export {
27
28
  displayToStorageValue,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aliou/pi-utils-settings",
3
- "version": "0.0.1",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "private": false,
6
6
  "license": "MIT",
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Settings command registration helper.
3
3
  *
4
- * Creates a /{name}:settings command with Local/Global tabs.
4
+ * Creates a /{name}:settings command with tabs for each enabled scope.
5
5
  * Changes are tracked in memory. Ctrl+S saves, Esc exits without saving.
6
6
  */
7
7
 
@@ -12,10 +12,19 @@ import {
12
12
  SectionedSettings,
13
13
  type SettingsSection,
14
14
  } from "./components/sectioned-settings";
15
- import type { ConfigStore } from "./config-loader";
16
- import { displayToStorageValue, setNestedValue } from "./helpers";
17
-
18
- type Tab = "local" | "global";
15
+ import type { ConfigStore, Scope } from "./config-loader";
16
+ import {
17
+ displayToStorageValue,
18
+ getNestedValue,
19
+ setNestedValue,
20
+ } from "./helpers";
21
+
22
+ /** Display labels for each scope */
23
+ const SCOPE_LABELS: Record<Scope, string> = {
24
+ global: "Global",
25
+ local: "Local",
26
+ memory: "Memory",
27
+ };
19
28
 
20
29
  export interface SettingsCommandOptions<
21
30
  TConfig extends object,
@@ -36,11 +45,18 @@ export interface SettingsCommandOptions<
36
45
  * Use ctx.setDraft in submenu onSave callbacks to store changes
37
46
  * in the draft. All changes (toggles, enums, submenus) are only
38
47
  * persisted to disk on Ctrl+S.
48
+ *
49
+ * For memory scope, tabConfig is null when no overrides exist yet.
50
+ * Use resolved values as display values in that case.
39
51
  */
40
52
  buildSections: (
41
53
  tabConfig: TConfig | null,
42
54
  resolved: TResolved,
43
- ctx: { setDraft: (config: TConfig) => void },
55
+ ctx: {
56
+ setDraft: (config: TConfig) => void;
57
+ scope: Scope;
58
+ isInherited: (path: string) => boolean;
59
+ },
44
60
  ) => SettingsSection[];
45
61
  /**
46
62
  * Custom change handler. Receives the setting ID, new display value,
@@ -100,7 +116,7 @@ export function registerSettingsCommand<
100
116
  } = options;
101
117
  const description =
102
118
  options.commandDescription ??
103
- `Configure ${commandName.split(":")[0]} (local/global)`;
119
+ `Configure ${commandName.split(":")[0]} settings`;
104
120
  const extensionLabel = commandName.split(":")[0] ?? title;
105
121
 
106
122
  pi.registerCommand(commandName, {
@@ -108,34 +124,50 @@ export function registerSettingsCommand<
108
124
  handler: async (_args, ctx) => {
109
125
  if (!ctx.hasUI) return;
110
126
 
111
- let activeTab: Tab = configStore.hasConfig("project")
112
- ? "local"
113
- : "global";
127
+ const enabledScopes = configStore.getEnabledScopes();
128
+ if (enabledScopes.length === 0) {
129
+ ctx.ui.notify("No scopes configured", "error");
130
+ return;
131
+ }
132
+
133
+ // Default to first scope with existing config, else first enabled scope
134
+ // Safe: we check enabledScopes.length > 0 above
135
+ let activeScope: Scope =
136
+ enabledScopes.find((s) => configStore.hasConfig(s)) ??
137
+ (enabledScopes[0] as Scope);
114
138
 
115
139
  await ctx.ui.custom((tui, theme, _kb, done) => {
116
140
  let settings: SectionedSettings | null = null;
117
141
  let currentSections: SettingsSection[] = [];
118
142
  const settingsTheme = getSettingsListTheme();
119
143
 
120
- // Per-tab draft configs. null = no changes from disk.
121
- const drafts: Record<Tab, TConfig | null> = {
122
- local: null,
123
- global: null,
124
- };
144
+ // Per-scope draft configs. null = no changes from disk/memory.
145
+ const drafts: Partial<Record<Scope, TConfig | null>> = {};
146
+ for (const scope of enabledScopes) {
147
+ drafts[scope] = null;
148
+ }
125
149
 
126
150
  // --- Helpers ---
127
151
 
128
- function tabScope(): "global" | "project" {
129
- return activeTab === "local" ? "project" : "global";
152
+ /** Get the effective config for the active scope (draft or stored). */
153
+ function getTabConfig(): TConfig | null {
154
+ return drafts[activeScope] ?? configStore.getRawConfig(activeScope);
130
155
  }
131
156
 
132
- /** Get the effective config for the active tab (draft or disk). */
133
- function getTabConfig(): TConfig | null {
134
- return drafts[activeTab] ?? configStore.getRawConfig(tabScope());
157
+ /**
158
+ * For memory scope: check if a path has a value in memory config.
159
+ * If not, it's inherited from lower-priority scopes.
160
+ */
161
+ function isInherited(path: string): boolean {
162
+ if (activeScope !== "memory") return false;
163
+ const memoryConfig =
164
+ drafts.memory ?? configStore.getRawConfig("memory");
165
+ if (!memoryConfig) return true; // No memory config = all inherited
166
+ return getNestedValue(memoryConfig, path) === undefined;
135
167
  }
136
168
 
137
169
  function isDirty(): boolean {
138
- return drafts.local !== null || drafts.global !== null;
170
+ return enabledScopes.some((scope) => drafts[scope] !== null);
139
171
  }
140
172
 
141
173
  function getSections(): SettingsSection[] {
@@ -143,8 +175,10 @@ export function registerSettingsCommand<
143
175
  const resolved = configStore.getConfig();
144
176
  currentSections = buildSections(tabConfig, resolved, {
145
177
  setDraft: (config) => {
146
- drafts[activeTab] = config;
178
+ drafts[activeScope] = config;
147
179
  },
180
+ scope: activeScope,
181
+ isInherited,
148
182
  });
149
183
  return currentSections;
150
184
  }
@@ -154,13 +188,13 @@ export function registerSettingsCommand<
154
188
  tui.requestRender();
155
189
  }
156
190
 
157
- function buildSettingsComponent(tab: Tab): SectionedSettings {
191
+ function buildSettingsComponent(scope: Scope): SectionedSettings {
158
192
  return new SectionedSettings(
159
193
  getSections(),
160
194
  15,
161
195
  settingsTheme,
162
196
  (id, newValue) => {
163
- handleChange(tab, id, newValue);
197
+ handleChange(scope, id, newValue);
164
198
  },
165
199
  () => done(undefined),
166
200
  { enableSearch: true, hintSuffix: "Ctrl+S to save" },
@@ -169,14 +203,23 @@ export function registerSettingsCommand<
169
203
 
170
204
  // --- Change handler (in-memory only) ---
171
205
 
172
- function handleChange(tab: Tab, id: string, newValue: string): void {
206
+ function handleChange(
207
+ scope: Scope,
208
+ id: string,
209
+ newValue: string,
210
+ ): void {
173
211
  // Submenu items handle their own saving.
174
212
  if (isSubmenuItem(currentSections, id)) {
175
213
  refresh();
176
214
  return;
177
215
  }
178
216
 
179
- const current = getTabConfig();
217
+ // For memory scope with no existing config, start from merged config
218
+ let current = getTabConfig();
219
+ if (scope === "memory" && current === null) {
220
+ current = configStore.getConfig() as unknown as TConfig;
221
+ }
222
+
180
223
  const handler = onSettingChange ?? defaultChangeHandler;
181
224
  const updated = handler(
182
225
  id,
@@ -186,7 +229,7 @@ export function registerSettingsCommand<
186
229
  if (!updated) return;
187
230
 
188
231
  // Store in draft, don't write to disk yet.
189
- drafts[tab] = updated;
232
+ drafts[scope] = updated;
190
233
  tui.requestRender();
191
234
  }
192
235
 
@@ -195,25 +238,27 @@ export function registerSettingsCommand<
195
238
  async function save(): Promise<void> {
196
239
  let saved = false;
197
240
 
198
- for (const tab of ["local", "global"] as const) {
199
- const draft = drafts[tab];
241
+ for (const scope of enabledScopes) {
242
+ const draft = drafts[scope];
200
243
  if (!draft) continue;
201
244
 
202
- const scope = tab === "local" ? "project" : "global";
203
245
  try {
204
246
  await configStore.save(scope, draft);
205
- drafts[tab] = null;
247
+ drafts[scope] = null;
206
248
  saved = true;
207
249
  } catch (error) {
208
- ctx.ui.notify(`Failed to save ${tab}: ${error}`, "error");
250
+ ctx.ui.notify(
251
+ `Failed to save ${SCOPE_LABELS[scope]}: ${error}`,
252
+ "error",
253
+ );
209
254
  }
210
255
  }
211
256
 
212
257
  if (saved) {
213
258
  ctx.ui.notify(`${extensionLabel}: saved`, "info");
214
259
  if (onSave) await onSave();
215
- // Rebuild with fresh disk data.
216
- settings = buildSettingsComponent(activeTab);
260
+ // Rebuild with fresh data.
261
+ settings = buildSettingsComponent(activeScope);
217
262
  }
218
263
 
219
264
  tui.requestRender();
@@ -222,30 +267,37 @@ export function registerSettingsCommand<
222
267
  // --- Tab rendering ---
223
268
 
224
269
  function renderTabs(): string[] {
225
- const dirtyMark = (tab: Tab) => (drafts[tab] ? " *" : "");
226
-
227
- const localLabel =
228
- activeTab === "local"
229
- ? theme.bg(
230
- "selectedBg",
231
- theme.fg("accent", ` Local${dirtyMark("local")} `),
232
- )
233
- : theme.fg("dim", ` Local${dirtyMark("local")} `);
234
- const globalLabel =
235
- activeTab === "global"
236
- ? theme.bg(
237
- "selectedBg",
238
- theme.fg("accent", ` Global${dirtyMark("global")} `),
239
- )
240
- : theme.fg("dim", ` Global${dirtyMark("global")} `);
241
-
242
- return ["", ` ${localLabel} ${globalLabel}`, ""];
270
+ // Single scope = no tabs needed
271
+ if (enabledScopes.length === 1) {
272
+ return [""];
273
+ }
274
+
275
+ const tabLabels = enabledScopes.map((scope) => {
276
+ const label = SCOPE_LABELS[scope];
277
+ const dirtyMark = drafts[scope] ? " *" : "";
278
+ const fullLabel = ` ${label}${dirtyMark} `;
279
+
280
+ if (scope === activeScope) {
281
+ return theme.bg("selectedBg", theme.fg("accent", fullLabel));
282
+ }
283
+ return theme.fg("dim", fullLabel);
284
+ });
285
+
286
+ return ["", ` ${tabLabels.join(" ")}`, ""];
243
287
  }
244
288
 
245
289
  function handleTabSwitch(data: string): boolean {
290
+ // Single scope = no tab switching
291
+ if (enabledScopes.length <= 1) return false;
292
+
246
293
  if (matchesKey(data, Key.tab) || matchesKey(data, Key.shift("tab"))) {
247
- activeTab = activeTab === "local" ? "global" : "local";
248
- settings = buildSettingsComponent(activeTab);
294
+ const currentIndex = enabledScopes.indexOf(activeScope);
295
+ const direction = matchesKey(data, Key.shift("tab")) ? -1 : 1;
296
+ const nextIndex =
297
+ (currentIndex + direction + enabledScopes.length) %
298
+ enabledScopes.length;
299
+ activeScope = enabledScopes[nextIndex] as Scope;
300
+ settings = buildSettingsComponent(activeScope);
249
301
  tui.requestRender();
250
302
  return true;
251
303
  }
@@ -254,7 +306,7 @@ export function registerSettingsCommand<
254
306
 
255
307
  // --- Init ---
256
308
 
257
- settings = buildSettingsComponent(activeTab);
309
+ settings = buildSettingsComponent(activeScope);
258
310
 
259
311
  return {
260
312
  render(width: number) {