#!/usr/bin/env bun import * as p from '@clack/prompts' import { existsSync, mkdirSync, cpSync, writeFileSync, readdirSync, rmSync } from 'node:fs' import { homedir } from 'node:os' import { join, dirname } from 'node:path' import { fileURLToPath } from 'node:url' import open from 'open' import { DeviceFlow } from '@meistrari/auth-cli' import { migrateLegacySkillDir } from './migrate-legacy-skill.ts' import { discoverLocalhostServices, readAuthApiSecret, fetchWorkspaces, generateLocalhostJwt, saveLocalhostSession, writeLocalhostEnv, readSavedReposPath, saveReposPath, } from './skill/localhost-utils.ts' const __dirname = dirname(fileURLToPath(import.meta.url)) const CLAUDE_SKILLS_DIR = join(homedir(), '.claude', 'skills') const SKILLS_DIR = join(CLAUDE_SKILLS_DIR, 'tela-studio') const LEGACY_SKILLS_DIR = join(CLAUDE_SKILLS_DIR, 'tela') const OPEN_ISSUE_SKILLS_DIR = join(CLAUDE_SKILLS_DIR, 'tela-open-issue') const LOCALHOST_SKILLS_DIR = join(CLAUDE_SKILLS_DIR, 'tela-localhost') const WORKFLOW_CODE_BUILDER_SKILLS_DIR = join(CLAUDE_SKILLS_DIR, 'workflow-code-builder') const WORKFLOW_CODE_BUILDER_FRAMEWORK_SKILLS_DIR = join(CLAUDE_SKILLS_DIR, 'workflow-code-builder-framework') const WORKFLOW_CODE_OPERATIONS_SKILLS_DIR = join(CLAUDE_SKILLS_DIR, 'workflow-code-operations') const UPDATE_REPO_CHANGELOG_SKILLS_DIR = join(CLAUDE_SKILLS_DIR, 'update-repo-changelog') const SESSION_DIR = join(homedir(), '.tela') // Device flow: the installer (requester) asks for access to Tela (target). Both are // applications registered in the auth-api admin dashboard. const DEVICE_AUTH = { production: { authApiUrl: 'https://auth.tela.com', requesterApplicationId: '5e7cce5a-33e4-4d4e-b919-56f01fde2a9d', targetApplicationId: 'fe9fa028-4d34-407c-b27a-2ecc4779ae69', }, staging: { authApiUrl: 'https://auth-api.telastaging.com', requesterApplicationId: 'c0333f9f-da9c-42bd-8284-a00c302ffd54', targetApplicationId: '1c021ec1-b7d1-4d72-8733-ac69d930b2df', }, } as const // --- CLI Flag Parsing --- function parseFlags(argv: string[]): Record { const flags: Record = {} for (let i = 0; i < argv.length; i++) { const arg = argv[i]! if (arg.startsWith('--')) { const key = arg.slice(2) const next = argv[i + 1] if (next && !next.startsWith('--')) { flags[key] = next i++ } else { flags[key] = true } } } return flags } const flags = parseFlags(process.argv.slice(2)) const YES = 'yes' in flags || 'y' in flags async function installSkills() { const spinner = p.spinner() spinner.start('Installing skill files...') try { mkdirSync(SKILLS_DIR, { recursive: true }) const sourceDir = join(__dirname, 'skill') const openIssueSkillFile = 'TELA_OPEN_ISSUE_SKILL.md' const telaSkillFile = 'SKILL.md' const localhostSkillFile = 'TELA_LOCALHOST_SKILL.md' const standaloneSkills = [ { source: 'workflow-code-builder', destination: WORKFLOW_CODE_BUILDER_SKILLS_DIR }, { source: 'workflow-code-builder-framework', destination: WORKFLOW_CODE_BUILDER_FRAMEWORK_SKILLS_DIR }, { source: 'workflow-code-operations', destination: WORKFLOW_CODE_OPERATIONS_SKILLS_DIR }, { source: 'update-repo-changelog', destination: UPDATE_REPO_CHANGELOG_SKILLS_DIR }, ] as const const excludedFiles = new Set([ openIssueSkillFile, telaSkillFile, localhostSkillFile, ]) const files = readdirSync(sourceDir, { withFileTypes: true }) .filter(entry => entry.isFile() && !excludedFiles.has(entry.name)) .map(entry => entry.name) for (const file of files) { cpSync(join(sourceDir, file), join(SKILLS_DIR, file)) } cpSync(join(sourceDir, telaSkillFile), join(SKILLS_DIR, 'SKILL.md')) rmSync(join(SKILLS_DIR, 'workflow-code-builder.md'), { force: true }) rmSync(join(SKILLS_DIR, 'workflow-code-builder-framework.md'), { force: true }) mkdirSync(OPEN_ISSUE_SKILLS_DIR, { recursive: true }) cpSync(join(sourceDir, openIssueSkillFile), join(OPEN_ISSUE_SKILLS_DIR, 'SKILL.md')) // Install localhost skill to its own directory mkdirSync(LOCALHOST_SKILLS_DIR, { recursive: true }) cpSync(join(sourceDir, localhostSkillFile), join(LOCALHOST_SKILLS_DIR, 'SKILL.md')) for (const skill of standaloneSkills) { rmSync(skill.destination, { recursive: true, force: true }) mkdirSync(skill.destination, { recursive: true }) cpSync(join(sourceDir, skill.source), skill.destination, { recursive: true }) } const agentsSourceDir = join(__dirname, 'agents') const agentsDestDir = join(homedir(), '.claude', 'agents') if (existsSync(agentsSourceDir)) { cpSync(agentsSourceDir, agentsDestDir, { recursive: true }) } const agentFiles = existsSync(agentsSourceDir) ? readdirSync(agentsSourceDir).length : 0 spinner.stop(`Installed ${files.length} Tela skill files + 1 open-issue skill + 1 localhost skill + ${standaloneSkills.length} standalone skills + ${agentFiles} agents`) const depSpinner = p.spinner() depSpinner.start('Installing dependencies...') const proc = Bun.spawn(['bash', join(SKILLS_DIR, 'startup.sh')], { cwd: SKILLS_DIR, stdout: 'pipe', stderr: 'pipe', }) const exitCode = await proc.exited if (exitCode !== 0) { const stderr = await new Response(proc.stderr).text() depSpinner.stop('Dependency installation failed') p.log.error(stderr || `Skill setup exited with code ${exitCode}`) process.exit(1) } depSpinner.stop('Installed and verified Tela skill dependencies') const migration = migrateLegacySkillDir(LEGACY_SKILLS_DIR) if (migration === 'migrated') { p.log.info('Moved the skill from ~/.claude/skills/tela/ to ~/.claude/skills/tela-studio/. Commands using the old preload path keep working; /tela is now /tela-studio.') } else if (migration === 'skipped') { p.log.warn('~/.claude/skills/tela/ does not look like a Tela skills install, so it was left alone. Claude Code may load it as a separate skill.') } } catch (err: unknown) { spinner.stop('Installation failed') p.log.error(err instanceof Error ? err.message : String(err)) process.exit(1) } } async function handleLocalhostSetup() { // 1. Discover Docker services const discoverSpinner = p.spinner() discoverSpinner.start('Discovering local services via Docker...') const services = discoverLocalhostServices() if (!services.authApiPort) { discoverSpinner.stop('auth-api container not found') p.log.error('Could not find auth-api Docker container. Make sure local services are running.') process.exit(1) } if (!services.telaApiPort) { discoverSpinner.stop('tela-api container not found') p.log.error('Could not find tela-api Docker container. Make sure local services are running.') process.exit(1) } if (!services.authApiDbPort) { discoverSpinner.stop('auth-postgres container not found') p.log.error('Could not find auth-postgres Docker container. Make sure local auth services are running.') process.exit(1) } discoverSpinner.stop(`Found services — auth-api :${services.authApiPort}, auth-postgres :${services.authApiDbPort}, tela-api :${services.telaApiPort}${services.telaAppPort ? `, tela-app :${services.telaAppPort}` : ''}`) // 2. Ask for meistrari repos path const savedPath = readSavedReposPath() const reposPathFlag = typeof flags['repos-path'] === 'string' ? flags['repos-path'] : null let reposPath: string if (reposPathFlag) { reposPath = reposPathFlag p.log.info(`Using repos path: ${reposPath}`) } else if (YES && savedPath) { reposPath = savedPath p.log.info(`Using saved repos path: ${reposPath}`) } else { const result = await p.text({ message: 'Where is the tela monorepo cloned?', placeholder: '/path/to/tela', ...(savedPath ? { defaultValue: savedPath } : {}), validate(value): string | undefined { if (!value.trim()) return 'Path is required' return undefined }, }) if (p.isCancel(result)) { p.cancel('Setup cancelled.') process.exit(0) } reposPath = result } const resolvedPath = reposPath.replace(/^~/, homedir()) saveReposPath(resolvedPath) // 3. Read auth-api access secret const keysSpinner = p.spinner() keysSpinner.start('Reading auth-api configuration...') let authApiSecret: string try { authApiSecret = readAuthApiSecret(resolvedPath) keysSpinner.stop('Auth-api configuration loaded') } catch (err) { keysSpinner.stop('Failed to read auth-api configuration') p.log.error(err instanceof Error ? err.message : String(err)) process.exit(1) } // 4. Fetch workspaces const authApiUrl = `http://localhost:${services.authApiPort}` const wsSpinner = p.spinner() wsSpinner.start('Fetching workspaces from auth-api...') let workspaces: Awaited> try { workspaces = await fetchWorkspaces(authApiUrl, authApiSecret) wsSpinner.stop(`Found ${workspaces.length} workspace(s)`) } catch (err) { wsSpinner.stop('Failed to fetch workspaces') p.log.error(err instanceof Error ? err.message : String(err)) process.exit(1) } if (workspaces.length === 0) { p.log.warning('No workspaces found. Create one using auth-api CLI scripts first.') process.exit(1) } // 5. Select workspace const workspaceFlag = typeof flags['workspace'] === 'string' ? flags['workspace'] : null let workspace: { id: string; title: string; slug: string } if (workspaceFlag) { const match = workspaces.find(ws => ws.id === workspaceFlag || ws.slug === workspaceFlag) if (!match) { p.log.error(`Workspace "${workspaceFlag}" not found. Available: ${workspaces.map(ws => `${ws.slug} (${ws.id})`).join(', ')}`) process.exit(1) } workspace = match p.log.info(`Using workspace: ${workspace.title}`) } else if (YES && workspaces.length === 1) { workspace = workspaces[0]! p.log.info(`Auto-selecting only workspace: ${workspace.title}`) } else { const selectedWorkspace = await p.select({ message: 'Which workspace do you want to connect to?', options: workspaces.map(ws => ({ value: ws, label: ws.title, hint: ws.slug, })), }) if (p.isCancel(selectedWorkspace)) { p.cancel('Setup cancelled.') process.exit(0) } workspace = selectedWorkspace as { id: string; title: string; slug: string } } // 6. Generate JWT and save const tokenSpinner = p.spinner() tokenSpinner.start('Generating authentication token...') try { const token = generateLocalhostJwt({ reposPath: resolvedPath, workspace: { id: workspace.id, title: workspace.title }, authApiDbPort: services.authApiDbPort, }) saveLocalhostSession(token) writeLocalhostEnv(services.telaApiPort, services.telaAppPort) tokenSpinner.stop('Token generated and saved') } catch (err) { tokenSpinner.stop('Failed to generate token') p.log.error(err instanceof Error ? err.message : String(err)) process.exit(1) } // 7. Install skills (reuse existing logic) if (YES) { await installSkills() } else { const skillsExist = existsSync(SKILLS_DIR) if (skillsExist) { const update = await p.confirm({ message: 'Tela skills already installed. Update to latest version?', initialValue: true, }) if (!p.isCancel(update) && update) { await installSkills() } } else { const install = await p.confirm({ message: 'Install Tela skills to ~/.claude/skills/tela-studio/?', initialValue: true, }) if (!p.isCancel(install) && install) { await installSkills() } } } writeFileSync(join(SESSION_DIR, 'environment'), 'localhost', { mode: 0o600 }) p.log.success(`Connected to workspace "${workspace.title}" on localhost`) p.note( `Use in Claude Code with bun --preload: bun --preload ~/.claude/skills/tela-studio/preload.ts -e " console.log(await tela.listProjects()) "`, 'Usage', ) p.outro('Localhost setup complete!') } async function main() { p.intro('Tela Skills for Claude Code') const envFlag = typeof flags['env'] === 'string' ? flags['env'] : null const validEnvs = ['production', 'staging', 'localhost'] as const let environment: string if (envFlag) { if (!validEnvs.includes(envFlag as any)) { p.log.error(`Invalid environment: "${envFlag}". Valid: ${validEnvs.join(', ')}`) process.exit(1) } environment = envFlag p.log.info(`Environment: ${environment}`) } else { const selected = await p.select({ message: 'Which environment do you want to connect to?', options: [ { value: 'production', label: 'Production' }, { value: 'staging', label: 'Staging' }, { value: 'localhost', label: 'Localhost (internal)' }, ], }) if (p.isCancel(selected)) { p.cancel('Setup cancelled.') process.exit(0) } environment = selected as string } if (environment === 'localhost') { await handleLocalhostSetup() return } const env = environment as 'production' | 'staging' const sessionPath = join(SESSION_DIR, `session.${env}`) const sessionLabel = `~/.tela/session.${env}` const deviceAuth = DEVICE_AUTH[env] if (YES) { await installSkills() } else { const skillsExist = existsSync(SKILLS_DIR) if (skillsExist) { const update = await p.confirm({ message: 'Tela skills already installed. Update to latest version?', initialValue: true, }) if (p.isCancel(update) || !update) { p.cancel('Installation cancelled.') process.exit(0) } } else { const install = await p.confirm({ message: 'Install Tela skills to ~/.claude/skills/tela-studio/?', initialValue: true, }) if (p.isCancel(install) || !install) { p.cancel('Installation cancelled.') process.exit(0) } } await installSkills() } if (existsSync(sessionPath)) { if (YES) { p.log.info(`Keeping existing session at ${sessionLabel}`) } else { const reconnect = await p.confirm({ message: 'Session already configured. Clear and reconnect?', initialValue: false, }) if (!p.isCancel(reconnect) && reconnect) { const { unlinkSync } = await import('node:fs') unlinkSync(sessionPath) const envPath = join(SESSION_DIR, 'environment') if (existsSync(envPath)) unlinkSync(envPath) p.log.info('Session cleared. Proceeding to authentication...') } else { p.log.info(`Keeping existing session at ${sessionLabel}`) } } } if (!existsSync(sessionPath)) { const shouldAuth = YES || await (async () => { const setupKey = await p.confirm({ message: 'Set up Tela authentication now?', initialValue: true, }) return !p.isCancel(setupKey) && setupKey })() if (shouldAuth) { const flow = new DeviceFlow({ apiUrl: deviceAuth.authApiUrl, requesterApplicationId: deviceAuth.requesterApplicationId, targetApplicationId: deviceAuth.targetApplicationId, }) const spinner = p.spinner() try { const { userCode, verificationUriComplete } = await flow.getUserCode() p.note(`${verificationUriComplete}\n\nCode: ${userCode}`, 'Approve this login in your browser') await open(verificationUriComplete).catch(() => {}) spinner.start('Waiting for browser approval...') const tokens = await flow.authenticate() spinner.stop('Authentication successful!') mkdirSync(SESSION_DIR, { recursive: true }) writeFileSync(sessionPath, JSON.stringify({ accessToken: tokens.accessToken, refreshToken: tokens.refreshToken, authApiUrl: deviceAuth.authApiUrl, }, null, 2), { mode: 0o600 }) writeFileSync(join(SESSION_DIR, 'environment'), env, { mode: 0o600 }) p.log.success(`Session saved to ${sessionLabel} (workspace: ${tokens.organization.name})`) } catch (err) { spinner.stop('Authentication failed') p.log.error(err instanceof Error ? err.message : 'Unknown error') } } else { p.log.info('You can authenticate later by re-running this installer.') } } const ENV_PATH = join(SESSION_DIR, '.env') const isStaging = env === 'staging' const apiUrl = isStaging ? 'https://api.telastaging.com' : 'https://api.tela.com' const appUrl = isStaging ? 'https://app.telastaging.com' : 'https://app.tela.com' mkdirSync(SESSION_DIR, { recursive: true }) writeFileSync(ENV_PATH, `TELA_API_URL=${apiUrl}\nTELA_APP_URL=${appUrl}\n`, { mode: 0o600 }) p.log.success(`Environment configured: ${env}`) p.note( `Use in Claude Code with bun --preload: bun --preload ~/.claude/skills/tela-studio/preload.ts -e " console.log(await tela.listProjects()) "`, 'Usage', ) p.outro('Tela skills installed successfully!') } main().catch(console.error)