@arnilo/prism-coding-agent 0.0.7 → 0.0.10

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.
@@ -0,0 +1,257 @@
1
+ /**
2
+ * Typed Git executable runner.
3
+ *
4
+ * Always uses argument arrays (never a shell), a noninteractive pager-safe
5
+ * environment, and finite stdout/stderr retention. Hosts may inject a custom
6
+ * runner (for example a sandbox `execFile` adapter) without changing tool code.
7
+ */
8
+ import { spawn } from "node:child_process";
9
+ import { access } from "node:fs/promises";
10
+ import { constants as fsConstants } from "node:fs";
11
+ import { isAbsolute } from "node:path";
12
+ import { DEFAULT_GIT_TIMEOUT_MS, DEFAULT_MAX_GIT_OUTPUT_BYTES, HARD_GIT_TIMEOUT_MS, HARD_MAX_GIT_OUTPUT_BYTES, validateCodingLimit, } from "./limits.js";
13
+ export class GitError extends Error {
14
+ code = "ERR_PRISM_GIT";
15
+ exitCode;
16
+ constructor(message, exitCode = null) {
17
+ super(message);
18
+ this.name = "GitError";
19
+ this.exitCode = exitCode;
20
+ }
21
+ }
22
+ /** Noninteractive, pager-safe, credential-prompt-free baseline for Git child processes. */
23
+ export const SAFE_GIT_ENV = Object.freeze({
24
+ PATH: "/usr/bin:/bin",
25
+ LANG: "C",
26
+ LC_ALL: "C",
27
+ GIT_TERMINAL_PROMPT: "0",
28
+ GIT_OPTIONAL_LOCKS: "0",
29
+ GIT_PAGER: "cat",
30
+ PAGER: "cat",
31
+ GCM_INTERACTIVE: "never",
32
+ GIT_CONFIG_NOSYSTEM: "1",
33
+ });
34
+ /** Config flags prepended to every git invocation to disable hooks/external helpers. */
35
+ export const SAFE_GIT_CONFIG_ARGS = Object.freeze([
36
+ "-c",
37
+ "core.hooksPath=/dev/null",
38
+ "-c",
39
+ "core.pager=cat",
40
+ "-c",
41
+ "sequence.editor=true",
42
+ "-c",
43
+ "credential.helper=",
44
+ "-c",
45
+ "advice.detachedHead=false",
46
+ ]);
47
+ export async function assertAbsoluteGit(path) {
48
+ if (!isAbsolute(path)) {
49
+ throw new GitError("gitPath must be an absolute executable path");
50
+ }
51
+ try {
52
+ await access(path, fsConstants.X_OK);
53
+ }
54
+ catch {
55
+ throw new GitError(`git executable is missing or not executable: ${path}`);
56
+ }
57
+ return path;
58
+ }
59
+ function mergeEnv(extra) {
60
+ return { ...SAFE_GIT_ENV, ...(extra ?? {}) };
61
+ }
62
+ /** Local spawn-based Git runner. Never invokes a shell. */
63
+ export async function runGitCli(request) {
64
+ if (request.signal?.aborted) {
65
+ throw new GitError("Git operation aborted before start");
66
+ }
67
+ for (const arg of request.args) {
68
+ if (typeof arg !== "string" || arg.includes("\0")) {
69
+ throw new GitError("Git args must be strings without NUL");
70
+ }
71
+ }
72
+ const maxOutputBytes = validateCodingLimit("maxOutputBytes", request.maxOutputBytes ?? DEFAULT_MAX_GIT_OUTPUT_BYTES, HARD_MAX_GIT_OUTPUT_BYTES);
73
+ const timeoutMs = validateCodingLimit("timeoutMs", request.timeoutMs ?? DEFAULT_GIT_TIMEOUT_MS, HARD_GIT_TIMEOUT_MS);
74
+ return await new Promise((resolve, reject) => {
75
+ let settled = false;
76
+ let timedOut = false;
77
+ let aborted = false;
78
+ let outputBytes = 0;
79
+ const stdoutChunks = [];
80
+ const stderrChunks = [];
81
+ let child;
82
+ let timer;
83
+ const cleanup = () => {
84
+ if (timer)
85
+ clearTimeout(timer);
86
+ request.signal?.removeEventListener("abort", onAbort);
87
+ };
88
+ const killTree = () => {
89
+ try {
90
+ if (child.pid)
91
+ process.kill(-child.pid, "SIGKILL");
92
+ }
93
+ catch {
94
+ try {
95
+ child.kill("SIGKILL");
96
+ }
97
+ catch {
98
+ /* ignore */
99
+ }
100
+ }
101
+ };
102
+ const finalize = (exitCode) => {
103
+ if (settled)
104
+ return;
105
+ settled = true;
106
+ cleanup();
107
+ resolve({
108
+ exitCode,
109
+ stdout: Buffer.concat(stdoutChunks),
110
+ stderr: Buffer.concat(stderrChunks),
111
+ timedOut,
112
+ aborted,
113
+ outputBytes,
114
+ });
115
+ };
116
+ const fail = (error) => {
117
+ if (settled)
118
+ return;
119
+ settled = true;
120
+ cleanup();
121
+ reject(error);
122
+ };
123
+ const onAbort = () => {
124
+ aborted = true;
125
+ killTree();
126
+ };
127
+ try {
128
+ child = spawn(request.gitPath, [...SAFE_GIT_CONFIG_ARGS, ...request.args], {
129
+ cwd: request.cwd,
130
+ env: mergeEnv(request.env),
131
+ stdio: ["pipe", "pipe", "pipe"],
132
+ detached: process.platform !== "win32",
133
+ windowsHide: true,
134
+ });
135
+ }
136
+ catch (error) {
137
+ const message = error instanceof Error ? error.message : String(error);
138
+ reject(new GitError(message));
139
+ return;
140
+ }
141
+ const track = (chunk, target) => {
142
+ outputBytes += chunk.length;
143
+ if (outputBytes > maxOutputBytes) {
144
+ killTree();
145
+ fail(new GitError(`Git output exceeded ${maxOutputBytes} byte limit`));
146
+ return;
147
+ }
148
+ target.push(chunk);
149
+ };
150
+ child.stdout.on("data", (chunk) => track(chunk, stdoutChunks));
151
+ child.stderr.on("data", (chunk) => track(chunk, stderrChunks));
152
+ child.on("error", (error) => fail(new GitError(error.message)));
153
+ child.on("close", (code) => finalize(code));
154
+ if (request.stdin !== undefined) {
155
+ const payload = typeof request.stdin === "string" ? Buffer.from(request.stdin) : request.stdin;
156
+ child.stdin.end(payload);
157
+ }
158
+ else {
159
+ child.stdin.end();
160
+ }
161
+ timer = setTimeout(() => {
162
+ timedOut = true;
163
+ killTree();
164
+ }, timeoutMs);
165
+ request.signal?.addEventListener("abort", onAbort, { once: true });
166
+ });
167
+ }
168
+ /**
169
+ * Resolve a bound Git runner from an absolute git path, custom runner, or sandbox execFile.
170
+ */
171
+ export async function createBoundGitRunner(options) {
172
+ const gitPath = await assertAbsoluteGit(options?.gitPath ?? "/usr/bin/git");
173
+ const maxOutputBytes = options?.maxOutputBytes;
174
+ const timeoutMs = options?.timeoutMs;
175
+ if (options?.runner) {
176
+ const custom = options.runner;
177
+ return {
178
+ gitPath,
179
+ exec: (request) => custom({
180
+ ...request,
181
+ gitPath,
182
+ maxOutputBytes: request.maxOutputBytes ?? maxOutputBytes,
183
+ timeoutMs: request.timeoutMs ?? timeoutMs,
184
+ }),
185
+ };
186
+ }
187
+ if (options?.execFile) {
188
+ const execFile = options.execFile;
189
+ return {
190
+ gitPath,
191
+ exec: async (request) => {
192
+ if (request.signal?.aborted)
193
+ throw new GitError("Git operation aborted before start");
194
+ const chunks = [];
195
+ let outputBytes = 0;
196
+ const limit = validateCodingLimit("maxOutputBytes", request.maxOutputBytes ?? maxOutputBytes ?? DEFAULT_MAX_GIT_OUTPUT_BYTES, HARD_MAX_GIT_OUTPUT_BYTES);
197
+ const args = [...SAFE_GIT_CONFIG_ARGS, ...request.args];
198
+ try {
199
+ const { exitCode } = await execFile({
200
+ file: gitPath,
201
+ args,
202
+ cwd: request.cwd,
203
+ env: mergeEnv(request.env),
204
+ signal: request.signal,
205
+ timeout: request.timeoutMs ?? timeoutMs ?? DEFAULT_GIT_TIMEOUT_MS,
206
+ onData: (data) => {
207
+ outputBytes += data.length;
208
+ if (outputBytes > limit) {
209
+ throw new GitError(`Git output exceeded ${limit} byte limit`);
210
+ }
211
+ chunks.push(data);
212
+ },
213
+ });
214
+ return {
215
+ exitCode,
216
+ stdout: Buffer.concat(chunks),
217
+ stderr: Buffer.alloc(0),
218
+ timedOut: false,
219
+ aborted: false,
220
+ outputBytes,
221
+ };
222
+ }
223
+ catch (error) {
224
+ if (error instanceof GitError)
225
+ throw error;
226
+ const message = error instanceof Error ? error.message : String(error);
227
+ throw new GitError(message);
228
+ }
229
+ },
230
+ };
231
+ }
232
+ return {
233
+ gitPath,
234
+ exec: (request) => runGitCli({
235
+ ...request,
236
+ gitPath,
237
+ maxOutputBytes: request.maxOutputBytes ?? maxOutputBytes,
238
+ timeoutMs: request.timeoutMs ?? timeoutMs,
239
+ }),
240
+ };
241
+ }
242
+ export function gitText(result, stream = "stdout") {
243
+ return (stream === "stdout" ? result.stdout : result.stderr).toString("utf8");
244
+ }
245
+ export async function gitRequireOk(runner, request, label) {
246
+ const result = await runner.exec(request);
247
+ if (result.timedOut)
248
+ throw new GitError(`${label} timed out`, result.exitCode);
249
+ if (result.aborted)
250
+ throw new GitError(`${label} aborted`, result.exitCode);
251
+ if (result.exitCode !== 0) {
252
+ const err = gitText(result, "stderr").trim() || gitText(result).trim() || `exit ${result.exitCode}`;
253
+ throw new GitError(`${label} failed: ${err}`, result.exitCode);
254
+ }
255
+ return result;
256
+ }
257
+ //# sourceMappingURL=git-exec.js.map
@@ -0,0 +1,30 @@
1
+ export type GitStatusEntryKind = "ordinary" | "rename" | "copy" | "unmerged" | "untracked" | "ignored";
2
+ export interface GitStatusBranch {
3
+ readonly oid: string | null;
4
+ readonly head: string | null;
5
+ readonly detached: boolean;
6
+ readonly upstream: string | null;
7
+ readonly ahead: number | null;
8
+ readonly behind: number | null;
9
+ readonly initial: boolean;
10
+ }
11
+ export interface GitStatusEntry {
12
+ readonly kind: GitStatusEntryKind;
13
+ readonly xy: string;
14
+ readonly path: string;
15
+ readonly origPath?: string;
16
+ readonly score?: string;
17
+ }
18
+ export interface GitStatusResult {
19
+ readonly branch: GitStatusBranch;
20
+ readonly entries: readonly GitStatusEntry[];
21
+ readonly dirty: boolean;
22
+ readonly truncated: boolean;
23
+ }
24
+ /**
25
+ * Parse porcelain v2 NUL-delimited status. `maxEntries` truncates retained
26
+ * entries without failing; callers surface truncation in tool metadata.
27
+ */
28
+ export declare function parsePorcelainV2(stdout: Buffer, options?: {
29
+ maxEntries?: number;
30
+ }): GitStatusResult;
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Parse `git status --porcelain=v2 -z --branch` output into structured records.
3
+ *
4
+ * Paths remain repository-relative as emitted by Git. Leading-dash and control
5
+ * characters are preserved as data; callers must pass paths after `--`.
6
+ */
7
+ import { GitError } from "./git-exec.js";
8
+ function splitNulRecords(buffer) {
9
+ const text = buffer.toString("utf8");
10
+ if (text.length === 0)
11
+ return [];
12
+ const parts = text.split("\0");
13
+ if (parts.length > 0 && parts[parts.length - 1] === "")
14
+ parts.pop();
15
+ return parts;
16
+ }
17
+ function parseAheadBehind(token) {
18
+ if (!token)
19
+ return { ahead: null, behind: null };
20
+ // Format: +<ahead> -<behind>
21
+ const match = /^\+(\d+) -(\d+)$/.exec(token);
22
+ if (!match)
23
+ return { ahead: null, behind: null };
24
+ return { ahead: Number(match[1]), behind: Number(match[2]) };
25
+ }
26
+ /**
27
+ * Parse porcelain v2 NUL-delimited status. `maxEntries` truncates retained
28
+ * entries without failing; callers surface truncation in tool metadata.
29
+ */
30
+ export function parsePorcelainV2(stdout, options) {
31
+ const records = splitNulRecords(stdout);
32
+ const branch = {
33
+ oid: null,
34
+ head: null,
35
+ detached: false,
36
+ upstream: null,
37
+ ahead: null,
38
+ behind: null,
39
+ initial: false,
40
+ };
41
+ const entries = [];
42
+ const maxEntries = options?.maxEntries;
43
+ let truncated = false;
44
+ let i = 0;
45
+ while (i < records.length) {
46
+ const record = records[i];
47
+ i++;
48
+ if (record.startsWith("# ")) {
49
+ const body = record.slice(2);
50
+ if (body.startsWith("branch.oid ")) {
51
+ const oid = body.slice("branch.oid ".length);
52
+ if (oid === "(initial)") {
53
+ branch.initial = true;
54
+ branch.oid = null;
55
+ }
56
+ else {
57
+ branch.oid = oid;
58
+ }
59
+ }
60
+ else if (body.startsWith("branch.head ")) {
61
+ const head = body.slice("branch.head ".length);
62
+ if (head === "(detached)") {
63
+ branch.detached = true;
64
+ branch.head = null;
65
+ }
66
+ else {
67
+ branch.head = head;
68
+ }
69
+ }
70
+ else if (body.startsWith("branch.upstream ")) {
71
+ branch.upstream = body.slice("branch.upstream ".length);
72
+ }
73
+ else if (body.startsWith("branch.ab ")) {
74
+ const ab = parseAheadBehind(body.slice("branch.ab ".length));
75
+ branch.ahead = ab.ahead;
76
+ branch.behind = ab.behind;
77
+ }
78
+ continue;
79
+ }
80
+ if (maxEntries !== undefined && entries.length >= maxEntries) {
81
+ truncated = true;
82
+ continue;
83
+ }
84
+ if (record.startsWith("? ")) {
85
+ entries.push({ kind: "untracked", xy: "??", path: record.slice(2) });
86
+ continue;
87
+ }
88
+ if (record.startsWith("! ")) {
89
+ entries.push({ kind: "ignored", xy: "!!", path: record.slice(2) });
90
+ continue;
91
+ }
92
+ if (record.startsWith("1 ")) {
93
+ // 1 <XY> <sub> <mH> <mI> <mW> <hH> <hI> <path>
94
+ const parts = record.split(" ");
95
+ if (parts.length < 9)
96
+ throw new GitError(`malformed ordinary status record: ${record}`);
97
+ const xy = parts[1];
98
+ const path = parts.slice(8).join(" ");
99
+ entries.push({ kind: "ordinary", xy, path });
100
+ continue;
101
+ }
102
+ if (record.startsWith("2 ")) {
103
+ // 2 <XY> <sub> <mH> <mI> <mW> <hH> <hI> <X><score> <path>\0<origPath>
104
+ const parts = record.split(" ");
105
+ if (parts.length < 10)
106
+ throw new GitError(`malformed rename/copy status record: ${record}`);
107
+ const xy = parts[1];
108
+ const scoreToken = parts[8];
109
+ const path = parts.slice(9).join(" ");
110
+ const origPath = records[i];
111
+ if (origPath === undefined)
112
+ throw new GitError("rename/copy status missing origPath");
113
+ i++;
114
+ const kind = scoreToken.startsWith("C") ? "copy" : "rename";
115
+ entries.push({ kind, xy, path, origPath, score: scoreToken });
116
+ continue;
117
+ }
118
+ if (record.startsWith("u ")) {
119
+ // u <XY> <sub> <m1> <m2> <m3> <mW> <h1> <h2> <h3> <path>
120
+ const parts = record.split(" ");
121
+ if (parts.length < 11)
122
+ throw new GitError(`malformed unmerged status record: ${record}`);
123
+ const xy = parts[1];
124
+ const path = parts.slice(10).join(" ");
125
+ entries.push({ kind: "unmerged", xy, path });
126
+ continue;
127
+ }
128
+ throw new GitError(`unrecognized porcelain v2 record: ${record.slice(0, 80)}`);
129
+ }
130
+ const dirty = entries.some((entry) => entry.kind !== "ignored");
131
+ return {
132
+ branch: {
133
+ oid: branch.oid,
134
+ head: branch.head,
135
+ detached: branch.detached,
136
+ upstream: branch.upstream,
137
+ ahead: branch.ahead,
138
+ behind: branch.behind,
139
+ initial: branch.initial,
140
+ },
141
+ entries,
142
+ dirty,
143
+ truncated,
144
+ };
145
+ }
146
+ //# sourceMappingURL=git-status.js.map
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Structured Git tool factories and aggregator.
3
+ *
4
+ * Tools cover status, diff, branch, worktree, apply, commit, and PR handoff.
5
+ * Shell is never used internally; all Git invocations go through typed arg arrays.
6
+ */
7
+ import type { ExecutionPolicy, ToolDefinition } from "@arnilo/prism";
8
+ import { type ArtifactWriter, type CreateGitOperationsOptions, type GitOperations } from "./git.js";
9
+ import { type CodingCheckToolOptions, type NamedCheckDefinition } from "./checks.js";
10
+ export interface GitToolsOptions {
11
+ readonly executionPolicy?: ExecutionPolicy;
12
+ readonly gitPath?: string;
13
+ readonly execFile?: CreateGitOperationsOptions["execFile"];
14
+ readonly runner?: CreateGitOperationsOptions["runner"];
15
+ readonly artifactWriter?: ArtifactWriter;
16
+ readonly commitIdentity?: CreateGitOperationsOptions["commitIdentity"];
17
+ readonly limits?: CreateGitOperationsOptions;
18
+ readonly operations?: GitOperations;
19
+ /** Optional named checks included by `createGitTools` when provided. */
20
+ readonly checks?: Readonly<Record<string, NamedCheckDefinition>>;
21
+ readonly checkOptions?: Omit<CodingCheckToolOptions, "checks" | "executionPolicy">;
22
+ }
23
+ export declare function createGitStatusTool(cwd: string, options?: GitToolsOptions): ToolDefinition;
24
+ export declare function createGitDiffTool(cwd: string, options?: GitToolsOptions): ToolDefinition;
25
+ export declare function createGitBranchTool(cwd: string, options?: GitToolsOptions): ToolDefinition;
26
+ export declare function createGitWorktreeTool(cwd: string, options?: GitToolsOptions): ToolDefinition;
27
+ export declare function createGitApplyTool(cwd: string, options?: GitToolsOptions): ToolDefinition;
28
+ export declare function createGitCommitTool(cwd: string, options?: GitToolsOptions): ToolDefinition;
29
+ export declare function createGitPrHandoffTool(cwd: string, options?: GitToolsOptions): ToolDefinition;
30
+ /**
31
+ * Structured Git tool set. Optionally appends `coding_check` when `checks` are declared.
32
+ * Not included in `createCodingTools()` — hosts opt in explicitly.
33
+ */
34
+ export declare function createGitTools(cwd: string, options?: GitToolsOptions): readonly ToolDefinition[];