diff --git a/src/lib/app.tsx b/src/lib/app.tsx index 28689c8..fdd9742 100644 --- a/src/lib/app.tsx +++ b/src/lib/app.tsx @@ -1149,22 +1149,31 @@ export function App({ ) 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 ( { 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}` }) diff --git a/src/lib/steps/analyze-project.ts b/src/lib/steps/analyze-project.ts index a5b2918..2fb5b3e 100644 --- a/src/lib/steps/analyze-project.ts +++ b/src/lib/steps/analyze-project.ts @@ -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 } diff --git a/src/lib/steps/detect-project.test.ts b/src/lib/steps/detect-project.test.ts index ba699d7..517bf52 100644 --- a/src/lib/steps/detect-project.test.ts +++ b/src/lib/steps/detect-project.test.ts @@ -10,6 +10,7 @@ import { type JsPackageManager, type ProjectInfo, type PythonInstaller, + type RubyInstaller, } from './detect-project.js' let dir = '' @@ -31,6 +32,7 @@ const jsProject = (packageManager: JsPackageManager): ProjectInfo => ({ detected_sdk: 'javascript', js_package_manager: packageManager, python_installer: 'pip', + ruby_installer: 'gem', }) const pythonProject = (installer: PythonInstaller): ProjectInfo => ({ @@ -38,6 +40,23 @@ const pythonProject = (installer: PythonInstaller): ProjectInfo => ({ 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', () => { @@ -59,6 +78,21 @@ 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') @@ -66,6 +100,24 @@ test('detectProject: detects no sdk when javascript and python markers are both 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() }) @@ -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([ diff --git a/src/lib/steps/detect-project.ts b/src/lib/steps/detect-project.ts index 43fa4a0..73a4020 100644 --- a/src/lib/steps/detect-project.ts +++ b/src/lib/steps/detect-project.ts @@ -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 { @@ -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 { @@ -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, @@ -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'] diff --git a/src/lib/steps/integrate.ts b/src/lib/steps/integrate.ts index 22a26d9..27ec538 100644 --- a/src/lib/steps/integrate.ts +++ b/src/lib/steps/integrate.ts @@ -226,13 +226,22 @@ export async function runIntegration(args: RunIntegrationArgs): Promise { } } +// 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 = { + 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 [ diff --git a/src/lib/store/config-store.ts b/src/lib/store/config-store.ts index 5ab376b..fbdc405 100644 --- a/src/lib/store/config-store.ts +++ b/src/lib/store/config-store.ts @@ -5,7 +5,12 @@ const sdkKey = 'sdk' export const readPreferredSdk = async (): Promise => { 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 => { diff --git a/test/store/config-store.test.ts b/test/store/config-store.test.ts index effb5cc..90ab00f 100644 --- a/test/store/config-store.test.ts +++ b/test/store/config-store.test.ts @@ -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() })