Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions my-project/SOUL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Identity

You are a helpful AI agent. You live inside a git repository.
You can run commands, read and write files, and remember things.
Be concise and action-oriented.
10 changes: 10 additions & 0 deletions my-project/agent.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
spec_version: "0.1.0"
name: C:\Users\Vivan Rajath\Desktop\gitclaw-lyzr\gitclaw\my-project
version: 0.1.0
description: Gitclaw agent for C:\Users\Vivan Rajath\Desktop\gitclaw-lyzr\gitclaw\my-project
model:
preferred: "groq:llama3-70b-8192"
fallback: []
tools: [cli, read, write, memory]
runtime:
max_turns: 50
1 change: 1 addition & 0 deletions my-project/memory/MEMORY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Memory
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
}
},
"scripts": {
"build": "tsc && cp src/voice/ui.html dist/voice/",
"build": "tsc && node -e \"require('fs').cpSync('src/voice/ui.html','dist/voice/ui.html')\"",
"dev": "tsc --watch",
"start": "node dist/index.js",
"test": "node --test test/*.test.ts --experimental-strip-types"
Expand Down
9 changes: 6 additions & 3 deletions src/hooks.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { spawn } from "child_process";
import { readFile } from "fs/promises";
import { join, resolve } from "path";
import { join, resolve, sep } from "path";
import yaml from "js-yaml";
import type { AgentTool } from "@mariozechner/pi-agent-core";

Expand Down Expand Up @@ -63,12 +63,15 @@ async function executeHook(
// Path traversal guard: ensure script doesn't escape its base directory
const resolvedScript = resolve(scriptPath);
const allowedBase = resolve(baseDir);
if (!resolvedScript.startsWith(allowedBase + "/") && resolvedScript !== allowedBase) {
if (!resolvedScript.startsWith(allowedBase + sep) && resolvedScript !== allowedBase) {
reject(new Error(`Hook "${hook.script}" escapes its base directory`));
return;
}

const child = spawn("sh", [resolvedScript], {
const isWin = process.platform === "win32";
const shell = isWin ? "cmd" : "sh";
const shellArgs = isWin ? ["/c", resolvedScript] : [resolvedScript];
const child = spawn(shell, shellArgs, {
cwd: baseDir,
stdio: ["pipe", "pipe", "pipe"],
env: { ...process.env },
Expand Down
26 changes: 18 additions & 8 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { formatComplianceWarnings } from "./compliance.js";
import { readFile, mkdir, writeFile, stat, access } from "fs/promises";
import { existsSync, readFileSync } from "fs";
import { join, resolve } from "path";
import { execSync } from "child_process";
import { execFileSync } from "child_process";
import { initLocalSession } from "./session.js";
import type { LocalSession } from "./session.js";
import { startVoiceServer } from "./voice/server.js";
Expand Down Expand Up @@ -160,6 +160,12 @@ function handleEvent(
break;
}
case "agent_end":
if (event.messages && event.messages.length > 0) {
const last = event.messages[event.messages.length - 1];
if (last.role === "assistant" && last.errorMessage) {
process.stdout.write(red(`\nError: ${last.errorMessage}\n`));
}
}
break;
}
}
Expand Down Expand Up @@ -190,7 +196,7 @@ function askQuestion(question: string): Promise<string> {

function isGitRepo(dir: string): boolean {
try {
execSync("git rev-parse --is-inside-work-tree", { cwd: dir, stdio: "pipe" });
execFileSync("git", ["rev-parse", "--is-inside-work-tree"], { cwd: dir, stdio: "pipe" });
return true;
} catch {
return false;
Expand Down Expand Up @@ -218,7 +224,7 @@ async function ensureRepo(dir: string, model?: string): Promise<string> {
// Git init if not a repo
if (!isGitRepo(absDir)) {
console.log(dim("Initializing git repository..."));
execSync("git init", { cwd: absDir, stdio: "pipe" });
execFileSync("git", ["init"], { cwd: absDir, stdio: "pipe" });

// Create .gitignore
const gitignorePath = join(absDir, ".gitignore");
Expand All @@ -227,7 +233,8 @@ async function ensureRepo(dir: string, model?: string): Promise<string> {
}

// Initial commit so memory saves work
execSync("git add -A && git commit -m 'Initial commit' --allow-empty", {
execFileSync("git", ["add", "-A"], { cwd: absDir, stdio: "pipe" });
execFileSync("git", ["commit", "-m", "Initial commit", "--allow-empty"], {
cwd: absDir,
stdio: "pipe",
});
Expand Down Expand Up @@ -284,10 +291,13 @@ async function ensureRepo(dir: string, model?: string): Promise<string> {

// Stage new scaffolded files
try {
execSync("git add -A && git diff --cached --quiet || git commit -m 'Scaffold gitclaw agent'", {
cwd: absDir,
stdio: "pipe",
});
execFileSync("git", ["add", "-A"], { cwd: absDir, stdio: "pipe" });
try {
execFileSync("git", ["diff", "--cached", "--quiet"], { cwd: absDir, stdio: "pipe" });
} catch {
// There are staged changes — commit them
execFileSync("git", ["commit", "-m", "Scaffold gitclaw agent"], { cwd: absDir, stdio: "pipe" });
}
} catch {
// ok if nothing to commit
}
Expand Down
8 changes: 4 additions & 4 deletions src/loader.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { readFile, mkdir, writeFile } from "fs/promises";
import { join } from "path";
import { randomUUID } from "crypto";
import { execSync } from "child_process";
import { execFileSync } from "child_process";
import { getModel } from "@mariozechner/pi-ai";
import type { Model } from "@mariozechner/pi-ai";
import yaml from "js-yaml";
Expand Down Expand Up @@ -158,7 +158,7 @@ async function resolveInheritance(
const parentDir = join(depsDir, parentName);

try {
execSync(`git clone --depth 1 "${manifest.extends}" "${parentDir}" 2>/dev/null || true`, {
execFileSync("git", ["clone", "--depth", "1", manifest.extends, parentDir], {
cwd: agentDir,
stdio: "pipe",
});
Expand Down Expand Up @@ -204,8 +204,8 @@ async function resolveDependencies(
for (const dep of manifest.dependencies) {
const depDir = join(depsDir, dep.name);
try {
execSync(
`git clone --depth 1 --branch "${dep.version}" "${dep.source}" "${depDir}" 2>/dev/null || true`,
execFileSync(
"git", ["clone", "--depth", "1", "--branch", dep.version, dep.source, depDir],
{ cwd: agentDir, stdio: "pipe" },
);
} catch {
Expand Down
1 change: 0 additions & 1 deletion src/plugin-cli.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { readFile, writeFile, mkdir, rm, cp, stat } from "fs/promises";
import { join, resolve } from "path";
import { execSync } from "child_process";
import yaml from "js-yaml";
// "yaml" (v2) is used here instead of js-yaml because parseDocument()
// preserves comments and formatting when editing agent.yaml.
Expand Down
5 changes: 2 additions & 3 deletions src/sandbox.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { execSync } from "child_process";
import { execFileSync } from "child_process";

// ── Types ───────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -31,8 +31,7 @@ export interface SandboxContext {

function detectRepoUrl(dir: string): string | null {
try {
return execSync("git remote get-url origin", { cwd: dir, stdio: "pipe" })
.toString()
return execFileSync("git", ["remote", "get-url", "origin"], { cwd: dir, stdio: "pipe", encoding: "utf-8" })
.trim();
} catch {
return null;
Expand Down
42 changes: 23 additions & 19 deletions src/session.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { execSync } from "child_process";
import { execFileSync } from "child_process";
import { existsSync, mkdirSync, writeFileSync } from "fs";
import { resolve } from "path";
import { randomBytes } from "crypto";
Expand Down Expand Up @@ -32,19 +32,23 @@ function cleanUrl(url: string): string {
return url.replace(/^https:\/\/[^@]+@/, "https://");
}

function git(args: string, cwd: string): string {
return execSync(`git ${args}`, { cwd, stdio: "pipe", encoding: "utf-8" }).trim();
/**
* Safe git execution using argument arrays to prevent command injection.
* Inputs are passed as separate arguments and never interpreted by a shell.
*/
function git(args: string[], cwd: string): string {
return execFileSync("git", args, { cwd, stdio: "pipe", encoding: "utf-8" }).trim();
}

function getDefaultBranch(cwd: string): string {
try {
// e.g. "origin/main" → "main"
const ref = git("symbolic-ref refs/remotes/origin/HEAD", cwd);
const ref = git(["symbolic-ref", "refs/remotes/origin/HEAD"], cwd);
return ref.replace("refs/remotes/origin/", "");
} catch {
// Fallback: try main, then master
try {
git("rev-parse --verify origin/main", cwd);
git(["rev-parse", "--verify", "origin/main"], cwd);
return "main";
} catch {
return "master";
Expand All @@ -61,15 +65,15 @@ export function initLocalSession(opts: LocalRepoOptions): LocalSession {

// Clone or update
if (!existsSync(dir)) {
execSync(`git clone --depth 1 --no-single-branch ${aUrl} ${dir}`, { stdio: "pipe" });
execFileSync("git", ["clone", "--depth", "1", "--no-single-branch", aUrl, dir], { stdio: "pipe" });
} else {
git(`remote set-url origin ${aUrl}`, dir);
git("fetch origin", dir);
git(["remote", "set-url", "origin", aUrl], dir);
git(["fetch", "origin"], dir);

// Reset local default branch to latest remote
const defaultBranch = getDefaultBranch(dir);
git(`checkout ${defaultBranch}`, dir);
git(`reset --hard origin/${defaultBranch}`, dir);
git(["checkout", defaultBranch], dir);
git(["reset", "--hard", `origin/${defaultBranch}`], dir);
}

// Determine branch
Expand All @@ -83,17 +87,17 @@ export function initLocalSession(opts: LocalRepoOptions): LocalSession {

// Try local checkout first, fall back to remote tracking
try {
git(`checkout ${branch}`, dir);
git(["checkout", branch], dir);
} catch {
git(`checkout -b ${branch} origin/${branch}`, dir);
git(["checkout", "-b", branch, `origin/${branch}`], dir);
}
// Pull latest for existing session branch
try { git(`pull origin ${branch}`, dir); } catch { /* branch may not exist on remote yet */ }
try { git(["pull", "origin", branch], dir); } catch { /* branch may not exist on remote yet */ }
} else {
// New session — branch off latest default branch
sessionId = randomBytes(4).toString("hex"); // 8-char hex
branch = `gitclaw/session-${sessionId}`;
git(`checkout -b ${branch}`, dir);
git(["checkout", "-b", branch], dir);
}

// Scaffold agent.yaml + memory if missing (on session branch only)
Expand Down Expand Up @@ -128,26 +132,26 @@ export function initLocalSession(opts: LocalRepoOptions): LocalSession {
sessionId,

commitChanges(msg?: string) {
git("add -A", dir);
git(["add", "-A"], dir);
try {
git("diff --cached --quiet", dir);
git(["diff", "--cached", "--quiet"], dir);
// Nothing staged — skip
} catch {
// There are staged changes
const commitMsg = msg || `gitclaw: auto-commit (${branch})`;
git(`commit -m "${commitMsg}"`, dir);
git(["commit", "-m", commitMsg], dir);
}
},

push() {
git(`push origin ${branch}`, dir);
git(["push", "origin", branch], dir);
},

finalize() {
localSession.commitChanges();
localSession.push();
// Strip PAT from remote URL
git(`remote set-url origin ${cleanUrl(url)}`, dir);
git(["remote", "set-url", "origin", cleanUrl(url)], dir);
},
};

Expand Down
5 changes: 4 additions & 1 deletion src/tool-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,10 @@ function createDeclarativeTool(
if (signal?.aborted) throw new Error("Operation aborted");

return new Promise((resolve, reject) => {
const child = spawn(runtime, [scriptPath], {
const isWin = process.platform === "win32";
const shellCmd = isWin ? "cmd" : runtime;
const shellArgs = isWin ? ["/c", scriptPath] : [scriptPath];
const child = spawn(shellCmd, shellArgs, {
cwd: agentDir,
stdio: ["pipe", "pipe", "pipe"],
env: { ...process.env },
Expand Down
5 changes: 3 additions & 2 deletions src/tools/capture-photo.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { readFile, writeFile, mkdir, stat } from "fs/promises";
import { join } from "path";
import { execSync } from "child_process";
import { execFileSync } from "child_process";
import type { AgentTool } from "@mariozechner/pi-agent-core";
import { capturePhotoSchema } from "./shared.js";

Expand Down Expand Up @@ -85,7 +85,8 @@ export function createCapturePhotoTool(cwd: string): AgentTool<typeof capturePho
// Git add + commit
const commitMsg = `Capture moment: ${reason}`;
try {
execSync(`git add "${photoRelPath}" "${INDEX_FILE}" && git commit -m "${commitMsg.replace(/"/g, '\\"')}"`, {
execFileSync("git", ["add", photoRelPath, INDEX_FILE], { cwd, stdio: "pipe" });
execFileSync("git", ["commit", "-m", commitMsg], {
cwd,
stdio: "pipe",
});
Expand Down
3 changes: 2 additions & 1 deletion src/tools/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ export function createCliTool(cwd: string, defaultTimeout?: number): AgentTool<t
return;
}

const child = spawn("sh", ["-c", command], {
const isWin = process.platform === "win32";
const child = spawn(isWin ? "cmd" : "sh", isWin ? ["/c", command] : ["-c", command], {
cwd,
stdio: ["ignore", "pipe", "pipe"],
env: { ...process.env },
Expand Down
7 changes: 4 additions & 3 deletions src/tools/memory.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { readFile, writeFile, mkdir } from "fs/promises";
import { join, dirname } from "path";
import { execSync } from "child_process";
import { execFileSync } from "child_process";
import type { AgentTool } from "@mariozechner/pi-agent-core";
import { memorySchema, DEFAULT_MEMORY_PATH } from "./shared.js";
import yaml from "js-yaml";
Expand Down Expand Up @@ -87,7 +87,7 @@ async function archiveOverflow(

// Try to git add the archive
try {
execSync(`git add "${archiveFile}"`, { cwd, stdio: "pipe" });
execFileSync("git", ["add", archiveFile], { cwd, stdio: "pipe" });
} catch {
// Not in git, that's fine
}
Expand Down Expand Up @@ -152,7 +152,8 @@ export function createMemoryTool(cwd: string, pluginLayers?: MemoryLayerDef[]):
await writeFile(memoryFile, finalContent, "utf-8");

try {
execSync(`git add "${memoryPath}" && git commit -m "${commitMsg.replace(/"/g, '\\"')}"`, {
execFileSync("git", ["add", memoryPath], { cwd, stdio: "pipe" });
execFileSync("git", ["commit", "-m", commitMsg], {
cwd,
stdio: "pipe",
});
Expand Down
9 changes: 5 additions & 4 deletions src/tools/skill-learner.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { readFile, writeFile, mkdir, readdir, rm } from "fs/promises";
import { join } from "path";
import { execSync } from "child_process";
import { execFileSync } from "child_process";
import type { AgentTool } from "@mariozechner/pi-agent-core";
import { skillLearnerSchema } from "./shared.js";
import { loadSkillStats, isSkillFlagged } from "../learning/reinforcement.js";
Expand Down Expand Up @@ -85,9 +85,9 @@ async function getExistingSkillDescriptions(agentDir: string): Promise<Array<{ n
function gitCommit(agentDir: string, files: string[], message: string): void {
try {
for (const f of files) {
execSync(`git add "${f}"`, { cwd: agentDir, stdio: "pipe" });
execFileSync("git", ["add", f], { cwd: agentDir, stdio: "pipe" });
}
execSync(`git commit -m "${message.replace(/"/g, '\\"')}"`, {
execFileSync("git", ["commit", "-m", message], {
cwd: agentDir,
stdio: "pipe",
});
Expand Down Expand Up @@ -391,7 +391,8 @@ export function createSkillLearnerTool(agentDir: string, gitagentDir: string): A
}

try {
execSync(`git add -A && git commit -m "Delete skill: ${params.skill_name.replace(/"/g, '\\"')}"`, {
execFileSync("git", ["add", "-A"], { cwd: agentDir, stdio: "pipe" });
execFileSync("git", ["commit", "-m", `Delete skill: ${params.skill_name}`], {
cwd: agentDir,
stdio: "pipe",
});
Expand Down