pi-shorthand 0.2.0 → 0.3.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
@@ -2,12 +2,12 @@
2
2
 
3
3
  ![A code call in Pi: the verdict, then the diff it applied, then the program's output](https://raw.githubusercontent.com/sebinsua/pi-shorthand/main/docs/screenshot.png?v=2)
4
4
 
5
- A [Pi](https://github.com/earendil-works/pi) tool for token-efficient writes. The model writes a whole
6
- change as one small Bun program, in shorthand, instead of calling `read`, `edit` and `bash` over
7
- and over: fewer tokens, and fewer round trips.
5
+ A [Pi](https://github.com/earendil-works/pi) tool for editing repositories with Bun programs.
6
+ The model can combine ordinary JavaScript, text edits and structural transformations in one call.
8
7
 
9
8
  The program sees your repo as normal, but its writes are held back. By default, they're applied only
10
9
  if the program succeeds and destination files are unchanged, and the model gets the diff.
10
+ The program performs the edit; tests, type-checks and other verification run separately afterward.
11
11
 
12
12
  ## Install
13
13
 
@@ -28,7 +28,7 @@ You also need Bun, git, and either [bubblewrap](https://github.com/containers/bu
28
28
  Anything in Bun or Node, plus these globals (no imports):
29
29
 
30
30
  ```ts
31
- await $`bun test src/api.test.ts`; // Bun's shell (the only async one)
31
+ await $`git ls-files`.text(); // Bun's shell (the only async one)
32
32
  glob("src/**/*.ts"); // → ["src/a.ts", …]
33
33
  grep("oldApi(", "src"); // → [{ file, line, text }, …]
34
34
  sg.find("oldApi($$$ARGS)", "src"); // ast-grep search
@@ -40,6 +40,16 @@ sg.parse(sg.Lang.TypeScript, source); // ast-grep's own API (or import from "@as
40
40
  grit("`console.log($x)` => `logger.info($x)`", "src");
41
41
  ```
42
42
 
43
+ `sg.find`, `sg.one`, `sg.rewrite` and `grit` also accept `sg.file("src/app.ts")` directly, including
44
+ in arrays mixed with paths. Search reads the file's current contents. Missing targets are valid insertion
45
+ destinations but cannot be searched. An explicit target can select an ignored file inside the workspace;
46
+ the tool still only applies Git-visible changes.
47
+
48
+ [api.d.ts](api.d.ts) exposes the actual injected helper types for editor completion and external TypeScript
49
+ checking of editing programs. Include it in the program's TypeScript project (or reference
50
+ `pi-shorthand/api` via `compilerOptions.types` when installed as a package). This supplies types, not runtime
51
+ globals. Bun execution does not automatically type-check programs or run application verification.
52
+
43
53
  ## Options
44
54
 
45
55
  - `title`: a short description shown with the call (required).
@@ -51,6 +61,9 @@ grit("`console.log($x)` => `logger.info($x)`", "src");
51
61
  ## Good to know
52
62
 
53
63
  - Only files git tracks, or would track, are applied.
64
+ - Successful edits are formatted with detected installed project tools (Prettier, oxfmt, Biome, Ruff,
65
+ Black, gofmt or rustfmt) before the final diff. Ambiguous setups are skipped; formatter failures warn
66
+ without discarding completed edits. Set `PI_SHORTHAND_FORMAT=0` to disable. No project config is required.
54
67
  - Each run snapshots the checkout first; reflinks make that cheap where supported, while other
55
68
  filesystems copy its contents and use corresponding temporary space. On macOS the program runs at
56
69
  a private AgentFS mount, so use paths relative to its working directory for repository files.
@@ -73,6 +86,8 @@ hook runs it; `npm run format` fixes formatting.
73
86
  `npm test` runs the tests against real overlays (it needs AgentFS on macOS, bubblewrap on Linux).
74
87
  `test/linux.sh` runs them on Linux in Docker.
75
88
 
76
- `bun e2e/run.ts --repo <path or git URL> --task "…" --setup baseline|code|read-code --check "…"` runs Pi
77
- with a real model on a fresh copy of a repo and summarises what it did (time, turns, tool calls,
78
- tokens, whether the check passed).
89
+ `bun e2e/suite.ts` previews the local benchmark suite without calling a model. Add `--execute` to run it.
90
+ The [comparison harness](e2e/README.md) supports stock, optional and replacement editing tools, controlled
91
+ documentation/skills, independent task checks, saved final changes and session reports.
92
+ `bun e2e/run.ts --repo <path or git URL> --task "…" --setups baseline,replace,code --check "…"` runs Pi
93
+ with a real model on fresh copies of your own repository.
package/api.d.ts ADDED
@@ -0,0 +1,14 @@
1
+ /** Types for programs executed by the code tool. Type-only: this does not install runtime globals. */
2
+ import type { ShorthandGlobals } from "./prelude.ts";
3
+
4
+ declare global {
5
+ const $: ShorthandGlobals["$"];
6
+ const edit: ShorthandGlobals["edit"];
7
+ const glob: ShorthandGlobals["glob"];
8
+ const grep: ShorthandGlobals["grep"];
9
+ const sg: ShorthandGlobals["sg"];
10
+ const grit: ShorthandGlobals["grit"];
11
+ }
12
+
13
+ export type { RewriteResult } from "./prelude.ts";
14
+ export type { ShorthandGlobals };
package/format.ts ADDED
@@ -0,0 +1,148 @@
1
+ /** Best-effort formatting using existing project tools. Runs inside the editing workspace. */
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { dirname, extname, join, relative, resolve } from "node:path";
4
+
5
+ type Command = { name: string; executable: string; args: string[]; cwd: string };
6
+
7
+ function text(file: string): string {
8
+ try {
9
+ return readFileSync(file, "utf8");
10
+ } catch {
11
+ return "";
12
+ }
13
+ }
14
+
15
+ function directories(start: string, root: string): string[] {
16
+ const result = [];
17
+ for (let dir = start; ; dir = dirname(dir)) {
18
+ result.push(dir);
19
+ if (dir === root || dirname(dir) === dir) return result;
20
+ }
21
+ }
22
+
23
+ export function formatterFor(file: string, root: string): Command | null {
24
+ root = resolve(root);
25
+ const extension = extname(file);
26
+ const js = /\.(?:[cm]?[jt]sx?|jsonc?|css|scss|less|html|vue|svelte|mdx?|ya?ml|graphql)$/i.test(extension);
27
+ for (const cwd of directories(dirname(resolve(root, file)), root)) {
28
+ const has = (...names: string[]) => names.some((name) => existsSync(join(cwd, name)));
29
+ let name: string | undefined;
30
+ let args: string[] = [];
31
+ if (js) {
32
+ let pkg: {
33
+ scripts?: Record<string, string>;
34
+ dependencies?: Record<string, string>;
35
+ devDependencies?: Record<string, string>;
36
+ prettier?: unknown;
37
+ };
38
+ try {
39
+ pkg = JSON.parse(text(join(cwd, "package.json")) || "{}");
40
+ } catch {
41
+ return null;
42
+ }
43
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
44
+ const choices = [
45
+ ...(deps.prettier ||
46
+ pkg.prettier !== undefined ||
47
+ has(
48
+ ".prettierrc",
49
+ ".prettierrc.json",
50
+ ".prettierrc.yaml",
51
+ ".prettierrc.yml",
52
+ ".prettierrc.js",
53
+ ".prettierrc.cjs",
54
+ ".prettierrc.mjs",
55
+ "prettier.config.js",
56
+ "prettier.config.cjs",
57
+ "prettier.config.mjs",
58
+ "prettier.config.ts",
59
+ )
60
+ ? ["prettier"]
61
+ : []),
62
+ ...(deps.oxfmt || has(".oxfmtrc.json", ".oxfmtrc.jsonc") ? ["oxfmt"] : []),
63
+ ...(deps["@biomejs/biome"] || has("biome.json", "biome.jsonc") ? ["biome"] : []),
64
+ ];
65
+ // Recognize the executable, never execute an arbitrary package script.
66
+ const script = pkg.scripts?.format;
67
+ const scripted = script?.match(/^(prettier|oxfmt|biome)(?:\s|$)/)?.[1];
68
+ // Custom wrappers/options may encode conventions we cannot reproduce. Leave them alone.
69
+ if (
70
+ script &&
71
+ (!scripted ||
72
+ /[;&|`$<>]/.test(script) ||
73
+ (script.match(/(?<!\S)--?[\w-]+(?:=\S+)?/g) ?? []).some(
74
+ (flag) => !["--write", "--check", "--ignore-unknown"].includes(flag),
75
+ ))
76
+ )
77
+ return null;
78
+ if (!scripted && choices.length > 1) return null;
79
+ name = scripted ?? choices[0];
80
+ args =
81
+ name === "prettier"
82
+ ? ["--write", "--ignore-unknown"]
83
+ : name === "biome"
84
+ ? ["format", "--write", "--files-ignore-unknown=true"]
85
+ : [];
86
+ } else if (extension === ".py" || extension === ".pyi") {
87
+ const config = text(join(cwd, "pyproject.toml"));
88
+ const ruff = has("ruff.toml", ".ruff.toml") || /\[tool\.ruff(?:\.|\])/.test(config);
89
+ const black = /\[tool\.black\]/.test(config);
90
+ if (ruff && black) return null;
91
+ name = ruff ? "ruff" : black ? "black" : undefined;
92
+ args = name === "ruff" ? ["format"] : [];
93
+ } else if (extension === ".go" && has("go.mod", "go.work")) {
94
+ name = "gofmt";
95
+ args = ["-w"];
96
+ } else if (extension === ".rs" && has("Cargo.toml")) {
97
+ const cargo = text(join(cwd, "Cargo.toml"));
98
+ if (/edition\s*\.\s*workspace\s*=/.test(cargo)) return null;
99
+ name = "rustfmt";
100
+ args = ["--edition", cargo.match(/^\s*edition\s*=\s*["'](\d+)["']/m)?.[1] ?? "2015"];
101
+ }
102
+ if (!name) continue;
103
+ const search = directories(cwd, root);
104
+ const candidates = search.map((dir) => join(dir, js ? "node_modules/.bin" : ".venv/bin", name!));
105
+ // JS formatters must belong to this project, not the extension's dependencies.
106
+ const executable = candidates.find((candidate) => Bun.which(candidate)) ?? (!js ? Bun.which(name) : null);
107
+ return executable ? { name, executable, args, cwd } : null;
108
+ }
109
+ return null;
110
+ }
111
+
112
+ export async function formatChanged(
113
+ files: string[],
114
+ root: string,
115
+ ): Promise<{ messages: string[]; warnings: string[] }> {
116
+ const groups = new Map<string, { command: Command; files: string[] }>();
117
+ const result = { messages: [] as string[], warnings: [] as string[] };
118
+ for (const file of files) {
119
+ const command = formatterFor(file, root);
120
+ if (!command) continue;
121
+ const key = JSON.stringify(command);
122
+ const group = groups.get(key) ?? { command, files: [] };
123
+ group.files.push("./" + relative(command.cwd, resolve(root, file)));
124
+ groups.set(key, group);
125
+ }
126
+ for (const { command, files: targets } of groups.values()) {
127
+ try {
128
+ const child = Bun.spawn([command.executable, ...command.args, ...targets], {
129
+ cwd: command.cwd,
130
+ stdout: "pipe",
131
+ stderr: "pipe",
132
+ });
133
+ const [code, stdout, stderr] = await Promise.all([
134
+ child.exited,
135
+ new Response(child.stdout).text(),
136
+ new Response(child.stderr).text(),
137
+ ]);
138
+ if (code !== 0)
139
+ result.warnings.push(
140
+ `${command.name} formatting failed: ${(stderr || stdout).trim().slice(-2000) || `exit ${code}`}`,
141
+ );
142
+ else result.messages.push(`Formatted ${targets.length} file(s) with ${command.name}.`);
143
+ } catch (error) {
144
+ result.warnings.push(`${command.name} formatting failed: ${String(error)}`);
145
+ }
146
+ }
147
+ return result;
148
+ }
package/index.ts CHANGED
@@ -15,28 +15,19 @@ import { callLine, countLines, fileMetadataSummary, resultLines, unstructuredRes
15
15
  import { RUN_HISTORY_FILE } from "./history.ts";
16
16
  import type { FileChange, RunOptions, RunResult } from "./runner.ts";
17
17
 
18
- // Runs typically take well under a second. Programs that run tests or builds pass a longer timeout.
18
+ // Runs typically take well under a second. Longer transformations can request more time.
19
19
  const DEFAULT_TIMEOUT_SECONDS = 2;
20
20
 
21
- const DESCRIPTION = `Make a repository change with one TypeScript program, run by Bun as a transaction: its writes are applied only if it exits successfully, and you get the diff. Put the checks that prove the change worked (type-check, targeted tests, no leftover matches) in the same program, and throw if they fail. Work out what to change inside the program (e.g. with grep) rather than copying lists from earlier output.
21
+ const DESCRIPTION = `Edit repository files with a TypeScript program run by Bun. Top-level await and ordinary Bun/Node APIs work. Use repository-relative paths. The program runs in an isolated workspace; changes apply on successful exit by default and the tool reports the diff. Run tests, type-checks and builds separately afterward with the shell tool.
22
22
 
23
- Use it when a change takes several deterministic steps (reads, searches, multi-file edits, structural rewrites, checks) and you already know what to do with each intermediate result. If seeing an intermediate result could change your plan, look first with a normal tool call.
23
+ Common operations:
24
+ - edit({ path, oldText, newText }) replaces exactly one literal occurrence; missing or ambiguous text is an error. Use text edits for known source, structural matching when it saves enumerating occurrences or preserves varying syntax.
25
+ - await Bun.file(path).text(); await Bun.write(path, text)
26
+ - sg.rewrite(pattern, replacement, files?) discovers and rewrites matching code; omit files for the working directory. $X captures one node; $$$X captures a sequence.
27
+ - sg.one(pattern, files?) selects exactly one match; sg.find returns an array. sg.rewrite also accepts a selected match or array without a file scope.
28
+ - A rewrite callback receives a match and returns text, a native node.replace(text) edit, or null to skip. Return native edits to apply them. Pass selected arrays together for independent edits; select again after changing their file.
24
29
 
25
- The program runs in an isolated copy of the working directory. Use relative paths for repository files. On Linux, host paths outside the repository are read-only and $TMPDIR is private to the run; on macOS the real checkout's absolute path is intentionally inaccessible. Top-level await works, and so do ordinary Bun and Node APIs. These globals are synchronous, and see the files git sees (not node_modules or ignored files):
26
- - glob(pattern, dir?) → string[]
27
- - grep(stringOrRegExp, paths?) → {file, line, text}[]. A string matches literally.
28
- - sg.find(pattern, files?) → {file, line, text, vars}[]. ast-grep pattern: $X is one node, $$$X is zero or more. files is a file, directory, glob or a list of them (JS/TS).
29
- - sg.rewrite(pattern, templateOrFunction, files?) → number rewritten. A template can use $X and $$$X; a function gets the match (its captures are on it: m.X) and returns the new text, or null to leave it.
30
- - sg.one(pattern, files?) requires exactly one match. sg.file(path) selects a JS/TS file root (also works for new files).
31
- - sg.insert(text, destination), sg.move(match, destination, transform?), sg.remove(match). destination is exactly one of {before: match}, {after: match}, {startOf: container}, {endOf: container}. JS/TS statements/declarations only; containers are file roots or matched statement blocks. Rematch after editing a file. move's optional function transforms its text; insert(match.text, destination) copies.
32
- - sg also has ast-grep's own API (sg.parse, sg.Lang, sg.findInFiles, …), and import "@ast-grep/napi" works too.
33
- - grit(gritqlPattern, paths?, {lang?, dryRun?}) → {file, matches}[], e.g. grit("\`a($x)\` => \`b($x)\`", "src")
34
- Bun's shell $ needs await: await $\`bun test src/foo.test.ts\`. You can also run the ast-grep, grit and git CLIs with it. For how to write these programs, see the shorthand skill.
35
-
36
- Throw or exit non-zero to fail. rollback decides what a failure undoes:
37
- - "all" (default): nothing is applied; you get the error and the candidate diff.
38
- - "file": on timeout, files not open for writing are applied if writer inspection succeeds. If inspection fails, or on another failure, nothing is applied.
39
- The default timeout is 2 seconds; pass a longer timeout when the program runs tests or builds. Only files git sees (tracked, or untracked and not ignored) are diffed and applied; writes to .git are blocked. Print what you need to know (counts, assertions), not whole files.`;
30
+ See the shorthand skill for common writes. For extraction, complex rewrites or other languages, read its advanced-refactors.md guide. The default timeout is two seconds; request more for longer programs.`;
40
31
 
41
32
  export default function (pi: ExtensionAPI) {
42
33
  // A failed run is an error, both for the model and for how Pi shows it. (execute() returns its details
@@ -50,12 +41,8 @@ export default function (pi: ExtensionAPI) {
50
41
  name: "code",
51
42
  label: "Code",
52
43
  description: DESCRIPTION,
53
- promptSnippet:
54
- "Make a change with one Bun program, run as a transaction: its edits are kept only if it exits 0, so put your checks inside it",
55
- promptGuidelines: [
56
- "Use code when several related reads, searches, edits or checks can be done without looking at intermediate results: put that logic in one program rather than many read/edit/bash calls.",
57
- "Don't use code to explore when you need to see results before deciding what to do.",
58
- ],
44
+ promptSnippet: "Make a change with one transactional Bun editing program; run verification separately afterward",
45
+
59
46
  parameters: Type.Object({
60
47
  title: Type.String({ description: "A few words describing the change, shown to the user" }),
61
48
  program: Type.String({ description: "TypeScript program run with Bun (top-level await allowed)" }),
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-shorthand",
3
- "version": "0.2.0",
4
- "description": "Pi tool for token-efficient writes: the model makes a whole change with one isolated Bun program.",
3
+ "version": "0.3.0",
4
+ "description": "Pi tool for editing repositories with Bun programs, text edits and structural transformations.",
5
5
  "keywords": [
6
6
  "ast-grep",
7
7
  "bun",
@@ -33,6 +33,7 @@
33
33
  "lint": "oxlint --deny-warnings",
34
34
  "format": "oxfmt",
35
35
  "check": "tsc -p . && oxlint --deny-warnings && oxfmt --check",
36
+ "postinstall": "grit init --global",
36
37
  "prepare": "git config core.hooksPath .githooks 2>/dev/null || true"
37
38
  },
38
39
  "dependencies": {
package/placement.ts CHANGED
@@ -9,6 +9,15 @@ export interface Match {
9
9
  node: SgNode;
10
10
  }
11
11
 
12
+ const fileTarget = Symbol("shorthand.file");
13
+ export interface FileTarget extends Match {
14
+ readonly [fileTarget]: true;
15
+ }
16
+
17
+ export function isFileTarget(value: unknown): value is FileTarget {
18
+ return typeof value === "object" && value !== null && fileTarget in value && snapshots.has(value as unknown as Match);
19
+ }
20
+
12
21
  export type Destination =
13
22
  | { before: Match; after?: never; startOf?: never; endOf?: never }
14
23
  | { after: Match; before?: never; startOf?: never; endOf?: never }
@@ -41,27 +50,45 @@ export function remember<T extends Match>(match: T, source: string, existed = tr
41
50
  }
42
51
 
43
52
  /** A file root, including a not-yet-created file. Merely selecting it performs no writes. */
44
- export function file(filename: string): Match {
53
+ export function file(filename: string): FileTarget {
45
54
  const lang = languages[filename.split(".").pop()!];
46
55
  if (!lang) throw new Error("sg.file requires a JS/TS filename");
47
56
  const existed = existsSync(filename);
48
57
  const source = existed ? readFileSync(filename, "utf8") : "";
49
- return remember({ file: filename, text: source, node: parse(lang, source).root() }, source, existed);
58
+ return remember(
59
+ { file: filename, text: source, node: parse(lang, source).root(), [fileTarget]: true as const },
60
+ source,
61
+ existed,
62
+ );
50
63
  }
51
64
 
52
- function snapshot(match: Match): Snapshot {
65
+ export function getMatchSnapshot(
66
+ match: Match,
67
+ sources = new Map<string, string | null>(),
68
+ staleAdvice = "match the file again after editing it",
69
+ ): Snapshot {
53
70
  const saved = snapshots.get(match);
54
71
  if (!saved) throw new Error("Expected a file-backed match from sg.find, sg.one, or sg.file");
55
- if (!languages[saved.file.split(".").pop()!]) throw new Error("Placement currently supports JS/TS only");
56
72
  if (
57
- existsSync(saved.file) !== saved.existed ||
58
- (saved.existed && readFileSync(saved.file, "utf8") !== saved.source)
59
- ) {
60
- throw new Error(`Stale match in ${match.file}; match the file again after editing it`);
73
+ match.node !== saved.node ||
74
+ (existsSync(match.file) ? realpathSync(match.file) : resolve(match.file)) !== saved.file
75
+ )
76
+ throw new Error("File-backed match identity was changed; select it again");
77
+ if (!sources.has(saved.file))
78
+ sources.set(saved.file, existsSync(saved.file) ? readFileSync(saved.file, "utf8") : null);
79
+ const source = sources.get(saved.file);
80
+ if ((source !== null) !== saved.existed || (saved.existed && source !== saved.source)) {
81
+ throw new Error(`Stale match in ${match.file}; ${staleAdvice}`);
61
82
  }
62
83
  return saved;
63
84
  }
64
85
 
86
+ function snapshot(match: Match): Snapshot {
87
+ const saved = getMatchSnapshot(match);
88
+ if (!languages[saved.file.split(".").pop()!]) throw new Error("Placement currently supports JS/TS only");
89
+ return saved;
90
+ }
91
+
65
92
  function container(node: SgNode): boolean {
66
93
  return node.kind() === "program" || node.kind() === "statement_block";
67
94
  }