Skip to content

Commit 3cd0b00

Browse files
shreyas-lyzrclaude
andcommitted
Add local repo mode with session branches
Adds `--repo <url> --pat <token>` to auto-clone a GitHub repo locally, create a session branch (gitclaw/session-<id>), run the agent, auto-commit changes, and push the session branch. Supports resuming sessions via `--session <branch>`. Works for both CLI and SDK (`query({ repo: {...} })`). - New: src/session.ts — session lifecycle (clone, branch, commit, push, finalize) - Modified: src/sdk-types.ts — LocalRepoOptions type, repo field on QueryOptions - Modified: src/index.ts — --repo, --pat, --session CLI flags with finalize on exit - Modified: src/sdk.ts — repo option support with finalize on success/error - Modified: src/exports.ts — export LocalRepoOptions, LocalSession, initLocalSession - New: examples/local-repo.ts — SDK usage example Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 6f91aac commit 3cd0b00

7 files changed

Lines changed: 329 additions & 8 deletions

File tree

examples/local-repo.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { query } from "../dist/exports.js";
2+
3+
/**
4+
* Local Repo Mode — clone a GitHub repo, run an agent on it,
5+
* auto-commit changes, and push to a session branch.
6+
*
7+
* Usage:
8+
* GITHUB_TOKEN=ghp_xxx npx tsx examples/local-repo.ts
9+
*/
10+
11+
const REPO_URL = "https://github.com/shreyas-lyzr/agent-designer";
12+
const TOKEN = process.env.GITHUB_TOKEN || process.env.GIT_TOKEN || "";
13+
14+
if (!TOKEN) {
15+
console.error("Set GITHUB_TOKEN or GIT_TOKEN env var");
16+
process.exit(1);
17+
}
18+
19+
async function main() {
20+
console.log("Starting local repo session...\n");
21+
22+
const stream = query({
23+
prompt: "Read the README and summarize what this project does.",
24+
model: "openai:gpt-4o-mini",
25+
repo: {
26+
url: REPO_URL,
27+
token: TOKEN,
28+
// dir: "/tmp/my-custom-dir", // optional — defaults to cwd
29+
// session: "gitclaw/session-abc123", // resume an existing session
30+
},
31+
});
32+
33+
for await (const msg of stream) {
34+
switch (msg.type) {
35+
case "delta":
36+
process.stdout.write(msg.content);
37+
break;
38+
case "assistant":
39+
console.log(`\n\n[done] model=${msg.model} tokens=${msg.usage?.totalTokens}`);
40+
break;
41+
case "tool_use":
42+
console.log(`\n[tool_use] ${msg.toolName}(${JSON.stringify(msg.args)})`);
43+
break;
44+
case "tool_result":
45+
console.log(`[tool_result] ${msg.content.slice(0, 200)}`);
46+
break;
47+
case "system":
48+
console.log(`[${msg.subtype}] ${msg.content}`);
49+
break;
50+
}
51+
}
52+
53+
console.log("\nSession complete — changes committed and pushed to session branch.");
54+
}
55+
56+
main().catch(console.error);

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "gitclaw",
3-
"version": "0.2.0",
3+
"version": "0.3.0",
44
"description": "A universal git-native agent powered by pi-agent-core",
55
"type": "module",
66
"main": "./dist/exports.js",

