pi-formatter 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.
Files changed (26) hide show
  1. package/README.md +28 -5
  2. package/extensions/formatter/config.ts +82 -0
  3. package/extensions/{format → formatter}/context.ts +58 -37
  4. package/extensions/{format → formatter}/dispatch.ts +114 -32
  5. package/extensions/{format → formatter}/path.ts +19 -3
  6. package/extensions/{format → formatter}/plan.ts +5 -5
  7. package/extensions/{format/runners/biome-check-write.ts → formatter/runners/biome.ts} +4 -4
  8. package/extensions/formatter/runners/config-patterns.ts +5 -0
  9. package/extensions/{format/runners/eslint-fix.ts → formatter/runners/eslint.ts} +3 -3
  10. package/extensions/{format → formatter}/runners/index.ts +10 -12
  11. package/extensions/{format/runners/markdownlint-fix.ts → formatter/runners/markdownlint.ts} +3 -3
  12. package/extensions/{format/runners/prettier-markdown.ts → formatter/runners/prettier.ts} +3 -3
  13. package/extensions/{format/runners/ruff-check-fix.ts → formatter/runners/ruff-check.ts} +3 -3
  14. package/extensions/{format → formatter}/types.ts +4 -6
  15. package/extensions/index.ts +91 -13
  16. package/package.json +1 -2
  17. package/DOCUMENTATION.md +0 -120
  18. package/extensions/format/config.ts +0 -11
  19. package/extensions/format/runners/config-patterns.ts +0 -6
  20. package/extensions/format/runners/prettier-config-write.ts +0 -11
  21. /package/extensions/{format → formatter}/runners/clang-format.ts +0 -0
  22. /package/extensions/{format → formatter}/runners/cmake-format.ts +0 -0
  23. /package/extensions/{format → formatter}/runners/helpers.ts +0 -0
  24. /package/extensions/{format → formatter}/runners/ruff-format.ts +0 -0
  25. /package/extensions/{format → formatter}/runners/shfmt.ts +0 -0
  26. /package/extensions/{format → formatter}/system.ts +0 -0
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # 🎨 pi-formatter
2
2
 
3
- A [pi](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent)
4
- extension that auto-formats files after every `write` and `edit` tool call.
3
+ A [pi](https://pi.dev) extension that auto-formats files after every `write` and
4
+ `edit` tool call.
5
5
 
6
6
  The extension hooks into successful tool results, detects the file type, and
7
7
  runs the appropriate formatter. Failures never block the tool result, so
@@ -28,13 +28,36 @@ Supported file types:
28
28
  - Python
29
29
  - JavaScript/TypeScript
30
30
 
31
+ For JS/TS and JSON, project-configured tools are preferred first (Biome,
32
+ ESLint), with Prettier as a fallback.
33
+
31
34
  ## 🔧 Configuration
32
35
 
33
- - `PI_FORMAT_COMMAND_TIMEOUT_MS`: timeout (ms) per formatter command (default: `10000`)
36
+ Create `<agent-dir>/formatter.json`, where `<agent-dir>` is pi's agent config
37
+ folder (default: `~/.pi/agent`, overridable via `PI_CODING_AGENT_DIR`):
38
+
39
+ ```json
40
+ {
41
+ "commandTimeoutMs": 10000,
42
+ "hideCallSummariesInTui": false
43
+ }
44
+ ```
45
+
46
+ - `commandTimeoutMs`: timeout (ms) per formatter command (default: `10000`)
47
+ - `hideCallSummariesInTui`: hide formatter pass/fail summaries in the TUI (default: `false`)
48
+
49
+ ## 🧩 Adding formatters
50
+
51
+ Each formatter is a _runner_ that wraps a CLI tool behind a common interface.
52
+ To add one:
34
53
 
35
- ## 🧩 Contributor docs
54
+ 1. Create a file in `extensions/formatter/runners/` using `defineRunner` and a
55
+ launcher helper (`direct`, `pypi`, or `goTool`).
56
+ 2. Register it in `extensions/formatter/runners/index.ts`.
57
+ 3. Add its id to a group in `extensions/formatter/plan.ts`.
36
58
 
37
- See the [runner API contract](DOCUMENTATION.md) for how to add new formatters.
59
+ The format plan maps file kinds to ordered runner groups. Each group runs in
60
+ `"all"` mode (every runner) or `"fallback"` mode (first match wins).
38
61
 
39
62
  ## 📄 License
40
63
 
@@ -0,0 +1,82 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { getAgentDir } from "@mariozechner/pi-coding-agent";
4
+
5
+ const DEFAULT_COMMAND_TIMEOUT_MS = 10_000;
6
+ const DEFAULT_HIDE_CALL_SUMMARIES_IN_TUI = false;
7
+ const FORMATTER_CONFIG_FILE = "formatter.json";
8
+
9
+ type FormatterConfig = {
10
+ commandTimeoutMs: number;
11
+ hideCallSummariesInTui: boolean;
12
+ };
13
+
14
+ function getFormatterConfigPath(): string {
15
+ return join(getAgentDir(), FORMATTER_CONFIG_FILE);
16
+ }
17
+
18
+ function readJsonObject(filePath: string): Record<string, unknown> | undefined {
19
+ try {
20
+ if (!existsSync(filePath)) {
21
+ return undefined;
22
+ }
23
+
24
+ const content = readFileSync(filePath, "utf8");
25
+ const parsed = JSON.parse(content);
26
+
27
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
28
+ return undefined;
29
+ }
30
+
31
+ return parsed as Record<string, unknown>;
32
+ } catch {
33
+ return undefined;
34
+ }
35
+ }
36
+
37
+ function parsePositiveInt(value: unknown, defaultValue: number): number {
38
+ if (typeof value !== "number") {
39
+ return defaultValue;
40
+ }
41
+
42
+ if (!Number.isInteger(value) || value <= 0) {
43
+ return defaultValue;
44
+ }
45
+
46
+ return value;
47
+ }
48
+
49
+ function parseBooleanValue(value: unknown, defaultValue: boolean): boolean {
50
+ if (typeof value !== "boolean") {
51
+ return defaultValue;
52
+ }
53
+
54
+ return value;
55
+ }
56
+
57
+ function loadFormatterConfig(): FormatterConfig {
58
+ const config = readJsonObject(getFormatterConfigPath());
59
+
60
+ if (!config) {
61
+ return {
62
+ commandTimeoutMs: DEFAULT_COMMAND_TIMEOUT_MS,
63
+ hideCallSummariesInTui: DEFAULT_HIDE_CALL_SUMMARIES_IN_TUI,
64
+ };
65
+ }
66
+
67
+ return {
68
+ commandTimeoutMs: parsePositiveInt(
69
+ config.commandTimeoutMs,
70
+ DEFAULT_COMMAND_TIMEOUT_MS,
71
+ ),
72
+ hideCallSummariesInTui: parseBooleanValue(
73
+ config.hideCallSummariesInTui,
74
+ DEFAULT_HIDE_CALL_SUMMARIES_IN_TUI,
75
+ ),
76
+ };
77
+ }
78
+
79
+ const formatterConfig = loadFormatterConfig();
80
+
81
+ export const commandTimeoutMs = formatterConfig.commandTimeoutMs;
82
+ export const hideCallSummariesInTui = formatterConfig.hideCallSummariesInTui;
@@ -3,7 +3,7 @@ import { dirname, join, resolve } from "node:path";
3
3
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
4
4
  import { getPathForGit, isWithinDirectory, pathExists } from "./path.js";
