Auto-create missing cwd for claude_local and codex_local

This commit is contained in:
Dotta
2026-03-03 12:29:32 -06:00
parent 01210cef49
commit 8351f7f1bd
11 changed files with 97 additions and 17 deletions

View File

@@ -14,7 +14,7 @@ The `claude_local` adapter runs Anthropic's Claude Code CLI locally. It supports
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `cwd` | string | Yes | Working directory for the agent process |
| `cwd` | string | Yes | Working directory for the agent process (absolute path; created automatically if missing when permissions allow) |
| `model` | string | No | Claude model to use (e.g. `claude-opus-4-6`) |
| `promptTemplate` | string | No | Prompt used for all runs |
| `env` | object | No | Environment variables (supports secret refs) |
@@ -52,5 +52,5 @@ The adapter creates a temporary directory with symlinks to Paperclip skills and
Use the "Test Environment" button in the UI to validate the adapter config. It checks:
- Claude CLI is installed and accessible
- Working directory exists and is valid
- Working directory is absolute and available (auto-created if missing and permitted)
- API key is configured (warning if missing)

View File

@@ -14,7 +14,7 @@ The `codex_local` adapter runs OpenAI's Codex CLI locally. It supports session p
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `cwd` | string | Yes | Working directory for the agent process |
| `cwd` | string | Yes | Working directory for the agent process (absolute path; created automatically if missing when permissions allow) |
| `model` | string | No | Model to use |
| `promptTemplate` | string | No | Prompt used for all runs |
| `env` | object | No | Environment variables (supports secret refs) |
@@ -35,5 +35,5 @@ The adapter symlinks Paperclip skills into the global Codex skills directory (`~
The environment test checks:
- Codex CLI is installed and accessible
- Working directory exists and is valid
- Working directory is absolute and available (auto-created if missing and permitted)
- API key is configured

View File

@@ -113,20 +113,40 @@ export function ensurePathInEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
return { ...env, PATH: defaultPathForPlatform() };
}
export async function ensureAbsoluteDirectory(cwd: string) {
export async function ensureAbsoluteDirectory(
cwd: string,
opts: { createIfMissing?: boolean } = {},
) {
if (!path.isAbsolute(cwd)) {
throw new Error(`Working directory must be an absolute path: "${cwd}"`);
}
let stats;
const assertDirectory = async () => {
const stats = await fs.stat(cwd);
if (!stats.isDirectory()) {
throw new Error(`Working directory is not a directory: "${cwd}"`);
}
};
try {
stats = await fs.stat(cwd);
} catch {
throw new Error(`Working directory does not exist: "${cwd}"`);
await assertDirectory();
return;
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
if (!opts.createIfMissing || code !== "ENOENT") {
if (code === "ENOENT") {
throw new Error(`Working directory does not exist: "${cwd}"`);
}
throw err instanceof Error ? err : new Error(String(err));
}
}
if (!stats.isDirectory()) {
throw new Error(`Working directory is not a directory: "${cwd}"`);
try {
await fs.mkdir(cwd, { recursive: true });
await assertDirectory();
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
throw new Error(`Could not create working directory "${cwd}": ${reason}`);
}
}

View File

@@ -12,7 +12,7 @@ export const agentConfigurationDoc = `# claude_local agent configuration
Adapter: claude_local
Core fields:
- cwd (string, optional): default absolute working directory fallback for the agent process
- cwd (string, optional): default absolute working directory fallback for the agent process (created if missing when possible)
- instructionsFilePath (string, optional): absolute path to a markdown instructions file injected at runtime
- model (string, optional): Claude model id
- effort (string, optional): reasoning effort passed via --effort (low|medium|high)

View File

@@ -116,7 +116,7 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<Cl
const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0;
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd();
await ensureAbsoluteDirectory(cwd);
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
const envConfig = parseObject(config.env);
const hasExplicitApiKey =

View File

@@ -30,7 +30,7 @@ export async function testEnvironment(
const cwd = asString(config.cwd, process.cwd());
try {
await ensureAbsoluteDirectory(cwd);
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
checks.push({
code: "claude_cwd_valid",
level: "info",

View File

@@ -18,7 +18,7 @@ export const agentConfigurationDoc = `# codex_local agent configuration
Adapter: codex_local
Core fields:
- cwd (string, optional): default absolute working directory fallback for the agent process
- cwd (string, optional): default absolute working directory fallback for the agent process (created if missing when possible)
- instructionsFilePath (string, optional): absolute path to a markdown instructions file prepended to stdin prompt at runtime
- model (string, optional): Codex model id
- modelReasoningEffort (string, optional): reasoning effort override (minimal|low|medium|high) passed via -c model_reasoning_effort=...

View File

@@ -132,7 +132,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0;
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd();
await ensureAbsoluteDirectory(cwd);
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
await ensureCodexSkillsInjected(onLog);
const envConfig = parseObject(config.env);
const hasExplicitApiKey =

View File

@@ -30,7 +30,7 @@ export async function testEnvironment(
const cwd = asString(config.cwd, process.cwd());
try {
await ensureAbsoluteDirectory(cwd);
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
checks.push({
code: "codex_cwd_valid",
level: "info",

View File

@@ -1,4 +1,7 @@
import { afterEach, describe, expect, it } from "vitest";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { testEnvironment } from "@paperclipai/adapter-claude-local/server";
const ORIGINAL_ANTHROPIC = process.env.ANTHROPIC_API_KEY;
@@ -60,4 +63,29 @@ describe("claude_local environment diagnostics", () => {
).toBe(true);
expect(result.checks.some((check) => check.level === "error")).toBe(false);
});
it("creates a missing working directory when cwd is absolute", async () => {
const cwd = path.join(
os.tmpdir(),
`paperclip-claude-local-cwd-${Date.now()}-${Math.random().toString(16).slice(2)}`,
"workspace",
);
await fs.rm(path.dirname(cwd), { recursive: true, force: true });
const result = await testEnvironment({
companyId: "company-1",
adapterType: "claude_local",
config: {
command: process.execPath,
cwd,
},
});
expect(result.checks.some((check) => check.code === "claude_cwd_valid")).toBe(true);
expect(result.checks.some((check) => check.level === "error")).toBe(false);
const stats = await fs.stat(cwd);
expect(stats.isDirectory()).toBe(true);
await fs.rm(path.dirname(cwd), { recursive: true, force: true });
});
});

View File

@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { testEnvironment } from "@paperclipai/adapter-codex-local/server";
describe("codex_local environment diagnostics", () => {
it("creates a missing working directory when cwd is absolute", async () => {
const cwd = path.join(
os.tmpdir(),
`paperclip-codex-local-cwd-${Date.now()}-${Math.random().toString(16).slice(2)}`,
"workspace",
);
await fs.rm(path.dirname(cwd), { recursive: true, force: true });
const result = await testEnvironment({
companyId: "company-1",
adapterType: "codex_local",
config: {
command: process.execPath,
cwd,
},
});
expect(result.checks.some((check) => check.code === "codex_cwd_valid")).toBe(true);
expect(result.checks.some((check) => check.level === "error")).toBe(false);
const stats = await fs.stat(cwd);
expect(stats.isDirectory()).toBe(true);
await fs.rm(path.dirname(cwd), { recursive: true, force: true });
});
});