pi-do-always 0.4.5 → 0.5.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 CHANGED
@@ -113,6 +113,18 @@ Fields:
113
113
  In the object form you can also configure the selector shortcut:
114
114
 
115
115
  - `shortcut` (optional) — key that opens the selector, e.g. `"f4"`. Set to `null` to disable the shortcut. Defaults to `F4`. The project file's value wins over the global one.
116
+ - `merge` (optional) — how project tasks combine with the global tasks: `"override"` (default) replaces a global task with the same `name`; `"append"` keeps the global tasks and only adds new project task names (a cascade, like CSS). The project file's value wins over the global one; when neither sets it, the default is `override` (the historical behavior).
117
+
118
+ Example project file that only *adds* tasks without overriding the global set:
119
+
120
+ ```json
121
+ {
122
+ "merge": "append",
123
+ "tasks": [
124
+ { "name": "deploy", "category": "Ops", "prompt": "Deploy this project to staging." }
125
+ ]
126
+ }
127
+ ```
116
128
 
117
129
  Reload Pi (or start a new session) after editing a config file.
118
130
 
@@ -71,22 +71,31 @@ import {
71
71
  * Project-local tasks override global tasks with the same name; new ones are appended.
72
72
  * Falls back to DEFAULT_TASKS when nothing is defined.
73
73
  */
74
- function loadConfig(cwd: string): { tasks: DoAlwaysTask[]; shortcut: string | null } {
74
+ function loadConfig(cwd: string): {
75
+ tasks: DoAlwaysTask[];
76
+ shortcut: string | null;
77
+ merge: "append" | "override";
78
+ } {
75
79
  const globalPath = join(getAgentDir(), "do-always.json");
76
80
  const projectPath = join(cwd, CONFIG_DIR_NAME, "do-always.json");
77
81
 
78
82
  const global = existsSync(globalPath)
79
83
  ? parseConfig(readFileSync(globalPath, "utf-8"), globalPath)
80
- : { tasks: [], shortcut: undefined };
84
+ : { tasks: [], shortcut: undefined, merge: undefined };
81
85
  const project = existsSync(projectPath)
82
86
  ? parseConfig(readFileSync(projectPath, "utf-8"), projectPath)
83
- : { tasks: [], shortcut: undefined };
87
+ : { tasks: [], shortcut: undefined, merge: undefined };
88
+
89
+ // The project file's merge mode wins; otherwise the global value; otherwise
90
+ // override (the historical behavior), so existing configs are unaffected.
91
+ const mode = project.merge ?? global.merge ?? "override";
84
92
 
85
93
  return {
86
94
  // Order the merged list by category so the selector numbers, digit-pick,
87
95
  // `/do-always <n>`, and `list` all share one consistent order.
88
- tasks: orderTasksByCategory(mergeTasks(global.tasks, project.tasks, DEFAULT_TASKS)),
96
+ tasks: orderTasksByCategory(mergeTasks(global.tasks, project.tasks, DEFAULT_TASKS, mode)),
89
97
  shortcut: resolveShortcut(global.shortcut, project.shortcut),
98
+ merge: mode,
90
99
  };
91
100
  }
92
101
 
@@ -32,8 +32,17 @@ export interface DoAlwaysTask {
32
32
  * A config file can be a bare array of tasks, or {"tasks": [...], "shortcut": ...}.
33
33
  * `shortcut` is a key id string (e.g. "f4", "ctrl+shift+p"), or null to disable
34
34
  * the keyboard shortcut.
35
+ * `merge` controls how project tasks combine with global tasks:
36
+ * `override` (default) replaces a global task with the same name;
37
+ * `append` keeps globals and only adds new project task names (a cascade).
35
38
  */
36
- export type DoAlwaysConfig = DoAlwaysTask[] | { tasks: DoAlwaysTask[]; shortcut?: string | null };
39
+ export type DoAlwaysConfig =
40
+ | DoAlwaysTask[]
41
+ | {
42
+ tasks: DoAlwaysTask[];
43
+ shortcut?: string | null;
44
+ merge?: "append" | "override";
45
+ };
37
46
 
38
47
  /** Shortcut used when neither config file specifies one. */
39
48
  export const DEFAULT_SHORTCUT = "f4";
@@ -46,6 +55,11 @@ export interface ParsedDoAlwaysConfig {
46
55
  * disabled, undefined when the file does not set one.
47
56
  */
48
57
  shortcut: string | null | undefined;
58
+ /**
59
+ * The `merge` field, if present: "append" or "override", undefined when the
60
+ * file does not set one.
61
+ */
62
+ merge?: "append" | "override" | undefined;
49
63
  }
50
64
 
51
65
  /**
@@ -196,14 +210,18 @@ export function parseConfig(
196
210
  }
197
211
 
198
212
  let shortcut: string | null | undefined;
213
+ let merge: "append" | "override" | undefined;
199
214
  if (!Array.isArray(data) && "shortcut" in data) {
200
215
  const s = data.shortcut;
201
216
  if (s === null) shortcut = null;
202
217
  else if (typeof s === "string") shortcut = s.trim() === "" ? null : s.trim();
203
218
  else onError(`do-always: ignoring invalid "shortcut" in ${path} (expected a key string or null)`);
204
219
  }
220
+ if (!Array.isArray(data) && "merge" in data) {
221
+ merge = parseMerge(data.merge, path, onError);
222
+ }
205
223
 
206
- return { tasks, shortcut };
224
+ return { tasks, shortcut, merge };
207
225
  }
208
226
 
209
227
  const KEY_MODIFIERS = new Set(["ctrl", "shift", "alt", "super"]);
@@ -242,12 +260,53 @@ export function resolveShortcut(
242
260
  return DEFAULT_SHORTCUT;
243
261
  }
244
262
 
263
+ /**
264
+ * Parse the optional `merge` field: "append" or "override" (case-insensitive),
265
+ * or undefined when absent. A non-string or unrecognized value is ignored with
266
+ * a warning, so it never silently changes behavior.
267
+ */
268
+ function parseMerge(
269
+ raw: unknown,
270
+ path: string,
271
+ onError: (message: string) => void,
272
+ ): "append" | "override" | undefined {
273
+ if (raw === undefined) return undefined;
274
+ if (typeof raw !== "string") {
275
+ onError(`do-always: ignoring invalid "merge" in ${path} (expected "append" or "override")`);
276
+ return undefined;
277
+ }
278
+ const v = raw.trim().toLowerCase();
279
+ if (v === "append" || v === "override") return v;
280
+ onError(`do-always: ignoring invalid "merge" in ${path} (expected "append" or "override")`);
281
+ return undefined;
282
+ }
283
+
245
284
  /**
246
285
  * Merge project-local tasks over global tasks.
247
- * A project task with a name matching a global task replaces it; new names are appended.
286
+ *
287
+ * When `mode` is "override" (default), a project task with a name matching a
288
+ * global task replaces it; new names are appended. When "append", globals are
289
+ * kept as-is and only new (non-duplicate) project task names are appended.
248
290
  * Returns fallback when the merged result is empty.
249
291
  */
250
- export function mergeTasks(globalTasks: DoAlwaysTask[], projectTasks: DoAlwaysTask[], fallback: DoAlwaysTask[]): DoAlwaysTask[] {
292
+ export function mergeTasks(
293
+ globalTasks: DoAlwaysTask[],
294
+ projectTasks: DoAlwaysTask[],
295
+ fallback: DoAlwaysTask[],
296
+ mode: "append" | "override" = "override",
297
+ ): DoAlwaysTask[] {
298
+ if (mode === "append") {
299
+ const merged = [...globalTasks];
300
+ const names = new Set(merged.map((t) => t.name));
301
+ for (const task of projectTasks) {
302
+ if (!names.has(task.name)) {
303
+ merged.push(task);
304
+ names.add(task.name);
305
+ }
306
+ }
307
+ return merged.length > 0 ? merged : fallback;
308
+ }
309
+
251
310
  const merged = [...globalTasks];
252
311
  for (const task of projectTasks) {
253
312
  const idx = merged.findIndex((t) => t.name === task.name);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-do-always",
3
- "version": "0.4.5",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "description": "Pi extension: /do-always — pick a common task by number, it fills your prompt",
6
6
  "author": {