pi-shorthand 0.8.0 → 0.9.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
@@ -6,11 +6,16 @@ A [Pi](https://github.com/earendil-works/pi) tool for editing repositories with
6
6
 
7
7
  ## Install
8
8
 
9
- Install pi-shorthand and initialize Grit:
9
+ Install [Bun](https://bun.sh) 1.4 or later, which runs the programs:
10
+
11
+ ```sh
12
+ curl -fsSL https://bun.sh/install | bash
13
+ ```
14
+
15
+ Then install pi-shorthand:
10
16
 
11
17
  ```sh
12
18
  pi install npm:pi-shorthand
13
- npx @getgrit/cli init --global
14
19
  ```
15
20
 
16
21
  On macOS, install clang and AgentFS:
@@ -34,3 +39,7 @@ pi-shorthand gives Pi a programming environment instead of a patch format. This
34
39
  Programs edit a private workspace, with host files outside the repository kept read-only. By default, a failure keeps completed files and rolls back failed or interrupted edits. Concurrent edits can cause a run to be rejected; conflict detection is best-effort, not an atomic commit.
35
40
 
36
41
  The `code` tool uses Pi's working directory by default. Pass `cwd` to target another checkout; relative paths are resolved from Pi's working directory. For example, when Pi starts in a bare worktree container, `cwd: "child"` targets its `child` worktree. The chosen directory must be inside a Git worktree.
42
+
43
+ ## Without Pi
44
+
45
+ The engine is published separately as `shorthand-code`, with a `shorthand` command that runs a program from a file or stdin and prints the same result. See `shorthand --help`.
package/package.json CHANGED
@@ -1,58 +1,36 @@
1
1
  {
2
2
  "name": "pi-shorthand",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Pi tool for editing repositories with Bun programs, with first-class support for TypeScript and JavaScript.",
5
5
  "keywords": [
6
6
  "ast-grep",
7
7
  "bun",
8
8
  "code-mode",
9
- "gritql",
10
9
  "overlay",
11
10
  "pi",
12
11
  "pi-extension",
13
12
  "pi-package"
14
13
  ],
15
- "homepage": "https://github.com/sebinsua/pi-shorthand#readme",
14
+ "homepage": "https://github.com/sebinsua/pi-shorthand/tree/main/packages/pi-shorthand#readme",
16
15
  "bugs": "https://github.com/sebinsua/pi-shorthand/issues",
17
16
  "license": "MIT",
18
17
  "author": "Seb Insua",
19
18
  "repository": {
20
19
  "type": "git",
21
- "url": "git+https://github.com/sebinsua/pi-shorthand.git"
20
+ "url": "git+https://github.com/sebinsua/pi-shorthand.git",
21
+ "directory": "packages/pi-shorthand"
22
22
  },
23
23
  "files": [
24
- "*.c",
25
- "*.ts",
24
+ "src",
26
25
  "skills",
27
26
  "README.md"
28
27
  ],
29
28
  "type": "module",
30
29
  "scripts": {
31
- "test": "bun test",
32
- "typecheck": "tsc -p .",
33
- "lint": "oxlint --deny-warnings",
34
- "format": "oxfmt",
35
- "check": "tsc -p . && oxlint --deny-warnings && oxfmt --check",
36
- "setup:grit": "grit init --global",
37
- "prepare": "git config core.hooksPath .githooks 2>/dev/null || true"
30
+ "prepack": "cp ../../README.md README.md"
38
31
  },
39
32
  "dependencies": {
40
- "@ast-grep/cli": "^0.45.3",
41
- "@ast-grep/napi": "^0.45.3",
42
- "@getgrit/cli": "0.1.0-alpha.1743007075",
43
- "diff": "^9.0.0",
44
- "typescript": "7.0.2",
45
- "vscode-jsonrpc": "^9.0.2"
46
- },
47
- "devDependencies": {
48
- "@earendil-works/pi-ai": "^0.85.1",
49
- "@earendil-works/pi-coding-agent": "^0.85.1",
50
- "@earendil-works/pi-tui": "^0.85.1",
51
- "@types/bun": "^1.4.2",
52
- "@types/node": "^26.6.1",
53
- "oxfmt": "^0.68.0",
54
- "oxlint": "^1.83.0",
55
- "typebox": "^1.3.7"
33
+ "shorthand-code": "0.9.0"
56
34
  },
57
35
  "peerDependencies": {
58
36
  "@earendil-works/pi-ai": "*",
@@ -74,9 +52,12 @@
74
52
  "optional": true
75
53
  }
76
54
  },
55
+ "engines": {
56
+ "bun": ">=1.4"
57
+ },
77
58
  "pi": {
78
59
  "extensions": [
79
- "./index.ts"
60
+ "./src/index.ts"
80
61
  ],
81
62
  "skills": [
82
63
  "./skills"
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: shorthand
3
- description: Edit repository files with Bun programs using plain text edits or structural matching. Covers renames, file and declaration moves, and call-site migrations; the advanced guide covers extracting code, moving syntax, GritQL and other languages.
3
+ description: Edit repository files with Bun programs using plain text edits or structural matching. Covers renames, file and declaration moves, and call-site migrations; the advanced guide covers extracting code, moving syntax and rule objects.
4
4
  ---
5
5
 
6
6
  # Shorthand
@@ -113,6 +113,5 @@ detected project formatter. Counters and console summaries aren't required.
113
113
 
114
114
  This page covers renames, file and declaration moves, and call-site migrations. Read
115
115
  [advanced-refactors.md](advanced-refactors.md) only to extract code into a new function or file,
116
- insert, move or remove other statements, use GritQL or the native ast-grep API, or edit languages other
117
- than JS/TS. The bundled TypeScript 7 package has no legacy compiler API; use the supplied structural
118
- tools instead.
116
+ insert, move or remove other statements, or use rule objects or the native ast-grep API. The bundled
117
+ TypeScript 7 package has no legacy compiler API; use the supplied structural tools instead.
@@ -93,6 +93,8 @@ sg.find(
93
93
  { rule: { pattern: "console.log($$$A)", not: { inside: { kind: "function_declaration", stopBy: "end" } } } },
94
94
  "src",
95
95
  );
96
+ // constraints restrict what a capture may match
97
+ sg.rewrite({ rule: { pattern: "$F($X)" }, constraints: { F: { regex: "^(oldApi|legacyApi)$" } } }, "newApi($X)", "src");
96
98
  ```
97
99
 
98
100
  `kind` names come from tree-sitter (`function_declaration`, `call_expression`, `import_statement`,
@@ -113,35 +115,6 @@ await Bun.write("src/app.ts", root.commitEdits(edits));
113
115
  The bundled TypeScript 7 package does not expose the legacy compiler API (`createSourceFile`,
114
116
  `ScriptTarget`); changing import syntax won't make it available.
115
117
 
116
- ## GritQL and other languages
117
-
118
- Code goes in backticks. `$x` captures a node, `=>` rewrites, and `where` adds conditions.
119
-
120
- ```ts
121
- grit("`oldApi($args)`", "src", { dryRun: true }); // find: [{ file, matches }]
122
- grit("`console.log($m)` => `logger.info($m)`", "src"); // rewrite in place
123
- grit("`$f($x)` where { $f <: `oldApi` }", "src", { dryRun: true }); // conditions
124
- grit("`print($x)` => `log($x)`", "src", { lang: "python" }); // other languages
125
- ```
126
-
127
- It defaults to JavaScript/TypeScript. Other languages include python, go, rust, java, ruby, css,
128
- json and yaml.
129
-
130
- Paths accept files, directories, globs, `sg.file()` targets or mixed arrays.
131
-
132
- Worth knowing:
133
-
134
- - Each `grit` call takes about a second to start. That time does not count toward the program's
135
- timeout.
136
- - A rewrite can drop a statement's trailing semicolon. Check the diff, or use `sg.rewrite` for
137
- simple JS/TS rewrites.
138
-
139
- The bundled ast-grep CLI also supports other languages:
140
-
141
- ```ts
142
- await $`ast-grep run -p 'print($A)' -r 'log($A)' -l python -U src`;
143
- ```
144
-
145
118
  ## File discovery and commands
146
119
 
147
120
  ```ts
@@ -150,7 +123,7 @@ grep("oldApi(", "src"); // [{ file, line, text }]; accepts a RegExp too
150
123
  const files = await $`git ls-files`.text();
151
124
  ```
152
125
 
153
- `glob`, `grep`, `sg` and `grit` are synchronous. Bun's `$` needs `await`; interpolated values are
126
+ `glob`, `grep` and `sg` are synchronous. Bun's `$` needs `await`; interpolated values are
154
127
  quoted as single arguments, and arrays become multiple arguments. These globals and bundled CLIs
155
128
  belong to `code`, not necessarily ordinary shell calls. `node:fs` and ordinary Bun APIs also work.
156
129
 
@@ -162,12 +135,12 @@ path is inaccessible on macOS. On both platforms, host paths outside the workspa
162
135
  Writes to `.git` are blocked.
163
136
 
164
137
  The default timeout is two seconds; request more for longer transformations. Time inside helpers
165
- (`edit`, `glob`, `grep`, `sg`, `grit`, `refactor`) does not count toward it, up to 60 extra seconds per run.
138
+ (`edit`, `glob`, `grep`, `sg`, `refactor`) does not count toward it, up to 60 extra seconds per run.
166
139
  By default, rollback happens per file: files involved in failed or interrupted edits are discarded,
167
140
  while the others are retained, including after exceptions. Unattributed failures
168
141
  (such as failed checks) preserve completed edits and report the failure. A failed file loses all
169
- its edits from this run, even if the error is caught. Multi-file operations such as moves and a
170
- single Grit invocation share one outcome. Arbitrary shell failures cannot identify failed closed
142
+ its edits from this run, even if the error is caught. Multi-file operations such as moves share
143
+ one outcome. Arbitrary shell failures cannot identify failed closed
171
144
  files. Open writers are inspected before failure exits and timeout termination; inspection failures
172
145
  or crashes that bypass exit handling retain nothing. Cancellation still discards everything.
173
146
  Use `rollback: "all"` when the whole change must be atomic.
@@ -8,8 +8,16 @@
8
8
  */
9
9
 
10
10
  import { getLanguageFromPath, highlightCode, keyHint, renderDiff, type Theme } from "@earendil-works/pi-coding-agent";
11
- import type { FileChange, RunResult, RunTimings } from "./runner.ts";
12
- import { diagnosticLines } from "./diagnostics.ts";
11
+ import {
12
+ type FileChange,
13
+ type RunResult,
14
+ countLines,
15
+ diagnosticLines,
16
+ fileMetadataSummary,
17
+ timeoutBudgetMs,
18
+ timeoutText,
19
+ timingBreakdown,
20
+ } from "shorthand-code";
13
21
 
14
22
  type ToolBackground = "toolSuccessBg" | "toolErrorBg";
15
23
 
@@ -99,49 +107,6 @@ export function resultLines(run: RunResult, expanded: boolean, theme: Theme): st
99
107
  return lines;
100
108
  }
101
109
 
102
- const TIMING_LABELS: Record<keyof RunTimings, string> = {
103
- resolveRepositoryMs: "repository",
104
- waitForLockMs: "lock wait",
105
- workspaceSetupMs: "workspace setup",
106
- programMs: "program",
107
- scanChangesMs: "change scan",
108
- formatMs: "formatter",
109
- workspaceCloseMs: "workspace cleanup",
110
- checkConflictsMs: "conflict check",
111
- applyMs: "apply",
112
- renderDiffMs: "diff",
113
- unattributedMs: "other",
114
- };
115
-
116
- /** Explain calls exceeding the configured program budget, even when no individual phase does. */
117
- export function timingBreakdown(run: RunResult): string | undefined {
118
- if (!run.timings || (run.diagnostics?.wallMs ?? run.durationMs) < timeoutBudgetMs(run)) return undefined;
119
- const significant = Object.entries(run.timings)
120
- .map(([phase, milliseconds]) => ({
121
- label: TIMING_LABELS[phase as keyof RunTimings],
122
- milliseconds,
123
- }))
124
- .filter(({ milliseconds }) => milliseconds > 0)
125
- .toSorted((a, b) => b.milliseconds - a.milliseconds);
126
- if (significant.length === 0) return undefined;
127
- return significant.map(({ label, milliseconds }) => `${label} ${formatDuration(milliseconds)}`).join(" · ");
128
- }
129
-
130
- function formatDuration(milliseconds: number): string {
131
- return milliseconds < 1_000 ? `${milliseconds}ms` : `${(milliseconds / 1_000).toFixed(1)}s`;
132
- }
133
-
134
- /** e.g. "2s" or "2s plus 60.0s in helpers": helper time does not count toward the timeout. */
135
- function timeoutText(run: RunResult): string {
136
- const helpers = run.helperMs ? ` plus ${formatDuration(run.helperMs)} in helpers` : "";
137
- return `${run.timeoutMs / 1000}s${helpers}`;
138
- }
139
-
140
- /** The longest a run can take before its timeout, including excluded helper time. */
141
- export function timeoutBudgetMs(run: RunResult): number {
142
- return run.timeoutMs + (run.helperMs ?? 0);
143
- }
144
-
145
110
  /** e.g. "✓ Applied 3 files · +6 −6 · 0.6s" or "✕ Failed · rolled back all changes · exit 1 · 0.2s" */
146
111
  function verdict(run: RunResult, applied: FileChange[], theme: Theme): string {
147
112
  const muted = (text: string) => theme.fg("muted", text);
@@ -369,17 +334,6 @@ function fileLine(change: FileChange, theme: Theme): string {
369
334
  return `${theme.fg("accent", change.path)}${kind}${metadata ? theme.fg("muted", ` (${metadata})`) : ""} ${stats([change], theme)}`;
370
335
  }
371
336
 
372
- export function fileMetadataSummary(change: FileChange): string {
373
- if (change.beforeType && change.afterType && change.beforeType !== change.afterType) {
374
- return `${change.beforeType} → ${change.afterType}`;
375
- }
376
- if (change.afterType === "symlink" && change.beforeType !== "symlink") return "symlink";
377
- if (change.beforeMode !== undefined && change.afterMode !== undefined && change.beforeMode !== change.afterMode) {
378
- return `${change.beforeMode.toString(8)} → ${change.afterMode.toString(8)}`;
379
- }
380
- return "";
381
- }
382
-
383
337
  /** "+6 −2", in the diff colours. */
384
338
  function stats(changes: FileChange[], theme: Theme): string {
385
339
  const counts = changes.map((change) => countLines(change.patch));
@@ -434,16 +388,3 @@ function toPiDiff(patch: string): string {
434
388
  }
435
389
  return out.join("\n");
436
390
  }
437
-
438
- /** Added and removed lines in a patch. Only lines after the first "@@" count, not the ---/+++ headers. */
439
- export function countLines(patch: string) {
440
- let additions = 0;
441
- let deletions = 0;
442
- let inHunks = false;
443
- for (const line of patch.split("\n")) {
444
- if (line.startsWith("@@")) inHunks = true;
445
- else if (inHunks && line.startsWith("+")) additions++;
446
- else if (inHunks && line.startsWith("-")) deletions++;
447
- }
448
- return { additions, deletions };
449
- }
package/src/index.ts ADDED
@@ -0,0 +1,153 @@
1
+ /**
2
+ * The `code` tool: the model writes one Bun program that makes a multi-step change to the repository.
3
+ * Its writes go to a copy-on-write overlay; if it succeeds, they're applied and the diff is returned.
4
+ */
5
+
6
+ import * as path from "node:path";
7
+ import { StringEnum } from "@earendil-works/pi-ai";
8
+ import { type ExtensionAPI, type Theme, truncateHead, truncateTail } from "@earendil-works/pi-coding-agent";
9
+ import { Text } from "@earendil-works/pi-tui";
10
+ import { Type } from "typebox";
11
+ import {
12
+ DEFAULT_TIMEOUT_SECONDS,
13
+ type Diagnostics,
14
+ type RunResult,
15
+ RunnerError,
16
+ diagnosticLines,
17
+ runFailed,
18
+ runWithBun,
19
+ textForModel,
20
+ } from "shorthand-code";
21
+ import { callLine, resultLines, unstructuredResultText } from "./display.ts";
22
+
23
+ const DESCRIPTION = `Edit repository files with a TypeScript program run by Bun. Best for changes across many files, repeated edits and semantic TypeScript renames or moves; a small change to one file is quicker as a direct edit. Top-level await and ordinary Bun/Node APIs work. Use repository-relative paths. Set cwd to a checkout path when Pi's working directory is outside the repository, such as a child worktree in a bare worktree container. 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.
24
+
25
+ Common operations:
26
+ - 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.
27
+ - await Bun.file(path).text(); await Bun.write(path, text)
28
+ - sg.rewrite(pattern, replacement, files?) discovers and rewrites matching code; omit files for the working directory. $X captures one node; $$$X captures a sequence.
29
+ - 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.
30
+ - 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.
31
+ - await refactor.rename({ file, symbol, to }) renames one resolved TypeScript symbol across the project without changing unrelated names.
32
+ - await refactor.renameFile({ from, to }) moves a TypeScript file and updates module paths that resolve to it.
33
+ - await refactor.move({ file, symbol, to }) moves a top-level declaration to another file and updates the imports that follow it.
34
+
35
+ See the shorthand skill for renames, moves and call-site migrations. Read its advanced-refactors.md guide only to extract code, move syntax or use rule objects. The default timeout is two seconds; request more for longer programs. Time spent inside the helpers above does not count toward it, up to 60 extra seconds.`;
36
+
37
+ export default function (pi: ExtensionAPI) {
38
+ // A failed run is an error, both for the model and for how Pi shows it. (execute() returns its details
39
+ // rather than throwing, since a thrown error loses them.)
40
+ pi.on("tool_result", async (event) => {
41
+ const run = event.details as RunResult | undefined;
42
+ if (event.toolName === "code" && run && runFailed(run)) return { isError: true };
43
+ });
44
+
45
+ pi.registerTool({
46
+ name: "code",
47
+ label: "Code",
48
+ description: DESCRIPTION,
49
+ promptSnippet:
50
+ "Make multi-file, repetitive or rename/move changes with one Bun program; a small change to one file is quicker as a direct edit. Run verification separately afterward",
51
+
52
+ parameters: Type.Object({
53
+ title: Type.String({ description: "A few words describing the change, shown to the user" }),
54
+ program: Type.String({ description: "TypeScript program run with Bun (top-level await allowed)" }),
55
+ cwd: Type.Optional(
56
+ Type.String({
57
+ description:
58
+ "Working directory for the program; relative to Pi's working directory, or an absolute path. Defaults to Pi's working directory. Must be inside a git worktree.",
59
+ }),
60
+ ),
61
+ rollback: Type.Optional(
62
+ StringEnum(["all", "file"] as const, {
63
+ description:
64
+ 'On failure: "file" (default) rolls back failed or interrupted file edits and retains the others; "all" applies nothing',
65
+ }),
66
+ ),
67
+ timeout: Type.Optional(
68
+ Type.Number({
69
+ description: "Seconds of program time before it is killed (default 2); time inside helpers is excluded",
70
+ }),
71
+ ),
72
+ }),
73
+
74
+ async execute(toolCallId, params, signal, onUpdate, ctx) {
75
+ // While it runs, show how long it's been going and the latest reported step.
76
+ const startedAt = Date.now();
77
+ let latest: string | undefined;
78
+ const progress = setInterval(() => {
79
+ const elapsed = `${((Date.now() - startedAt) / 1000).toFixed(1)} s`;
80
+ onUpdate?.({
81
+ content: [{ type: "text", text: "running" }],
82
+ details: { progress: latest ? `${elapsed} · ${latest}` : elapsed },
83
+ });
84
+ }, 500);
85
+
86
+ let result: RunResult;
87
+ try {
88
+ result = await runWithBun(
89
+ {
90
+ cwd: path.resolve(ctx.cwd, params.cwd ?? "."),
91
+ program: params.program,
92
+ timeoutMs: (params.timeout ?? DEFAULT_TIMEOUT_SECONDS) * 1000,
93
+ rollback: params.rollback ?? "file",
94
+ },
95
+ signal,
96
+ (step) => (latest = step),
97
+ );
98
+ } catch (error) {
99
+ if (!(error instanceof RunnerError)) throw error;
100
+ return {
101
+ isError: true,
102
+ content: [{ type: "text" as const, text: [error.message, ...diagnosticLines(error.diagnostics)].join("\n") }],
103
+ details: { infrastructureError: error.message, diagnostics: error.diagnostics },
104
+ };
105
+ } finally {
106
+ clearInterval(progress);
107
+ }
108
+ return {
109
+ content: [
110
+ {
111
+ type: "text",
112
+ text: textForModel(result, { name: `pi-shorthand-${toolCallId}`, truncateHead, truncateTail }),
113
+ },
114
+ ],
115
+ details: result,
116
+ };
117
+ },
118
+
119
+ renderCall(args, theme) {
120
+ return new Text(callLine(args, theme), 0, 0);
121
+ },
122
+
123
+ renderResult(result, options, theme) {
124
+ return renderCodeResult(result, options, theme);
125
+ },
126
+ });
127
+ }
128
+
129
+ export function renderCodeResult(
130
+ result: { content: readonly unknown[]; details?: unknown },
131
+ { expanded, isPartial }: { expanded: boolean; isPartial: boolean },
132
+ theme: Theme,
133
+ ): Text {
134
+ if (isPartial) {
135
+ const progress = (result.details as { progress?: string } | undefined)?.progress;
136
+ return new Text(theme.fg("muted", progress ? `running… ${progress}` : "running…"), 0, 0);
137
+ }
138
+ if (result.details && typeof result.details === "object" && "infrastructureError" in result.details) {
139
+ const failure = result.details as { infrastructureError: string; diagnostics: Diagnostics };
140
+ return new Text(
141
+ [
142
+ theme.fg("error", failure.infrastructureError),
143
+ "",
144
+ ...diagnosticLines(failure.diagnostics).map((line) => theme.fg("muted", line)),
145
+ ].join("\n"),
146
+ 0,
147
+ 0,
148
+ );
149
+ }
150
+ const run = result.details as RunResult | undefined;
151
+ if (!run) return new Text(theme.fg("error", unstructuredResultText(result.content)), 0, 0);
152
+ return new Text(resultLines(run, expanded, theme).join("\n"), 0, 0);
153
+ }
@@ -1,141 +0,0 @@
1
- /** Reuse only pristine, never-mounted AgentFS databases; each run gets independent copies. */
2
- import { createHash } from "node:crypto";
3
- import * as fs from "node:fs/promises";
4
- import { constants } from "node:fs";
5
- import { homedir } from "node:os";
6
- import * as path from "node:path";
7
- import { $ } from "bun";
8
- import { Database } from "bun:sqlite";
9
-
10
- async function ownedDirectory(directory: string): Promise<void> {
11
- await fs.mkdir(directory, { recursive: true, mode: 0o700 });
12
- const stat = await fs.lstat(directory);
13
- if (!stat.isDirectory() || stat.isSymbolicLink() || stat.uid !== process.getuid?.() || stat.mode & 0o077)
14
- throw new Error(`Unsafe AgentFS template directory: ${directory}`);
15
- }
16
-
17
- async function templateKey(agentfs: string, repo: string): Promise<string> {
18
- const executable = await fs.stat(agentfs, { bigint: true });
19
- const repository = await fs.stat(repo, { bigint: true });
20
- // Do not tie the template to checkout contents: it contains schema and the base
21
- // path, not source files. Replacing either the executable or root invalidates it.
22
- return createHash("sha256")
23
- .update(
24
- JSON.stringify(
25
- [
26
- 1,
27
- process.getuid?.(),
28
- process.getgid?.(),
29
- agentfs,
30
- executable.dev,
31
- executable.ino,
32
- executable.size,
33
- executable.mtimeNs,
34
- executable.ctimeNs,
35
- repo,
36
- repository.dev,
37
- repository.ino,
38
- ],
39
- (_, value) => (typeof value === "bigint" ? String(value) : value),
40
- ),
41
- )
42
- .digest("hex");
43
- }
44
-
45
- async function copyTemplate(template: string, destination: string): Promise<void> {
46
- const directory = await fs.lstat(template);
47
- if (
48
- !directory.isDirectory() ||
49
- directory.isSymbolicLink() ||
50
- directory.uid !== process.getuid?.() ||
51
- directory.mode & 0o077
52
- )
53
- throw new Error("Unsafe AgentFS database template");
54
- const names = await fs.readdir(template);
55
- if (!names.includes("run.db")) throw new Error("Incomplete AgentFS database template");
56
- await fs.mkdir(destination, { recursive: true, mode: 0o700 });
57
- for (const name of names) {
58
- const source = path.join(template, name);
59
- const stat = await fs.lstat(source);
60
- if (
61
- !(name === "run.db" || name === "run.db-wal") ||
62
- !stat.isFile() ||
63
- stat.uid !== process.getuid?.() ||
64
- stat.mode & 0o077 ||
65
- stat.nlink !== 1
66
- )
67
- throw new Error("Unsafe AgentFS database template entry");
68
- // No hardlinks: serving a transaction must never mutate the template.
69
- await fs.copyFile(source, path.join(destination, name), constants.COPYFILE_EXCL);
70
- }
71
- }
72
-
73
- /** Inspect the private copy, so SQLite never opens or modifies the shared template. */
74
- function validateEmptyDatabase(file: string, repo: string): void {
75
- const db = new Database(file, { readonly: true, strict: true });
76
- try {
77
- const base = db.query("SELECT value FROM fs_overlay_config WHERE key = 'base_path'").get() as
78
- | { value: string }
79
- | undefined;
80
- const root = db.query("SELECT mode, uid, gid FROM fs_inode WHERE ino = 1").get() as
81
- | { mode: number; uid: number; gid: number }
82
- | undefined;
83
- if (
84
- base?.value !== repo ||
85
- !root ||
86
- (root.mode & 0o170000) !== 0o040000 ||
87
- root.uid !== process.getuid?.() ||
88
- root.gid !== process.getgid?.()
89
- )
90
- throw new Error("AgentFS template has the wrong base or root identity");
91
- for (const table of ["fs_dentry", "fs_whiteout", "fs_origin", "fs_data", "fs_symlink", "kv_store", "tool_calls"]) {
92
- const count = db.query(`SELECT COUNT(*) AS count FROM ${table}`).get() as { count: number };
93
- if (count.count !== 0) throw new Error(`AgentFS template contains private ${table} state`);
94
- }
95
- const inodes = db.query("SELECT COUNT(*) AS count FROM fs_inode").get() as { count: number };
96
- if (inodes.count !== 1) throw new Error("AgentFS template contains private inode state");
97
- } finally {
98
- db.close();
99
- }
100
- }
101
-
102
- export async function createAgentFsDatabase(
103
- agentfs: string,
104
- base: string,
105
- tempDir: string,
106
- cache = path.join(homedir(), ".cache", "pi-shorthand", "agentfs-templates"),
107
- ): Promise<string> {
108
- const executable = await fs.realpath(Bun.which(agentfs) ?? agentfs);
109
- const repo = await fs.realpath(base);
110
- await ownedDirectory(cache);
111
- const key = await templateKey(executable, repo);
112
- const template = path.join(cache, key);
113
- const existing = await fs.lstat(template).catch((error: NodeJS.ErrnoException) => {
114
- if (error.code !== "ENOENT") throw error;
115
- return null;
116
- });
117
- if (!existing) {
118
- const staging = await fs.mkdtemp(path.join(cache, ".initializing-"));
119
- try {
120
- await $`${executable} init run --base ${repo}`.cwd(staging).quiet();
121
- if (key !== (await templateKey(executable, repo)))
122
- throw new Error("AgentFS or repository changed during initialization");
123
- const database = path.join(staging, ".agentfs");
124
- await fs.chmod(database, 0o700);
125
- for (const name of await fs.readdir(database)) await fs.chmod(path.join(database, name), 0o600);
126
- // Publish only after the initializing process has exited, including its
127
- // database sidecars. Concurrent creators can safely use the winner.
128
- await fs.rename(database, template).catch((error: NodeJS.ErrnoException) => {
129
- if (error.code !== "EEXIST" && error.code !== "ENOTEMPTY") throw error;
130
- });
131
- } finally {
132
- await fs.rm(staging, { recursive: true, force: true });
133
- }
134
- }
135
- const destination = path.join(tempDir, ".agentfs");
136
- await copyTemplate(template, destination);
137
- validateEmptyDatabase(path.join(destination, "run.db"), repo);
138
- if (key !== (await templateKey(executable, repo)))
139
- throw new Error("AgentFS or repository changed while preparing the transaction database");
140
- return path.join(destination, "run.db");
141
- }
package/api.d.ts DELETED
@@ -1,15 +0,0 @@
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
- const refactor: ShorthandGlobals["refactor"];
12
- }
13
-
14
- export type { RewriteResult } from "./prelude.ts";
15
- export type { ShorthandGlobals };