mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-16 08:25:14 +02:00
feat: wire claude-code agent runner backend
This commit is contained in:
parent
eb90d2f32c
commit
3de32c43a1
16 changed files with 229 additions and 21 deletions
|
|
@ -34,6 +34,7 @@
|
|||
"type-check": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.142",
|
||||
"@clack/prompts": "1.4.0",
|
||||
"@commander-js/extra-typings": "14.0.0",
|
||||
"@ktx/connector-bigquery": "workspace:*",
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ function embeddingBackend(value: string): 'openai' | 'sentence-transformers' {
|
|||
}
|
||||
|
||||
function llmBackend(value: string): KtxSetupLlmBackend {
|
||||
if (value === 'anthropic' || value === 'vertex') {
|
||||
if (value === 'anthropic' || value === 'vertex' || value === 'claude-code') {
|
||||
return value;
|
||||
}
|
||||
throw new InvalidArgumentError(`invalid choice '${value}'`);
|
||||
|
|
@ -361,12 +361,18 @@ export function registerSetupCommands(program: Command, context: KtxCliCommandCo
|
|||
context.setExitCode(1);
|
||||
return;
|
||||
}
|
||||
if (options.llmBackend === 'vertex' && (options.anthropicApiKeyEnv || options.anthropicApiKeyFile)) {
|
||||
if (
|
||||
(options.llmBackend === 'vertex' || options.llmBackend === 'claude-code') &&
|
||||
(options.anthropicApiKeyEnv || options.anthropicApiKeyFile)
|
||||
) {
|
||||
context.io.stderr.write('Anthropic API key flags are only valid with --llm-backend anthropic.\n');
|
||||
context.setExitCode(1);
|
||||
return;
|
||||
}
|
||||
if (options.llmBackend === 'anthropic' && (options.vertexProject || options.vertexLocation)) {
|
||||
if (
|
||||
(options.llmBackend === 'anthropic' || options.llmBackend === 'claude-code') &&
|
||||
(options.vertexProject || options.vertexLocation)
|
||||
) {
|
||||
context.io.stderr.write('Vertex AI flags are only valid with --llm-backend vertex.\n');
|
||||
context.setExitCode(1);
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -913,6 +913,7 @@ describe('runContextBuild', () => {
|
|||
llm: {
|
||||
provider: { backend: 'gateway', gateway: { api_key: 'env:KTX_GATEWAY_API_KEY' } }, // pragma: allowlist secret
|
||||
models: { default: 'gpt-test' },
|
||||
agentRunner: { backend: 'ai-sdk' },
|
||||
},
|
||||
scan: {
|
||||
...projectWithConnections({ warehouse: { driver: 'postgres' } }).config.scan,
|
||||
|
|
|
|||
|
|
@ -1074,6 +1074,41 @@ describe('runKtxCli', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('dispatches Claude Code setup flags to the setup runner', async () => {
|
||||
const setup = vi.fn(async () => 0);
|
||||
const setupIo = makeIo();
|
||||
|
||||
await expect(
|
||||
runKtxCli(
|
||||
[
|
||||
'--project-dir',
|
||||
tempDir,
|
||||
'setup',
|
||||
'--no-input',
|
||||
'--llm-backend',
|
||||
'claude-code',
|
||||
'--anthropic-model',
|
||||
'claude-sonnet-4-6',
|
||||
],
|
||||
setupIo.io,
|
||||
{ setup },
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(setup).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
command: 'run',
|
||||
projectDir: tempDir,
|
||||
inputMode: 'disabled',
|
||||
cliVersion: '0.0.0-private',
|
||||
llmBackend: 'claude-code',
|
||||
anthropicModel: 'claude-sonnet-4-6',
|
||||
skipLlm: false,
|
||||
}),
|
||||
setupIo.io,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects conflicting Anthropic credential setup flags', async () => {
|
||||
const setup = vi.fn(async () => 0);
|
||||
const setupIo = makeIo();
|
||||
|
|
|
|||
|
|
@ -271,7 +271,7 @@ export class CliLookerSlWritingAgentRunner extends AgentRunnerService {
|
|||
verifiedIdentifiers: ['prod-warehouse', 'public.orders'],
|
||||
unverifiedIdentifiers: [],
|
||||
},
|
||||
{ toolCallId: 'cli-looker-verification-ledger', messages: [] },
|
||||
{ toolCallId: 'cli-looker-verification-ledger' },
|
||||
);
|
||||
const slWrite = params.toolSet.sl_write_source;
|
||||
if (!slWrite?.execute) {
|
||||
|
|
@ -292,10 +292,13 @@ export class CliLookerSlWritingAgentRunner extends AgentRunnerService {
|
|||
measures: [{ name: 'total_revenue', expr: 'sum(revenue)' }],
|
||||
},
|
||||
},
|
||||
{ toolCallId: 'cli-looker-sl-write', messages: [] },
|
||||
{ toolCallId: 'cli-looker-sl-write' },
|
||||
);
|
||||
if (!result.structured.success) {
|
||||
throw new Error(result.markdown);
|
||||
const structured =
|
||||
result && typeof result === 'object' && 'structured' in result ? result.structured : undefined;
|
||||
if (!structured || typeof structured !== 'object' || !('success' in structured) || structured.success !== true) {
|
||||
const message = result && typeof result === 'object' && 'markdown' in result ? result.markdown : String(result);
|
||||
throw new Error(typeof message === 'string' ? message : 'sl_write_source failed');
|
||||
}
|
||||
}
|
||||
return { stopReason: 'natural' as const };
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ async function writeReadyProject(projectDir: string, overrides: ReadyProjectOver
|
|||
llm: {
|
||||
provider: { backend: 'anthropic' },
|
||||
models: { default: 'claude-sonnet-4-6' },
|
||||
agentRunner: { backend: 'ai-sdk' },
|
||||
},
|
||||
ingest: {
|
||||
...defaults.ingest,
|
||||
|
|
|
|||
|
|
@ -975,6 +975,26 @@ describe('setup Anthropic model step', () => {
|
|||
expect(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8')).not.toContain('completed_steps:');
|
||||
});
|
||||
|
||||
it('writes claude-code agent runner config when requested as the LLM backend', async () => {
|
||||
const result = await runKtxSetupAnthropicModelStep(
|
||||
{
|
||||
projectDir: tempDir,
|
||||
inputMode: 'disabled',
|
||||
llmBackend: 'claude-code',
|
||||
anthropicModel: 'claude-sonnet-4-6',
|
||||
skipLlm: false,
|
||||
},
|
||||
makeIo().io,
|
||||
{ claudeCodeAuthProbe: vi.fn(async () => ({ ok: true as const })) },
|
||||
);
|
||||
|
||||
expect(result.status).toBe('ready');
|
||||
const config = parseKtxProjectConfig(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8'));
|
||||
expect(config.llm.provider.backend).toBe('none');
|
||||
expect(config.llm.agentRunner.backend).toBe('claude-code');
|
||||
expect(config.llm.models.default).toBe('claude-sonnet-4-6');
|
||||
});
|
||||
|
||||
it('returns back without writing config when Back is selected', async () => {
|
||||
const prompts = makePromptAdapter({ credentialChoice: 'back' });
|
||||
const result = await runKtxSetupAnthropicModelStep(
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ export interface AnthropicModelChoice {
|
|||
recommended: boolean;
|
||||
}
|
||||
|
||||
export type KtxSetupLlmBackend = 'anthropic' | 'vertex';
|
||||
export type KtxSetupLlmBackend = 'anthropic' | 'vertex' | 'claude-code';
|
||||
|
||||
export interface KtxSetupModelPromptAdapter {
|
||||
select(options: { message: string; options: KtxSetupPromptOption[] }): Promise<string>;
|
||||
|
|
@ -68,6 +68,7 @@ export interface KtxSetupModelDeps {
|
|||
prompts?: KtxSetupModelPromptAdapter;
|
||||
listModels?: (apiKey: string) => Promise<AnthropicModelChoice[]>;
|
||||
healthCheck?: (config: KtxLlmConfig) => Promise<KtxLlmHealthCheckResult>;
|
||||
claudeCodeAuthProbe?: () => Promise<{ ok: true } | { ok: false; message: string }>;
|
||||
readGcloudProject?: () => Promise<string | undefined>;
|
||||
listGcloudProjects?: () => Promise<GcloudProjectChoice[]>;
|
||||
spinner?: () => KtxCliSpinner;
|
||||
|
|
@ -238,6 +239,9 @@ export async function fetchAnthropicModels(
|
|||
}
|
||||
|
||||
export function isKtxSetupLlmConfigReady(config: KtxProjectLlmConfig): boolean {
|
||||
if (config.agentRunner?.backend === 'claude-code') {
|
||||
return Boolean(config.models.default);
|
||||
}
|
||||
let resolved: KtxLlmConfig | null;
|
||||
try {
|
||||
resolved = resolveLocalKtxLlmConfig(config, process.env);
|
||||
|
|
@ -274,6 +278,7 @@ function buildProjectLlmConfig(
|
|||
},
|
||||
models: { ...existing.models, default: model },
|
||||
promptCaching: { ...(existing.promptCaching ?? {}), enabled: true, vertexFallbackTo5m: true },
|
||||
agentRunner: { backend: 'ai-sdk' },
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -284,6 +289,7 @@ function buildProjectLlmConfig(
|
|||
},
|
||||
models: { ...existing.models, default: model },
|
||||
promptCaching: { ...(existing.promptCaching ?? {}), enabled: true },
|
||||
agentRunner: { backend: 'ai-sdk' },
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -305,6 +311,30 @@ function buildVertexHealthConfig(vertex: { project?: string; location: string },
|
|||
};
|
||||
}
|
||||
|
||||
async function defaultClaudeCodeAuthProbe(): Promise<{ ok: true } | { ok: false; message: string }> {
|
||||
const { query } = await import('@anthropic-ai/claude-agent-sdk');
|
||||
const session = query({
|
||||
prompt: '',
|
||||
options: {
|
||||
tools: [],
|
||||
settingSources: [],
|
||||
skills: [],
|
||||
allowedTools: [],
|
||||
disallowedTools: ['Bash', 'Read', 'Edit', 'Write', 'Grep', 'Glob', 'WebFetch', 'WebSearch', 'Task'],
|
||||
permissionMode: 'dontAsk',
|
||||
maxTurns: 1,
|
||||
},
|
||||
});
|
||||
try {
|
||||
await session.accountInfo();
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
return { ok: false, message: error instanceof Error ? error.message : String(error) };
|
||||
} finally {
|
||||
session.close();
|
||||
}
|
||||
}
|
||||
|
||||
type LlmHealthProvider = 'Anthropic API' | 'Vertex AI';
|
||||
|
||||
function llmHealthCheckStartText(provider: LlmHealthProvider, model: string): string {
|
||||
|
|
@ -483,13 +513,18 @@ async function chooseBackend(
|
|||
options: [
|
||||
{ value: 'anthropic', label: 'Anthropic API' },
|
||||
{ value: 'vertex', label: 'Google Vertex AI for Anthropic Claude' },
|
||||
{ value: 'claude-code', label: 'Claude Code local session (agent runner only)' },
|
||||
{ value: 'back', label: 'Back' },
|
||||
],
|
||||
});
|
||||
if (choice === 'back') {
|
||||
return { status: 'back' };
|
||||
}
|
||||
return { status: 'ready', backend: choice === 'vertex' ? 'vertex' : 'anthropic', prompted: true };
|
||||
return {
|
||||
status: 'ready',
|
||||
backend: choice === 'vertex' ? 'vertex' : choice === 'claude-code' ? 'claude-code' : 'anthropic',
|
||||
prompted: true,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveProvidedVertexRef(
|
||||
|
|
@ -826,6 +861,21 @@ async function persistLlmConfig(
|
|||
await markKtxSetupStateStepComplete(projectDir, 'llm');
|
||||
}
|
||||
|
||||
async function persistClaudeCodeAgentRunnerConfig(projectDir: string, model: string): Promise<void> {
|
||||
const project = await loadKtxProject({ projectDir });
|
||||
const nextConfig: KtxProjectConfig = {
|
||||
...project.config,
|
||||
llm: {
|
||||
...project.config.llm,
|
||||
provider: { backend: 'none' },
|
||||
models: { ...project.config.llm.models, default: model },
|
||||
agentRunner: { backend: 'claude-code' },
|
||||
},
|
||||
};
|
||||
await writeFile(project.configPath, serializeKtxProjectConfig(nextConfig), 'utf-8');
|
||||
await markKtxSetupStateStepComplete(projectDir, 'llm');
|
||||
}
|
||||
|
||||
function buildInteractiveRetryArgs(args: KtxSetupModelArgs, backend?: KtxSetupLlmBackend): KtxSetupModelArgs {
|
||||
return {
|
||||
projectDir: args.projectDir,
|
||||
|
|
@ -874,6 +924,18 @@ export async function runKtxSetupAnthropicModelStep(
|
|||
? ({ ...attemptArgs, llmBackend: backendChoice.backend, showPromptInstructions: false } satisfies KtxSetupModelArgs)
|
||||
: attemptArgs;
|
||||
|
||||
if (backendChoice.backend === 'claude-code') {
|
||||
const model = backendArgs.anthropicModel ?? 'claude-sonnet-4-6';
|
||||
const probe = await (deps.claudeCodeAuthProbe ?? defaultClaudeCodeAuthProbe)();
|
||||
if (!probe.ok) {
|
||||
io.stderr.write(`Claude Code authentication check failed: ${probe.message}\n`);
|
||||
return { status: 'failed', projectDir: args.projectDir };
|
||||
}
|
||||
await persistClaudeCodeAgentRunnerConfig(args.projectDir, model);
|
||||
io.stdout.write(`│ LLM ready: yes (Claude Code agent runner, ${model})\n`);
|
||||
return { status: 'ready', projectDir: args.projectDir };
|
||||
}
|
||||
|
||||
if (backendChoice.backend === 'vertex') {
|
||||
const vertex = await chooseVertexConfig(backendArgs, io, deps);
|
||||
if (vertex.status === 'back' && backendChoice.prompted) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue