From e5a59c0e531d2abf1418a730f2f5b5c4f4aa8cef Mon Sep 17 00:00:00 2001 From: haosenwang1018 <1293965075@qq.com> Date: Mon, 27 Apr 2026 16:54:17 +0800 Subject: [PATCH] feat: add run workspace option --- README.md | 3 +++ docs.md | 6 ++++++ src/commands/run.ts | 26 ++++++++++++++++---------- src/runners/claude.ts | 8 ++++++-- src/runners/crewai.ts | 13 ++++++++++--- src/runners/gemini.ts | 5 +++++ src/runners/git.ts | 20 ++++++++++++-------- src/runners/gitclaw.ts | 5 +++++ src/runners/github.ts | 1 + src/runners/lyzr.ts | 1 + src/runners/nanobot.ts | 8 ++++++-- src/runners/openai.ts | 13 ++++++++++--- src/runners/openclaw.ts | 10 +++++++--- src/runners/opencode.ts | 5 +++++ 14 files changed, 93 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 30ac8d3..cf289e7 100644 --- a/README.md +++ b/README.md @@ -374,6 +374,9 @@ gapman export --format system-prompt # Run an agent directly gapman run ./my-agent --adapter lyzr + +# Run an agent definition against a separate target workspace +gapman run --dir ./agents/reviewer --workspace ~/code/my-app --adapter claude -p "Review this repository" ``` ## Inheritance & Composition diff --git a/docs.md b/docs.md index 1551619..f5447b8 100644 --- a/docs.md +++ b/docs.md @@ -629,12 +629,15 @@ gitagent run [options] | `-d, --dir ` | — | Local directory (alternative to `--repo`) | | `-a, --adapter ` | `claude` | Adapter (see table below) | | `-b, --branch ` | `main` | Git branch or tag to clone | +| `-w, --workspace ` | Agent directory | Working directory for spawned agent process | | `--refresh` | `false` | Force re-clone (pull latest) | | `--no-cache` | `false` | Clone to temp dir, delete on exit | | `-p, --prompt ` | — | Initial prompt (non-interactive for some adapters) | Either `--repo` or `--dir` is required. +`--workspace` lets an agent definition live separately from the repository it operates on. It is honored by adapters that can safely set the spawned process working directory directly, including `claude`, `openai`, `crewai`, `openclaw`, and `nanobot`. Adapters that generate an isolated runtime workspace, such as `opencode`, `gemini`, and `gitclaw`, continue to run from that prepared workspace to avoid overwriting files such as `AGENTS.md`, `GEMINI.md`, or `agent.yaml` in the target repository. + **Available adapters:** | Adapter | Mode | Requirements | @@ -668,6 +671,9 @@ gitagent run -r https://github.com/user/agent -a git -p "Hello" # One-shot prompt mode gitagent run -d ./my-agent -p "Review my authentication code" +# Run an agent definition against a separate target workspace +gitagent run -d ./agents/reviewer --workspace ~/code/my-app -a claude -p "Review this repository" + # Run a specific branch, force refresh gitagent run -r https://github.com/user/agent -b develop --refresh diff --git a/src/commands/run.ts b/src/commands/run.ts index bdc8480..733f017 100644 --- a/src/commands/run.ts +++ b/src/commands/run.ts @@ -24,6 +24,7 @@ interface RunOptions { cache: boolean; prompt?: string; dir?: string; + workspace?: string; } export const runCommand = new Command('run') @@ -35,6 +36,7 @@ export const runCommand = new Command('run') .option('--no-cache', 'Clone to temp dir, delete on exit') .option('-p, --prompt ', 'Initial prompt to send to the agent') .option('-d, --dir ', 'Use local directory instead of git URL') + .option('-w, --workspace ', 'Working directory for the spawned agent process') .action(async (options: RunOptions) => { let agentDir: string; let cleanup: (() => void) | undefined; @@ -89,40 +91,43 @@ export const runCommand = new Command('run') label('Model', manifest.model.preferred); } label('Adapter', options.adapter); + if (options.workspace) { + label('Workspace', resolve(options.workspace)); + } divider(); // Run with selected adapter try { switch (options.adapter) { case 'claude': - runWithClaude(agentDir, manifest, { prompt: options.prompt }); + runWithClaude(agentDir, manifest, { prompt: options.prompt, workspace: options.workspace }); break; case 'openai': - runWithOpenAI(agentDir, manifest); + runWithOpenAI(agentDir, manifest, { workspace: options.workspace }); break; case 'crewai': - runWithCrewAI(agentDir, manifest); + runWithCrewAI(agentDir, manifest, { workspace: options.workspace }); break; case 'openclaw': - runWithOpenClaw(agentDir, manifest, { prompt: options.prompt }); + runWithOpenClaw(agentDir, manifest, { prompt: options.prompt, workspace: options.workspace }); break; case 'nanobot': - runWithNanobot(agentDir, manifest, { prompt: options.prompt }); + runWithNanobot(agentDir, manifest, { prompt: options.prompt, workspace: options.workspace }); break; case 'lyzr': - await runWithLyzr(agentDir, manifest, { prompt: options.prompt }); + await runWithLyzr(agentDir, manifest, { prompt: options.prompt, workspace: options.workspace }); break; case 'github': - await runWithGitHub(agentDir, manifest, { prompt: options.prompt }); + await runWithGitHub(agentDir, manifest, { prompt: options.prompt, workspace: options.workspace }); break; case 'opencode': - runWithOpenCode(agentDir, manifest, { prompt: options.prompt }); + runWithOpenCode(agentDir, manifest, { prompt: options.prompt, workspace: options.workspace }); break; case 'gemini': - runWithGemini(agentDir, manifest, { prompt: options.prompt }); + runWithGemini(agentDir, manifest, { prompt: options.prompt, workspace: options.workspace }); break; case 'gitclaw': - runWithGitclaw(agentDir, manifest, { prompt: options.prompt }); + runWithGitclaw(agentDir, manifest, { prompt: options.prompt, workspace: options.workspace }); break; case 'git': if (!options.repo) { @@ -135,6 +140,7 @@ export const runCommand = new Command('run') refresh: options.refresh, noCache: !options.cache, prompt: options.prompt, + workspace: options.workspace, }); break; case 'prompt': diff --git a/src/runners/claude.ts b/src/runners/claude.ts index 5c5d6b3..63be9ec 100644 --- a/src/runners/claude.ts +++ b/src/runners/claude.ts @@ -1,5 +1,5 @@ import { writeFileSync, unlinkSync, existsSync, readdirSync, readFileSync } from 'node:fs'; -import { join } from 'node:path'; +import { join, resolve } from 'node:path'; import { tmpdir } from 'node:os'; import { spawnSync } from 'node:child_process'; import { randomBytes } from 'node:crypto'; @@ -11,6 +11,7 @@ import { error, info, warn } from '../utils/format.js'; export interface ClaudeRunOptions { prompt?: string; + workspace?: string; } export function runWithClaude(agentDir: string, manifest: AgentManifest, options: ClaudeRunOptions = {}): void { @@ -80,7 +81,10 @@ export function runWithClaude(agentDir: string, manifest: AgentManifest, options // from interfering with argument parsing of other flags args.push('--append-system-prompt', systemPrompt); + const runCwd = resolve(options.workspace ?? agentDir); + info(`Launching Claude Code with agent "${manifest.name}"...`); + info(`Working directory: ${runCwd}`); // Resolve the real Claude Code binary, skipping any node_modules/.bin/claude // that may shadow it (e.g. when running via npx) @@ -90,7 +94,7 @@ export function runWithClaude(agentDir: string, manifest: AgentManifest, options try { const result = spawnSync(claudePath, args, { stdio: 'inherit', - cwd: agentDir, + cwd: runCwd, }); if (result.error) { diff --git a/src/runners/crewai.ts b/src/runners/crewai.ts index 8947331..c3dc2c4 100644 --- a/src/runners/crewai.ts +++ b/src/runners/crewai.ts @@ -1,5 +1,5 @@ import { writeFileSync, unlinkSync } from 'node:fs'; -import { join } from 'node:path'; +import { join, resolve } from 'node:path'; import { tmpdir } from 'node:os'; import { spawnSync } from 'node:child_process'; import { randomBytes } from 'node:crypto'; @@ -7,18 +7,25 @@ import { exportToCrewAI } from '../adapters/crewai.js'; import { AgentManifest } from '../utils/loader.js'; import { error, info } from '../utils/format.js'; -export function runWithCrewAI(agentDir: string, _manifest: AgentManifest): void { +export interface CrewAIRunOptions { + workspace?: string; +} + +export function runWithCrewAI(agentDir: string, _manifest: AgentManifest, options: CrewAIRunOptions = {}): void { const config = exportToCrewAI(agentDir); const tmpFile = join(tmpdir(), `gitagent-${randomBytes(4).toString('hex')}.yaml`); writeFileSync(tmpFile, config, 'utf-8'); + const runCwd = resolve(options.workspace ?? agentDir); + info(`Running CrewAI agent from "${agentDir}"...`); + info(`Working directory: ${runCwd}`); try { const result = spawnSync('crewai', ['kickoff', '--config', tmpFile], { stdio: 'inherit', - cwd: agentDir, + cwd: runCwd, env: { ...process.env }, }); diff --git a/src/runners/gemini.ts b/src/runners/gemini.ts index 645a0f9..c2f9aca 100644 --- a/src/runners/gemini.ts +++ b/src/runners/gemini.ts @@ -9,6 +9,7 @@ import { error, info } from '../utils/format.js'; export interface GeminiRunOptions { prompt?: string; + workspace?: string; } /** @@ -24,6 +25,10 @@ export interface GeminiRunOptions { * Supports both interactive mode (no prompt) and single-shot mode (`gemini -p`). */ export function runWithGemini(agentDir: string, manifest: AgentManifest, options: GeminiRunOptions = {}): void { + if (options.workspace) { + info('--workspace is not applied to Gemini because it reads GEMINI.md and .gemini/settings.json from the prepared temporary workspace.'); + } + const exp = exportToGemini(agentDir); // Create a temporary workspace diff --git a/src/runners/git.ts b/src/runners/git.ts index cc9f421..f0fafec 100644 --- a/src/runners/git.ts +++ b/src/runners/git.ts @@ -21,6 +21,7 @@ export interface GitRunOptions { noCache?: boolean; adapter?: string; prompt?: string; + workspace?: string; } /** @@ -85,33 +86,36 @@ export async function runWithGit( label('Model', manifest.model.preferred); } label('Adapter', adapter); + if (options.workspace) { + label('Workspace', resolve(options.workspace)); + } divider(); try { switch (adapter) { case 'claude': - runWithClaude(agentDir, manifest, { prompt: options.prompt }); + runWithClaude(agentDir, manifest, { prompt: options.prompt, workspace: options.workspace }); break; case 'openai': - runWithOpenAI(agentDir, manifest); + runWithOpenAI(agentDir, manifest, { workspace: options.workspace }); break; case 'crewai': - runWithCrewAI(agentDir, manifest); + runWithCrewAI(agentDir, manifest, { workspace: options.workspace }); break; case 'openclaw': - runWithOpenClaw(agentDir, manifest, { prompt: options.prompt }); + runWithOpenClaw(agentDir, manifest, { prompt: options.prompt, workspace: options.workspace }); break; case 'nanobot': - runWithNanobot(agentDir, manifest, { prompt: options.prompt }); + runWithNanobot(agentDir, manifest, { prompt: options.prompt, workspace: options.workspace }); break; case 'opencode': - runWithOpenCode(agentDir, manifest, { prompt: options.prompt }); + runWithOpenCode(agentDir, manifest, { prompt: options.prompt, workspace: options.workspace }); break; case 'lyzr': - await runWithLyzr(agentDir, manifest, { prompt: options.prompt }); + await runWithLyzr(agentDir, manifest, { prompt: options.prompt, workspace: options.workspace }); break; case 'github': - await runWithGitHub(agentDir, manifest, { prompt: options.prompt }); + await runWithGitHub(agentDir, manifest, { prompt: options.prompt, workspace: options.workspace }); break; case 'prompt': console.log(exportToSystemPrompt(agentDir)); diff --git a/src/runners/gitclaw.ts b/src/runners/gitclaw.ts index 3d2d7af..f50e067 100644 --- a/src/runners/gitclaw.ts +++ b/src/runners/gitclaw.ts @@ -9,6 +9,7 @@ import { error, info } from '../utils/format.js'; export interface GitclawRunOptions { prompt?: string; + workspace?: string; } /** @@ -23,6 +24,10 @@ export interface GitclawRunOptions { * Supports both interactive mode (no prompt) and single-shot mode (`gitclaw run -p`). */ export function runWithGitclaw(agentDir: string, manifest: AgentManifest, options: GitclawRunOptions = {}): void { + if (options.workspace) { + info('--workspace is not applied to gitclaw because it reads agent.yaml and related files from the prepared temporary workspace.'); + } + const exp = exportToGitclaw(agentDir); // Create a temporary workspace diff --git a/src/runners/github.ts b/src/runners/github.ts index 4ee9334..44a5037 100644 --- a/src/runners/github.ts +++ b/src/runners/github.ts @@ -13,6 +13,7 @@ const DEFAULT_MODEL = 'openai/gpt-4.1'; export interface GitHubRunOptions { prompt?: string; token?: string; + workspace?: string; } /** diff --git a/src/runners/lyzr.ts b/src/runners/lyzr.ts index 390f25d..b81ca89 100644 --- a/src/runners/lyzr.ts +++ b/src/runners/lyzr.ts @@ -12,6 +12,7 @@ export interface LyzrRunOptions { prompt?: string; apiKey?: string; userId?: string; + workspace?: string; } /** diff --git a/src/runners/nanobot.ts b/src/runners/nanobot.ts index 713737f..99b4163 100644 --- a/src/runners/nanobot.ts +++ b/src/runners/nanobot.ts @@ -1,5 +1,5 @@ import { writeFileSync, mkdirSync, rmSync } from 'node:fs'; -import { join } from 'node:path'; +import { join, resolve } from 'node:path'; import { tmpdir, homedir } from 'node:os'; import { spawnSync } from 'node:child_process'; import { randomBytes } from 'node:crypto'; @@ -10,6 +10,7 @@ import { ensureNanobotAuth } from '../utils/auth-provision.js'; export interface NanobotRunOptions { prompt?: string; + workspace?: string; } export function runWithNanobot(agentDir: string, manifest: AgentManifest, options: NanobotRunOptions = {}): void { @@ -38,7 +39,10 @@ export function runWithNanobot(agentDir: string, manifest: AgentManifest, option args.push('--message', options.prompt); } + const runCwd = resolve(options.workspace ?? agentDir); + info(`Launching Nanobot agent "${manifest.name}"...`); + info(`Working directory: ${runCwd}`); if (!options.prompt) { info('Starting interactive mode. Type your messages to chat.'); } @@ -46,7 +50,7 @@ export function runWithNanobot(agentDir: string, manifest: AgentManifest, option try { const result = spawnSync('nanobot', args, { stdio: 'inherit', - cwd: agentDir, + cwd: runCwd, env: { ...process.env, NANOBOT_CONFIG: configFile, diff --git a/src/runners/openai.ts b/src/runners/openai.ts index 7f289e7..b9067da 100644 --- a/src/runners/openai.ts +++ b/src/runners/openai.ts @@ -1,5 +1,5 @@ import { writeFileSync, unlinkSync } from 'node:fs'; -import { join } from 'node:path'; +import { join, resolve } from 'node:path'; import { tmpdir } from 'node:os'; import { spawnSync } from 'node:child_process'; import { randomBytes } from 'node:crypto'; @@ -8,7 +8,11 @@ import { AgentManifest } from '../utils/loader.js'; import { error, info } from '../utils/format.js'; import { resolveOpenAIKey } from '../utils/auth-provision.js'; -export function runWithOpenAI(agentDir: string, _manifest: AgentManifest): void { +export interface OpenAIRunOptions { + workspace?: string; +} + +export function runWithOpenAI(agentDir: string, _manifest: AgentManifest, options: OpenAIRunOptions = {}): void { if (!resolveOpenAIKey()) { error('OPENAI_API_KEY environment variable is not set'); info('Set it with: export OPENAI_API_KEY="sk-..."'); @@ -20,12 +24,15 @@ export function runWithOpenAI(agentDir: string, _manifest: AgentManifest): void writeFileSync(tmpFile, script, 'utf-8'); + const runCwd = resolve(options.workspace ?? agentDir); + info(`Running OpenAI agent from "${agentDir}"...`); + info(`Working directory: ${runCwd}`); try { const result = spawnSync('python3', [tmpFile], { stdio: 'inherit', - cwd: agentDir, + cwd: runCwd, env: { ...process.env }, }); diff --git a/src/runners/openclaw.ts b/src/runners/openclaw.ts index b38e180..76eb662 100644 --- a/src/runners/openclaw.ts +++ b/src/runners/openclaw.ts @@ -1,5 +1,5 @@ import { writeFileSync, mkdirSync, rmSync, existsSync } from 'node:fs'; -import { join } from 'node:path'; +import { join, resolve } from 'node:path'; import { tmpdir } from 'node:os'; import { spawnSync } from 'node:child_process'; import { randomBytes } from 'node:crypto'; @@ -10,6 +10,7 @@ import { ensureOpenClawAuth } from '../utils/auth-provision.js'; export interface OpenClawRunOptions { prompt?: string; + workspace?: string; } export function runWithOpenClaw(agentDir: string, manifest: AgentManifest, options: OpenClawRunOptions = {}): void { @@ -58,6 +59,8 @@ export function runWithOpenClaw(agentDir: string, manifest: AgentManifest, optio info(` Sub-agent workspace: workspace-${sub.name}/`); } + const runCwd = resolve(options.workspace ?? workspaceDir); + // Write openclaw.json config, pointing workspaces to temp dirs const config = exp.config as Record>; if (hasSubAgents) { @@ -72,7 +75,7 @@ export function runWithOpenClaw(agentDir: string, manifest: AgentManifest, optio } } else { config.agent = config.agent ?? {}; - config.agent.workspace = workspaceDir; + config.agent.workspace = runCwd; } const configFile = join(workspaceDir, 'openclaw.json'); @@ -104,11 +107,12 @@ export function runWithOpenClaw(agentDir: string, manifest: AgentManifest, optio } info(`Launching OpenClaw agent "${manifest.name}"...`); + info(`Working directory: ${runCwd}`); try { const result = spawnSync('openclaw', args, { stdio: 'inherit', - cwd: workspaceDir, + cwd: runCwd, env: { ...process.env, OPENCLAW_CONFIG: configFile, diff --git a/src/runners/opencode.ts b/src/runners/opencode.ts index 87a7d53..55f9956 100644 --- a/src/runners/opencode.ts +++ b/src/runners/opencode.ts @@ -9,6 +9,7 @@ import { error, info } from '../utils/format.js'; export interface OpenCodeRunOptions { prompt?: string; + workspace?: string; } /** @@ -24,6 +25,10 @@ export interface OpenCodeRunOptions { * Supports both interactive mode (no prompt) and single-shot mode (`opencode run ""`). */ export function runWithOpenCode(agentDir: string, manifest: AgentManifest, options: OpenCodeRunOptions = {}): void { + if (options.workspace) { + info('--workspace is not applied to OpenCode because it reads AGENTS.md and opencode.json from the prepared temporary workspace.'); + } + const exp = exportToOpenCode(agentDir); // Create a temporary workspace