Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
15 changes: 15 additions & 0 deletions schema/v1/devspace.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,21 @@
"effort": {
"type": "string",
"minLength": 1
},
"command": {
"type": "string",
"minLength": 1,
"pattern": "\\S"
},
Comment thread
Waishnav marked this conversation as resolved.
"env": {
"type": "object",
"propertyNames": {
"type": "string",
"pattern": "^[A-Za-z_][A-Za-z0-9_]*$"
},
"additionalProperties": {
"type": "string"
}
}
},
"required": [
Expand Down
9 changes: 6 additions & 3 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,10 @@ async function runInit({ force }: { force: boolean }): Promise<void> {
}

const currentSubagents = files.config.subagents;
const availability = getLocalAgentProviderAvailabilitySnapshot();
const availability = getLocalAgentProviderAvailabilitySnapshot(
process.env,
currentSubagents,
);
const configuredProviders = currentSubagents.providers
.filter((provider) => provider.enabled)
.map((provider) => provider.id);
Expand Down Expand Up @@ -361,7 +364,7 @@ async function runDoctor(): Promise<void> {
console.log(`Allowed hosts: ${config.allowedHosts.join(", ")}`);
const providers = buildLocalAgentProviderStatuses(
config.subagents,
getLocalAgentProviderAvailabilitySnapshot(),
getLocalAgentProviderAvailabilitySnapshot(process.env, config.subagents),
);
console.log(`Subagents: ${config.subagents.enabled ? "enabled" : "disabled"}`);
console.log(`Subagent providers: ${formatLocalAgentProviderStatusSummary(providers)}`);
Expand Down Expand Up @@ -486,7 +489,7 @@ async function runAgentsTargets(args: string[], json: boolean): Promise<void> {
const profiles = await loadLocalAgentProfiles(config, scope.workspaceRoot);
const providers = buildLocalAgentProviderStatuses(
config.subagents,
getLocalAgentProviderAvailabilitySnapshot(),
getLocalAgentProviderAvailabilitySnapshot(process.env, config.subagents),
);
const catalog = buildLocalAgentCatalog(config.subagents, profiles, providers);
const output = presentAgentTargetCatalog(catalog);
Expand Down
20 changes: 15 additions & 5 deletions src/local-agent-adapters.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
import {
localAgentProviderEnvironment,
type SubagentsConfig,
} from "./local-agent-config.js";
import type { LocalAgentProvider } from "./local-agent-profiles.js";
import {
AcpLocalAgentDriver,
resolveAcpCommand,
Expand Down Expand Up @@ -27,6 +32,7 @@ export type LocalAgentAdapter = LocalAgentDriver;

export interface LocalAgentDriverOptions {
env?: NodeJS.ProcessEnv;
subagents?: SubagentsConfig;
claudeQueryFactory?: ClaudeQueryFactory;
opencodeFactory?: OpencodeFactory;
piSessionFactory?: PiSessionFactory;
Expand All @@ -35,14 +41,18 @@ export interface LocalAgentDriverOptions {
export function createLocalAgentDrivers(
options: LocalAgentDriverOptions = {},
): LocalAgentDriver[] {
const env = options.env ?? process.env;
const providerEnv = (provider: LocalAgentProvider) => options.subagents
? localAgentProviderEnvironment(options.subagents, provider, env)
: env;
return [
new CodexLocalAgentDriver(options.env),
new ClaudeLocalAgentDriver(options.claudeQueryFactory, options.env),
new CodexLocalAgentDriver(providerEnv("codex")),
new ClaudeLocalAgentDriver(options.claudeQueryFactory, providerEnv("claude")),
new OpencodeLocalAgentDriver(options.opencodeFactory),
new PiLocalAgentDriver(options.piSessionFactory),
new AcpLocalAgentDriver("cursor", options.env),
new AcpLocalAgentDriver("copilot", options.env),
new AcpLocalAgentDriver("grok", options.env),
new AcpLocalAgentDriver("cursor", providerEnv("cursor")),
new AcpLocalAgentDriver("copilot", providerEnv("copilot")),
new AcpLocalAgentDriver("grok", providerEnv("grok")),
];
}

Expand Down
48 changes: 48 additions & 0 deletions src/local-agent-availability.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import assert from "node:assert/strict";
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { getLocalAgentProviderAvailabilitySnapshot } from "./local-agent-availability.js";

const snapshot = getLocalAgentProviderAvailabilitySnapshot({
Expand All @@ -10,3 +13,48 @@ assert.deepEqual(snapshot.find((provider) => provider.name === "codex"), {
available: false,
reason: "/definitely/missing/devspace-codex executable not found",
});
assert.equal(
getLocalAgentProviderAvailabilitySnapshot({ ...process.env, CODEX_COMMAND: "" })
.find((provider) => provider.name === "codex")?.available,
false,
);

{
const directory = mkdtempSync(join(tmpdir(), "devspace-provider-command-"));
const executable = join(directory, "codex-wrapper");
try {
assert.equal(
getLocalAgentProviderAvailabilitySnapshot({
...process.env,
CODEX_COMMAND: directory,
}).find((provider) => provider.name === "codex")?.available,
false,
);
writeFileSync(executable, "#!/bin/sh\nexit 0\n");
chmodSync(executable, 0o700);
const availability = getLocalAgentProviderAvailabilitySnapshot(
{
...process.env,
CODEX_COMMAND: "/definitely/missing/devspace-codex",
OPENAI_API_KEY: "must-not-appear",
},
{
enabled: true,
providers: [{
id: "codex",
enabled: true,
command: executable,
env: { OPENAI_API_KEY: "configured-secret", EMPTY_VALUE: "" },
}],
},
).find((provider) => provider.name === "codex");
assert.deepEqual(availability, {
name: "codex",
available: true,
note: "available",
});
assert.doesNotMatch(JSON.stringify(availability), /configured-secret|must-not-appear/);
} finally {
rmSync(directory, { recursive: true, force: true });
}
}
31 changes: 22 additions & 9 deletions src/local-agent-availability.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import { accessSync, constants } from "node:fs";
import { accessSync, constants, statSync } from "node:fs";
import { delimiter, resolve } from "node:path";
import {
LOCAL_AGENT_PROVIDERS,
type LocalAgentProvider,
} from "./local-agent-profiles.js";
import {
localAgentProviderEnvironment,
type SubagentsConfig,
} from "./local-agent-config.js";

export interface LocalAgentProviderAvailability {
name: LocalAgentProvider;
Expand All @@ -14,37 +18,45 @@ export interface LocalAgentProviderAvailability {

export function getLocalAgentProviderAvailabilitySnapshot(
env: NodeJS.ProcessEnv = process.env,
config?: SubagentsConfig,
): LocalAgentProviderAvailability[] {
return LOCAL_AGENT_PROVIDERS.map((provider) => checkLocalAgentProviderAvailability(provider, env));
return LOCAL_AGENT_PROVIDERS.map((provider) => (
checkLocalAgentProviderAvailability(provider, env, config)
));
}

function checkLocalAgentProviderAvailability(
provider: LocalAgentProvider,
env: NodeJS.ProcessEnv = process.env,
config?: SubagentsConfig,
): LocalAgentProviderAvailability {
const providerEnv = config ? localAgentProviderEnvironment(config, provider, env) : env;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
switch (provider) {
case "codex":
return codexAvailability(env);
return codexAvailability(providerEnv);
case "claude":
return packageAvailability(provider, "@anthropic-ai/claude-agent-sdk");
return providerEnv.CLAUDE_COMMAND
? commandAvailability(provider, providerEnv.CLAUDE_COMMAND, providerEnv)
: packageAvailability(provider, "@anthropic-ai/claude-agent-sdk");
case "opencode":
return packageAvailability(provider, "@opencode-ai/sdk/v2");
case "pi":
return packageAvailability(provider, "@earendil-works/pi-coding-agent");
case "cursor":
return commandAvailability(provider, env.CURSOR_COMMAND ?? "cursor-agent", env);
return commandAvailability(provider, providerEnv.CURSOR_COMMAND ?? "cursor-agent", providerEnv);
case "copilot":
return commandAvailability(provider, env.COPILOT_COMMAND ?? "copilot", env);
return commandAvailability(provider, providerEnv.COPILOT_COMMAND ?? "copilot", providerEnv);
case "grok":
return commandAvailability(provider, env.GROK_COMMAND ?? "grok", env);
return commandAvailability(provider, providerEnv.GROK_COMMAND ?? "grok", providerEnv);
}
}

export function assertLocalAgentProviderAvailable(
provider: LocalAgentProvider,
env: NodeJS.ProcessEnv = process.env,
config?: SubagentsConfig,
): void {
const availability = checkLocalAgentProviderAvailability(provider, env);
const availability = checkLocalAgentProviderAvailability(provider, env, config);
if (availability.available) return;
throw new Error(
`${provider} provider is not available: ${availability.reason ?? "provider preflight failed"}`,
Expand Down Expand Up @@ -91,6 +103,7 @@ function commandAvailability(
}

function resolveCommand(command: string, env: NodeJS.ProcessEnv): string | undefined {
if (!command) return undefined;
if (command.includes("/") || command.includes("\\")) {
return executableExists(command) ? command : undefined;
}
Expand All @@ -113,7 +126,7 @@ function executableExists(command: string): boolean {
const mode = process.platform === "win32" ? constants.F_OK : constants.X_OK;
try {
accessSync(command, mode);
return true;
return statSync(command).isFile();
} catch {
return false;
}
Expand Down
38 changes: 38 additions & 0 deletions src/local-agent-claude.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import {
type ClaudeQueryLike,
type ClaudeUserMessage,
} from "./local-agent-claude.js";
import { createLocalAgentDrivers } from "./local-agent-adapters.js";
import { subagentsConfigSchema } from "./local-agent-config.js";
import type { LocalAgentRuntimeContext } from "./local-agent-runtime.js";

class FakeClaudeQuery implements ClaudeQueryLike, AsyncIterator<unknown> {
Expand Down Expand Up @@ -230,3 +232,39 @@ await assert.rejects(
TypeError,
"programmer defects must not be reclassified as provider failures",
);

let configuredOptions: Record<string, unknown> | undefined;
const configuredDriver = createLocalAgentDrivers({
env: {
PATH: "/usr/bin",
CLAUDE_COMMAND: "/usr/bin/claude",
ANTHROPIC_API_KEY: "inherited",
INHERITED: "yes",
},
subagents: subagentsConfigSchema.parse({
enabled: true,
providers: [{
id: "claude",
enabled: true,
command: "/opt/bin/claude-wrapper",
env: { ANTHROPIC_API_KEY: "configured", EMPTY_VALUE: "" },
}],
}),
claudeQueryFactory: ({ prompt, options }) => {
configuredOptions = options;
return new FakeClaudeQuery(prompt);
},
}).find((driver) => driver.provider === "claude");
assert.ok(configuredDriver);
const configuredRuntime = await configuredDriver.createRuntime(context);
assert.equal(configuredRuntime.isOk(), true);
if (configuredRuntime.isErr()) throw configuredRuntime.error;
assert.equal(configuredOptions?.pathToClaudeCodeExecutable, "/opt/bin/claude-wrapper");
assert.deepEqual(configuredOptions?.env, {
PATH: "/usr/bin",
CLAUDE_COMMAND: "/opt/bin/claude-wrapper",
ANTHROPIC_API_KEY: "configured",
INHERITED: "yes",
EMPTY_VALUE: "",
});
await configuredRuntime.value.close();
58 changes: 56 additions & 2 deletions src/local-agent-config.test.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,36 @@
import assert from "node:assert/strict";
import {
isSubagentProviderEnabled,
localAgentProviderEnvironment,
subagentProviderConfig,
subagentsConfigSchema,
} from "./local-agent-config.js";

const config = subagentsConfigSchema.parse({
enabled: true,
providers: [
{ id: "codex", enabled: true, model: " gpt-5.4 ", effort: " high " },
{
id: "codex",
enabled: true,
model: " gpt-5.4 ",
effort: " high ",
command: " /opt/bin/codex-wrapper ",
env: { OPENAI_API_KEY: "configured", EMPTY_VALUE: "" },
},
{ id: "claude", enabled: false, model: "sonnet" },
],
});
assert.deepEqual(config, {
enabled: true,
providers: [
{ id: "codex", enabled: true, model: "gpt-5.4", effort: "high" },
{
id: "codex",
enabled: true,
model: "gpt-5.4",
effort: "high",
command: "/opt/bin/codex-wrapper",
env: { OPENAI_API_KEY: "configured", EMPTY_VALUE: "" },
},
{ id: "claude", enabled: false, model: "sonnet" },
],
});
Expand All @@ -24,6 +39,22 @@ assert.equal(isSubagentProviderEnabled(config, "claude"), false);
assert.equal(isSubagentProviderEnabled(config, "pi"), false);
assert.equal(subagentProviderConfig(config, "codex")?.model, "gpt-5.4");

const inherited = {
CODEX_COMMAND: "/usr/bin/codex",
OPENAI_API_KEY: "inherited",
UNCHANGED: "yes",
};
assert.deepEqual(localAgentProviderEnvironment(config, "codex", inherited), {
CODEX_COMMAND: "/opt/bin/codex-wrapper",
OPENAI_API_KEY: "configured",
EMPTY_VALUE: "",
UNCHANGED: "yes",
});
assert.deepEqual(inherited, {
CODEX_COMMAND: "/usr/bin/codex",
OPENAI_API_KEY: "inherited",
UNCHANGED: "yes",
});
assert.throws(
() => subagentsConfigSchema.parse({
enabled: true,
Expand All @@ -45,3 +76,26 @@ assert.throws(
}),
/Too small/,
);
assert.throws(
() => subagentsConfigSchema.parse({
enabled: true,
providers: [{ id: "codex", enabled: true, command: " " }],
}),
/non-whitespace character/,
);
assert.throws(
() => subagentsConfigSchema.parse({
enabled: true,
providers: [{ id: "codex", enabled: true, env: { "INVALID-NAME": "value" } }],
}),
/Invalid environment variable name/,
);
for (const id of ["opencode", "pi"] as const) {
assert.throws(
() => subagentsConfigSchema.parse({
enabled: true,
providers: [{ id, enabled: true, command: "/opt/bin/agent" }],
}),
new RegExp(`${id} is embedded and does not support command or env configuration`),
);
}
Loading
Loading