Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
190 lines
6.9 KiB
TypeScript
190 lines
6.9 KiB
TypeScript
import { tool } from 'ai';
|
|
import { z } from 'zod';
|
|
|
|
const MAX_OUTPUT = 30_000;
|
|
const MAX_LOG = 40;
|
|
|
|
const cap = (s: string) =>
|
|
s.length <= MAX_OUTPUT ? s : `${s.slice(0, MAX_OUTPUT)}\n... [truncated ${s.length - MAX_OUTPUT} chars]`;
|
|
|
|
type GitResult = { ok: true; stdout: string } | { ok: false; message: string };
|
|
|
|
/**
|
|
* Runs git with an argument array, never a shell string.
|
|
*
|
|
* Arguments come from model output, so a shell would make `git log --author="; rm -rf /"`
|
|
* an injection. Spawning the binary directly with a fixed argv removes that entirely,
|
|
* which is also why these tools can be auto-approved.
|
|
*/
|
|
export async function git(args: string[], cwd: string, timeout = 30_000): Promise<GitResult> {
|
|
let proc: Bun.Subprocess<'ignore', 'pipe', 'pipe'>;
|
|
try {
|
|
proc = Bun.spawn(['git', ...args], { cwd, stdout: 'pipe', stderr: 'pipe', timeout });
|
|
} catch {
|
|
return { ok: false, message: 'git is not installed or not on PATH.' };
|
|
}
|
|
|
|
const [stdout, stderr, code] = await Promise.all([
|
|
new Response(proc.stdout).text(),
|
|
new Response(proc.stderr).text(),
|
|
proc.exited,
|
|
]);
|
|
|
|
if (code !== 0) {
|
|
const message = stderr.trim() || stdout.trim() || `git exited ${code}`;
|
|
if (/not a git repository/i.test(message)) {
|
|
return { ok: false, message: `${cwd} is not a git repository.` };
|
|
}
|
|
return { ok: false, message };
|
|
}
|
|
|
|
return { ok: true, stdout };
|
|
}
|
|
|
|
const run = async (args: string[], empty: string): Promise<string> => {
|
|
const result = await git(args, process.cwd());
|
|
if (!result.ok) throw new Error(result.message);
|
|
return cap(result.stdout.trim() || empty);
|
|
};
|
|
|
|
const STATUS_LABEL: Record<string, string> = {
|
|
M: 'modified',
|
|
A: 'added',
|
|
D: 'deleted',
|
|
R: 'renamed',
|
|
C: 'copied',
|
|
U: 'conflicted',
|
|
'?': 'untracked',
|
|
'!': 'ignored',
|
|
};
|
|
|
|
export const gitStatusTool = tool({
|
|
description:
|
|
'Working tree status: current branch, and which files are staged, modified, or untracked. ' +
|
|
'Use it before proposing a commit, and to see what you have changed so far.',
|
|
inputSchema: z.object({}),
|
|
execute: async () => {
|
|
const branch = await git(['rev-parse', '--abbrev-ref', 'HEAD'], process.cwd());
|
|
if (!branch.ok) throw new Error(branch.message);
|
|
|
|
const status = await git(['status', '--porcelain=v1'], process.cwd());
|
|
if (!status.ok) throw new Error(status.message);
|
|
|
|
const lines = status.stdout.split('\n').filter(Boolean);
|
|
if (lines.length === 0) return `On ${branch.stdout.trim()}, working tree clean.`;
|
|
|
|
// Porcelain v1 packs staged and unstaged state into two leading columns; naming
|
|
// them is the difference between the model understanding the state and guessing.
|
|
const described = lines.slice(0, 200).map((line) => {
|
|
const staged = line[0] ?? ' ';
|
|
const unstaged = line[1] ?? ' ';
|
|
const path = line.slice(3);
|
|
const parts: string[] = [];
|
|
if (staged !== ' ' && staged !== '?') parts.push(`staged ${STATUS_LABEL[staged] ?? staged}`);
|
|
if (unstaged !== ' ') parts.push(`${STATUS_LABEL[unstaged] ?? unstaged}`);
|
|
return `${path} (${parts.join(', ') || 'unknown'})`;
|
|
});
|
|
|
|
return cap([`On ${branch.stdout.trim()}, ${lines.length} changed:`, ...described].join('\n'));
|
|
},
|
|
});
|
|
|
|
export const gitDiffTool = tool({
|
|
description:
|
|
'Unified diff of uncommitted changes. Pass staged to see what is staged instead, or a path to narrow it. ' +
|
|
'Use it to review your own edits before claiming they are done.',
|
|
inputSchema: z.object({
|
|
staged: z.boolean().optional().describe('Diff the index against HEAD instead of the working tree'),
|
|
path: z.string().optional().describe('Limit the diff to one file or directory'),
|
|
}),
|
|
execute: async ({ staged, path }) => {
|
|
const args = ['diff', '--no-color'];
|
|
if (staged) args.push('--staged');
|
|
if (path) args.push('--', path);
|
|
return run(args, staged ? 'Nothing staged.' : 'No uncommitted changes.');
|
|
},
|
|
});
|
|
|
|
export const gitLogTool = tool({
|
|
description:
|
|
'Recent commits, newest first: short hash, date, author, subject. Pass a path to see only commits touching it. ' +
|
|
'Use it to find when something changed and who changed it.',
|
|
inputSchema: z.object({
|
|
limit: z.number().int().min(1).max(MAX_LOG).optional().describe(`Commits to return, default 15, max ${MAX_LOG}`),
|
|
path: z.string().optional().describe('Only commits touching this file or directory'),
|
|
}),
|
|
execute: async ({ limit, path }) => {
|
|
const args = ['log', `-n${limit ?? 15}`, '--date=short', '--pretty=format:%h %ad %an %s'];
|
|
if (path) args.push('--', path);
|
|
return run(args, 'No commits.');
|
|
},
|
|
});
|
|
|
|
export const gitShowTool = tool({
|
|
description:
|
|
'One commit in full: message, author, and its diff. Takes a hash, tag, or ref like HEAD~2. ' +
|
|
'Use it after git_log to see what a specific commit actually did.',
|
|
inputSchema: z.object({
|
|
ref: z.string().describe('Commit hash, tag, or ref'),
|
|
path: z.string().optional().describe('Limit the diff to one file'),
|
|
}),
|
|
execute: async ({ ref, path }) => {
|
|
const args = ['show', '--no-color', '--date=short', ref];
|
|
if (path) args.push('--', path);
|
|
return run(args, 'Nothing to show.');
|
|
},
|
|
});
|
|
|
|
export const gitBlameTool = tool({
|
|
description:
|
|
'Who last changed each line of a file, with the commit and date. Narrow with startLine and endLine. ' +
|
|
'Use it when a line looks wrong and its history explains why.',
|
|
inputSchema: z.object({
|
|
path: z.string().describe('File to blame'),
|
|
startLine: z.number().int().min(1).optional(),
|
|
endLine: z.number().int().min(1).optional(),
|
|
}),
|
|
execute: async ({ path, startLine, endLine }) => {
|
|
const args = ['blame', '--date=short', '-w'];
|
|
if (startLine) args.push('-L', `${startLine},${endLine ?? startLine + 40}`);
|
|
args.push('--', path);
|
|
return run(args, 'No blame output.');
|
|
},
|
|
});
|
|
|
|
export const gitBranchTool = tool({
|
|
description:
|
|
'Branches in this repository, newest commit first, with the current one marked. Pass remote to include ' +
|
|
'remote-tracking branches. Use it before proposing a branch name, so a name already taken is obvious.',
|
|
inputSchema: z.object({
|
|
remote: z.boolean().optional().describe('Include remote-tracking branches'),
|
|
}),
|
|
execute: async ({ remote }) => {
|
|
const args = [
|
|
'branch',
|
|
'--list',
|
|
'--sort=-committerdate',
|
|
'--format=%(if)%(HEAD)%(then)* %(else) %(end)%(refname:short) %(committerdate:short) %(contents:subject)',
|
|
];
|
|
if (remote) args.push('--all');
|
|
return run(args, 'No branches yet.');
|
|
},
|
|
});
|
|
|
|
export const gitTools = {
|
|
git_status: gitStatusTool,
|
|
git_diff: gitDiffTool,
|
|
git_log: gitLogTool,
|
|
git_show: gitShowTool,
|
|
git_blame: gitBlameTool,
|
|
git_branch: gitBranchTool,
|
|
};
|
|
|
|
/**
|
|
* Read-only, so none of these ever prompt for approval.
|
|
*
|
|
* `git_commit_message` is built in `src/commit.ts` and wired in `cli.tsx`, because it
|
|
* needs the model at construction. It belongs to this set for gating like the rest.
|
|
*/
|
|
export const GIT_TOOL_NAMES = [...Object.keys(gitTools), 'git_commit_message'];
|