diff --git a/.changeset/sdk-executor-by-default.md b/.changeset/sdk-executor-by-default.md new file mode 100644 index 0000000..7462b90 --- /dev/null +++ b/.changeset/sdk-executor-by-default.md @@ -0,0 +1,5 @@ +--- +"uptide": minor +--- + +`npx uptide fix` gets the Agent SDK executor. The package stays as small as before: `@anthropic-ai/claude-agent-sdk` is not installed with Uptide. An SDK installed next to Uptide is used as it is; otherwise the first `fix` that needs it installs the version Uptide is tested with into Uptide's cache (`~/.cache/uptide/agent-sdk/`), says so in one line with its size, and later runs reuse it. When that install fails (offline, a proxy) or the SDK cannot run, `fix` runs the patch executor and says so on the first line of its output and at the top of the PR body, with the reason and how to get the SDK executor. `report.json` records `llm.executor` and, after a fallback, the reason in `llm.sdkMissing`. The privacy notice names the SDK only when the SDK will run, and the LLM line names the executor. `--agent sdk` without a usable SDK stops before any work instead of failing mid-run. diff --git a/docs/architecture.md b/docs/architecture.md index 26df7a5..57eaeec 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -538,7 +538,7 @@ block with the exact commands for the repository. `--details` keeps the full lis With `--agent patch` or a non-Anthropic provider, sites a rule leaves manual go to the patch agent (`src/fix/assisted.ts`): one site at a time, a snippet and a guide in, one unified diff out, applied and judged per -attempt by exact diagnostic position. Authenticated Anthropic runs default to `--agent sdk` when the SDK (an optional peer) is installed, replacing +attempt by exact diagnostic position. Authenticated Anthropic runs default to `--agent sdk` when the SDK can load (next to Uptide, or fetched once into its cache), replacing that step with `assistWithSdk` (`src/fix/sdk-agent.ts`). It runs one Claude Agent SDK session for the package's manual sites and their rule knowledge, in the clone, with read and edit tools, Uptide's own checks as in-process tools and no shell. One gate runs at the end of each task, matching diagnostics diff --git a/docs/commands/fix.md b/docs/commands/fix.md index e45bb8f..bc9cb37 100644 --- a/docs/commands/fix.md +++ b/docs/commands/fix.md @@ -69,15 +69,22 @@ Declarations outside the repository or in `node_modules` are never editable. ## Assisted-fix executor With `ANTHROPIC_API_KEY` set and Anthropic selected, assisted fixes use the Claude Agent SDK -by default, when `@anthropic-ai/claude-agent-sdk` is installed next to Uptide. It is an -optional peer, not a dependency: it brings the platform-specific Claude Code binary (about -270 MB), which `--no-llm`, OpenAI and Gemini runs do not need. Without it, `fix` uses the -patch executor and says so in one line of its output. With `npx`: -`npx -p uptide -p @anthropic-ai/claude-agent-sdk uptide fix `. The Uptide GitHub -Action installs it. +by default, `npx uptide fix ` included. `@anthropic-ai/claude-agent-sdk` is not +installed with Uptide: it brings the Claude Code binary for your platform (about 310 MB), which +`check`, `list`, `--no-llm`, OpenAI and Gemini runs never need. An SDK installed next to Uptide +is used as it is (the Uptide GitHub Action installs one). Otherwise the first `fix` that needs +it installs the version Uptide is tested with into Uptide's cache +(`~/.cache/uptide/agent-sdk/`, or under `UPTIDE_CACHE_DIR` / `XDG_CACHE_HOME`), says so +in one line with its size, and later runs reuse it. + +If that install fails (offline, a proxy) or the SDK cannot run, `fix` uses the patch executor +and says so first: as the first line of its output, and at the top of the PR body, with the +reason and how to get the SDK executor. `report.json` records the executor in `llm.executor` +and the reason in `llm.sdkMissing`. Use `--agent patch` for the snippet-and-diff executor. OpenAI and Gemini keep that executor; -`--agent sdk` requires Anthropic. The SDK is never bundled with Uptide. +`--agent sdk` requires Anthropic and stops before any work when the SDK cannot load. The SDK +is installed from npm, never bundled with Uptide. Without a key, rule-based packs still work; `--no-llm` disables both executors. SDK sessions can read files throughout the temporary clone and send their contents and check output to Anthropic; see [privacy](../privacy.md#agent-sdk-executor). diff --git a/docs/decisions/0016-agent-sdk-executor.md b/docs/decisions/0016-agent-sdk-executor.md index 31e5f7d..a93d53a 100644 --- a/docs/decisions/0016-agent-sdk-executor.md +++ b/docs/decisions/0016-agent-sdk-executor.md @@ -3,7 +3,8 @@ Status: default for authenticated Anthropic fixes. `--agent patch` opts out; other providers and callers supplying a fixer retain the patch executor. Without a key, rule-based fixes remain available. `--no-llm` disables either executor. The SDK is an optional peer, never -bundled: without it installed, the default is the patch executor. +bundled: without one next to Uptide, the first fix installs it into Uptide's cache, and when it +cannot run, the default is the patch executor, said first in the output and the PR body. ## Why @@ -101,13 +102,41 @@ unchanged. throws", which forbid failing loudly on a missing period. - **Final verdict.** Unchanged, and the same for both agents: diagnostics against the pre-upgrade baseline through `gitLineMoves`. -- **Report.** `report.json` has `llm.agent: 'sdk'` and, per task, `llm.tasks[]`: turns, +- **Report.** `report.json` has `llm.executor` (`sdk` or `patch`), `llm.sdkMissing` when the + default fell back, `llm.agent: 'sdk'` and, per task, `llm.tasks[]`: turns, tokens, cost, wall time, tool calls by name, discarded files, gate verdict, rejection, and the summary. ## Dependency and terms -### Optional peer, the default only where installed (2026-10-12) +### Fetched on the first fix, into Uptide's cache (2026-10-18) + +**Decision:** the SDK stays out of the `uptide` package (still an optional peer, still +external to both builds). An SDK resolvable next to Uptide wins, so the Action's install path +is unchanged. Otherwise the first `fix` that would use it installs the pinned `SDK_VERSION` +(the version the tests run) with `npm install` into `/agent-sdk/`, says so in +one line with its size, and loads it from there; later runs reuse it. When that install fails +(offline, a proxy) or the SDK cannot run, the default runs the patch executor and says so as +the first line of the output and at the top of the PR body, with the reason and the remedy; +`report.json` has `llm.executor` and `llm.sdkMissing`. `--agent sdk` without a usable SDK +stops before any clone. This supersedes the decision below. + +- **Why.** The UpgradeBench pilot (#169, round 3) ran `npx uptide@0.15.1 fix` as the README + says: nothing installed the peer, so every run used the patch executor while the notice and + docs described the SDK, and no output said so. A default nobody gets is not a default. +- **Why not a dependency.** Tried first (UPT-108): as an optional dependency, a plain + `npm install` of the packed tarball grows from 13.6 MB and 3 packages to 323.6 MB and 110 + packages (node:22, linux arm64: binary 250 MB, SDK 5.5 MB, its peers 36 MB). Every `npx + uptide check` and every consumer of `uptide/onboarding` or `uptide/report` would pay that for + an executor only `fix` uses. Fetched on demand, they pay nothing; the first fix pays once. +- **Install.** `npm install --prefix --no-save --include=optional`, with lifecycle + scripts off and secrets stripped from the environment like every other install Uptide runs, + then a rename into place, so the cache holds a complete install or none. +- **Terms.** Unchanged from below. Question 2 there ("Is an npm dependency 'preinstalling' + Claude Code in a product?") becomes: is installing the published package on first use? + Still open. + +### Optional peer, the default only where installed (2026-10-12, superseded) **Decision:** the SDK stays an optional peer of `uptide`, never a dependency. The default uses it when it can be imported; otherwise `fix` runs the patch executor and says, in one diff --git a/docs/privacy.md b/docs/privacy.md index 89fc0f5..34371ec 100644 --- a/docs/privacy.md +++ b/docs/privacy.md @@ -67,9 +67,11 @@ provider you chose (or your `OPENAI_BASE_URL`); your provider's data policy appl ## Agent SDK executor Anthropic assisted fixes use the Claude Agent SDK by default (`--agent sdk`) when -`@anthropic-ai/claude-agent-sdk` is installed next to Uptide, which runs Anthropic's Claude -Code binary on your machine ([decision record](decisions/0016-agent-sdk-executor.md)). It is -an optional peer: Uptide neither bundles nor modifies it, and without it the patch agent runs. Its use is governed by +`@anthropic-ai/claude-agent-sdk` can load: installed next to Uptide, or installed by the first +`fix` that needs it into Uptide's cache. It runs Anthropic's Claude Code binary on your machine +([decision record](decisions/0016-agent-sdk-executor.md)). npm installs Anthropic's unmodified +package: Uptide neither bundles nor modifies it, and without it the patch agent runs and `fix` +says so. That install contacts only your npm registry. Its use is governed by [Anthropic's terms](https://code.claude.com/docs/en/legal-and-compliance), not Uptide's license. diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 491d304..6924c84 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -19,6 +19,8 @@ import { ReportedError, registeredPacks, scaffoldPack, + sdkFallbackNote, + sdkUnavailable, selectLlm, TIER_LEGEND, testPack, @@ -1052,6 +1054,19 @@ ${runnable(PRIVACY)}`, }); // `--only zod --target stripe@23.0.0` would upgrade zod to 23.0.0, or fail late. if (target) parseTargets([target], [only]); + // The Anthropic default is the SDK executor: fetched once into the cache if needed, + // and when it cannot run, that comes first. + const sdkMissing = + llmOn && + !flags.pinCurrentApi && + selection.available && + selection.provider === 'anthropic' && + agent !== 'patch' + ? await sdkUnavailable((text) => io.err(text)) + : undefined; + if (sdkMissing && agent === undefined) + io.err(`warning: ${sdkFallbackNote(sdkMissing)}\n`); + const sdk = selection.provider === 'anthropic' && agent !== 'patch' && !sdkMissing; if (flags.pr && !flags.yes) io.err('--pr without --yes: the publication plan is printed, nothing is pushed.\n'); const repo = await progress.phase( @@ -1112,14 +1127,9 @@ ${runnable(PRIVACY)}`, ); else if (selection.available) { io.err( - `LLM: ${selection.provider}${selection.provider === 'gemini' ? ' (experimental)' : ''} / ${selection.model} · budget $${maxCost.toFixed(2)}\n`, - ); - io.err( - assistedNote( - selection.provider, - selection.provider === 'anthropic' && agent !== 'patch', - ), + `LLM: ${selection.provider}${selection.provider === 'gemini' ? ' (experimental)' : ''} / ${selection.model} · executor ${sdk ? 'sdk' : 'patch'} · budget $${maxCost.toFixed(2)}\n`, ); + io.err(assistedNote(selection.provider, sdk)); const price = priceFor(selection.provider, selection.model); if (price.fallback) io.err( diff --git a/packages/cli/src/package.test.ts b/packages/cli/src/package.test.ts index 1a4b457..3644fa3 100644 --- a/packages/cli/src/package.test.ts +++ b/packages/cli/src/package.test.ts @@ -12,6 +12,7 @@ import { import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { SDK_VERSION } from '@uptide/core'; import { describe, expect, it } from 'vitest'; const root = fileURLToPath(new URL('..', import.meta.url)); @@ -26,8 +27,12 @@ describe('published package', () => { // CommonJS build calls require("process"), which breaks consumers' ES module builds. expect(Object.keys(manifest.dependencies)).toEqual(['yaml']); expect(manifest.devDependencies?.yaml).toBeUndefined(); - // The experimental `--agent sdk` loads the Claude Agent SDK only if the user installed it. - expect(manifest.peerDependencies).toEqual({ '@anthropic-ai/claude-agent-sdk': '>=0.3.296' }); + // Never installed with uptide: an SDK next to it wins, else the first fix that needs it + // installs SDK_VERSION, the version the tests run, into Uptide's cache. + expect(manifest.peerDependencies).toEqual({ + '@anthropic-ai/claude-agent-sdk': `>=${SDK_VERSION}`, + }); + expect(manifest.devDependencies['@anthropic-ai/claude-agent-sdk']).toBe(SDK_VERSION); expect(manifest.peerDependenciesMeta).toEqual({ '@anthropic-ai/claude-agent-sdk': { optional: true }, }); diff --git a/packages/core/src/fix/fix.test.ts b/packages/core/src/fix/fix.test.ts index 8a376a7..7fa30e0 100644 --- a/packages/core/src/fix/fix.test.ts +++ b/packages/core/src/fix/fix.test.ts @@ -18,7 +18,7 @@ import { published, version } from '../version.js'; import { buildWorkspaces } from './build.js'; import { git } from './process.js'; import { publicationBlockers } from './publish.js'; -import { formatFix, prBody, summaryCells } from './report.js'; +import { formatFix, prBody, renderMigration, summaryCells } from './report.js'; import { reverify } from './reverify.js'; import { type FixOptions, fix } from './run.js'; import * as sdkAgent from './sdk-agent.js'; @@ -106,24 +106,46 @@ describe('fix transaction', () => { key: string; sdk: boolean; installed?: boolean; + executor?: 'sdk' | 'patch'; + refused?: boolean; }>([ - { name: 'Anthropic defaults to SDK', options: {}, key: 'test-key', sdk: true }, + { + name: 'Anthropic defaults to SDK', + options: {}, + key: 'test-key', + sdk: true, + executor: 'sdk', + }, { name: 'default without the SDK installed falls back to patch', options: {}, key: 'test-key', sdk: false, installed: false, + executor: 'patch', }, { name: 'explicit SDK without it installed', options: { agent: 'sdk' }, key: 'test-key', - sdk: true, + sdk: false, installed: false, + refused: true, + }, + { + name: 'explicit SDK', + options: { agent: 'sdk' }, + key: 'test-key', + sdk: true, + executor: 'sdk', + }, + { + name: 'explicit patch', + options: { agent: 'patch' }, + key: 'test-key', + sdk: false, + executor: 'patch', }, - { name: 'explicit SDK', options: { agent: 'sdk' }, key: 'test-key', sdk: true }, - { name: 'explicit patch', options: { agent: 'patch' }, key: 'test-key', sdk: false }, { name: 'no key', options: {}, key: '', sdk: false }, { name: 'no LLM overrides SDK', @@ -143,37 +165,58 @@ describe('fix transaction', () => { }, key: 'test-key', sdk: false, + executor: 'patch', }, { name: 'OpenAI', options: { provider: 'openai' }, key: 'test-key', sdk: false }, { name: 'Gemini', options: { provider: 'gemini' }, key: 'test-key', sdk: false }, - ])('$name selects the expected executor', async ({ options, key, sdk, installed = true }) => { - const { root, services } = fixture(); - vi.stubEnv('ANTHROPIC_API_KEY', key); - const tests = vi.spyOn(services, 'tests'); - const assist = vi.spyOn(sdkAgent, 'assistWithSdk').mockResolvedValue({ - available: true, - inputTokens: 0, - outputTokens: 0, - costUsd: 0, - agent: 'sdk', - }); - try { - const report = await fix( - { cwd: root, only: 'zod', provider: 'anthropic', ...options }, - { ...services, sdkInstalled: async () => installed }, - ); - expect(assist).toHaveBeenCalledTimes(sdk ? 1 : 0); - expect(report.llm.agent).toBe(sdk ? 'sdk' : undefined); - const fellBack = !installed && !sdk; - expect(report.llm.sdkMissing).toBe(fellBack || undefined); - expect(formatFix(report).includes('@anthropic-ai/claude-agent-sdk')).toBe(fellBack); - expect(tests.mock.calls.length).toBeGreaterThanOrEqual(2); - expect(tests.mock.calls.every(([, , , , options]) => options?.fullSuite === true)).toBe(true); - } finally { - assist.mockRestore(); - vi.unstubAllEnvs(); - } - }); + ])( + '$name selects the expected executor', + async ({ options, key, sdk, installed = true, executor, refused }) => { + const { root, services } = fixture(); + vi.stubEnv('ANTHROPIC_API_KEY', key); + const tests = vi.spyOn(services, 'tests'); + const assist = vi.spyOn(sdkAgent, 'assistWithSdk').mockResolvedValue({ + available: true, + inputTokens: 0, + outputTokens: 0, + costUsd: 0, + agent: 'sdk', + }); + try { + const run = fix( + { cwd: root, only: 'zod', provider: 'anthropic', ...options }, + { ...services, sdkUnavailable: async () => (installed ? undefined : 'not installed') }, + ); + // Asked for by name, the SDK never falls back: the run stops before any clone. + if (refused) { + await expect(run).rejects.toThrow( + /^--agent sdk cannot run: not installed; let Uptide reach the npm registry/, + ); + expect(assist).not.toHaveBeenCalled(); + return; + } + const report = await run; + expect(assist).toHaveBeenCalledTimes(sdk ? 1 : 0); + expect(report.llm.agent).toBe(sdk ? 'sdk' : undefined); + expect(report.llm.executor).toBe(executor); + const fellBack = !installed && !sdk; + expect(report.llm.sdkMissing).toBe(fellBack ? 'not installed' : undefined); + // The fallback is the first thing the summary and the PR body say after the title. + for (const text of [formatFix(report), renderMigration(report)]) + expect(text.split('\n')[2]?.startsWith('> ⚠️ Assisted fixes use the patch executor')).toBe( + fellBack, + ); + expect(renderMigration(report)).not.toContain('npx uptide with'); + expect(tests.mock.calls.length).toBeGreaterThanOrEqual(2); + expect(tests.mock.calls.every(([, , , , options]) => options?.fullSuite === true)).toBe( + true, + ); + } finally { + assist.mockRestore(); + vi.unstubAllEnvs(); + } + }, + ); it('does not pass a run whose baseline cannot see a package the repository installs', async () => { const { root, services } = fixture(); diff --git a/packages/core/src/fix/report.ts b/packages/core/src/fix/report.ts index 36831d7..e602ad0 100644 --- a/packages/core/src/fix/report.ts +++ b/packages/core/src/fix/report.ts @@ -18,6 +18,7 @@ import { noPackBlockers } from './no-pack.js'; import { uniqueSites } from './paired.js'; import { groupPeerBlockers, peerDescription } from './peer-preflight.js'; import { agentDecisionLines, testOutput } from './review-evidence.js'; +import { sdkFallbackNote } from './sdk-agent.js'; import { stripeChangelog } from './stripe-changelog.js'; import { isTestFile } from './test-edits.js'; import type { FixDiagnostic, FixPhase, FixReport, FixSite, ReviewSection } from './types.js'; @@ -834,6 +835,8 @@ export function renderMigration( ? `## Pin the Stripe API version to ${report.apiVersion}` : `## Upgrade ${report.package} ${from} → ${report.target}`; const lines: (string | Piece)[] = [title, '']; + // Never a silent switch: the executor that ran is the first thing said when it is not the SDK. + if (report.llm.sdkMissing) lines.push(`> ⚠️ ${sdkFallbackNote(report.llm.sdkMissing)}`, ''); // Why the mechanical path stopped and the agent led: the first thing a reviewer reads. if (report.agentFallback?.length) lines.push( @@ -994,11 +997,6 @@ export function renderMigration( if (report.tier === 'generic' && report.sites.some((s) => s.outcome === 'agent')) lines.push(`> ${GENERIC_NOTE(report.package)}`, ''); if (report.llm.disabled) lines.push('Assisted fixes disabled (--no-llm).', ''); - if (mode === 'compact' && report.llm.sdkMissing) - lines.push( - 'Assisted fixes used the patch agent: install @anthropic-ai/claude-agent-sdk next to uptide for the SDK executor (--agent patch silences this).', - '', - ); lines.push(...summaryRows(report), ''); if (mode === 'full' && budget !== undefined && report.package === 'stripe') { for (const stored of report.reviewSections ?? []) { diff --git a/packages/core/src/fix/run.ts b/packages/core/src/fix/run.ts index 522f52b..a2f5587 100644 --- a/packages/core/src/fix/run.ts +++ b/packages/core/src/fix/run.ts @@ -60,7 +60,7 @@ import { command, git, projectRoot } from './process.js'; import { publicationBlockers } from './publish.js'; import { changeRule, editDiff, prBody } from './report.js'; import { describeRootPin, planRootPins, writeRootPins } from './root-pins.js'; -import { assistWithSdk, sdkInstalled, type TaskRunner } from './sdk-agent.js'; +import { assistWithSdk, SDK_REMEDY, sdkUnavailable, type TaskRunner } from './sdk-agent.js'; import { selectedFindings } from './select.js'; import { applyFollowUps, commit, type Followed, safeFile, settle } from './settle.js'; import { formatFiles, lintFiles } from './style.js'; @@ -198,8 +198,8 @@ export interface FixServices { metadata?: PackageFetcher['metadata']; /** `--agent sdk` sessions; the Claude Agent SDK by default. */ agentRunner?: TaskRunner; - /** Whether the optional SDK peer can be imported; decides the default executor. */ - sdkInstalled?: () => Promise; + /** Why the optional SDK cannot run, or undefined when it can; decides the default executor. */ + sdkUnavailable?: () => Promise; /** The publish step of a `--pr` run; `publishVerified` by default. */ publish?( result: FixReport, @@ -320,9 +320,11 @@ async function fixPackage(options: FixOptions, services: FixServices): Promise l.status !== 'failed'), }, - llm: sdkMissing ? { ...llm, sdkMissing: true } : llm, + llm: { + ...llm, + ...(sdkAgent ? { executor: 'sdk' } : fixer ? { executor: 'patch' } : {}), + ...(sdkMissing ? { sdkMissing } : {}), + }, ...(behavior ? { behavior } : {}), timingMs: Date.now() - started, phases, diff --git a/packages/core/src/fix/sdk-agent.ts b/packages/core/src/fix/sdk-agent.ts index dbd8577..3974ea7 100644 --- a/packages/core/src/fix/sdk-agent.ts +++ b/packages/core/src/fix/sdk-agent.ts @@ -1,13 +1,27 @@ import { createHash } from 'node:crypto'; -import { existsSync, lstatSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; +import { + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + realpathSync, + renameSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { createRequire } from 'node:module'; import { dirname, isAbsolute, join, relative, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; import type { SDKMessage } from '@anthropic-ai/claude-agent-sdk'; import { ts } from 'ts-morph'; +import { defaultCacheDir } from '../cache/paths.js'; import type { ProgressListener } from '../domain/progress.js'; import { modelCapabilities } from '../llm/capabilities.js'; import { packDecisions } from '../packs/decisions.js'; import type { MigrationPack, PackContext } from '../packs/types.js'; -import { git } from './process.js'; +import { command, git } from './process.js'; import { commit, followedText, safeFile } from './settle.js'; import { formatFiles, lintFiles } from './style.js'; import { isTestFile, testEditObjection } from './test-edits.js'; @@ -696,17 +710,130 @@ export async function assistWithSdk(input: SdkAssistInput): Promise import('@anthropic-ai/claude-agent-sdk'); +const SDK = '@anthropic-ai/claude-agent-sdk'; +/** The SDK version Uptide is tested with (core's devDependency), fetched when none is installed. */ +export const SDK_VERSION = '0.3.296'; +/** Where the first fix that needs the SDK installs it, outside the uptide package. */ +export const sdkCacheDir = (env: NodeJS.ProcessEnv = process.env): string => + join(defaultCacheDir(env), 'agent-sdk', SDK_VERSION); +/** The SDK entry that passed `sdkUnavailable`; the bare specifier before any check. */ +let sdkEntry: string | undefined; -/** Whether the default may use the SDK; without it the default is the patch agent. */ -export async function sdkInstalled(): Promise { +/** The Claude Agent SDK: external to the bundle, never a dependency, loaded only here. */ +const loadSdk = (): Promise => + sdkEntry ? import(pathToFileURL(sdkEntry).href) : import('@anthropic-ai/claude-agent-sdk'); + +/** How to get the SDK executor, said wherever it is missing. */ +export const SDK_REMEDY = `let Uptide reach the npm registry once to install it into its cache, or install ${SDK}@${SDK_VERSION} next to Uptide`; + +/** The fallback, said first in the output and the PR body: which executor, why, how to change it. */ +export const sdkFallbackNote = (reason: string): string => + `Assisted fixes use the patch executor, not the Agent SDK: ${reason}. For the SDK executor, ${SDK_REMEDY}; --agent patch chooses the patch executor and drops this note.`; + +/** Why the SDK at this entry cannot run: it fails to load, or has no Claude Code binary here. */ +async function sdkProblem(entry: string): Promise { + try { + await import(pathToFileURL(entry).href); + } catch (error) { + return `${SDK} failed to load: ${String((error as Error)?.message ?? error).split('\n')[0]}`; + } + const sdk = createRequire(entry); + const base = `${SDK}-${process.platform}-${process.arch}`; + const binary = process.platform === 'win32' ? 'claude.exe' : 'claude'; + for (const name of [base, `${base}-musl`]) + try { + if (existsSync(sdk.resolve(`${name}/${binary}`))) return undefined; + } catch {} + return `the Claude Code binary for ${process.platform}-${process.arch} (${base}) is not installed`; +} + +const resolveFrom = (from: string): string | undefined => { + try { + return createRequire(from).resolve(SDK); + } catch { + return undefined; + } +}; + +const diskSize = (path: string): number => { + const stat = lstatSync(path); + return stat.isDirectory() + ? readdirSync(path).reduce((sum, entry) => sum + diskSize(join(path, entry)), 0) + : stat.size; +}; + +/** The pinned SDK, binary included, into `dir`: a complete install or nothing there. */ +async function installSdk(dir: string, log?: (text: string) => void): Promise { + let temp: string; + try { + mkdirSync(dirname(dir), { recursive: true }); + temp = mkdtempSync(`${dir}.tmp-`); + } catch (error) { + return `installing ${SDK}@${SDK_VERSION} into ${dir} failed (${(error as Error).message})`; + } + log?.(`Agent SDK ${SDK_VERSION} is not installed: installing it once into ${dir} … `); + // Optional dependencies are the binary: included even where the user's npm config omits them. + const npm = await command( + temp, + process.platform === 'win32' ? 'npm.cmd' : 'npm', + [ + 'install', + '--prefix', + temp, + '--no-save', + '--no-package-lock', + '--no-audit', + '--no-fund', + '--include=optional', + '--loglevel=error', + `${SDK}@${SDK_VERSION}`, + ], + 600_000, + ); + if (npm.code !== 0) { + rmSync(temp, { recursive: true, force: true }); + log?.('failed\n'); + const line = npm.output.split('\n').find((l) => /ERR!|error/i.test(l)) ?? npm.output.trim(); + return `installing ${SDK}@${SDK_VERSION} into ${dir} failed (${npm.timeout ? 'timed out' : line.trim().slice(0, 200) || `exit ${npm.code}`})`; + } + rmSync(dir, { recursive: true, force: true }); try { - await loadSdk(); - return true; + renameSync(temp, dir); } catch { - return false; + // Another run installed it first: theirs is as good. + rmSync(temp, { recursive: true, force: true }); } + log?.(`${Math.round(diskSize(dir) / 1e6)} MB, reused by later runs\n`); + return undefined; +} + +let checked: Promise | undefined; +/** + * Why the SDK executor cannot run here, or undefined when it can. An SDK next to Uptide (the + * Action's install, or the user's) wins; then the one in Uptide's cache; else the pinned + * version is installed there once, saying so through `log`. Checked once per process. + */ +export function sdkUnavailable(log?: (text: string) => void): Promise { + checked ??= (async () => { + const cached = join(sdkCacheDir(), 'node_modules', '.uptide'); + let reason = `${SDK} is not installed`; + for (const entry of [resolveFrom(import.meta.url), resolveFrom(cached)]) { + if (!entry) continue; + const problem = await sdkProblem(entry); + if (!problem) { + sdkEntry = entry; + return undefined; + } + reason = problem; + } + const failed = await installSdk(sdkCacheDir(), log); + if (failed) return `${reason}, and ${failed}`; + const entry = resolveFrom(cached); + const problem = entry ? await sdkProblem(entry) : `${SDK} is not in ${sdkCacheDir()}`; + if (!problem) sdkEntry = entry; + return problem; + })(); + return checked; } const sdkRunner: TaskRunner = async ({ @@ -723,7 +850,7 @@ const sdkRunner: TaskRunner = async ({ sdk = await loadSdk(); } catch { throw new Error( - '--agent sdk needs @anthropic-ai/claude-agent-sdk installed next to uptide (npm install -g @anthropic-ai/claude-agent-sdk), or use --agent patch', + `--agent sdk: @anthropic-ai/claude-agent-sdk did not load; ${SDK_REMEDY}, or use --agent patch`, ); } const text = (t: string) => ({ content: [{ type: 'text' as const, text: t }] }); diff --git a/packages/core/src/fix/types.ts b/packages/core/src/fix/types.ts index 6c01361..d630814 100644 --- a/packages/core/src/fix/types.ts +++ b/packages/core/src/fix/types.ts @@ -329,8 +329,10 @@ export interface FixReport { estimate?: CostEstimate; /** Agent SDK sessions (the Anthropic default) instead of the patch agent. */ agent?: 'sdk'; - /** The SDK would have been the default but is not installed: the patch agent ran. */ - sdkMissing?: true; + /** The executor assisted fixes use in this run; absent when they are off or have no key. */ + executor?: 'sdk' | 'patch'; + /** Why the SDK, the Anthropic default, could not run, so the patch executor did. */ + sdkMissing?: string; tasks?: AgentTaskRecord[]; feedback?: { roundsUsed: number; maxRounds: number; maxCostUsd: number }; }; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index fb879c6..3a0d268 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -164,6 +164,13 @@ export { } from './fix/report.js'; export { type ReverifyOptions, reverify } from './fix/reverify.js'; export { type FixOptions, fix } from './fix/run.js'; +export { + SDK_REMEDY, + SDK_VERSION, + sdkCacheDir, + sdkFallbackNote, + sdkUnavailable, +} from './fix/sdk-agent.js'; export { detectStyle } from './fix/style.js'; export type { Fixer, FixPhase, FixReport, FixRequest, FixResponse } from './fix/types.js'; export { buildToolMajor } from './list/evidence.js';