Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion eval/real-runner.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { getInferenceBaseUrl } from 'lib/seam-api.js'
import { getInferenceBaseUrl } from 'lib/api.js'
import { buildIntegrationSteps } from 'lib/steps/build-plan.js'
import { runIntegration } from 'lib/steps/integrate.js'

Expand Down
5 changes: 1 addition & 4 deletions eval/run.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
import { existsSync, readdirSync, readFileSync } from 'node:fs'
import { join } from 'node:path'

import {
exchangeWizardInferenceToken,
getInferenceBaseUrl,
} from 'lib/seam-api.js'
import { exchangeWizardInferenceToken, getInferenceBaseUrl } from 'lib/api.js'
import type { BuildMode } from 'lib/steps/build-plan.js'

import { createRealRunner } from './real-runner.js'
Expand Down
2 changes: 1 addition & 1 deletion eval/score.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { callInferenceForText } from 'lib/seam-api.js'
import { callInferenceForText } from 'lib/api.js'
import type { BuildMode } from 'lib/steps/build-plan.js'

import { getRubric, type RubricDimension } from './rubric.js'
Expand Down
1,406 changes: 733 additions & 673 deletions package-lock.json

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.3.216",
"@earendil-works/pi-coding-agent": "^0.79.10",
"@seamapi/http": "^2.23.0",
"ink-select-input": "^6.2.0",
"ink-spinner": "^5.0.0",
"ink-text-input": "^6.0.0",
Expand All @@ -93,7 +94,7 @@
"pi-mcp-adapter": "~2.15.0"
},
"devDependencies": {
"@seamapi/cli": "^0.23.0",
"@seamapi/cli": "^0.32.0",
"@types/minimist": "^1.2.5",
"@types/node": "^24.10.9",
"@types/react": "^19.2.17",
Expand Down
43 changes: 43 additions & 0 deletions src/lib/api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { beforeEach, expect, test, vi } from 'vitest'

const { get, post } = vi.hoisted(() => ({
get: vi.fn(),
post: vi.fn(),
}))

vi.mock('@seamapi/http', () => ({
isSeamHttpApiError: () => false,
isSeamHttpUnauthorizedError: () => false,
SeamHttpInvalidTokenError: class extends Error {},
SeamHttpWorkspaces: class {
get = get
client = { post }
},
}))

import { exchangeWizardInferenceToken, getWorkspaceForApiKey } from './api.js'

beforeEach(() => vi.clearAllMocks())

test('uses the workspace SDK and its raw client', async () => {
const workspace = {
workspace_id: 'workspace-1',
name: 'Test',
is_sandbox: true,
}
get.mockResolvedValue(workspace)
post.mockResolvedValue({
data: {
wizard_session: { token: 'token', expires_at: 'tomorrow' },
onboarding: null,
},
})

await expect(getWorkspaceForApiKey('seam_key')).resolves.toBe(workspace)
await expect(exchangeWizardInferenceToken('seam_key')).resolves.toEqual({
token: 'token',
expires_at: 'tomorrow',
onboarding: null,
})
expect(post).toHaveBeenCalledWith('/internal/wizard_inference/session', {})
})
123 changes: 52 additions & 71 deletions src/lib/seam-api.ts → src/lib/api.ts
Original file line number Diff line number Diff line change
@@ -1,59 +1,50 @@
// Minimal Seam API access used to validate a pasted API key. We deliberately
// avoid pulling in the full SDK just for a health check — a single fetch keeps
// the wizard's install footprint tiny.
import {
isSeamHttpApiError,
isSeamHttpUnauthorizedError,
SeamHttpInvalidTokenError,
SeamHttpWorkspaces,
type Workspace,
} from '@seamapi/http'

import { getAuth } from 'lib/adapter.js'

// Whichever server the host is pointed at.
export function getSeamApiBaseUrl(): string {
export function getApiBaseUrl(): string {
return getAuth().endpoint.replace(/\/+$/, '')
}

export interface SeamWorkspace {
workspace_id: string
name: string
is_sandbox: boolean
}
export type SeamWorkspace = Pick<
Workspace,
'workspace_id' | 'name' | 'is_sandbox'
>

export class ApiKeyError extends Error {}

// Validates the key by fetching the workspace it belongs to. Returns the
// workspace so the wizard can show which workspace the key is for.
const getApi = (apiKey: string): SeamHttpWorkspaces =>
new SeamHttpWorkspaces({ apiKey, endpoint: getApiBaseUrl() })

export async function getWorkspaceForApiKey(
apiKey: string,
): Promise<SeamWorkspace> {
let response: Response
try {
response = await fetch(`${getSeamApiBaseUrl()}/workspaces/get`, {
method: 'POST',
headers: {
authorization: `Bearer ${apiKey}`,
'content-type': 'application/json',
},
body: '{}',
})
} catch {
return await getApi(apiKey).get()
} catch (error) {
if (
error instanceof SeamHttpInvalidTokenError ||
isSeamHttpUnauthorizedError(error)
) {
throw new ApiKeyError(
'That key was rejected (401). Make sure you copied the full key, including the seam_ prefix.',
)
}
if (isSeamHttpApiError(error)) {
throw new ApiKeyError(
`The Seam API returned ${error.statusCode}. Please try again in a moment.`,
)
}
throw new ApiKeyError(
'Could not reach the Seam API. Check your network connection and try again.',
)
}

if (response.status === 401) {
throw new ApiKeyError(
'That key was rejected (401). Make sure you copied the full key, including the seam_ prefix.',
)
}
if (!response.ok) {
throw new ApiKeyError(
`The Seam API returned ${response.status}. Please try again in a moment.`,
)
}

const body = (await response.json()) as { workspace?: SeamWorkspace }
if (body.workspace == null) {
throw new ApiKeyError('Unexpected response from the Seam API.')
}
return body.workspace
}

export function looksLikeSeamApiKey(value: string): boolean {
Expand All @@ -63,7 +54,7 @@ export function looksLikeSeamApiKey(value: string): boolean {
// Base URL for Seam-hosted inference. The embedded agent's SDK appends
// /v1/messages; the exchange endpoint below lives at /session.
export function getInferenceBaseUrl(): string {
return `${getSeamApiBaseUrl()}/internal/wizard_inference`
return `${getApiBaseUrl()}/internal/wizard_inference`
}

// The Console-collected onboarding answers Seam returns alongside the token, so
Expand All @@ -89,42 +80,32 @@ export interface WizardInferenceSession {
export async function exchangeWizardInferenceToken(
apiKey: string,
): Promise<WizardInferenceSession> {
let response: Response
try {
response = await fetch(`${getInferenceBaseUrl()}/session`, {
method: 'POST',
headers: {
authorization: `Bearer ${apiKey}`,
'content-type': 'application/json',
},
body: '{}',
})
} catch {
const { data: body } = await getApi(apiKey).client.post<{
wizard_session?: { token: string; expires_at: string }
onboarding?: WizardOnboarding | null
}>('/internal/wizard_inference/session', {})
if (body.wizard_session == null) {
throw new ApiKeyError(
'Unexpected response from Seam starting the AI session.',
)
}
return {
token: body.wizard_session.token,
expires_at: body.wizard_session.expires_at,
onboarding: body.onboarding ?? null,
}
} catch (error) {
if (error instanceof ApiKeyError) throw error
if (isSeamHttpApiError(error)) {
throw new ApiKeyError(
`Seam couldn't start the AI session (${error.statusCode}). Please try again in a moment.`,
)
}
throw new ApiKeyError(
'Could not reach Seam to start the AI session. Check your network connection and try again.',
)
}

if (!response.ok) {
throw new ApiKeyError(
`Seam couldn't start the AI session (${response.status}). Please try again in a moment.`,
)
}

const body = (await response.json()) as {
wizard_session?: { token: string; expires_at: string }
onboarding?: WizardOnboarding | null
}
if (body.wizard_session == null) {
throw new ApiKeyError(
'Unexpected response from Seam starting the AI session.',
)
}
return {
token: body.wizard_session.token,
expires_at: body.wizard_session.expires_at,
onboarding: body.onboarding ?? null,
}
}

// One-shot call to Seam-hosted inference (Anthropic Messages API shape). Sent
Expand Down
16 changes: 8 additions & 8 deletions src/lib/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@ import {
} from 'react'

import { getAuth } from './adapter.js'
import {
ApiKeyError,
exchangeWizardInferenceToken,
getInferenceBaseUrl,
looksLikeSeamApiKey,
type SeamWorkspace,
type WizardInferenceSession,
} from './api.js'
import { ensureProjectEnvConventions, findExistingApiKey } from './env-file.js'
import { runInstall } from './run-install.js'
import { AnalyzeScreen } from './screens/analyze.js'
Expand All @@ -24,14 +32,6 @@ import { IntegrationModeScreen } from './screens/integration-mode.js'
import { NoteScreen } from './screens/note.js'
import { SetupProgress } from './screens/setup-progress.js'
import { WelcomeScreen } from './screens/welcome.js'
import {
ApiKeyError,
exchangeWizardInferenceToken,
getInferenceBaseUrl,
looksLikeSeamApiKey,
type SeamWorkspace,
type WizardInferenceSession,
} from './seam-api.js'
import {
analyzeProject,
type ProjectAnalysis,
Expand Down
2 changes: 1 addition & 1 deletion src/lib/steps/analyze-project.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { existsSync, readFileSync } from 'node:fs'
import { join } from 'node:path'

import { callInferenceForText, type WizardOnboarding } from 'lib/api.js'
import { findExistingApiKey } from 'lib/env-file.js'
import { callInferenceForText, type WizardOnboarding } from 'lib/seam-api.js'

import type { BuildMode } from './build-plan.js'
import type { ProjectInfo, Sdk } from './detect-project.js'
Expand Down
2 changes: 1 addition & 1 deletion src/lib/steps/authenticate.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { getAuth } from 'lib/adapter.js'
import { getWorkspaceForApiKey, type SeamWorkspace } from 'lib/api.js'
import { findExistingApiKey, saveProjectApiKey } from 'lib/env-file.js'
import { getWorkspaceForApiKey, type SeamWorkspace } from 'lib/seam-api.js'

export interface AuthResult {
workspace: SeamWorkspace
Expand Down
2 changes: 1 addition & 1 deletion src/lib/steps/connect-web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import { createServer, type ServerResponse } from 'node:http'

import open from 'open'

import { getWorkspaceForApiKey, type SeamWorkspace } from 'lib/api.js'
import { saveProjectApiKey } from 'lib/env-file.js'
import { getWorkspaceForApiKey, type SeamWorkspace } from 'lib/seam-api.js'

// The dashboard "wizard" page mints a key and posts it back to the local
// callback. Override the console host with SEAM_CONSOLE_URL for dev.
Expand Down
2 changes: 1 addition & 1 deletion src/lib/steps/connection.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { getAuth } from 'lib/adapter.js'
import type { SeamWorkspace } from 'lib/api.js'
import { fingerprintApiKey } from 'lib/api-key.js'
import type { SeamWorkspace } from 'lib/seam-api.js'
import {
type ConnectionSource,
type ProjectConnection,
Expand Down
2 changes: 1 addition & 1 deletion test/steps/connection.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { afterEach, beforeEach, expect, test } from 'vitest'

import { createMemoryAdapter, resetAdapter, setAdapter } from 'lib/adapter.js'
import type { SeamWorkspace } from 'lib/api.js'
import { fingerprintApiKey } from 'lib/api-key.js'
import type { SeamWorkspace } from 'lib/seam-api.js'
import { compareConnection, saveConnection } from 'lib/steps/connection.js'
import { type ProjectConnection, readProjectRecord } from 'lib/store/index.js'

Expand Down
Loading