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
31 changes: 20 additions & 11 deletions src/lib/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1149,22 +1149,31 @@ export function App({
</Prompt>
)
case 'sdk': {
const javascriptItem = {
label: 'JavaScript / TypeScript',
value: 'javascript',
}
const pythonItem = { label: 'Python', value: 'python' }
const sdkItems = [
{ label: 'JavaScript / TypeScript', value: 'javascript' },
{ label: 'Python', value: 'python' },
{ label: 'Ruby', value: 'ruby' },
{ label: 'PHP', value: 'php' },
]
// Float the previously-chosen SDK to the top, otherwise keep the order.
const items =
preferredSdk == null
? sdkItems
: [
...sdkItems.filter((item) => item.value === preferredSdk),
...sdkItems.filter((item) => item.value !== preferredSdk),
]
return (
<Prompt title='Which SDK are you using?'>
<SelectInput
items={
preferredSdk === 'python'
? [pythonItem, javascriptItem]
: [javascriptItem, pythonItem]
}
items={items}
onSelect={(item) => {
const chosen: Sdk =
item.value === 'python' ? 'python' : 'javascript'
item.value === 'python' ||
item.value === 'ruby' ||
item.value === 'php'
? item.value
: 'javascript'
setSdk(chosen)
writePreferredSdk(chosen).catch(() => {})
addMessage({ tone: 'info', text: `SDK: ${chosen}` })
Expand Down
17 changes: 17 additions & 0 deletions src/lib/steps/analyze-project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,23 @@ function detectFramework(
return null
}

if (sdk === 'ruby') {
// Rails ships a bin/rails and config/application.rb; either is a reliable marker.
if (
existsSync(join(root, 'bin', 'rails')) ||
existsSync(join(root, 'config', 'application.rb'))
) {
return 'Rails'
}
return null
}

if (sdk === 'php') {
// Laravel's `artisan` console entrypoint sits at the project root.
if (existsSync(join(root, 'artisan'))) return 'Laravel'
return null
}

return null
}

Expand Down
77 changes: 77 additions & 0 deletions src/lib/steps/detect-project.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
type JsPackageManager,
type ProjectInfo,
type PythonInstaller,
type RubyInstaller,
} from './detect-project.js'

let dir = ''
Expand All @@ -31,13 +32,31 @@ const jsProject = (packageManager: JsPackageManager): ProjectInfo => ({
detected_sdk: 'javascript',
js_package_manager: packageManager,
python_installer: 'pip',
ruby_installer: 'gem',
})

const pythonProject = (installer: PythonInstaller): ProjectInfo => ({
root: '/example',
detected_sdk: 'python',
js_package_manager: 'npm',
python_installer: installer,
ruby_installer: 'gem',
})

const rubyProject = (installer: RubyInstaller): ProjectInfo => ({
root: '/example',
detected_sdk: 'ruby',
js_package_manager: 'npm',
python_installer: 'pip',
ruby_installer: installer,
})

const phpProject = (): ProjectInfo => ({
root: '/example',
detected_sdk: 'php',
js_package_manager: 'npm',
python_installer: 'pip',
ruby_installer: 'gem',
})

test('detectProject: reports the given directory as the root', () => {
Expand All @@ -59,13 +78,46 @@ test.each(['pyproject.toml', 'requirements.txt', 'setup.py', 'Pipfile'])(
},
)

test.each(['Gemfile', 'Gemfile.lock'])(
'detectProject: detects ruby from %s',
(marker) => {
touch(marker)

expect(detectProject(dir).detected_sdk).toBe('ruby')
},
)

test('detectProject: detects php from composer.json', () => {
touch('composer.json')

expect(detectProject(dir).detected_sdk).toBe('php')
})

test('detectProject: detects no sdk when javascript and python markers are both present', () => {
touch('package.json')
touch('requirements.txt')

expect(detectProject(dir).detected_sdk).toBeNull()
})

test('detectProject: detects no sdk when several languages match', () => {
touch('package.json')
touch('Gemfile')
touch('composer.json')

expect(detectProject(dir).detected_sdk).toBeNull()
})

test('detectProject: detects the bundler ruby installer from a Gemfile', () => {
touch('Gemfile')

expect(detectProject(dir).ruby_installer).toBe('bundler')
})

test('detectProject: defaults to the gem ruby installer without a Gemfile', () => {
expect(detectProject(dir).ruby_installer).toBe('gem')
})

test('detectProject: detects no sdk when neither marker is present', () => {
expect(detectProject(dir).detected_sdk).toBeNull()
})
Expand Down Expand Up @@ -170,12 +222,37 @@ test('installSeamSdkCommand: installs the python sdk with uv', () => {
])
})

test('installSeamSdkCommand: installs the ruby sdk with bundler', () => {
expect(installSeamSdkCommand('ruby', rubyProject('bundler'))).toEqual([
'bundle',
'add',
'seam',
])
})

test('installSeamSdkCommand: installs the ruby sdk with gem when there is no Gemfile', () => {
expect(installSeamSdkCommand('ruby', rubyProject('gem'))).toEqual([
'gem',
'install',
'seam',
])
})

test('installSeamSdkCommand: installs the php sdk with composer', () => {
expect(installSeamSdkCommand('php', phpProject())).toEqual([
'composer',
'require',
'seamapi/seam',
])
})

test('installSeamSdkCommand: ignores the python installer for the javascript sdk', () => {
const project: ProjectInfo = {
root: '/example',
detected_sdk: 'javascript',
js_package_manager: 'yarn',
python_installer: 'poetry',
ruby_installer: 'gem',
}

expect(installSeamSdkCommand('javascript', project)).toEqual([
Expand Down
44 changes: 32 additions & 12 deletions src/lib/steps/detect-project.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
import { existsSync } from 'node:fs'
import { join } from 'node:path'

export type Sdk = 'javascript' | 'python'
export type Sdk = 'javascript' | 'python' | 'ruby' | 'php'
export type JsPackageManager = 'npm' | 'pnpm' | 'yarn' | 'bun'
export type PythonInstaller = 'pip' | 'poetry' | 'uv'
export type RubyInstaller = 'bundler' | 'gem'

export interface ProjectInfo {
root: string
detected_sdk: Sdk | null
js_package_manager: JsPackageManager
python_installer: PythonInstaller
ruby_installer: RubyInstaller
}

export function detectProject(cwd: string): ProjectInfo {
Expand All @@ -18,21 +20,25 @@ export function detectProject(cwd: string): ProjectInfo {
detected_sdk: detectSdk(cwd),
js_package_manager: detectJsPackageManager(cwd),
python_installer: detectPythonInstaller(cwd),
ruby_installer: detectRubyInstaller(cwd),
}
}

// null when the project is ambiguous (both or neither) — the wizard then asks.
// null when the project is ambiguous (zero or several languages match) — the
// wizard then asks. Each SDK has its own marker files.
function detectSdk(cwd: string): Sdk | null {
const isJavascript = existsSync(join(cwd, 'package.json'))
const isPython = [
'pyproject.toml',
'requirements.txt',
'setup.py',
'Pipfile',
].some((marker) => existsSync(join(cwd, marker)))
if (isJavascript && !isPython) return 'javascript'
if (isPython && !isJavascript) return 'python'
return null
const has = (...markers: string[]): boolean =>
markers.some((marker) => existsSync(join(cwd, marker)))

const matches: Sdk[] = []
if (has('package.json')) matches.push('javascript')
if (has('pyproject.toml', 'requirements.txt', 'setup.py', 'Pipfile')) {
matches.push('python')
}
if (has('Gemfile', 'Gemfile.lock')) matches.push('ruby')
if (has('composer.json')) matches.push('php')

return matches.length === 1 ? (matches[0] ?? null) : null
}

function detectJsPackageManager(cwd: string): JsPackageManager {
Expand All @@ -48,6 +54,11 @@ function detectPythonInstaller(cwd: string): PythonInstaller {
return 'pip'
}

function detectRubyInstaller(cwd: string): RubyInstaller {
// A Gemfile means Bundler manages deps; without one, install the gem directly.
return existsSync(join(cwd, 'Gemfile')) ? 'bundler' : 'gem'
}

export function installSeamSdkCommand(
sdk: Sdk,
project: ProjectInfo,
Expand All @@ -62,6 +73,15 @@ export function installSeamSdkCommand(
return ['pip', 'install', 'seam']
}
}
if (sdk === 'ruby') {
return project.ruby_installer === 'bundler'
? ['bundle', 'add', 'seam']
: ['gem', 'install', 'seam']
}
if (sdk === 'php') {
// The Composer package is seamapi/seam (unlike the bare `seam` npm/gem name).
return ['composer', 'require', 'seamapi/seam']
}
switch (project.js_package_manager) {
case 'pnpm':
return ['pnpm', 'add', 'seam']
Expand Down
11 changes: 10 additions & 1 deletion src/lib/steps/integrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,13 +226,22 @@ export async function runIntegration(args: RunIntegrationArgs): Promise<void> {
}
}

// How each SDK's language reads in the agent's system prompt. A Record keyed by
// Sdk so a new language must add its label here (exhaustiveness).
const SDK_LANGUAGE_LABELS: Record<Sdk, string> = {
javascript: 'JavaScript/TypeScript',
python: 'Python',
ruby: 'Ruby',
php: 'PHP',
}

function buildSystemAppend(
sdk: Sdk,
workspaceName: string,
framework?: string | null,
mode?: 'full_api' | 'customer_portal',
): string {
const language = sdk === 'python' ? 'Python' : 'JavaScript/TypeScript'
const language = SDK_LANGUAGE_LABELS[sdk]
const frameworkLabel = framework ?? "this project's framework"
const modeLabel = mode === 'customer_portal' ? 'Customer Portal' : 'full-API'
return [
Expand Down
7 changes: 6 additions & 1 deletion src/lib/store/config-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,12 @@ const sdkKey = 'sdk'

export const readPreferredSdk = async (): Promise<Sdk | null> => {
const sdk = await getAdapter().config.get(sdkKey)
return sdk === 'javascript' || sdk === 'python' ? sdk : null
return sdk === 'javascript' ||
sdk === 'python' ||
sdk === 'ruby' ||
sdk === 'php'
? sdk
: null
}

export const writePreferredSdk = async (sdk: Sdk): Promise<void> => {
Expand Down
4 changes: 2 additions & 2 deletions test/store/config-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@ test('preferred SDK: is kept in the settings the host holds', async () => {
expect(await getAdapter().config.get('sdk')).toBe('javascript')
})

test('preferred SDK: ignores an SDK the wizard no longer offers', async () => {
await getAdapter().config.set('sdk', 'ruby')
test('preferred SDK: ignores an SDK the wizard does not offer', async () => {
await getAdapter().config.set('sdk', 'go')

expect(await readPreferredSdk()).toBeNull()
})
Loading