pi-formatter 0.2.0 → 1.0.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
@@ -1,11 +1,13 @@
1
1
  # 🎨 pi-formatter
2
2
 
3
- A [pi](https://pi.dev) extension that auto-formats files after every `write` and
4
- `edit` tool call.
3
+ A [pi](https://pi.dev) extension that auto-formats files after `write` and
4
+ `edit` tool calls.
5
5
 
6
- The extension hooks into successful tool results, detects the file type, and
7
- runs the appropriate formatter. Failures never block the tool result, so
8
- formatting is always best-effort.
6
+ By default, formatting runs once per turn. You can also format after each tool
7
+ call or defer formatting until the current session shuts down.
8
+
9
+ This default changed from the previous immediate-per-tool behavior. If you want
10
+ the old default, set `"formatMode": "tool"`.
9
11
 
10
12
  ## 📦 Install
11
13
 
@@ -15,8 +17,24 @@ pi install npm:pi-formatter
15
17
 
16
18
  ## ⚙️ What it does
17
19
 
18
- `pi-formatter` listens to successful `write` and `edit` tool calls and applies
19
- best-effort formatting. Formatter failures never block tool results.
20
+ `pi-formatter` detects file types and runs the appropriate formatter as
21
+ best-effort post-processing. Formatter failures never block tool results.
22
+
23
+ Formatting modes:
24
+
25
+ - `tool`: format immediately after each successful `write` or `edit` tool
26
+ result.
27
+ Use this mode when you want the file on disk to stay formatted after every
28
+ edit, even while the agent is still working.
29
+ - `turn`: collect files touched during the current turn and format them once at
30
+ `turn_end`. This is the default.
31
+ Use this mode when you want to avoid mid-turn formatter drift while still
32
+ keeping files formatted throughout the run.
33
+ - `session`: collect files touched during the current session and format them
34
+ once at `session_shutdown`.
35
+ Use this mode when you want the fewest formatter interruptions and are okay
36
+ with formatting only when the session exits, reloads, or switches. Interrupted
37
+ runs stay pending until the session ends or changes.
20
38
 
21
39
  Supported file types:
22
40
 
@@ -31,6 +49,11 @@ Supported file types:
31
49
  For JS/TS and JSON, project-configured tools are preferred first (Biome,
32
50
  ESLint), with Prettier as a fallback.
33
51
 
52
+ ## 🎮 Commands
53
+
54
+ - `/formatter`: open the interactive formatter settings editor and save changes
55
+ to `formatter.json`
56
+
34
57
  ## 🔧 Configuration
35
58
 
36
59
  Create `<agent-dir>/formatter.json`, where `<agent-dir>` is pi's agent config
@@ -38,13 +61,17 @@ folder (default: `~/.pi/agent`, overridable via `PI_CODING_AGENT_DIR`):
38
61
 
39
62
  ```json
40
63
  {
64
+ "formatMode": "turn",
41
65
  "commandTimeoutMs": 10000,
42
66
  "hideCallSummariesInTui": false
43
67
  }
44
68
  ```
45
69
 
70
+ - `formatMode`: formatting strategy (`"tool"` | `"turn"` | `"session"`,
71
+ default: `"turn"`). Use `"tool"` to restore the old immediate default.
46
72
  - `commandTimeoutMs`: timeout (ms) per formatter command (default: `10000`)
47
- - `hideCallSummariesInTui`: hide formatter pass/fail summaries in the TUI (default: `false`)
73
+ - `hideCallSummariesInTui`: hide formatter pass/fail summaries in the TUI
74
+ (default: `false`)
48
75
 
49
76
  ## 🧩 Adding formatters
50
77
 
@@ -1,17 +1,27 @@
1
- import { existsSync, readFileSync } from "node:fs";
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { getAgentDir } from "@mariozechner/pi-coding-agent";
4
4
 
5
+ export type FormatMode = "tool" | "turn" | "session";
6
+
5
7
  const DEFAULT_COMMAND_TIMEOUT_MS = 10_000;
6
8
  const DEFAULT_HIDE_CALL_SUMMARIES_IN_TUI = false;
9
+ const DEFAULT_FORMAT_MODE: FormatMode = "turn";
7
10
  const FORMATTER_CONFIG_FILE = "formatter.json";
8
11
 
9
- type FormatterConfig = {
12
+ export type FormatterConfigSnapshot = {
10
13
  commandTimeoutMs: number;
11
14
  hideCallSummariesInTui: boolean;
15
+ formatMode: FormatMode;
16
+ };
17
+
18
+ export const DEFAULT_FORMATTER_CONFIG: FormatterConfigSnapshot = {
19
+ commandTimeoutMs: DEFAULT_COMMAND_TIMEOUT_MS,
20
+ hideCallSummariesInTui: DEFAULT_HIDE_CALL_SUMMARIES_IN_TUI,
21
+ formatMode: DEFAULT_FORMAT_MODE,
12
22
  };
13
23
 
14
- function getFormatterConfigPath(): string {
24
+ export function getFormatterConfigPath(): string {
15
25
  return join(getAgentDir(), FORMATTER_CONFIG_FILE);
16
26
  }
17
27
 
@@ -54,14 +64,34 @@ function parseBooleanValue(value: unknown, defaultValue: boolean): boolean {
54
64
  return value;
55
65
  }
56
66
 
57
- function loadFormatterConfig(): FormatterConfig {
67
+ function parseFormatMode(value: unknown, defaultValue: FormatMode): FormatMode {
68
+ if (value === "tool" || value === "turn" || value === "session") {
69
+ return value;
70
+ }
71
+
72
+ return defaultValue;
73
+ }
74
+
75
+ function toFormatterConfigObject(
76
+ config: FormatterConfigSnapshot,
77
+ ): Record<string, unknown> {
78
+ return {
79
+ commandTimeoutMs: config.commandTimeoutMs,
80
+ hideCallSummariesInTui: config.hideCallSummariesInTui,
81
+ formatMode: config.formatMode,
82
+ };
83
+ }
84
+
85
+ function writeFormatterConfigFile(content: string): void {
86
+ mkdirSync(getAgentDir(), { recursive: true });
87
+ writeFileSync(getFormatterConfigPath(), content, "utf8");
88
+ }
89
+
90
+ export function loadFormatterConfig(): FormatterConfigSnapshot {
58
91
  const config = readJsonObject(getFormatterConfigPath());
59
92
 
60
93
  if (!config) {
61
- return {
62
- commandTimeoutMs: DEFAULT_COMMAND_TIMEOUT_MS,
63
- hideCallSummariesInTui: DEFAULT_HIDE_CALL_SUMMARIES_IN_TUI,
64
- };
94
+ return { ...DEFAULT_FORMATTER_CONFIG };
65
95
  }
66
96
 
67
97
  return {
@@ -73,10 +103,24 @@ function loadFormatterConfig(): FormatterConfig {
73
103
  config.hideCallSummariesInTui,
74
104
  DEFAULT_HIDE_CALL_SUMMARIES_IN_TUI,
75
105
  ),
106
+ formatMode: parseFormatMode(config.formatMode, DEFAULT_FORMAT_MODE),
76
107
  };
77
108
  }
78
109
 
79
- const formatterConfig = loadFormatterConfig();
110
+ export function cloneFormatterConfig(
111
+ config: FormatterConfigSnapshot,
112
+ ): FormatterConfigSnapshot {
113
+ return {
114
+ commandTimeoutMs: config.commandTimeoutMs,
115
+ hideCallSummariesInTui: config.hideCallSummariesInTui,
116
+ formatMode: config.formatMode,
117
+ };
118
+ }
80
119
 
81
- export const commandTimeoutMs = formatterConfig.commandTimeoutMs;
82
- export const hideCallSummariesInTui = formatterConfig.hideCallSummariesInTui;
120
+ export function writeFormatterConfigSnapshot(
121
+ config: FormatterConfigSnapshot,
122
+ ): void {
123
+ writeFormatterConfigFile(
124
+ `${JSON.stringify(toFormatterConfigObject(config), null, 2)}\n`,
125
+ );
126
+ }
@@ -1,7 +1,11 @@
1
1
  import { readdir, readFile } from "node:fs/promises";
2
2
  import { dirname, join, resolve } from "node:path";
3
3
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
4
- import { getPathForGit, isWithinDirectory, pathExists } from "./path.js";
4
+ import {
5
+ getRelativePathOrAbsolute,
6
+ isWithinDirectory,
7
+ pathExists,
8
+ } from "./path.js";
5
9
  import { hasCommand } from "./system.js";
6
10
  import type { FileKind, RequiredMajorVersion, RunnerContext } from "./types.js";
7
11
 
@@ -239,12 +243,12 @@ export class FormatRunContext implements RunnerContext {
239
243
  }
240
244
 
241
245
  private async resolveChangedLines(): Promise<string[]> {
242
- const gitPath = getPathForGit(this.filePath, this.cwd);
246
+ const diffPath = getRelativePathOrAbsolute(this.filePath, this.cwd);
243
247
  const rangeSet = new Set<string>();
244
248
 
245
249
  const diffArgSets = [
246
- ["diff", "--unified=0", "--", gitPath],
247
- ["diff", "--cached", "--unified=0", "--", gitPath],
250
+ ["diff", "--unified=0", "--", diffPath],
251
+ ["diff", "--cached", "--unified=0", "--", diffPath],
248
252
  ];
249
253
 
250
254
  for (const args of diffArgSets) {
@@ -54,8 +54,11 @@ export function isWithinDirectory(
54
54
  );
55
55
  }
56
56
 
57
- export function getPathForGit(filePath: string, cwd: string): string {
58
- const relPath = relative(cwd, filePath);
57
+ export function getRelativePathOrAbsolute(
58
+ filePath: string,
59
+ directory: string,
60
+ ): string {
61
+ const relPath = relative(directory, filePath);
59
62
  if (
60
63
  !relPath ||
61
64
  relPath === "." ||
@@ -1,49 +1,119 @@
1
- import { basename } from "node:path";
2
1
  import {
3
2
  type ExtensionAPI,
3
+ getSettingsListTheme,
4
4
  isEditToolResult,
5
5
  isWriteToolResult,
6
6
  } from "@mariozechner/pi-coding-agent";
7
7
  import {
8
- commandTimeoutMs,
9
- hideCallSummariesInTui,
8
+ Container,
9
+ type SettingItem,
10
+ SettingsList,
11
+ Text,
12
+ } from "@mariozechner/pi-tui";
13
+ import {
14
+ cloneFormatterConfig,
15
+ type FormatterConfigSnapshot,
16
+ getFormatterConfigPath,
17
+ loadFormatterConfig,
18
+ writeFormatterConfigSnapshot,
10
19
  } from "./formatter/config.js";
11
20
  import { type FormatCallSummary, formatFile } from "./formatter/dispatch.js";
12
21
  import {
13
- getPathForGit,
22
+ getRelativePathOrAbsolute,
14
23
  pathExists,
15
24
  resolveToolPath,
16
25
  } from "./formatter/path.js";
17
26
 
27
+ function normalizeSummaryMessage(message: string): string {
28
+ return message.replace(/\s+/g, " ").trim();
29
+ }
30
+
18
31
  function formatError(error: unknown): string {
19
32
  return error instanceof Error ? error.message : String(error);
20
33
  }
21
34
 
22
- function formatSummaryPath(filePath: string, cwd: string): string {
23
- const pathForDisplay = getPathForGit(filePath, cwd);
24
- return pathForDisplay.startsWith("/")
25
- ? basename(pathForDisplay)
26
- : pathForDisplay;
27
- }
35
+ function formatCallSummary(
36
+ summary: FormatCallSummary,
37
+ fileLabel: string,
38
+ ): string {
39
+ const prefix = summary.status === "succeeded" ? "✔︎" : "✘";
40
+ const base = `${prefix} ${summary.runnerId}: ${fileLabel}`;
28
41
 
29
- function formatCallSuccessSummary(summary: FormatCallSummary): string {
30
- return `✔︎ ${summary.runnerId}`;
31
- }
42
+ if (summary.status === "succeeded") {
43
+ return base;
44
+ }
32
45
 
33
- function formatCallFailureSummary(summary: FormatCallSummary): string {
34
46
  if (summary.failureMessage) {
35
- return `✘ ${summary.runnerId}: ${summary.failureMessage}`;
47
+ return `${base}: ${normalizeSummaryMessage(summary.failureMessage)}`;
36
48
  }
37
49
 
38
50
  if (summary.exitCode !== undefined) {
39
- return `✘ ${summary.runnerId} (exit ${summary.exitCode})`;
51
+ return `${base} (exit ${summary.exitCode})`;
52
+ }
53
+
54
+ return base;
55
+ }
56
+
57
+ function resolveEventPath(rawPath: unknown, cwd: string): string | undefined {
58
+ if (typeof rawPath !== "string" || rawPath.length === 0) {
59
+ return undefined;
60
+ }
61
+
62
+ return resolveToolPath(rawPath, cwd);
63
+ }
64
+
65
+ function getFormatterSettingItems(
66
+ config: FormatterConfigSnapshot,
67
+ ): SettingItem[] {
68
+ const timeoutValues = ["2000", "5000", "10000", "30000", "60000"];
69
+
70
+ if (!timeoutValues.includes(String(config.commandTimeoutMs))) {
71
+ timeoutValues.push(String(config.commandTimeoutMs));
72
+ timeoutValues.sort(
73
+ (a, b) => Number.parseInt(a, 10) - Number.parseInt(b, 10),
74
+ );
40
75
  }
41
76
 
42
- return `✘ ${summary.runnerId}`;
77
+ return [
78
+ {
79
+ id: "formatMode",
80
+ label: "Format mode",
81
+ description:
82
+ "Choose whether formatting runs after each successful write/edit tool call, once after each turn, or once when the session shuts down.",
83
+ currentValue: config.formatMode,
84
+ values: ["tool", "turn", "session"],
85
+ },
86
+ {
87
+ id: "commandTimeoutMs",
88
+ label: "Command timeout",
89
+ description: "Maximum runtime per formatter command in milliseconds.",
90
+ currentValue: String(config.commandTimeoutMs),
91
+ values: timeoutValues,
92
+ },
93
+ {
94
+ id: "hideCallSummariesInTui",
95
+ label: "Hide TUI summaries",
96
+ description:
97
+ "Hide per-run formatter pass/fail summaries in the interactive UI.",
98
+ currentValue: config.hideCallSummariesInTui ? "on" : "off",
99
+ values: ["off", "on"],
100
+ },
101
+ ];
43
102
  }
44
103
 
104
+ type FormatterContext = {
105
+ cwd: string;
106
+ hasUI: boolean;
107
+ ui: {
108
+ notify(message: string, level: "info" | "warning" | "error"): void;
109
+ };
110
+ };
111
+
45
112
  export default function (pi: ExtensionAPI) {
113
+ let formatterConfig = loadFormatterConfig();
46
114
  const formatQueueByPath = new Map<string, Promise<void>>();
115
+ const pendingTurnPaths = new Set<string>();
116
+ const pendingSessionPaths = new Set<string>();
47
117
 
48
118
  const enqueueFormat = async (
49
119
  filePath: string,
@@ -65,29 +135,17 @@ export default function (pi: ExtensionAPI) {
65
135
  await next;
66
136
  };
67
137
 
68
- pi.on("tool_result", async (event, ctx) => {
69
- if (event.isError) {
70
- return;
71
- }
72
-
73
- if (!isWriteToolResult(event) && !isEditToolResult(event)) {
74
- return;
75
- }
76
-
77
- const rawPath = event.input.path;
78
- if (rawPath.length === 0) {
79
- return;
80
- }
81
-
82
- const filePath = resolveToolPath(rawPath, ctx.cwd);
83
-
138
+ const formatResolvedPath = async (
139
+ filePath: string,
140
+ ctx: FormatterContext,
141
+ ): Promise<void> => {
84
142
  if (!(await pathExists(filePath))) {
85
143
  return;
86
144
  }
87
145
 
88
- const showSummaries = !hideCallSummariesInTui && ctx.hasUI;
146
+ const showSummaries = !formatterConfig.hideCallSummariesInTui && ctx.hasUI;
89
147
  const notifyWarning = (message: string) => {
90
- const normalizedMessage = message.replace(/\s+/g, " ").trim();
148
+ const normalizedMessage = normalizeSummaryMessage(message);
91
149
 
92
150
  if (ctx.hasUI) {
93
151
  ctx.ui.notify(normalizedMessage, "warning");
@@ -105,24 +163,23 @@ export default function (pi: ExtensionAPI) {
105
163
  }
106
164
  : undefined;
107
165
 
108
- const runnerWarningReporter =
109
- showSummaries && ctx.hasUI
110
- ? () => {
111
- // Summary mode already reports failures compactly.
112
- }
113
- : notifyWarning;
166
+ const runnerWarningReporter = showSummaries
167
+ ? () => {
168
+ // Summary mode already reports failures compactly.
169
+ }
170
+ : notifyWarning;
114
171
 
115
172
  try {
116
173
  await formatFile(
117
174
  pi,
118
175
  ctx.cwd,
119
176
  filePath,
120
- commandTimeoutMs,
177
+ formatterConfig.commandTimeoutMs,
121
178
  summaryReporter,
122
179
  runnerWarningReporter,
123
180
  );
124
181
  } catch (error) {
125
- const fileLabel = formatSummaryPath(filePath, ctx.cwd);
182
+ const fileLabel = getRelativePathOrAbsolute(filePath, ctx.cwd);
126
183
  notifyWarning(`Failed to format ${fileLabel}: ${formatError(error)}`);
127
184
  }
128
185
 
@@ -130,14 +187,189 @@ export default function (pi: ExtensionAPI) {
130
187
  return;
131
188
  }
132
189
 
133
- for (const summary of summaries) {
134
- if (summary.status === "succeeded") {
135
- ctx.ui.notify(formatCallSuccessSummary(summary), "info");
136
- continue;
137
- }
190
+ const fileLabel = getRelativePathOrAbsolute(filePath, ctx.cwd);
138
191
 
139
- ctx.ui.notify(formatCallFailureSummary(summary), "info");
192
+ for (const summary of summaries) {
193
+ ctx.ui.notify(formatCallSummary(summary, fileLabel), "info");
140
194
  }
141
195
  });
196
+ };
197
+
198
+ const flushPaths = async (
199
+ paths: Set<string>,
200
+ ctx: FormatterContext,
201
+ ): Promise<void> => {
202
+ const batch = [...paths];
203
+ paths.clear();
204
+
205
+ for (const filePath of batch) {
206
+ await formatResolvedPath(filePath, ctx);
207
+ }
208
+ };
209
+
210
+ const flushPendingPaths = async (ctx: FormatterContext): Promise<void> => {
211
+ await flushPaths(pendingTurnPaths, ctx);
212
+ await flushPaths(pendingSessionPaths, ctx);
213
+ };
214
+
215
+ const reloadFormatterConfig = () => {
216
+ formatterConfig = loadFormatterConfig();
217
+ };
218
+
219
+ pi.on("tool_result", async (event, ctx) => {
220
+ if (!isWriteToolResult(event) && !isEditToolResult(event)) {
221
+ return;
222
+ }
223
+
224
+ if (event.isError) {
225
+ return;
226
+ }
227
+
228
+ const filePath = resolveEventPath(event.input.path, ctx.cwd);
229
+ if (!filePath) {
230
+ return;
231
+ }
232
+
233
+ if (formatterConfig.formatMode === "tool") {
234
+ await formatResolvedPath(filePath, ctx);
235
+ return;
236
+ }
237
+
238
+ if (formatterConfig.formatMode === "turn") {
239
+ pendingTurnPaths.add(filePath);
240
+ return;
241
+ }
242
+
243
+ pendingSessionPaths.add(filePath);
244
+ });
245
+
246
+ pi.on("turn_end", async (_event, ctx) => {
247
+ if (pendingTurnPaths.size === 0) {
248
+ return;
249
+ }
250
+
251
+ await flushPaths(pendingTurnPaths, ctx);
252
+ });
253
+
254
+ pi.on("agent_end", async (_event, ctx) => {
255
+ if (pendingTurnPaths.size === 0) {
256
+ return;
257
+ }
258
+
259
+ await flushPaths(pendingTurnPaths, ctx);
260
+ });
261
+
262
+ pi.on("session_switch", async (_event, ctx) => {
263
+ if (pendingTurnPaths.size === 0 && pendingSessionPaths.size === 0) {
264
+ return;
265
+ }
266
+
267
+ await flushPendingPaths(ctx);
268
+ });
269
+
270
+ pi.on("session_shutdown", async (_event, ctx) => {
271
+ if (pendingTurnPaths.size === 0 && pendingSessionPaths.size === 0) {
272
+ return;
273
+ }
274
+
275
+ await flushPendingPaths(ctx);
276
+ });
277
+
278
+ pi.registerCommand("formatter", {
279
+ description: "Configure formatter behavior.",
280
+ handler: async (_args, ctx) => {
281
+ if (!ctx.hasUI) {
282
+ console.warn("/formatter requires interactive UI mode");
283
+ return;
284
+ }
285
+
286
+ const configPath = getFormatterConfigPath();
287
+ reloadFormatterConfig();
288
+ const draft = cloneFormatterConfig(formatterConfig);
289
+
290
+ await ctx.ui.custom((tui, theme, _kb, done) => {
291
+ const container = new Container();
292
+ container.addChild(
293
+ new Text(theme.fg("accent", theme.bold("Formatter Settings")), 1, 0),
294
+ );
295
+ container.addChild(new Text(theme.fg("dim", configPath), 1, 0));
296
+ container.addChild(new Text("", 0, 0));
297
+
298
+ const syncDraftToSettingsList = (settingsList: SettingsList) => {
299
+ settingsList.updateValue("formatMode", draft.formatMode);
300
+ settingsList.updateValue(
301
+ "commandTimeoutMs",
302
+ String(draft.commandTimeoutMs),
303
+ );
304
+ settingsList.updateValue(
305
+ "hideCallSummariesInTui",
306
+ draft.hideCallSummariesInTui ? "on" : "off",
307
+ );
308
+ };
309
+
310
+ const settingsList = new SettingsList(
311
+ getFormatterSettingItems(draft),
312
+ 8,
313
+ getSettingsListTheme(),
314
+ (id, newValue) => {
315
+ const previous = cloneFormatterConfig(draft);
316
+
317
+ if (id === "formatMode") {
318
+ draft.formatMode =
319
+ newValue as FormatterConfigSnapshot["formatMode"];
320
+ } else if (id === "commandTimeoutMs") {
321
+ draft.commandTimeoutMs = Number.parseInt(newValue, 10);
322
+ } else if (id === "hideCallSummariesInTui") {
323
+ draft.hideCallSummariesInTui = newValue === "on";
324
+ }
325
+
326
+ try {
327
+ writeFormatterConfigSnapshot(draft);
328
+ reloadFormatterConfig();
329
+
330
+ if (
331
+ id === "formatMode" &&
332
+ previous.formatMode !== draft.formatMode
333
+ ) {
334
+ void flushPendingPaths(ctx).catch((error) => {
335
+ const message =
336
+ error instanceof Error ? error.message : String(error);
337
+ ctx.ui.notify(
338
+ `Failed to flush pending formats: ${message}`,
339
+ "error",
340
+ );
341
+ });
342
+ }
343
+ } catch (error) {
344
+ const message =
345
+ error instanceof Error ? error.message : String(error);
346
+ draft.commandTimeoutMs = previous.commandTimeoutMs;
347
+ draft.hideCallSummariesInTui = previous.hideCallSummariesInTui;
348
+ draft.formatMode = previous.formatMode;
349
+ syncDraftToSettingsList(settingsList);
350
+ ctx.ui.notify(`Failed to save config: ${message}`, "error");
351
+ }
352
+ },
353
+ () => {
354
+ done(undefined);
355
+ },
356
+ );
357
+
358
+ container.addChild(settingsList);
359
+
360
+ return {
361
+ render(width: number) {
362
+ return container.render(width);
363
+ },
364
+ invalidate() {
365
+ container.invalidate();
366
+ },
367
+ handleInput(data: string) {
368
+ settingsList.handleInput?.(data);
369
+ tui.requestRender();
370
+ },
371
+ };
372
+ });
373
+ },
142
374
  });
143
375
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-formatter",
3
- "version": "0.2.0",
3
+ "version": "1.0.0",
4
4
  "description": "Pi extension that auto-formats files after write/edit tool calls.",
5
5
  "type": "module",
6
6
  "files": [