src/exports.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export { query, tool } from "./sdk.js";
55
export type {
66
Query,
77
QueryOptions,
8+
LocalRepoOptions,
89
SandboxOptions,
910
GCMessage,
1011
GCAssistantMessage,
@@ -32,5 +33,9 @@ export type { EnvConfig } from "./config.js";
3233
export type { SandboxConfig, SandboxContext } from "./sandbox.js";
3334
export { createSandboxContext } from "./sandbox.js";
3435

36+
// Session
37+
export type { LocalSession } from "./session.js";
38+
export { initLocalSession } from "./session.js";
39+
3540
// Loader (escape hatch)
3641
export { loadAgent } from "./loader.js";

src/index.ts

Lines changed: 67 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ import { formatComplianceWarnings } from "./compliance.js";
1616
import { readFile, mkdir, writeFile, stat, access } from "fs/promises";
1717
import { join, resolve } from "path";
1818
import { execSync } from "child_process";
19+
import { initLocalSession } from "./session.js";
20+
import type { LocalSession } from "./session.js";
1921

2022
// ANSI helpers
2123
const dim = (s: string) => `\x1b[2m${s}\x1b[0m`;
@@ -31,6 +33,9 @@ interface ParsedArgs {
3133
sandbox?: boolean;
3234
sandboxRepo?: string;
3335
sandboxToken?: string;
36+
repo?: string;
37+
pat?: string;
38+
session?: string;
3439
}
3540

3641
function parseArgs(argv: string[]): ParsedArgs {
@@ -42,6 +47,9 @@ function parseArgs(argv: string[]): ParsedArgs {
4247
let sandbox = false;
4348
let sandboxRepo: string | undefined;
4449
let sandboxToken: string | undefined;
50+
let repo: string | undefined;
51+
let pat: string | undefined;
52+
let session: string | undefined;
4553

4654
for (let i = 0; i < args.length; i++) {
4755
switch (args[i]) {
@@ -71,6 +79,16 @@ function parseArgs(argv: string[]): ParsedArgs {
7179
case "--sandbox-token":
7280
sandboxToken = args[++i];
7381
break;
82+
case "--repo":
83+
case "-r":
84+
repo = args[++i];
85+
break;
86+
case "--pat":
87+
pat = args[++i];
88+
break;
89+
case "--session":
90+
session = args[++i];
91+
break;
7492
default:
7593
if (!args[i].startsWith("-")) {
7694
prompt = args[i];
@@ -79,7 +97,7 @@ function parseArgs(argv: string[]): ParsedArgs {
7997
}
8098
}
8199

82-
return { model, dir, prompt, env, sandbox, sandboxRepo, sandboxToken };
100+
return { model, dir, prompt, env, sandbox, sandboxRepo, sandboxToken, repo, pat, session };
83101
}
84102

85103
function handleEvent(
@@ -257,11 +275,41 @@ async function ensureRepo(dir: string, model?: string): Promise<string> {
257275
}
258276

259277
async function main(): Promise<void> {
260-
const { model, dir: rawDir, prompt, env, sandbox: useSandbox, sandboxRepo, sandboxToken } = parseArgs(process.argv);
278+
const { model, dir: rawDir, prompt, env, sandbox: useSandbox, sandboxRepo, sandboxToken, repo, pat, session: sessionBranch } = parseArgs(process.argv);
261279

262-
// If no --dir given interactively, ask for it
280+
// If --repo is given, derive a default dir from the repo URL (skip interactive prompt)
263281
let dir = rawDir;
264-
if (dir === process.cwd() && !prompt) {
282+
let localSession: LocalSession | undefined;
283+
284+
if (repo) {
285+
// Validate mutually exclusive flags
286+
if (useSandbox) {
287+
console.error(red("Error: --repo and --sandbox are mutually exclusive"));
288+
process.exit(1);
289+
}
290+
291+
const token = pat || process.env.GITHUB_TOKEN || process.env.GIT_TOKEN;
292+
if (!token) {
293+
console.error(red("Error: --pat, GITHUB_TOKEN, or GIT_TOKEN is required with --repo"));
294+
process.exit(1);
295+
}
296+
297+
// Default dir: /tmp/gitclaw/<repo-name> if no --dir given
298+
if (dir === process.cwd()) {
299+
const repoName = repo.split("/").pop()?.replace(/\.git$/, "") || "repo";
300+
dir = resolve(`/tmp/gitclaw/${repoName}`);
301+
}
302+
303+
localSession = initLocalSession({
304+
url: repo,
305+
token,
306+
dir,
307+
session: sessionBranch,
308+
});
309+
dir = localSession.dir;
310+
console.log(dim(`Local session: ${localSession.branch} (${localSession.dir})`));
311+
} else if (dir === process.cwd() && !prompt) {
312+
// No --repo: interactive prompt for dir
265313
const answer = await askQuestion(green("? ") + bold("Repository path") + dim(" (. for current dir)") + green(": "));
266314
if (answer) {
267315
dir = resolve(answer === "." ? process.cwd() : answer);
@@ -282,8 +330,10 @@ async function main(): Promise<void> {
282330
console.log(dim(`Sandbox ready (repo: ${sandboxCtx.repoPath})`));
283331
}
284332

285-
// Ensure the target is a valid gitclaw repo (skip in sandbox mode — gitmachine clones the repo)
286-
if (!useSandbox) {
333+
// Ensure the target is a valid gitclaw repo (skip in sandbox/local-repo mode)
334+
if (localSession) {
335+
// Already cloned and scaffolded by initLocalSession
336+
} else if (!useSandbox) {
287337
dir = await ensureRepo(dir, model);
288338
} else {
289339
dir = resolve(dir);
@@ -420,6 +470,10 @@ async function main(): Promise<void> {
420470
}
421471
throw err;
422472
} finally {
473+
if (localSession) {
474+
console.log(dim("Finalizing session..."));
475+
localSession.finalize();
476+
}
423477
if (sandboxCtx) {
424478
console.log(dim("Stopping sandbox..."));
425479
await sandboxCtx.gitMachine.stop();
@@ -445,6 +499,10 @@ async function main(): Promise<void> {
445499

446500
if (trimmed === "/quit" || trimmed === "/exit") {
447501
rl.close();
502+
if (localSession) {
503+
console.log(dim("Finalizing session..."));
504+
localSession.finalize();
505+
}
448506
await stopSandbox();
449507
process.exit(0);
450508
}
@@ -523,6 +581,9 @@ async function main(): Promise<void> {
523581
} else {
524582
console.log("\nBye!");
525583
rl.close();
584+
if (localSession) {
585+
try { localSession.finalize(); } catch { /* best-effort */ }
586+
}
526587
stopSandbox().finally(() => process.exit(0));
527588
}
528589
});

src/sdk-types.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,15 @@ export interface GCToolDefinition {
100100
handler: (args: any, signal?: AbortSignal) => Promise<string | { text: string; details?: any }>;
101101
}
102102

103+
// ── Local repo options ──────────────────────────────────────────────────
104+
105+
export interface LocalRepoOptions {
106+
url: string;
107+
token: string;
108+
dir?: string;
109+
session?: string;
110+
}
111+
103112
// ── Sandbox options ─────────────────────────────────────────────────────
104113

105114
export interface SandboxOptions {
@@ -126,6 +135,7 @@ export interface QueryOptions {
126135
replaceBuiltinTools?: boolean;
127136
allowedTools?: string[];
128137
disallowedTools?: string[];
138+
repo?: LocalRepoOptions;
129139
sandbox?: SandboxOptions | boolean;
130140
hooks?: GCHooks;
131141
maxTurns?: number;

src/sdk.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import { loadHooksConfig, runHooks, wrapToolWithHooks } from "./hooks.js";
1010
import { loadDeclarativeTools } from "./tool-loader.js";
1111
import { buildTypeboxSchema } from "./tool-loader.js";
1212
import { wrapToolWithProgrammaticHooks } from "./sdk-hooks.js";
13+
import { initLocalSession } from "./session.js";
14+
import type { LocalSession } from "./session.js";
1315
import type {
1416
GCMessage,
1517
GCAssistantMessage,
@@ -120,10 +122,32 @@ export function query(options: QueryOptions): Query {
120122

121123
// Sandbox context (hoisted for cleanup in catch)
122124
let sandboxCtx: SandboxContext | undefined;
125+
// Local session (hoisted for cleanup in catch)
126+
let localSession: LocalSession | undefined;
123127

124128
// Async initialization + run
125129
const runPromise = (async () => {
126-
const dir = options.dir ?? process.cwd();
130+
// Validate mutually exclusive options
131+
if (options.repo && options.sandbox) {
132+
throw new Error("repo and sandbox options are mutually exclusive");
133+
}
134+
135+
let dir = options.dir ?? process.cwd();
136+
137+
// Local repo mode
138+
if (options.repo) {
139+
const token = options.repo.token || process.env.GITHUB_TOKEN || process.env.GIT_TOKEN;
140+
if (!token) {
141+
throw new Error("repo.token, GITHUB_TOKEN, or GIT_TOKEN is required with repo option");
142+
}
143+
localSession = initLocalSession({
144+
url: options.repo.url,
145+
token,
146+
dir: options.repo.dir || dir,
147+
session: options.repo.session,
148+
});
149+
dir = localSession.dir;
150+
}
127151

128152
// 1. Load agent
129153
const loaded = await loadAgent(dir, options.model, options.env);
@@ -397,6 +421,11 @@ export function query(options: QueryOptions): Query {
397421
}
398422
}
399423

424+
// Finalize local session if active
425+
if (localSession) {
426+
try { localSession.finalize(); } catch { /* best-effort */ }
427+
}
428+
400429
// Stop sandbox if active
401430
if (sandboxCtx) {
402431
await sandboxCtx.gitMachine.stop().catch(() => {});
@@ -405,6 +434,11 @@ export function query(options: QueryOptions): Query {
405434
// Ensure channel finishes even if no agent_end event
406435
channel.finish();
407436
})().catch(async (err) => {
437+
// Finalize local session on error
438+
if (localSession) {
439+
try { localSession.finalize(); } catch { /* best-effort */ }
440+
}
441+
408442
// Stop sandbox on error
409443
if (sandboxCtx) {
410444
await sandboxCtx.gitMachine.stop().catch(() => {});

0 commit comments

Comments
 (0)