From 77bfe7ce837881c07f5b05de5324a7a0551b89bb Mon Sep 17 00:00:00 2001 From: Lucas Braum Date: Sun, 18 Oct 2026 10:56:30 -0700 Subject: [PATCH 1/3] feat(fix): ship the Agent SDK with uptide, never fall back silently (UPT-108) `npx uptide fix` never installed @anthropic-ai/claude-agent-sdk, an optional peer, so the Anthropic default quietly ran the patch executor while the notice and docs described the SDK. - The SDK is an optionalDependency (pinned to the tested 0.3.296): npm, pnpm and yarn install it with uptide, binary included. It stays external and loads only through import(). - sdkUnavailable() says why the SDK cannot run: not installed, failed to load, or no Claude Code binary for the platform. A fallback is the first line of the output and of the PR body, with the reason and how to get the SDK executor. `--agent sdk` stops before any clone. - report.json records llm.executor (sdk or patch) and the fallback reason in llm.sdkMissing. - The privacy notice names the SDK only when it will run; the LLM line names the executor. - ADR 0016 records the new decision with measured install sizes; fix, privacy and architecture docs follow. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Lucas Braum --- .changeset/sdk-executor-by-default.md | 5 + docs/architecture.md | 2 +- docs/commands/fix.md | 17 ++-- docs/decisions/0016-agent-sdk-executor.md | 36 +++++++- docs/privacy.md | 7 +- packages/cli/package.json | 12 +-- packages/cli/src/cli.ts | 22 +++-- packages/cli/src/package.test.ts | 9 +- packages/core/src/fix/fix.test.ts | 107 +++++++++++++++------- packages/core/src/fix/report.ts | 8 +- packages/core/src/fix/run.ts | 24 +++-- packages/core/src/fix/sdk-agent.ts | 38 ++++++-- packages/core/src/fix/types.ts | 6 +- packages/core/src/index.ts | 1 + pnpm-lock.yaml | 7 +- 15 files changed, 209 insertions(+), 92 deletions(-) create mode 100644 .changeset/sdk-executor-by-default.md diff --git a/.changeset/sdk-executor-by-default.md b/.changeset/sdk-executor-by-default.md new file mode 100644 index 0000000..850037c --- /dev/null +++ b/.changeset/sdk-executor-by-default.md @@ -0,0 +1,5 @@ +--- +"uptide": minor +--- + +`npx uptide fix` gets the Agent SDK executor. `@anthropic-ai/claude-agent-sdk` is now an optional dependency instead of an optional peer, so npm, pnpm and yarn install it with Uptide, along with the Claude Code binary for the platform (about 310 MB more on disk). When it cannot load (`--omit=optional`, no binary for the platform), `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 loadable SDK stops before any work instead of failing mid-run. diff --git a/docs/architecture.md b/docs/architecture.md index 26df7a5..c68be0b 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 (an optional dependency) can load, 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..4d8577b 100644 --- a/docs/commands/fix.md +++ b/docs/commands/fix.md @@ -69,15 +69,18 @@ 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. `@anthropic-ai/claude-agent-sdk` is an optional dependency of `uptide`, so npm, +pnpm and yarn install it with Uptide, `npx uptide fix ` included. It brings the +Claude Code binary for your platform: about 310 MB more on disk with npm. + +If it cannot load (installed with `--omit=optional`, or no binary for the platform), `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 back. `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..e0f1ace 100644 --- a/docs/decisions/0016-agent-sdk-executor.md +++ b/docs/decisions/0016-agent-sdk-executor.md @@ -2,8 +2,9 @@ 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. +remain available. `--no-llm` disables either executor. The SDK is an optional dependency, +installed with Uptide and never bundled: when it cannot load, the default is the patch +executor, said first in the output and the PR body. ## Why @@ -101,13 +102,40 @@ 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) +### Optional dependency, installed by default (2026-10-18) + +**Decision:** the SDK moves from optional peer to `optionalDependencies` of `uptide`, pinned to +the version the tests run. npm, pnpm, yarn classic and yarn berry (`dlx`) install it with +Uptide, binary included, and it still loads only through `import()`. When it cannot run (not +installed, or no Claude Code binary for the platform), 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 it +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: no install brought 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 optional, not a regular dependency.** Both install the binary for everyone by + default. Only an optional dependency lets `--omit=optional` drop all of it (SDK, binary + and the SDK's peers) and still leave a working CLI, which falls back and says so. That is + the way out for library consumers of `uptide/onboarding` and `uptide/report`, and for + `--no-llm`, OpenAI and Gemini users who do not want the download. +- **Size, measured** (node:22 on linux arm64, plain `npm install` of the packed tarball): the + tarball is unchanged (2.5 MB); `node_modules` grows from 13.6 MB and 3 packages to 323.6 MB + and 110 packages. The binary is 250 MB of it, the SDK 5.5 MB, its peers 36 MB + (`@anthropic-ai/sdk`, `zod`, `@modelcontextprotocol/sdk`). +- **Terms.** Unchanged from below. Question 2 there ("Is an npm dependency 'preinstalling' + Claude Code in a product?") is now the live one: this decision ships it as one before an + answer. + +### 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..622fb02 100644 --- a/docs/privacy.md +++ b/docs/privacy.md @@ -67,9 +67,10 @@ 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`, an optional dependency installed with Uptide, can load; 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. 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/package.json b/packages/cli/package.json index 542f11b..6eba64a 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -32,6 +32,9 @@ "dependencies": { "yaml": "^2.9.1" }, + "optionalDependencies": { + "@anthropic-ai/claude-agent-sdk": "0.3.296" + }, "engines": { "node": ">=20" }, @@ -62,7 +65,6 @@ "postpack": "node scripts/prepack.mjs --clean" }, "devDependencies": { - "@anthropic-ai/claude-agent-sdk": "0.3.296", "@types/node": "^22.0.0", "@uptide/core": "workspace:*", "commander": "^15.0.0", @@ -70,13 +72,5 @@ "tsup": "^8.5.1", "typescript": "^5.9.3", "vitest": "^5.0.3" - }, - "peerDependencies": { - "@anthropic-ai/claude-agent-sdk": ">=0.3.296" - }, - "peerDependenciesMeta": { - "@anthropic-ai/claude-agent-sdk": { - "optional": true - } } } diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 491d304..4034150 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,17 @@ ${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; when it cannot run, that comes first. + const sdkMissing = + llmOn && + !flags.pinCurrentApi && + selection.available && + selection.provider === 'anthropic' && + agent === undefined + ? await sdkUnavailable() + : undefined; + if (sdkMissing) 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 +1125,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..ba60d02 100644 --- a/packages/cli/src/package.test.ts +++ b/packages/cli/src/package.test.ts @@ -26,11 +26,10 @@ 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' }); - expect(manifest.peerDependenciesMeta).toEqual({ - '@anthropic-ai/claude-agent-sdk': { optional: true }, - }); + // The Anthropic default executor: installed with uptide by npm, pnpm and yarn, never bundled, + // and optional so an install without it still works (and says it fell back). + expect(manifest.optionalDependencies).toEqual({ '@anthropic-ai/claude-agent-sdk': '0.3.296' }); + expect(manifest.peerDependencies).toBeUndefined(); expect(manifest.engines.node).toBe('>=20'); // Library entries besides the command, for the GitHub App and the Uptide app // (libs-export.test.ts): the pure onboarding generator, what reads it from disk, and the diff --git a/packages/core/src/fix/fix.test.ts b/packages/core/src/fix/fix.test.ts index 8a376a7..927f657 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,56 @@ 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; reinstall/); + 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..7cdf109 100644 --- a/packages/core/src/fix/sdk-agent.ts +++ b/packages/core/src/fix/sdk-agent.ts @@ -1,5 +1,6 @@ import { createHash } from 'node:crypto'; import { existsSync, lstatSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; import { dirname, isAbsolute, join, relative, resolve } from 'node:path'; import type { SDKMessage } from '@anthropic-ai/claude-agent-sdk'; import { ts } from 'ts-morph'; @@ -696,17 +697,40 @@ export async function assistWithSdk(input: SdkAssistInput): Promise import('@anthropic-ai/claude-agent-sdk'); -/** Whether the default may use the SDK; without it the default is the patch agent. */ -export async function sdkInstalled(): Promise { +/** How to get the SDK executor, said wherever it is missing. */ +export const SDK_REMEDY = + 'reinstall Uptide with its optional dependencies (no --omit=optional or --no-optional)'; + +/** 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 executor cannot run here, or undefined when it can: the SDK loads and finds the + * Claude Code binary for this platform, its own optional dependency, where the SDK looks for it. + */ +export async function sdkUnavailable(): Promise { try { await loadSdk(); - return true; - } catch { - return false; + } catch (error) { + const code = (error as { code?: string }).code; + return code === 'ERR_MODULE_NOT_FOUND' || code === 'MODULE_NOT_FOUND' + ? '@anthropic-ai/claude-agent-sdk is not installed' + : `@anthropic-ai/claude-agent-sdk failed to load: ${String((error as Error)?.message ?? error).split('\n')[0]}`; } + const sdk = createRequire( + createRequire(import.meta.url).resolve('@anthropic-ai/claude-agent-sdk'), + ); + const base = `@anthropic-ai/claude-agent-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 sdkRunner: TaskRunner = async ({ @@ -723,7 +747,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..c63244f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -164,6 +164,7 @@ export { } from './fix/report.js'; export { type ReverifyOptions, reverify } from './fix/reverify.js'; export { type FixOptions, fix } from './fix/run.js'; +export { SDK_REMEDY, 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'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ed2d40b..7b9b3f2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -45,9 +45,6 @@ importers: specifier: ^2.9.1 version: 2.9.1 devDependencies: - '@anthropic-ai/claude-agent-sdk': - specifier: 0.3.296 - version: 0.3.296(@anthropic-ai/sdk@0.133.0(zod@4.6.6))(@modelcontextprotocol/sdk@1.32.1(zod@4.6.6))(zod@4.6.6) '@types/node': specifier: ^22.0.0 version: 22.20.4 @@ -69,6 +66,10 @@ importers: vitest: specifier: ^5.0.3 version: 5.0.3(@types/node@22.20.4)(vite@8.3.1(@types/node@22.20.4)(esbuild@0.28.2)(tsx@4.23.15)(yaml@2.9.1)) + optionalDependencies: + '@anthropic-ai/claude-agent-sdk': + specifier: 0.3.296 + version: 0.3.296(@anthropic-ai/sdk@0.133.0(zod@4.6.6))(@modelcontextprotocol/sdk@1.32.1(zod@4.6.6))(zod@4.6.6) packages/core: dependencies: From 22e5cdecfeeed66103e78025ea7fd89b23e4b813 Mon Sep 17 00:00:00 2001 From: Lucas Braum Date: Sun, 18 Oct 2026 11:24:50 -0700 Subject: [PATCH 2/3] feat(fix): fetch the Agent SDK on the first fix instead of shipping it (UPT-108) As an optional dependency the SDK made every `npx uptide` 323.6 MB, `check` included, which never uses it. The package goes back to 13.6 MB and an optional peer: - An SDK resolvable next to uptide wins (the Action's install path is unchanged). - Otherwise the first fix that would use it installs the pinned SDK_VERSION into /agent-sdk/ (npm install into a temporary directory, scripts off, secrets stripped, renamed into place), prints one line with its size, and loads it from there. Later runs reuse it. - If that install fails (offline, a proxy), the run falls back to the patch executor loudly, as before, with the reason and the remedy. - A package test pins SDK_VERSION to the CLI's devDependency and peer range. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Lucas Braum --- .changeset/sdk-executor-by-default.md | 2 +- docs/architecture.md | 2 +- docs/commands/fix.md | 20 ++-- docs/decisions/0016-agent-sdk-executor.md | 47 ++++---- docs/privacy.md | 9 +- packages/cli/package.json | 12 +- packages/cli/src/cli.ts | 10 +- packages/cli/src/package.test.ts | 14 ++- packages/core/src/fix/fix.test.ts | 4 +- packages/core/src/fix/sdk-agent.ts | 138 ++++++++++++++++++---- packages/core/src/index.ts | 8 +- pnpm-lock.yaml | 7 +- 12 files changed, 199 insertions(+), 74 deletions(-) diff --git a/.changeset/sdk-executor-by-default.md b/.changeset/sdk-executor-by-default.md index 850037c..7462b90 100644 --- a/.changeset/sdk-executor-by-default.md +++ b/.changeset/sdk-executor-by-default.md @@ -2,4 +2,4 @@ "uptide": minor --- -`npx uptide fix` gets the Agent SDK executor. `@anthropic-ai/claude-agent-sdk` is now an optional dependency instead of an optional peer, so npm, pnpm and yarn install it with Uptide, along with the Claude Code binary for the platform (about 310 MB more on disk). When it cannot load (`--omit=optional`, no binary for the platform), `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 loadable SDK stops before any work instead of failing mid-run. +`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 c68be0b..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 dependency) can load, 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 4d8577b..bc9cb37 100644 --- a/docs/commands/fix.md +++ b/docs/commands/fix.md @@ -69,14 +69,18 @@ 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. `@anthropic-ai/claude-agent-sdk` is an optional dependency of `uptide`, so npm, -pnpm and yarn install it with Uptide, `npx uptide fix ` included. It brings the -Claude Code binary for your platform: about 310 MB more on disk with npm. - -If it cannot load (installed with `--omit=optional`, or no binary for the platform), `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 back. `report.json` records the -executor in `llm.executor` and the reason in `llm.sdkMissing`. +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 and stops before any work when the SDK cannot load. The SDK diff --git a/docs/decisions/0016-agent-sdk-executor.md b/docs/decisions/0016-agent-sdk-executor.md index e0f1ace..a93d53a 100644 --- a/docs/decisions/0016-agent-sdk-executor.md +++ b/docs/decisions/0016-agent-sdk-executor.md @@ -2,9 +2,9 @@ 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 dependency, -installed with Uptide and never bundled: when it cannot load, the default is the patch -executor, said first in the output and the PR body. +remain available. `--no-llm` disables either executor. The SDK is an optional peer, never +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 @@ -109,31 +109,32 @@ unchanged. ## Dependency and terms -### Optional dependency, installed by default (2026-10-18) +### Fetched on the first fix, into Uptide's cache (2026-10-18) -**Decision:** the SDK moves from optional peer to `optionalDependencies` of `uptide`, pinned to -the version the tests run. npm, pnpm, yarn classic and yarn berry (`dlx`) install it with -Uptide, binary included, and it still loads only through `import()`. When it cannot run (not -installed, or no Claude Code binary for the platform), 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 it +**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: no install brought 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 optional, not a regular dependency.** Both install the binary for everyone by - default. Only an optional dependency lets `--omit=optional` drop all of it (SDK, binary - and the SDK's peers) and still leave a working CLI, which falls back and says so. That is - the way out for library consumers of `uptide/onboarding` and `uptide/report`, and for - `--no-llm`, OpenAI and Gemini users who do not want the download. -- **Size, measured** (node:22 on linux arm64, plain `npm install` of the packed tarball): the - tarball is unchanged (2.5 MB); `node_modules` grows from 13.6 MB and 3 packages to 323.6 MB - and 110 packages. The binary is 250 MB of it, the SDK 5.5 MB, its peers 36 MB - (`@anthropic-ai/sdk`, `zod`, `@modelcontextprotocol/sdk`). + 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?") is now the live one: this decision ships it as one before an - answer. + 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) diff --git a/docs/privacy.md b/docs/privacy.md index 622fb02..34371ec 100644 --- a/docs/privacy.md +++ b/docs/privacy.md @@ -67,10 +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`, an optional dependency installed with Uptide, can load; 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. 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/package.json b/packages/cli/package.json index 6eba64a..542f11b 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -32,9 +32,6 @@ "dependencies": { "yaml": "^2.9.1" }, - "optionalDependencies": { - "@anthropic-ai/claude-agent-sdk": "0.3.296" - }, "engines": { "node": ">=20" }, @@ -65,6 +62,7 @@ "postpack": "node scripts/prepack.mjs --clean" }, "devDependencies": { + "@anthropic-ai/claude-agent-sdk": "0.3.296", "@types/node": "^22.0.0", "@uptide/core": "workspace:*", "commander": "^15.0.0", @@ -72,5 +70,13 @@ "tsup": "^8.5.1", "typescript": "^5.9.3", "vitest": "^5.0.3" + }, + "peerDependencies": { + "@anthropic-ai/claude-agent-sdk": ">=0.3.296" + }, + "peerDependenciesMeta": { + "@anthropic-ai/claude-agent-sdk": { + "optional": true + } } } diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 4034150..6924c84 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -1054,16 +1054,18 @@ ${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; when it cannot run, that comes first. + // 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 === undefined - ? await sdkUnavailable() + agent !== 'patch' + ? await sdkUnavailable((text) => io.err(text)) : undefined; - if (sdkMissing) io.err(`warning: ${sdkFallbackNote(sdkMissing)}\n`); + 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'); diff --git a/packages/cli/src/package.test.ts b/packages/cli/src/package.test.ts index ba60d02..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,10 +27,15 @@ 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 Anthropic default executor: installed with uptide by npm, pnpm and yarn, never bundled, - // and optional so an install without it still works (and says it fell back). - expect(manifest.optionalDependencies).toEqual({ '@anthropic-ai/claude-agent-sdk': '0.3.296' }); - expect(manifest.peerDependencies).toBeUndefined(); + // 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 }, + }); expect(manifest.engines.node).toBe('>=20'); // Library entries besides the command, for the GitHub App and the Uptide app // (libs-export.test.ts): the pure onboarding generator, what reads it from disk, and the diff --git a/packages/core/src/fix/fix.test.ts b/packages/core/src/fix/fix.test.ts index 927f657..7fa30e0 100644 --- a/packages/core/src/fix/fix.test.ts +++ b/packages/core/src/fix/fix.test.ts @@ -189,7 +189,9 @@ describe('fix transaction', () => { ); // 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; reinstall/); + await expect(run).rejects.toThrow( + /^--agent sdk cannot run: not installed; let Uptide reach the npm registry/, + ); expect(assist).not.toHaveBeenCalled(); return; } diff --git a/packages/core/src/fix/sdk-agent.ts b/packages/core/src/fix/sdk-agent.ts index 7cdf109..3b4902e 100644 --- a/packages/core/src/fix/sdk-agent.ts +++ b/packages/core/src/fix/sdk-agent.ts @@ -1,14 +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'; @@ -697,34 +710,35 @@ 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; + +/** 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 = - 'reinstall Uptide with its optional dependencies (no --omit=optional or --no-optional)'; +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 executor cannot run here, or undefined when it can: the SDK loads and finds the - * Claude Code binary for this platform, its own optional dependency, where the SDK looks for it. - */ -export async function sdkUnavailable(): Promise { +/** 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 loadSdk(); + await import(pathToFileURL(entry).href); } catch (error) { - const code = (error as { code?: string }).code; - return code === 'ERR_MODULE_NOT_FOUND' || code === 'MODULE_NOT_FOUND' - ? '@anthropic-ai/claude-agent-sdk is not installed' - : `@anthropic-ai/claude-agent-sdk failed to load: ${String((error as Error)?.message ?? error).split('\n')[0]}`; + return `${SDK} failed to load: ${String((error as Error)?.message ?? error).split('\n')[0]}`; } - const sdk = createRequire( - createRequire(import.meta.url).resolve('@anthropic-ai/claude-agent-sdk'), - ); - const base = `@anthropic-ai/claude-agent-sdk-${process.platform}-${process.arch}`; + 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 { @@ -733,6 +747,90 @@ export async function sdkUnavailable(): Promise { 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 { + mkdirSync(dirname(dir), { recursive: true }); + const temp = mkdtempSync(`${dir}.tmp-`); + 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 { + renameSync(temp, dir); + } catch { + // 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 ({ root, task, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c63244f..3a0d268 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -164,7 +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, sdkFallbackNote, sdkUnavailable } from './fix/sdk-agent.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'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7b9b3f2..ed2d40b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -45,6 +45,9 @@ importers: specifier: ^2.9.1 version: 2.9.1 devDependencies: + '@anthropic-ai/claude-agent-sdk': + specifier: 0.3.296 + version: 0.3.296(@anthropic-ai/sdk@0.133.0(zod@4.6.6))(@modelcontextprotocol/sdk@1.32.1(zod@4.6.6))(zod@4.6.6) '@types/node': specifier: ^22.0.0 version: 22.20.4 @@ -66,10 +69,6 @@ importers: vitest: specifier: ^5.0.3 version: 5.0.3(@types/node@22.20.4)(vite@8.3.1(@types/node@22.20.4)(esbuild@0.28.2)(tsx@4.23.15)(yaml@2.9.1)) - optionalDependencies: - '@anthropic-ai/claude-agent-sdk': - specifier: 0.3.296 - version: 0.3.296(@anthropic-ai/sdk@0.133.0(zod@4.6.6))(@modelcontextprotocol/sdk@1.32.1(zod@4.6.6))(zod@4.6.6) packages/core: dependencies: From df41ef8d4352d5f6ffb80233e51fa8abd8c561f3 Mon Sep 17 00:00:00 2001 From: Lucas Braum Date: Sun, 18 Oct 2026 11:40:02 -0700 Subject: [PATCH 3/3] fix(fix): an uncreatable cache directory falls back loudly instead of throwing (UPT-108) Co-Authored-By: Claude Opus 5.5 Signed-off-by: Lucas Braum --- packages/core/src/fix/sdk-agent.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/core/src/fix/sdk-agent.ts b/packages/core/src/fix/sdk-agent.ts index 3b4902e..3974ea7 100644 --- a/packages/core/src/fix/sdk-agent.ts +++ b/packages/core/src/fix/sdk-agent.ts @@ -764,8 +764,13 @@ const diskSize = (path: string): number => { /** The pinned SDK, binary included, into `dir`: a complete install or nothing there. */ async function installSdk(dir: string, log?: (text: string) => void): Promise { - mkdirSync(dirname(dir), { recursive: true }); - const temp = mkdtempSync(`${dir}.tmp-`); + 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(