pi-do-always 0.1.0 → 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 aginies
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -23,7 +23,7 @@ the task's prompt is **filled into the input editor**. Review it, tweak it, pres
23
23
 
24
24
  |Command|What it does|
25
25
  |---|---|
26
- |`/do-always` or `Ctrl+Shift+P`|Show the numbered task selector|
26
+ |`/do-always` or the shortcut key (default `F4`)|Show the numbered task selector|
27
27
  |`/do-always 2`|Fill the prompt for task #2 directly|
28
28
  |`/do-always review`|Fill the prompt for the task named `review` (task names autocomplete after `/do-always`)|
29
29
  |`/do-always list`|Print the task list|
@@ -56,7 +56,7 @@ For development you can also load it explicitly: `npm run dev` (runs `pi --exten
56
56
 
57
57
  ## Tasks configuration
58
58
 
59
- Tasks are read from JSON files (an array of tasks, or `{"tasks": [...]}`):
59
+ Tasks are read from JSON files (an array of tasks, or the object form `{"tasks": [...], "shortcut": "f4"}`):
60
60
 
61
61
  |File|Scope|
62
62
  |---|---|
@@ -83,6 +83,10 @@ Fields:
83
83
  - `description` (optional) — one-line label shown in the selector
84
84
  - `prompt` (required) — the text filled into the editor
85
85
 
86
+ In the object form you can also configure the selector shortcut:
87
+
88
+ - `shortcut` (optional) — key that opens the selector, e.g. `"f4"` or `"ctrl+shift+p"`. Set to `null` to disable the shortcut. Defaults to `F4`. The project file's value wins over the global one.
89
+
86
90
  Reload Pi (or start a new session) after editing a config file.
87
91
 
88
92
  ## Development
@@ -111,3 +115,7 @@ npm publish
111
115
  `@earendil-works/pi-coding-agent` and `@earendil-works/pi-tui` are declared as `peerDependencies`
112
116
  with a `"*"` range and are **not** bundled — Pi supplies them to extensions. The `pi-package`
113
117
  keyword makes this package eligible for the [Pi package gallery](https://pi.dev/packages).
118
+
119
+ The gallery page is built from `package.json` (name, description, author, version, license,
120
+ repository, `pi` manifest) plus the README. Optional `pi.image` / `pi.video` fields in
121
+ `package.json` add a gallery preview if you want one.
package/do-always.json CHANGED
@@ -1,42 +1,45 @@
1
- [
2
- {
3
- "name": "review",
4
- "description": "Review code and double-check changes",
5
- "prompt": "Review the recent code changes in this project. Check `git status` and `git diff` to see what changed, then double-check the changes for bugs, edge cases, security issues, and consistency with the rest of the codebase. Do a plan proposal for the fixes."
6
- },
7
- {
8
- "name": "readme",
9
- "description": "Update the README",
10
- "prompt": "Update the README to match the current state of the project. Check the code, scripts, and configuration, then update the README sections that are now out of date (description, installation, usage, configuration). Keep it concise and accurate."
11
- },
12
- {
13
- "name": "tests",
14
- "description": "Run tests and fix failures",
15
- "prompt": "Run the project's test suite (and type check / lint if available). If anything fails, diagnose and fix the failures, then re-run until green. Summarize the results."
16
- },
17
- {
18
- "name": "commit",
19
- "description": "Prepare a clean commit",
20
- "prompt": "Prepare the working tree for a clean commit: review `git status` and `git diff`, stage the relevant changes, and write a clear commit message describing what changed and why. Do not push."
21
- },
22
- {
23
- "name": "cleanup",
24
- "description": "Propose a plan to clean up dead code and duplicates",
25
- "prompt": "Scan the project for dead code, unused imports, commented-out blocks, and duplicated logic. Do a plan proposal for the removals and consolidations, keeping behavior unchanged. Do not make any changes yet."
26
- },
27
- {
28
- "name": "release",
29
- "description": "Prepare a release (version, changelog, tag)",
30
- "prompt": "Prepare a release for this project: check `git log` since the last tag, update the version in package.json (or the equivalent location), add a changelog entry summarizing the changes, and create a git tag. Do not push."
31
- },
32
- {
33
- "name": "security",
34
- "description": "Security audit — plan proposal",
35
- "prompt": "Audit this project for security issues: hardcoded secrets or credentials, unsafe patterns (injection, path traversal, unsafe deserialization), and vulnerable or outdated dependencies. Do a plan proposal for the fixes. Do not make any changes yet."
36
- },
37
- {
38
- "name": "perf",
39
- "description": "Performance review — plan proposal",
40
- "prompt": "Review this project for likely performance bottlenecks: inefficient algorithms, redundant I/O or computation, missing caching, and memory leaks. Do a plan proposal for the optimizations, prioritized by impact. Do not make any changes yet."
41
- }
42
- ]
1
+ {
2
+ "shortcut": "f4",
3
+ "tasks": [
4
+ {
5
+ "name": "review",
6
+ "description": "Review code and double-check changes",
7
+ "prompt": "Review the recent code changes in this project. Check `git status` and `git diff` to see what changed, then double-check the changes for bugs, edge cases, security issues, and consistency with the rest of the codebase. Do a plan proposal for the fixes."
8
+ },
9
+ {
10
+ "name": "readme",
11
+ "description": "Update the README",
12
+ "prompt": "Update the README to match the current state of the project. Check the code, scripts, and configuration, then update the README sections that are now out of date (description, installation, usage, configuration). Keep it concise and accurate."
13
+ },
14
+ {
15
+ "name": "tests",
16
+ "description": "Run tests and fix failures",
17
+ "prompt": "Run the project's test suite (and type check / lint if available). If anything fails, diagnose and fix the failures, then re-run until green. Summarize the results."
18
+ },
19
+ {
20
+ "name": "commit",
21
+ "description": "Prepare a clean commit",
22
+ "prompt": "Prepare the working tree for a clean commit: review `git status` and `git diff`, stage the relevant changes, and write a clear commit message describing what changed and why. Do not push."
23
+ },
24
+ {
25
+ "name": "cleanup",
26
+ "description": "Propose a plan to clean up dead code and duplicates",
27
+ "prompt": "Scan the project for dead code, unused imports, commented-out blocks, and duplicated logic. Do a plan proposal for the removals and consolidations, keeping behavior unchanged. Do not make any changes yet."
28
+ },
29
+ {
30
+ "name": "release",
31
+ "description": "Prepare a release (version, changelog, tag)",
32
+ "prompt": "Prepare a release for this project: check `git log` since the last tag, update the version in package.json (or the equivalent location), add a changelog entry summarizing the changes, and create a git tag. Do not push."
33
+ },
34
+ {
35
+ "name": "security",
36
+ "description": "Security audit — plan proposal",
37
+ "prompt": "Audit this project for security issues: hardcoded secrets or credentials, unsafe patterns (injection, path traversal, unsafe deserialization), and vulnerable or outdated dependencies. Do a plan proposal for the fixes. Do not make any changes yet."
38
+ },
39
+ {
40
+ "name": "perf",
41
+ "description": "Performance review — plan proposal",
42
+ "prompt": "Review this project for likely performance bottlenecks: inefficient algorithms, redundant I/O or computation, missing caching, and memory leaks. Do a plan proposal for the optimizations, prioritized by impact. Do not make any changes yet."
43
+ }
44
+ ]
45
+ }
package/index.ts CHANGED
@@ -7,7 +7,8 @@
7
7
  * The selected task's prompt is filled into the input editor — review,
8
8
  * tweak, then press Enter to run it.
9
9
  *
10
- * Config (JSON array of tasks; project file overrides global by name):
10
+ * Config (JSON array of tasks, or {"tasks": [...], "shortcut": "f4" | null};
11
+ * project file overrides global by name):
11
12
  * ~/.pi/agent/do-always.json (global)
12
13
  * <cwd>/.pi/do-always.json (project)
13
14
  *
@@ -19,8 +20,11 @@
19
20
  * }
20
21
  * ]
21
22
  *
23
+ * The object form also accepts "shortcut": a key id (e.g. "f4",
24
+ * "ctrl+shift+p") or null to disable the keyboard shortcut. Default: F4.
25
+ *
22
26
  * Usage:
23
- * /do-always → numbered selector (or press Ctrl+Shift+P)
27
+ * /do-always → numbered selector (or press the configured shortcut, default F4)
24
28
  * /do-always 2 → fill prompt for task #2
25
29
  * /do-always review → fill prompt for task named "review"
26
30
  * /do-always list → print the task list
@@ -33,22 +37,39 @@ import { existsSync, readFileSync } from "node:fs";
33
37
  import { join } from "node:path";
34
38
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
35
39
  import { CONFIG_DIR_NAME, getAgentDir } from "@earendil-works/pi-coding-agent";
36
- import { Container, Key, type SelectItem, SelectList, Text } from "@earendil-works/pi-tui";
37
- import { DEFAULT_TASKS, formatList, mergeTasks, parseConfig, resolveTask, type DoAlwaysTask } from "./tasks";
40
+ import { Container, type KeyId, type SelectItem, SelectList, Text } from "@earendil-works/pi-tui";
41
+ import {
42
+ DEFAULT_SHORTCUT,
43
+ DEFAULT_TASKS,
44
+ formatList,
45
+ isValidKeyId,
46
+ mergeTasks,
47
+ parseConfig,
48
+ resolveShortcut,
49
+ resolveTask,
50
+ type DoAlwaysTask,
51
+ } from "./tasks";
38
52
 
39
53
  /**
40
- * Load tasks from config files.
54
+ * Load tasks and the selector shortcut from config files.
41
55
  * Project-local tasks override global tasks with the same name; new ones are appended.
42
56
  * Falls back to DEFAULT_TASKS when nothing is defined.
43
57
  */
44
- function loadTasks(cwd: string): DoAlwaysTask[] {
58
+ function loadConfig(cwd: string): { tasks: DoAlwaysTask[]; shortcut: string | null } {
45
59
  const globalPath = join(getAgentDir(), "do-always.json");
46
60
  const projectPath = join(cwd, CONFIG_DIR_NAME, "do-always.json");
47
61
 
48
- const globalTasks = existsSync(globalPath) ? parseConfig(readFileSync(globalPath, "utf-8"), globalPath) : [];
49
- const projectTasks = existsSync(projectPath) ? parseConfig(readFileSync(projectPath, "utf-8"), projectPath) : [];
50
-
51
- return mergeTasks(globalTasks, projectTasks, DEFAULT_TASKS);
62
+ const global = existsSync(globalPath)
63
+ ? parseConfig(readFileSync(globalPath, "utf-8"), globalPath)
64
+ : { tasks: [], shortcut: undefined };
65
+ const project = existsSync(projectPath)
66
+ ? parseConfig(readFileSync(projectPath, "utf-8"), projectPath)
67
+ : { tasks: [], shortcut: undefined };
68
+
69
+ return {
70
+ tasks: mergeTasks(global.tasks, project.tasks, DEFAULT_TASKS),
71
+ shortcut: resolveShortcut(global.shortcut, project.shortcut),
72
+ };
52
73
  }
53
74
 
54
75
  export default function doAlwaysExtension(pi: ExtensionAPI) {
@@ -57,7 +78,7 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
57
78
 
58
79
  pi.on("session_start", async (_event, ctx) => {
59
80
  loadedCwd = ctx.cwd;
60
- tasks = loadTasks(ctx.cwd);
81
+ tasks = loadConfig(ctx.cwd).tasks;
61
82
  });
62
83
 
63
84
  /** Put the task prompt into the editor (TUI) or send it as a user message (other modes). */
@@ -141,19 +162,29 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
141
162
  });
142
163
 
143
164
  // Keyboard shortcut: open the task selector without typing the command.
144
- pi.registerShortcut(Key.ctrlShift("p"), {
145
- description: "do-always: pick a common task",
146
- handler: async (ctx) => {
147
- await runDoAlways("", ctx);
148
- },
149
- });
165
+ // The key is configurable via the "shortcut" field in do-always.json
166
+ // (null disables it). It is read once at extension load, so changing it
167
+ // requires a reload or a new session.
168
+ const configuredShortcut = loadConfig(process.cwd()).shortcut;
169
+ if (configuredShortcut !== null) {
170
+ const shortcutKey = isValidKeyId(configuredShortcut) ? configuredShortcut : DEFAULT_SHORTCUT;
171
+ if (shortcutKey !== configuredShortcut) {
172
+ console.warn(`do-always: invalid shortcut "${configuredShortcut}" in do-always.json — using ${DEFAULT_SHORTCUT}`);
173
+ }
174
+ pi.registerShortcut(shortcutKey as KeyId, {
175
+ description: "do-always: pick a common task",
176
+ handler: async (ctx) => {
177
+ await runDoAlways("", ctx);
178
+ },
179
+ });
180
+ }
150
181
 
151
182
  async function runDoAlways(args: string, ctx: ExtensionContext): Promise<void> {
152
183
  // Reload when the active directory changes, so switching projects
153
184
  // mid-session serves the right config instead of stale tasks.
154
185
  if (ctx.cwd !== loadedCwd) {
155
186
  loadedCwd = ctx.cwd;
156
- tasks = loadTasks(ctx.cwd);
187
+ tasks = loadConfig(ctx.cwd).tasks;
157
188
  }
158
189
 
159
190
  const arg = args.trim();
package/package.json CHANGED
@@ -1,10 +1,16 @@
1
1
  {
2
2
  "name": "pi-do-always",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "description": "Pi extension: /do-always — pick a common task by number, it fills your prompt",
6
+ "author": {
7
+ "name": "aginies",
8
+ "email": "antoine@ginies.org"
9
+ },
6
10
  "keywords": [
7
11
  "pi-package",
12
+ "pi",
13
+ "pi-coding-agent",
8
14
  "pi-extensions",
9
15
  "do-always"
10
16
  ],
package/tasks.ts CHANGED
@@ -13,8 +13,25 @@ export interface DoAlwaysTask {
13
13
  prompt: string;
14
14
  }
15
15
 
16
- /** A config file can be a bare array of tasks, or {"tasks": [...]}. */
17
- export type DoAlwaysConfig = DoAlwaysTask[] | { tasks: DoAlwaysTask[] };
16
+ /**
17
+ * A config file can be a bare array of tasks, or {"tasks": [...], "shortcut": ...}.
18
+ * `shortcut` is a key id string (e.g. "f4", "ctrl+shift+p"), or null to disable
19
+ * the keyboard shortcut.
20
+ */
21
+ export type DoAlwaysConfig = DoAlwaysTask[] | { tasks: DoAlwaysTask[]; shortcut?: string | null };
22
+
23
+ /** Shortcut used when neither config file specifies one. */
24
+ export const DEFAULT_SHORTCUT = "f4";
25
+
26
+ /** Result of parsing a config file. */
27
+ export interface ParsedDoAlwaysConfig {
28
+ tasks: DoAlwaysTask[];
29
+ /**
30
+ * The `shortcut` field, if present: a key id string, null when explicitly
31
+ * disabled, undefined when the file does not set one.
32
+ */
33
+ shortcut: string | null | undefined;
34
+ }
18
35
 
19
36
  /** Used when neither config file defines any task. */
20
37
  export const DEFAULT_TASKS: DoAlwaysTask[] = [
@@ -70,27 +87,28 @@ export const DEFAULT_TASKS: DoAlwaysTask[] = [
70
87
 
71
88
  /**
72
89
  * Parse and validate a config file's contents.
73
- * Accepts a bare array of tasks or {"tasks": [...]}. Invalid entries are skipped with a warning.
74
- * Returns [] when the JSON is malformed or the shape is wrong.
90
+ * Accepts a bare array of tasks or {"tasks": [...], "shortcut": "f4" | null}.
91
+ * Invalid entries are skipped with a warning.
92
+ * Returns empty results when the JSON is malformed or the shape is wrong.
75
93
  */
76
94
  export function parseConfig(
77
95
  raw: string,
78
96
  path: string,
79
97
  onError: (message: string) => void = () => {},
80
- ): DoAlwaysTask[] {
98
+ ): ParsedDoAlwaysConfig {
81
99
  let data: DoAlwaysConfig;
82
100
  try {
83
101
  data = JSON.parse(raw);
84
102
  } catch (err) {
85
103
  onError(`do-always: invalid JSON in ${path}: ${err}`);
86
- return [];
104
+ return { tasks: [], shortcut: undefined };
87
105
  }
88
106
 
89
107
  const list = Array.isArray(data) ? data : data?.tasks;
90
108
 
91
109
  if (!Array.isArray(list)) {
92
110
  onError(`do-always: ${path} must be a JSON array of tasks or {"tasks": [...]}`);
93
- return [];
111
+ return { tasks: [], shortcut: undefined };
94
112
  }
95
113
 
96
114
  const tasks: DoAlwaysTask[] = [];
@@ -106,7 +124,52 @@ export function parseConfig(
106
124
  onError(`do-always: skipping invalid task in ${path} (each task needs "name" and "prompt")`);
107
125
  }
108
126
  }
109
- return tasks;
127
+
128
+ let shortcut: string | null | undefined;
129
+ if (!Array.isArray(data) && "shortcut" in data) {
130
+ const s = data.shortcut;
131
+ if (s === null) shortcut = null;
132
+ else if (typeof s === "string") shortcut = s.trim() === "" ? null : s.trim();
133
+ else onError(`do-always: ignoring invalid "shortcut" in ${path} (expected a key string or null)`);
134
+ }
135
+
136
+ return { tasks, shortcut };
137
+ }
138
+
139
+ const KEY_MODIFIERS = new Set(["ctrl", "shift", "alt", "super"]);
140
+ const KEY_SPECIALS = new Set([
141
+ "escape", "esc", "enter", "return", "tab", "space", "backspace", "delete",
142
+ "insert", "clear", "home", "end", "pageup", "pagedown", "up", "down",
143
+ "left", "right", "f1", "f2", "f3", "f4", "f5", "f6", "f7", "f8", "f9",
144
+ "f10", "f11", "f12",
145
+ ]);
146
+
147
+ /**
148
+ * Check whether a string is a valid key id (e.g. "f4", "ctrl+shift+p").
149
+ * Mirrors the KeyId syntax: optional ctrl/shift/alt/super modifiers (each at
150
+ * most once) followed by a base key (letter, digit, or special key).
151
+ */
152
+ export function isValidKeyId(key: string): boolean {
153
+ const parts = key.toLowerCase().split("+");
154
+ const base = parts[parts.length - 1];
155
+ if (!base) return false;
156
+ if (!(/^[a-z0-9]$/.test(base) || KEY_SPECIALS.has(base))) return false;
157
+ const mods = parts.slice(0, -1);
158
+ return mods.every((m) => KEY_MODIFIERS.has(m)) && new Set(mods).size === mods.length;
159
+ }
160
+
161
+ /**
162
+ * Resolve the shortcut from global and project config. A value present in the
163
+ * project file wins (null disables); otherwise the global value; otherwise
164
+ * DEFAULT_SHORTCUT.
165
+ */
166
+ export function resolveShortcut(
167
+ globalShortcut: string | null | undefined,
168
+ projectShortcut: string | null | undefined,
169
+ ): string | null {
170
+ if (projectShortcut !== undefined) return projectShortcut;
171
+ if (globalShortcut !== undefined) return globalShortcut;
172
+ return DEFAULT_SHORTCUT;
110
173
  }
111
174
 
112
175
  /**