5
5
  import { hasCommand } from "./system.js";
6
- import type { FileKind, RequiredMajorVersion, RunnerContext, SourceTool } from "./types.js";
6
+ import type { FileKind, RequiredMajorVersion, RunnerContext } from "./types.js";
7
7
 
8
8
  const globRegexCache = new Map<string, RegExp>();
9
9
 
@@ -66,14 +66,44 @@ async function findConfigFileFromPath(
66
66
  return undefined;
67
67
  }
68
68
 
69
+ function getLineRangesFromDiff(diffOutput: string): string[] {
70
+ const ranges: string[] = [];
71
+
72
+ for (const line of diffOutput.split(/\r?\n/)) {
73
+ const match = line.match(/^@@ .* \+([0-9]+)(?:,([0-9]+))? @@/);
74
+ if (!match) {
75
+ continue;
76
+ }
77
+
78
+ const start = Number.parseInt(match[1], 10);
79
+ const count = match[2] ? Number.parseInt(match[2], 10) : 1;
80
+
81
+ if (!Number.isFinite(start) || !Number.isFinite(count) || count <= 0) {
82
+ continue;
83
+ }
84
+
85
+ ranges.push(`${start}:${start + count - 1}`);
86
+ }
87
+
88
+ return ranges;
89
+ }
90
+
91
+ function compareLineRanges(a: string, b: string): number {
92
+ const [aStart] = a.split(":", 1);
93
+ const [bStart] = b.split(":", 1);
94
+ return Number.parseInt(aStart, 10) - Number.parseInt(bStart, 10);
95
+ }
96
+
97
+ export type FormatWarningReporter = (message: string) => void;
98
+
69
99
  export class FormatRunContext implements RunnerContext {
70
100
  readonly filePath: string;
71
101
  readonly cwd: string;
72
- readonly sourceTool: SourceTool;
73
102
  readonly kind: FileKind;
74
103
 
75
104
  private readonly pi: ExtensionAPI;
76
105
  private readonly timeoutMs: number;
106
+ private readonly warningReporter?: FormatWarningReporter;
77
107
 
78
108
  private readonly configLookupCache = new Map<
79
109
  string,
@@ -95,23 +125,25 @@ export class FormatRunContext implements RunnerContext {
95
125
  pi: ExtensionAPI,
96
126
  cwd: string,
97
127
  filePath: string,
98
- sourceTool: SourceTool,
99
128
  kind: FileKind,
100
129
  timeoutMs: number,
130
+ warningReporter?: FormatWarningReporter,
101
131
  ) {
102
132
  this.pi = pi;
103
133
  this.cwd = cwd;
104
134
  this.filePath = filePath;
105
- this.sourceTool = sourceTool;
106
135
  this.kind = kind;
107
136
  this.timeoutMs = timeoutMs;
137
+ this.warningReporter = warningReporter;
108
138
  }
109
139
 
110
140
  async hasCommand(command: string): Promise<boolean> {
111
141
  return hasCommand(command);
112
142
  }
113
143
 
114
- async findConfigFile(patterns: readonly string[]): Promise<string | undefined> {
144
+ async findConfigFile(
145
+ patterns: readonly string[],
146
+ ): Promise<string | undefined> {
115
147
  const key = patterns.join("\u0000");
116
148
  let cached = this.configLookupCache.get(key);
117
149
 
@@ -136,14 +168,10 @@ export class FormatRunContext implements RunnerContext {
136
168
  }
137
169
 
138
170
  async exec(command: string, args: string[]) {
139
- try {
140
- return await this.pi.exec(command, args, {
141
- cwd: this.cwd,
142
- timeout: this.timeoutMs,
143
- });
144
- } catch {
145
- return undefined;
146
- }
171
+ return this.pi.exec(command, args, {
172
+ cwd: this.cwd,
173
+ timeout: this.timeoutMs,
174
+ });
147
175
  }
148
176
 
149
177
  async getChangedLines(): Promise<string[]> {
@@ -182,6 +210,11 @@ export class FormatRunContext implements RunnerContext {
182
210
  }
183
211
 
184
212
  warn(message: string): void {
213
+ if (this.warningReporter) {
214
+ this.warningReporter(message);
215
+ return;
216
+ }
217
+
185
218
  console.warn(message);
186
219
  }
187
220
 
@@ -207,37 +240,25 @@ export class FormatRunContext implements RunnerContext {
207
240
 
208
241
  private async resolveChangedLines(): Promise<string[]> {
209
242
  const gitPath = getPathForGit(this.filePath, this.cwd);
210
- const diffResult = await this.exec("git", [
211
- "diff",
212
- "--cached",
213
- "--unified=0",
214
- "--",
215
- gitPath,
216
- ]);
217
-
218
- if (!diffResult || diffResult.code !== 0) {
219
- return [];
220
- }
243
+ const rangeSet = new Set<string>();
221
244
 
222
- const ranges: string[] = [];
245
+ const diffArgSets = [
246
+ ["diff", "--unified=0", "--", gitPath],
247
+ ["diff", "--cached", "--unified=0", "--", gitPath],
248
+ ];
223
249
 
224
- for (const line of diffResult.stdout.split(/\r?\n/)) {
225
- const match = line.match(/^@@ .* \+([0-9]+)(?:,([0-9]+))? @@/);
226
- if (!match) {
250
+ for (const args of diffArgSets) {
251
+ const diffResult = await this.exec("git", args);
252
+ if (diffResult.code !== 0) {
227
253
  continue;
228
254
  }
229
255
 
230
- const start = Number.parseInt(match[1], 10);
231
- const count = match[2] ? Number.parseInt(match[2], 10) : 1;
232
-
233
- if (!Number.isFinite(start) || !Number.isFinite(count) || count <= 0) {
234
- continue;
256
+ for (const range of getLineRangesFromDiff(diffResult.stdout)) {
257
+ rangeSet.add(range);
235
258
  }
236
-
237
- ranges.push(`${start}:${start + count - 1}`);
238
259
  }
239
260
 
240
- return ranges;
261
+ return [...rangeSet].sort(compareLineRanges);
241
262
  }
242
263
 
243
264
  private async resolveRequiredMajorVersionFromConfig(
@@ -266,7 +287,7 @@ export class FormatRunContext implements RunnerContext {
266
287
  command: string,
267
288
  ): Promise<string | undefined> {
268
289
  const result = await this.exec(command, ["--version"]);
269
- if (!result || result.code !== 0) {
290
+ if (result.code !== 0) {
270
291
  return undefined;
271
292
  }
272
293
 
@@ -1,28 +1,17 @@
1
- import type { ExtensionAPI, ExecResult } from "@mariozechner/pi-coding-agent";
2
- import { FormatRunContext } from "./context.js";
1
+ import type { ExecResult, ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import { FormatRunContext, type FormatWarningReporter } from "./context.js";
3
3
  import { detectFileKind } from "./path.js";
4
4
  import { FORMAT_PLAN } from "./plan.js";
5
5
  import { RUNNERS } from "./runners/index.js";
6
6
  import {
7
7
  isDynamicRunner,
8
8
  type ResolvedLauncher,
9
+ type RunnerContext,
9
10
  type RunnerDefinition,
10
11
  type RunnerGroup,
11
12
  type RunnerLauncher,
12
- type RunnerContext,
13
- type SourceTool,
14
13
  } from "./types.js";
15
14
 
16
- function summarizeExecResult(result: ExecResult): string {
17
- const output = `${result.stderr}\n${result.stdout}`.trim();
18
- if (!output) {
19
- return "";
20
- }
21
-
22
- const firstLine = output.split(/\r?\n/, 1)[0];
23
- return `: ${firstLine}`;
24
- }
25
-
26
15
  async function resolveLauncher(
27
16
  launcher: RunnerLauncher,
28
17
  ctx: RunnerContext,
@@ -93,15 +82,17 @@ async function satisfiesRunnerRequirements(
93
82
  const onInvalid = requirement.onInvalid ?? "warn-skip";
94
83
  if (onInvalid === "warn-skip") {
95
84
  ctx.warn(
96
- `[pi-formatter] ${runner.id} skipped: invalid version requirement in ${requirement.patterns.join(", ")}`,
85
+ `${runner.id} skipped: invalid version requirement in ${requirement.patterns.join(", ")}`,
97
86
  );
98
87
  }
99
88
 
100
89
  return false;
101
90
  }
102
91
 
103
- const versionCommand = requirement.command ?? defaultVersionCommand(runner.launcher);
104
- const installedVersion = await ctx.getInstalledToolMajorVersion(versionCommand);
92
+ const versionCommand =
93
+ requirement.command ?? defaultVersionCommand(runner.launcher);
94
+ const installedVersion =
95
+ await ctx.getInstalledToolMajorVersion(versionCommand);
105
96
 
106
97
  if (installedVersion === requiredVersion) {
107
98
  return true;
@@ -110,7 +101,7 @@ async function satisfiesRunnerRequirements(
110
101
  const onMismatch = requirement.onMismatch ?? "warn-skip";
111
102
  if (onMismatch === "warn-skip") {
112
103
  ctx.warn(
113
- `[pi-formatter] ${runner.id} skipped: ${versionCommand} version mismatch (have ${installedVersion ?? "unknown"}, need ${requiredVersion})`,
104
+ `${runner.id} skipped: ${versionCommand} version mismatch (have ${installedVersion ?? "unknown"}, need ${requiredVersion})`,
114
105
  );
115
106
  }
116
107
 
@@ -135,9 +126,56 @@ async function resolveRunnerArgs(
135
126
 
136
127
  type RunnerOutcome = "skipped" | "failed" | "succeeded";
137
128
 
129
+ export interface FormatCallSummary {
130
+ runnerId: string;
131
+ status: "succeeded" | "failed";
132
+ exitCode?: number;
133
+ failureMessage?: string;
134
+ }
135
+
136
+ export type FormatCallSummaryReporter = (summary: FormatCallSummary) => void;
137
+
138
+ const MAX_FAILURE_MESSAGE_LENGTH = 140;
139
+ const ANSI_ESCAPE = String.fromCharCode(27);
140
+ const ANSI_COLOR_SEQUENCE_PATTERN = new RegExp(
141
+ `${ANSI_ESCAPE}\\[[0-9;]*m`,
142
+ "g",
143
+ );
144
+
145
+ function normalizeFailureLine(line: string): string {
146
+ return line
147
+ .replace(ANSI_COLOR_SEQUENCE_PATTERN, "")
148
+ .replace(/^\s*\[error\]\s*/i, "")
149
+ .replace(/^\s*error:\s*/i, "")
150
+ .replace(/^\s*[×✖✘]\s*/u, "")
151
+ .replace(/\s+/g, " ")
152
+ .trim();
153
+ }
154
+
155
+ function summarizeFailureMessage(result: ExecResult): string | undefined {
156
+ const lines = `${result.stderr}\n${result.stdout}`
157
+ .split(/\r?\n/)
158
+ .map((line) => normalizeFailureLine(line))
159
+ .filter((line) => line.length > 0);
160
+
161
+ if (lines.length === 0) {
162
+ return undefined;
163
+ }
164
+
165
+ const withMarker = lines.find((line) =>
166
+ /\b(error|failed|invalid|unexpected|expected|syntax)\b/i.test(line),
167
+ );
168
+ const message = withMarker ?? lines[0];
169
+
170
+ return message.length <= MAX_FAILURE_MESSAGE_LENGTH
171
+ ? message
172
+ : `${message.slice(0, MAX_FAILURE_MESSAGE_LENGTH - 1)}…`;
173
+ }
174
+
138
175
  async function runRunner(
139
176
  ctx: RunnerContext,
140
177
  runner: RunnerDefinition,
178
+ summaryReporter?: FormatCallSummaryReporter,
141
179
  ): Promise<RunnerOutcome> {
142
180
  const launcher = await resolveLauncher(runner.launcher, ctx);
143
181
  if (!launcher) {
@@ -157,60 +195,104 @@ async function runRunner(
157
195
  return "skipped";
158
196
  }
159
197
 
160
- const result = await ctx.exec(launcher.command, [...launcher.argsPrefix, ...args]);
161
- if (!result) {
162
- ctx.warn(`[pi-formatter] ${runner.id} failed to execute`);
163
- return "failed";
164
- }
198
+ const commandArgs = [...launcher.argsPrefix, ...args];
199
+
200
+ const result = await ctx.exec(launcher.command, commandArgs);
165
201
 
166
202
  if (result.code !== 0) {
203
+ const failureMessage = summarizeFailureMessage(result);
204
+
205
+ summaryReporter?.({
206
+ runnerId: runner.id,
207
+ status: "failed",
208
+ exitCode: result.code,
209
+ failureMessage,
210
+ });
211
+
167
212
  ctx.warn(
168
- `[pi-formatter] ${runner.id} exited with code ${result.code}${summarizeExecResult(result)}`,
213
+ `${runner.id} failed (${result.code})${failureMessage ? `: ${failureMessage}` : ""}`,
169
214
  );
170
215
  return "failed";
171
216
  }
172
217
 
218
+ summaryReporter?.({
219
+ runnerId: runner.id,
220
+ status: "succeeded",
221
+ });
222
+
173
223
  return "succeeded";
174
224
  }
175
225
 
176
226
  async function runRunnerGroup(
177
227
  ctx: RunnerContext,
178
228
  group: RunnerGroup,
229
+ summaryReporter?: FormatCallSummaryReporter,
179
230
  ): Promise<void> {
180
231
  if (group.mode === "all") {
181
232
  for (const runnerId of group.runnerIds) {
182
233
  const runner = RUNNERS.get(runnerId);
183
234
  if (!runner) {
184
- ctx.warn(`[pi-formatter] unknown runner in format plan: ${runnerId}`);
235
+ ctx.warn(`unknown runner in format plan: ${runnerId}`);
185
236
  continue;
186
237
  }
187
238
 
188
- await runRunner(ctx, runner);
239
+ await runRunner(ctx, runner, summaryReporter);
189
240
  }
190
241
 
191
242
  return;
192
243
  }
193
244
 
245
+ const fallbackSummaries: FormatCallSummary[] = [];
246
+ const fallbackSummaryReporter = summaryReporter
247
+ ? (summary: FormatCallSummary) => {
248
+ fallbackSummaries.push(summary);
249
+ }
250
+ : undefined;
251
+
194
252
  for (const runnerId of group.runnerIds) {
195
253
  const runner = RUNNERS.get(runnerId);
196
254
  if (!runner) {
197
- ctx.warn(`[pi-formatter] unknown runner in format plan: ${runnerId}`);
255
+ ctx.warn(`unknown runner in format plan: ${runnerId}`);
198
256
  continue;
199
257
  }
200
258
 
201
- const outcome = await runRunner(ctx, runner);
259
+ const outcome = await runRunner(ctx, runner, fallbackSummaryReporter);
202
260
  if (outcome === "succeeded") {
203
- break;
261
+ if (!summaryReporter) {
262
+ return;
263
+ }
264
+
265
+ const successSummary = [...fallbackSummaries]
266
+ .reverse()
267
+ .find((summary) => summary.status === "succeeded");
268
+ if (successSummary) {
269
+ summaryReporter(successSummary);
270
+ }
271
+
272
+ return;
204
273
  }
205
274
  }
275
+
276
+ if (!summaryReporter) {
277
+ return;
278
+ }
279
+
280
+ const lastFailureSummary = [...fallbackSummaries]
281
+ .reverse()
282
+ .find((summary) => summary.status === "failed");
283
+
284
+ if (lastFailureSummary) {
285
+ summaryReporter(lastFailureSummary);
286
+ }
206
287
  }
207
288
 
208
289
  export async function formatFile(
209
290
  pi: ExtensionAPI,
210
291
  cwd: string,
211
- sourceTool: SourceTool,
212
292
  filePath: string,
213
293
  timeoutMs: number,
294
+ summaryReporter?: FormatCallSummaryReporter,
295
+ warningReporter?: FormatWarningReporter,
214
296
  ): Promise<void> {
215
297
  const kind = detectFileKind(filePath);
216
298
  if (!kind) {
@@ -226,12 +308,12 @@ export async function formatFile(
226
308
  pi,
227
309
  cwd,
228
310
  filePath,
229
- sourceTool,
230
311
  kind,
231
312
  timeoutMs,
313
+ warningReporter,
232
314
  );
233
315
 
234
316
  for (const group of groups) {
235
- await runRunnerGroup(runContext, group);
317
+ await runRunnerGroup(runContext, group, summaryReporter);
236
318
  }
237
319
  }
@@ -3,8 +3,17 @@ import { homedir } from "node:os";
3
3
  import { basename, isAbsolute, join, relative, resolve } from "node:path";
4
4
  import type { FileKind } from "./types.js";
5
5
 
6
+ const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
7
+
8
+ function normalizeUnicodeSpaces(value: string): string {
9
+ return value.replace(UNICODE_SPACES, " ");
10
+ }
11
+
6
12
  export function normalizeToolPath(filePath: string): string {
7
- const normalizedAt = filePath.startsWith("@") ? filePath.slice(1) : filePath;
13
+ const normalizedInput = normalizeUnicodeSpaces(filePath);
14
+ const normalizedAt = normalizedInput.startsWith("@")
15
+ ? normalizedInput.slice(1)
16
+ : normalizedInput;
8
17
 
9
18
  if (normalizedAt === "~") {
10
19
  return homedir();
@@ -33,7 +42,10 @@ export async function pathExists(path: string): Promise<boolean> {
33
42
  }
34
43
  }
35
44
 
36
- export function isWithinDirectory(pathToCheck: string, directory: string): boolean {
45
+ export function isWithinDirectory(
46
+ pathToCheck: string,
47
+ directory: string,
48
+ ): boolean {
37
49
  const relPath = relative(directory, pathToCheck);
38
50
  return (
39
51
  relPath === "" ||
@@ -57,7 +69,11 @@ export function getPathForGit(filePath: string, cwd: string): string {
57
69
  }
58
70
 
59
71
  export function detectFileKind(filePath: string): FileKind | undefined {
60
- if (/\.(cpp|hpp|cpp\.in|hpp\.in)$/.test(filePath)) {
72
+ if (
73
+ /(\.(c|h|cc|hh|cpp|hpp|cxx|hxx|ixx|ipp|inl|tpp)|\.(c|h|cc|hh|cpp|hpp|cxx|hxx)\.in)$/i.test(
74
+ filePath,
75
+ )
76
+ ) {
61
77
  return "cxx";
62
78
  }
63
79
 
@@ -5,22 +5,22 @@ export const FORMAT_PLAN: Record<FileKind, RunnerGroup[]> = {
5
5
  cmake: [{ mode: "all", runnerIds: ["cmake-format"] }],
6
6
  markdown: [
7
7
  {
8
- mode: "all",
9
- runnerIds: ["markdownlint-fix", "prettier-markdown"],
8
+ mode: "fallback",
9
+ runnerIds: ["prettier", "markdownlint"],
10
10
  },
11
11
  ],
12
12
  json: [
13
13
  {
14
14
  mode: "fallback",
15
- runnerIds: ["biome-check-write", "prettier-config-write"],
15
+ runnerIds: ["biome", "prettier"],
16
16
  },
17
17
  ],
18
18
  shell: [{ mode: "all", runnerIds: ["shfmt"] }],
19
- python: [{ mode: "all", runnerIds: ["ruff-format", "ruff-check-fix"] }],
19
+ python: [{ mode: "all", runnerIds: ["ruff-format", "ruff-check"] }],
20
20
  jsts: [
21
21
  {
22
22
  mode: "fallback",
23
- runnerIds: ["biome-check-write", "eslint-fix", "prettier-config-write"],
23
+ runnerIds: ["biome", "eslint", "prettier"],
24
24
  },
25
25
  ],
26
26
  };
@@ -1,11 +1,11 @@
1
- import { defineRunner, direct } from "./helpers.js";
2
1
  import { BIOME_CONFIG_PATTERNS } from "./config-patterns.js";
2
+ import { defineRunner, direct } from "./helpers.js";
3
3
 
4
- const biomeCheckWriteRunner = defineRunner({
5
- id: "biome-check-write",
4
+ const biomeRunner = defineRunner({
5
+ id: "biome",
6
6
  launcher: direct("biome"),
7
7
  when: (ctx) => ctx.hasConfig(BIOME_CONFIG_PATTERNS),
8
8
  args: ["check", "--write"],
9
9
  });
10
10
 
11
- export default biomeCheckWriteRunner;
11
+ export default biomeRunner;
@@ -0,0 +1,5 @@
1
+ export const BIOME_CONFIG_PATTERNS = ["biome.json", "biome.jsonc"] as const;
2
+ export const ESLINT_CONFIG_PATTERNS = [
3
+ "eslint.config.*",
4
+ ".eslintrc*",
5
+ ] as const;
@@ -1,11 +1,11 @@
1
1
  import { ESLINT_CONFIG_PATTERNS } from "./config-patterns.js";
2
2
  import { defineRunner, direct } from "./helpers.js";
3
3
 
4
- const eslintFixRunner = defineRunner({
5
- id: "eslint-fix",
4
+ const eslintRunner = defineRunner({
5
+ id: "eslint",
6
6
  launcher: direct("eslint"),
7
7
  when: (ctx) => ctx.hasConfig(ESLINT_CONFIG_PATTERNS),
8
8
  args: ["--fix"],
9
9
  });
10
10
 
11
- export default eslintFixRunner;
11
+ export default eslintRunner;
@@ -1,26 +1,24 @@
1
1
  import type { RunnerDefinition } from "../types.js";
2
- import biomeCheckWriteRunner from "./biome-check-write.js";
2
+ import biomeRunner from "./biome.js";
3
3
  import clangFormatRunner from "./clang-format.js";
4
4
  import cmakeFormatRunner from "./cmake-format.js";
5
- import eslintFixRunner from "./eslint-fix.js";
6
- import markdownlintFixRunner from "./markdownlint-fix.js";
7
- import prettierConfigWriteRunner from "./prettier-config-write.js";
8
- import prettierMarkdownRunner from "./prettier-markdown.js";
9
- import ruffCheckFixRunner from "./ruff-check-fix.js";
5
+ import eslintRunner from "./eslint.js";
6
+ import markdownlintRunner from "./markdownlint.js";
7
+ import prettierRunner from "./prettier.js";
8
+ import ruffCheckRunner from "./ruff-check.js";
10
9
  import ruffFormatRunner from "./ruff-format.js";
11
10
  import shfmtRunner from "./shfmt.js";
12
11
 
13
12
  export const RUNNER_DEFINITIONS: RunnerDefinition[] = [
14
13
  clangFormatRunner,
15
14
  cmakeFormatRunner,
16
- markdownlintFixRunner,
17
- prettierMarkdownRunner,
18
- biomeCheckWriteRunner,
19
- eslintFixRunner,
20
- prettierConfigWriteRunner,
15
+ markdownlintRunner,
16
+ biomeRunner,
17
+ eslintRunner,
18
+ prettierRunner,
21
19
  shfmtRunner,
22
20
  ruffFormatRunner,
23
- ruffCheckFixRunner,
21
+ ruffCheckRunner,
24
22
  ];
25
23
 
26
24
  function buildRunnerRegistry(
@@ -1,9 +1,9 @@
1
1
  import { defineRunner, direct } from "./helpers.js";
2
2
 
3
- const markdownlintFixRunner = defineRunner({
4
- id: "markdownlint-fix",
3
+ const markdownlintRunner = defineRunner({
4
+ id: "markdownlint",
5
5
  launcher: direct("markdownlint"),
6
6
  args: ["--fix"],
7
7
  });
8
8
 
9
- export default markdownlintFixRunner;
9
+ export default markdownlintRunner;
@@ -1,9 +1,9 @@
1
1
  import { defineRunner, direct } from "./helpers.js";
2
2
 
3
- const prettierMarkdownRunner = defineRunner({
4
- id: "prettier-markdown",
3
+ const prettierRunner = defineRunner({
4
+ id: "prettier",
5
5
  launcher: direct("prettier"),
6
6
  args: ["--write"],
7
7
  });
8
8
 
9
- export default prettierMarkdownRunner;
9
+ export default prettierRunner;
@@ -1,9 +1,9 @@
1
1
  import { defineRunner, pypi } from "./helpers.js";
2
2
 
3
- const ruffCheckFixRunner = defineRunner({
4
- id: "ruff-check-fix",
3
+ const ruffCheckRunner = defineRunner({
4
+ id: "ruff-check",
5
5
  launcher: pypi("ruff"),
6
6
  args: ["check", "--fix"],
7
7
  });
8
8
 
9
- export default ruffCheckFixRunner;
9
+ export default ruffCheckRunner;
@@ -1,7 +1,5 @@
1
1
  import type { ExecResult } from "@mariozechner/pi-coding-agent";
2
2
 
3
- export type SourceTool = "write" | "edit";
4
-
5
3
  export type FileKind =
6
4
  | "cxx"
7
5
  | "cmake"
@@ -40,7 +38,6 @@ export interface ResolvedLauncher {
40
38
  export interface RunnerContext {
41
39
  readonly filePath: string;
42
40
  readonly cwd: string;
43
- readonly sourceTool: SourceTool;
44
41
  readonly kind: FileKind;
45
42
 
46
43
  hasCommand(command: string): Promise<boolean>;
@@ -48,7 +45,7 @@ export interface RunnerContext {
48
45
  findConfigFile(patterns: readonly string[]): Promise<string | undefined>;
49
46
  hasEditorConfigInCwd(): Promise<boolean>;
50
47
 
51
- exec(command: string, args: string[]): Promise<ExecResult | undefined>;
48
+ exec(command: string, args: string[]): Promise<ExecResult>;
52
49
  getChangedLines(): Promise<string[]>;
53
50
  getRequiredMajorVersionFromConfig(
54
51
  patterns: readonly string[],
@@ -94,8 +91,9 @@ export interface StaticRunnerDefinition extends RunnerBase {
94
91
  }
95
92
 
96
93
  export interface DynamicRunnerDefinition extends RunnerBase {
97
- buildArgs:
98
- (ctx: RunnerContext) => Promise<string[] | undefined> | string[] | undefined;
94
+ buildArgs: (
95
+ ctx: RunnerContext,
96
+ ) => Promise<string[] | undefined> | string[] | undefined;
99
97
  }
100
98
 
101
99
  export type RunnerDefinition = StaticRunnerDefinition | DynamicRunnerDefinition;
@@ -1,13 +1,47 @@
1
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
- import { commandTimeoutMs } from "./format/config.js";
3
- import { formatFile } from "./format/dispatch.js";
4
- import { pathExists, resolveToolPath } from "./format/path.js";
5
- import type { SourceTool } from "./format/types.js";
1
+ import { basename } from "node:path";
2
+ import {
3
+ type ExtensionAPI,
4
+ isEditToolResult,
5
+ isWriteToolResult,
6
+ } from "@mariozechner/pi-coding-agent";
7
+ import {
8
+ commandTimeoutMs,
9
+ hideCallSummariesInTui,
10
+ } from "./formatter/config.js";
11
+ import { type FormatCallSummary, formatFile } from "./formatter/dispatch.js";
12
+ import {
13
+ getPathForGit,
14
+ pathExists,
15
+ resolveToolPath,
16
+ } from "./formatter/path.js";
6
17
 
7
18
  function formatError(error: unknown): string {
8
19
  return error instanceof Error ? error.message : String(error);
9
20
  }
10
21
 
22
+ function formatSummaryPath(filePath: string, cwd: string): string {
23
+ const pathForDisplay = getPathForGit(filePath, cwd);
24
+ return pathForDisplay.startsWith("/")
25
+ ? basename(pathForDisplay)
26
+ : pathForDisplay;
27
+ }
28
+
29
+ function formatCallSuccessSummary(summary: FormatCallSummary): string {
30
+ return `✔︎ ${summary.runnerId}`;
31
+ }
32
+
33
+ function formatCallFailureSummary(summary: FormatCallSummary): string {
34
+ if (summary.failureMessage) {
35
+ return `✘ ${summary.runnerId}: ${summary.failureMessage}`;
36
+ }
37
+
38
+ if (summary.exitCode !== undefined) {
39
+ return `✘ ${summary.runnerId} (exit ${summary.exitCode})`;
40
+ }
41
+
42
+ return `✘ ${summary.runnerId}`;
43
+ }
44
+
11
45
  export default function (pi: ExtensionAPI) {
12
46
  const formatQueueByPath = new Map<string, Promise<void>>();
13
47
 
@@ -36,29 +70,73 @@ export default function (pi: ExtensionAPI) {
36
70
  return;
37
71
  }
38
72
 
39
- if (event.toolName !== "write" && event.toolName !== "edit") {
73
+ if (!isWriteToolResult(event) && !isEditToolResult(event)) {
40
74
  return;
41
75
  }
42
76
 
43
- const rawPath = (event.input as { path?: unknown }).path;
44
- if (typeof rawPath !== "string" || rawPath.length === 0) {
77
+ const rawPath = event.input.path;
78
+ if (rawPath.length === 0) {
45
79
  return;
46
80
  }
47
81
 
48
- const sourceTool = event.toolName as SourceTool;
49
82
  const filePath = resolveToolPath(rawPath, ctx.cwd);
50
83
 
51
84
  if (!(await pathExists(filePath))) {
52
85
  return;
53
86
  }
54
87
 
88
+ const showSummaries = !hideCallSummariesInTui && ctx.hasUI;
89
+ const notifyWarning = (message: string) => {
90
+ const normalizedMessage = message.replace(/\s+/g, " ").trim();
91
+
92
+ if (ctx.hasUI) {
93
+ ctx.ui.notify(normalizedMessage, "warning");
94
+ return;
95
+ }
96
+
97
+ console.warn(normalizedMessage);
98
+ };
99
+
55
100
  await enqueueFormat(filePath, async () => {
101
+ const summaries: FormatCallSummary[] = [];
102
+ const summaryReporter = showSummaries
103
+ ? (summary: FormatCallSummary) => {
104
+ summaries.push(summary);
105
+ }
106
+ : undefined;
107
+
108
+ const runnerWarningReporter =
109
+ showSummaries && ctx.hasUI
110
+ ? () => {
111
+ // Summary mode already reports failures compactly.
112
+ }
113
+ : notifyWarning;
114
+
56
115
  try {
57
- await formatFile(pi, ctx.cwd, sourceTool, filePath, commandTimeoutMs);
58
- } catch (error) {
59
- console.warn(
60
- `[pi-formatter] Failed to format ${filePath}: ${formatError(error)}`,
116
+ await formatFile(
117
+ pi,
118
+ ctx.cwd,
119
+ filePath,
120
+ commandTimeoutMs,
121
+ summaryReporter,
122
+ runnerWarningReporter,
61
123
  );
124
+ } catch (error) {
125
+ const fileLabel = formatSummaryPath(filePath, ctx.cwd);
126
+ notifyWarning(`Failed to format ${fileLabel}: ${formatError(error)}`);
127
+ }
128
+
129
+ if (!showSummaries || summaries.length === 0) {
130
+ return;
131
+ }
132
+
133
+ for (const summary of summaries) {
134
+ if (summary.status === "succeeded") {
135
+ ctx.ui.notify(formatCallSuccessSummary(summary), "info");
136
+ continue;
137
+ }
138
+
139
+ ctx.ui.notify(formatCallFailureSummary(summary), "info");
62
140
  }
63
141
  });
64
142
  });
package/package.json CHANGED
@@ -1,11 +1,10 @@
1
1
  {
2
2
  "name": "pi-formatter",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Pi extension that auto-formats files after write/edit tool calls.",
5
5
  "type": "module",
6
6
  "files": [
7
7
  "extensions",
8
- "DOCUMENTATION.md",
9
8
  "README.md",
10
9
  "LICENSE"
11
10
  ],
package/DOCUMENTATION.md DELETED
@@ -1,120 +0,0 @@
1
- # Runner API
2
-
3
- A *runner* wraps a single formatting tool (for example, `prettier` or `ruff`).
4
- This document describes how runners work and how to add new ones.
5
-
6
- ## File layout
7
-
8
- | File | Purpose |
9
- | ---- | ------- |
10
- | `extensions/format/runners/*.ts` | One file per runner |
11
- | `extensions/format/runners/helpers.ts` | Helper constructors (`direct`, `pypi`, `goTool`, `defineRunner`) |
12
- | `extensions/format/runners/index.ts` | Runner registry |
13
- | `extensions/format/plan.ts` | File-kind → runner-group mapping |
14
- | `extensions/format/types.ts` | Shared types |
15
-
16
- ## Add a new runner
17
-
18
- 1. Create a file under `extensions/format/runners/`.
19
- 2. Define the runner with the helper constructors from `helpers.ts`.
20
- 3. Register it in `extensions/format/runners/index.ts`.
21
- 4. Add its `id` to one or more groups in `extensions/format/plan.ts`.
22
-
23
- ## Runner definition
24
-
25
- Every runner needs two things:
26
-
27
- - `id`: a unique identifier
28
- - `launcher`: how to locate and execute the tool
29
-
30
- ### Launchers
31
-
32
- Use the helper constructors to create a launcher:
33
-
34
- | Constructor | Behavior |
35
- | ----------- | -------- |
36
- | `direct("prettier")` | Run the command directly |
37
- | `pypi("ruff")` | Run the tool natively, fall back to `uv tool run` |
38
- | `goTool("shfmt", "mvdan.cc/sh/v3/cmd/shfmt@v3.10.0")` | Run the tool natively, fall back to `go run` |
39
-
40
- All commands execute in the project working directory and respect the
41
- configured timeout.
42
-
43
- ### Arguments
44
-
45
- There are two ways to supply arguments:
46
-
47
- - **Static** (preferred): Set `args: string[]`. The file path is appended
48
- automatically unless you set `appendFile: false`.
49
- - **Dynamic**: Implement `buildArgs(ctx)` and return the argument array, or
50
- `undefined` to skip the runner.
51
-
52
- ### Optional predicates and requirements
53
-
54
- - `when(ctx)`: Return `false` to skip the runner based on an extra condition.
55
- - `requires.majorVersionFromConfig`: Gate execution on a major version match:
56
- - `patterns`: Config-file globs to read the required major version from.
57
- - `command` (optional): Command to inspect with `--version`.
58
- - `onInvalid` / `onMismatch`: `"warn-skip"` (default) or `"skip"`.
59
-
60
- ### Runner context
61
-
62
- The `ctx` object passed to `when` and `buildArgs` exposes:
63
-
64
- | Member | Description |
65
- | ------ | ----------- |
66
- | `filePath`, `cwd`, `sourceTool`, `kind` | Current invocation metadata |
67
- | `hasCommand(cmd)` | Check whether a command is available |
68
- | `hasConfig(patterns)`, `findConfigFile(patterns)` | Look up configuration files |
69
- | `hasEditorConfigInCwd()` | Check for an `.editorconfig` in the working directory |
70
- | `getChangedLines()` | Get the line ranges that changed |
71
- | `warn(msg)` | Emit a warning without failing the run |
72
-
73
- ## Format plan
74
-
75
- `extensions/format/plan.ts` maps each file kind to an ordered list of runner
76
- groups. Each group has a mode:
77
-
78
- - **`"all"`**: Run every qualifying runner in order.
79
- - **`"fallback"`**: Run the first qualifying runner and stop.
80
-
81
- ## Examples
82
-
83
- ### Static runner
84
-
85
- ```ts
86
- import { defineRunner, direct } from "./helpers.js";
87
-
88
- const myRunner = defineRunner({
89
- id: "my-formatter",
90
- launcher: direct("my-formatter"),
91
- args: ["--write"],
92
- });
93
-
94
- export default myRunner;
95
- ```
96
-
97
- ### Dynamic runner with a version requirement
98
-
99
- ```ts
100
- import { defineRunner, direct } from "./helpers.js";
101
-
102
- const clangFormatRunner = defineRunner({
103
- id: "clang-format",
104
- launcher: direct("clang-format"),
105
- requires: {
106
- majorVersionFromConfig: {
107
- patterns: [".clang-format-version"],
108
- },
109
- },
110
- async buildArgs(ctx) {
111
- const lines = await ctx.getChangedLines();
112
- if (lines.length > 0) {
113
- return [...lines.map((line) => `--lines=${line}`), "-i", ctx.filePath];
114
- }
115
- return ["-i", ctx.filePath];
116
- },
117
- });
118
-
119
- export default clangFormatRunner;
120
- ```
@@ -1,11 +0,0 @@
1
- const DEFAULT_COMMAND_TIMEOUT_MS = 10_000;
2
-
3
- const configuredTimeoutMs = Number.parseInt(
4
- process.env.PI_FORMAT_COMMAND_TIMEOUT_MS ?? "",
5
- 10,
6
- );
7
-
8
- export const commandTimeoutMs =
9
- Number.isFinite(configuredTimeoutMs) && configuredTimeoutMs > 0
10
- ? configuredTimeoutMs
11
- : DEFAULT_COMMAND_TIMEOUT_MS;
@@ -1,6 +0,0 @@
1
- export const BIOME_CONFIG_PATTERNS = ["biome.json", "biome.jsonc"] as const;
2
- export const ESLINT_CONFIG_PATTERNS = ["eslint.config.*", ".eslintrc*"] as const;
3
- export const PRETTIER_CONFIG_PATTERNS = [
4
- ".prettierrc*",
5
- "prettier.config.*",
6
- ] as const;
@@ -1,11 +0,0 @@
1
- import { PRETTIER_CONFIG_PATTERNS } from "./config-patterns.js";
2
- import { defineRunner, direct } from "./helpers.js";
3
-
4
- const prettierConfigWriteRunner = defineRunner({
5
- id: "prettier-config-write",
6
- launcher: direct("prettier"),
7
- when: (ctx) => ctx.hasConfig(PRETTIER_CONFIG_PATTERNS),
8
- args: ["--write"],
9
- });
10
-
11
- export default prettierConfigWriteRunner;
File without changes