@meistrari/tela-cli 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -14,6 +14,18 @@ This installs the package globally and adds a `tela` alias to your `~/.zshrc`. A
14
14
  source ~/.zshrc
15
15
  ```
16
16
 
17
+ ## Default command
18
+
19
+ ### `tela`
20
+
21
+ List workspace repositories that are available to sync locally.
22
+
23
+ When to use: Run this with no subcommand when you want to see which workspace repositories are available before using `tela sync <repo>`.
24
+
25
+ Examples:
26
+ - `tela` — Render the available repositories in a human-readable table
27
+ - `tela --json` — Emit the available repositories as JSON
28
+
17
29
  ## Commands
18
30
 
19
31
  ### `setup`
@@ -29,7 +41,7 @@ Examples:
29
41
 
30
42
  Clone or update a Tela repository, or refresh the current checkout
31
43
 
32
- When to use: Use this before coding to get a clean local checkout, or to refresh every repository under the configured clone root.
44
+ When to use: Use this before coding to get a clean local checkout, or to refresh every repository under the configured clone root. SSH-backed sync automatically accepts first-seen host keys during clone and update flows.
33
45
 
34
46
  Examples:
35
47
  - `tela sync agent-api` — Clone or update a specific repository under the clone root
@@ -38,14 +50,14 @@ Examples:
38
50
 
39
51
  ### `run`
40
52
 
41
- Run the local agent repository with Claude
53
+ Run the local agent repository with project-scoped Claude
42
54
 
43
- When to use: Use this from inside an agent repository when you want Claude to follow the local contract files, show its progress live, and produce the expected result artifact.
55
+ When to use: Use this from inside an agent repository when you want Claude to follow the repo-local contract files, ignore user/global Claude config by default, show its progress live, and produce the expected result artifact.
44
56
 
45
57
  Examples:
46
- - `tela run` — Run the current agent repository with the live viewer
47
- - `tela run --plain` — Force scrollback-first text output
48
- - `tela run --json` — Emit the final execution and artifact envelope as JSON
58
+ - `tela run` — Run the current agent repository with project-scoped Claude and the live viewer
59
+ - `tela run --plain` — Force project-scoped execution with scrollback-first text output
60
+ - `tela run --json` — Emit the final project-scoped execution and artifact envelope as JSON
49
61
 
50
62
  ### `push`
51
63
 
@@ -96,6 +108,11 @@ Examples:
96
108
  - Human output is structured for terminal use with grouped summaries and next actions.
97
109
  - `--json` is available on read-heavy flows and on `tela run` for the final execution envelope.
98
110
 
111
+ ## `tela run` scope
112
+
113
+ - `tela run` loads project-scoped Claude settings by default and excludes user/global Claude config.
114
+ - Remaining external inputs are explicit: Claude's built-in system prompt, managed enterprise settings, auth/provider credentials, minimal shell runtime environment, and the default model when `agent.config.json` does not set one.
115
+
99
116
  ## First run
100
117
 
101
118
  1. `tela setup`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meistrari/tela-cli",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "tela": "cli.ts"
package/src/agent-api.ts CHANGED
@@ -1,10 +1,15 @@
1
1
  import { buildAgentApiHeaders, type FetchLike } from './auth.ts'
2
2
  import type {
3
+ AgentRepoSummary,
4
+ AvailableRepositorySummary,
3
5
  AgentFeedbackItem,
4
6
  AgentFeedbackListResponse,
5
7
  ForgejoUserCreateResponse,
6
8
  ForgejoUserSshKeyInput,
7
9
  OrganizationListResponse,
10
+ OrganizationSummary,
11
+ RepositoryListItemResponse,
12
+ RepositoryListResponse,
8
13
  RepositoryLookupResponse,
9
14
  TelaCliConfig,
10
15
  TelaCliForgejoOrganization,
@@ -45,6 +50,39 @@ function isForgejoOrganization(value: unknown): value is TelaCliForgejoOrganizat
45
50
  return typeof candidate.id === 'string' && typeof candidate.name === 'string'
46
51
  }
47
52
 
53
+ function isOrganizationSummary(value: unknown): value is OrganizationSummary {
54
+ if (!value || typeof value !== 'object') {
55
+ return false
56
+ }
57
+
58
+ const candidate = value as Partial<OrganizationSummary>
59
+ return typeof candidate.id === 'string'
60
+ && typeof candidate.workspaceId === 'string'
61
+ && typeof candidate.name === 'string'
62
+ }
63
+
64
+ function isAgentRepoSummary(value: unknown): value is AgentRepoSummary {
65
+ if (!value || typeof value !== 'object') {
66
+ return false
67
+ }
68
+
69
+ const candidate = value as Partial<AgentRepoSummary>
70
+ return typeof candidate.id === 'string'
71
+ && typeof candidate.workspaceId === 'string'
72
+ && typeof candidate.organizationId === 'string'
73
+ && typeof candidate.repository === 'string'
74
+ }
75
+
76
+ function isRepositoryListItemResponse(value: unknown): value is RepositoryListItemResponse {
77
+ if (!value || typeof value !== 'object') {
78
+ return false
79
+ }
80
+
81
+ const candidate = value as Partial<RepositoryListItemResponse>
82
+ return isOrganizationSummary(candidate.organization)
83
+ && isAgentRepoSummary(candidate.agentRepo)
84
+ }
85
+
48
86
  export class AgentApiError extends Error {
49
87
  constructor(
50
88
  message: string,
@@ -173,6 +211,44 @@ export async function listOrganizations(
173
211
  return payload.organizations
174
212
  }
175
213
 
214
+ export async function listAvailableRepositories(
215
+ config: TelaCliConfig,
216
+ fetchFn: FetchLike = fetch,
217
+ ): Promise<AvailableRepositorySummary[]> {
218
+ const headers = await buildAgentApiHeaders(config, { fetchFn })
219
+ const response = await fetchFn(
220
+ joinUrl(config.agentApiUrl, '/v3/agents/repositories'),
221
+ {
222
+ method: 'GET',
223
+ headers,
224
+ signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS),
225
+ },
226
+ )
227
+
228
+ const responseText = await response.text()
229
+ const payload = parseJsonBody(responseText) as RepositoryListResponse | null
230
+
231
+ if (!response.ok) {
232
+ throw new AgentApiError(
233
+ payload?.error ?? `Agent API repository listing failed with status ${response.status}`,
234
+ response.status,
235
+ responseText,
236
+ )
237
+ }
238
+
239
+ if (!payload
240
+ || payload.success !== true
241
+ || !Array.isArray(payload.repositories)
242
+ || !payload.repositories.every(isRepositoryListItemResponse)) {
243
+ throw new AgentApiError('Agent API repository listing returned an invalid payload', response.status, responseText)
244
+ }
245
+
246
+ return payload.repositories.map(repository => ({
247
+ ...repository,
248
+ syncStatus: 'available' as const,
249
+ }))
250
+ }
251
+
176
252
  export async function provisionForgejoUser(
177
253
  input: {
178
254
  organizationName: string
package/src/claude.ts ADDED
@@ -0,0 +1,101 @@
1
+ import { commandExists, runCommand } from './process.ts'
2
+
3
+ export interface ClaudeCliVersion {
4
+ major: number
5
+ minor: number
6
+ patch: number
7
+ }
8
+
9
+ export interface ClaudeCliInspection {
10
+ available: boolean
11
+ version?: string
12
+ parsedVersion?: ClaudeCliVersion
13
+ supportsProjectScopedRun: boolean
14
+ problem?: 'missing' | 'unparseable' | 'unsupported'
15
+ detail?: string
16
+ }
17
+
18
+ export const MIN_CLAUDE_VERSION_FOR_PROJECT_SCOPED_RUN: ClaudeCliVersion = {
19
+ major: 2,
20
+ minor: 1,
21
+ patch: 108,
22
+ }
23
+
24
+ export function formatClaudeCliVersion(version: ClaudeCliVersion): string {
25
+ return `${version.major}.${version.minor}.${version.patch}`
26
+ }
27
+
28
+ export function compareClaudeCliVersions(left: ClaudeCliVersion, right: ClaudeCliVersion): number {
29
+ if (left.major !== right.major) {
30
+ return left.major - right.major
31
+ }
32
+
33
+ if (left.minor !== right.minor) {
34
+ return left.minor - right.minor
35
+ }
36
+
37
+ return left.patch - right.patch
38
+ }
39
+
40
+ export function parseClaudeCliVersion(raw: string): ClaudeCliVersion | null {
41
+ const match = raw.match(/\b(\d+)\.(\d+)\.(\d+)\b/)
42
+ if (!match) {
43
+ return null
44
+ }
45
+
46
+ return {
47
+ major: Number(match[1]),
48
+ minor: Number(match[2]),
49
+ patch: Number(match[3]),
50
+ }
51
+ }
52
+
53
+ export async function inspectClaudeCli(options: {
54
+ commandExistsFn?: typeof commandExists
55
+ runCommandFn?: typeof runCommand
56
+ } = {}): Promise<ClaudeCliInspection> {
57
+ const commandExistsFn = options.commandExistsFn ?? commandExists
58
+ const runCommandFn = options.runCommandFn ?? runCommand
59
+
60
+ if (!await commandExistsFn('claude')) {
61
+ return {
62
+ available: false,
63
+ supportsProjectScopedRun: false,
64
+ problem: 'missing',
65
+ detail: '`claude` is not available on PATH.',
66
+ }
67
+ }
68
+
69
+ const result = await runCommandFn(['claude', '--version'], { allowFailure: true })
70
+ const output = `${result.stdout}\n${result.stderr}`.trim()
71
+ const parsedVersion = parseClaudeCliVersion(output)
72
+
73
+ if (!parsedVersion) {
74
+ return {
75
+ available: true,
76
+ supportsProjectScopedRun: false,
77
+ problem: 'unparseable',
78
+ detail: output || 'Claude CLI version output was empty.',
79
+ }
80
+ }
81
+
82
+ const version = formatClaudeCliVersion(parsedVersion)
83
+ if (compareClaudeCliVersions(parsedVersion, MIN_CLAUDE_VERSION_FOR_PROJECT_SCOPED_RUN) < 0) {
84
+ return {
85
+ available: true,
86
+ version,
87
+ parsedVersion,
88
+ supportsProjectScopedRun: false,
89
+ problem: 'unsupported',
90
+ detail: `Claude CLI ${version} is older than the required ${formatClaudeCliVersion(MIN_CLAUDE_VERSION_FOR_PROJECT_SCOPED_RUN)}.`,
91
+ }
92
+ }
93
+
94
+ return {
95
+ available: true,
96
+ version,
97
+ parsedVersion,
98
+ supportsProjectScopedRun: true,
99
+ detail: `Claude CLI ${version} supports project-scoped runs.`,
100
+ }
101
+ }
@@ -32,7 +32,7 @@ const COMMANDS: CommandMetadata[] = [
32
32
  name: 'sync',
33
33
  summary: 'Clone or update a Tela repository, or refresh the current checkout',
34
34
  argumentHint: '[repo]',
35
- whenToUse: 'Use this before coding to get a clean local checkout, or to refresh every repository under the configured clone root.',
35
+ whenToUse: 'Use this before coding to get a clean local checkout, or to refresh every repository under the configured clone root. SSH-backed sync automatically accepts first-seen host keys during clone and update flows.',
36
36
  examples: [
37
37
  {
38
38
  command: 'tela sync agent-api',
@@ -52,20 +52,20 @@ const COMMANDS: CommandMetadata[] = [
52
52
  },
53
53
  {
54
54
  name: 'run',
55
- summary: 'Run the local agent repository with Claude',
56
- whenToUse: 'Use this from inside an agent repository when you want Claude to follow the local contract files, show its progress live, and produce the expected result artifact.',
55
+ summary: 'Run the local agent repository with project-scoped Claude',
56
+ whenToUse: 'Use this from inside an agent repository when you want Claude to follow the repo-local contract files, ignore user/global Claude config by default, show its progress live, and produce the expected result artifact.',
57
57
  examples: [
58
58
  {
59
59
  command: 'tela run',
60
- description: 'Run the current agent repository with the live viewer',
60
+ description: 'Run the current agent repository with project-scoped Claude and the live viewer',
61
61
  },
62
62
  {
63
63
  command: 'tela run --plain',
64
- description: 'Force scrollback-first text output',
64
+ description: 'Force project-scoped execution with scrollback-first text output',
65
65
  },
66
66
  {
67
67
  command: 'tela run --json',
68
- description: 'Emit the final execution and artifact envelope as JSON',
68
+ description: 'Emit the final project-scoped execution and artifact envelope as JSON',
69
69
  },
70
70
  ],
71
71
  supportsJson: true,
@@ -176,6 +176,18 @@ export function renderReadmeMarkdown(): string {
176
176
  'source ~/.zshrc',
177
177
  '```',
178
178
  '',
179
+ '## Default command',
180
+ '',
181
+ '### `tela`',
182
+ '',
183
+ 'List workspace repositories that are available to sync locally.',
184
+ '',
185
+ 'When to use: Run this with no subcommand when you want to see which workspace repositories are available before using `tela sync <repo>`.',
186
+ '',
187
+ 'Examples:',
188
+ '- `tela` — Render the available repositories in a human-readable table',
189
+ '- `tela --json` — Emit the available repositories as JSON',
190
+ '',
179
191
  '## Commands',
180
192
  '',
181
193
  ]
@@ -205,6 +217,11 @@ export function renderReadmeMarkdown(): string {
205
217
  lines.push('- Human output is structured for terminal use with grouped summaries and next actions.')
206
218
  lines.push('- `--json` is available on read-heavy flows and on `tela run` for the final execution envelope.')
207
219
  lines.push('')
220
+ lines.push('## `tela run` scope')
221
+ lines.push('')
222
+ lines.push('- `tela run` loads project-scoped Claude settings by default and excludes user/global Claude config.')
223
+ lines.push('- Remaining external inputs are explicit: Claude\'s built-in system prompt, managed enterprise settings, auth/provider credentials, minimal shell runtime environment, and the default model when `agent.config.json` does not set one.')
224
+ lines.push('')
208
225
  lines.push('## First run')
209
226
  lines.push('')
210
227
  lines.push('1. `tela setup`')
@@ -1,9 +1,16 @@
1
+ import { existsSync } from 'node:fs'
1
2
  import { readFile } from 'node:fs/promises'
2
3
  import { basename, join } from 'node:path'
4
+ import {
5
+ formatClaudeCliVersion,
6
+ inspectClaudeCli,
7
+ MIN_CLAUDE_VERSION_FOR_PROJECT_SCOPED_RUN,
8
+ } from '../claude.ts'
3
9
  import { getRepositoryRoot } from '../git.ts'
4
10
  import { createUserFacingError } from '../errors.ts'
5
11
  import {
6
12
  commandExists,
13
+ runCommand,
7
14
  runInteractiveCommand,
8
15
  runStreamingCommand,
9
16
  spawnStreamingCommand,
@@ -37,6 +44,7 @@ export interface PreparedLocalAgentRun {
37
44
  config: AgentRunConfig
38
45
  resultKind: 'json' | 'markdown'
39
46
  resultPath: string
47
+ mcpConfigPath?: string
40
48
  }
41
49
 
42
50
  export interface RunStepSummary {
@@ -110,6 +118,8 @@ export interface RunLocalAgentCommandOptions {
110
118
  contract?: PreparedLocalAgentRun
111
119
  surface?: RunOutputSurface
112
120
  commandExists?: typeof commandExists
121
+ inspectClaudeCli?: typeof inspectClaudeCli
122
+ runCommand?: typeof runCommand
113
123
  runInteractiveCommand?: typeof runInteractiveCommand
114
124
  runStreamingCommand?: typeof runStreamingCommand
115
125
  spawnStreamingCommand?: typeof spawnStreamingCommand
@@ -1130,6 +1140,88 @@ function parseAgentConfig(path: string, content: string): AgentRunConfig {
1130
1140
  }
1131
1141
  }
1132
1142
 
1143
+ const CLAUDE_RUN_ENV_DENYLIST = new Set([
1144
+ 'CLAUDE_CONFIG_DIR',
1145
+ 'CLAUDE_ENV_FILE',
1146
+ 'ANTHROPIC_MODEL',
1147
+ 'ANTHROPIC_CUSTOM_MODEL_OPTION',
1148
+ 'ANTHROPIC_CUSTOM_MODEL_OPTION_NAME',
1149
+ 'ANTHROPIC_CUSTOM_MODEL_OPTION_DESCRIPTION',
1150
+ 'ANTHROPIC_CUSTOM_MODEL_OPTION_SUPPORTED_CAPABILITIES',
1151
+ 'ANTHROPIC_DEFAULT_OPUS_MODEL',
1152
+ 'ANTHROPIC_DEFAULT_OPUS_MODEL_NAME',
1153
+ 'ANTHROPIC_DEFAULT_OPUS_MODEL_DESCRIPTION',
1154
+ 'ANTHROPIC_DEFAULT_OPUS_MODEL_SUPPORTED_CAPABILITIES',
1155
+ 'ANTHROPIC_DEFAULT_SONNET_MODEL',
1156
+ 'ANTHROPIC_DEFAULT_SONNET_MODEL_NAME',
1157
+ 'ANTHROPIC_DEFAULT_SONNET_MODEL_DESCRIPTION',
1158
+ 'ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES',
1159
+ 'ANTHROPIC_DEFAULT_HAIKU_MODEL',
1160
+ 'ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME',
1161
+ 'ANTHROPIC_DEFAULT_HAIKU_MODEL_DESCRIPTION',
1162
+ 'ANTHROPIC_DEFAULT_HAIKU_MODEL_SUPPORTED_CAPABILITIES',
1163
+ 'CLAUDE_CODE_SUBAGENT_MODEL',
1164
+ ])
1165
+
1166
+ function buildClaudeRunEnvironment(
1167
+ baseEnv: NodeJS.ProcessEnv = process.env,
1168
+ ): Record<string, string | undefined> {
1169
+ return Object.fromEntries(
1170
+ Object.entries(baseEnv).filter(([key]) => !CLAUDE_RUN_ENV_DENYLIST.has(key)),
1171
+ )
1172
+ }
1173
+
1174
+ function buildUnsupportedClaudeVersionError(detail: string | undefined): ReturnType<typeof createUserFacingError> {
1175
+ const requiredVersion = formatClaudeCliVersion(MIN_CLAUDE_VERSION_FOR_PROJECT_SCOPED_RUN)
1176
+
1177
+ return createUserFacingError({
1178
+ code: 'run_claude_version_unsupported',
1179
+ summary: `\`tela run\` requires Claude CLI ${requiredVersion} or newer for project-scoped runs.`,
1180
+ details: detail,
1181
+ fixes: [
1182
+ 'Update Claude CLI before using `tela run`.',
1183
+ 'Run `claude --version` to confirm the installed version.',
1184
+ ],
1185
+ })
1186
+ }
1187
+
1188
+ function buildClaudeBaseCommand(prepared: PreparedLocalAgentRun): string[] {
1189
+ const command = [
1190
+ 'claude',
1191
+ '--setting-sources',
1192
+ 'project',
1193
+ ]
1194
+
1195
+ if (prepared.mcpConfigPath) {
1196
+ command.push('--strict-mcp-config', '--mcp-config', prepared.mcpConfigPath)
1197
+ }
1198
+
1199
+ if (prepared.config.model) {
1200
+ command.push('--model', prepared.config.model)
1201
+ }
1202
+
1203
+ return command
1204
+ }
1205
+
1206
+ function buildClaudeCommand(
1207
+ prepared: PreparedLocalAgentRun,
1208
+ mode: RunMode,
1209
+ ): string[] {
1210
+ const command = buildClaudeBaseCommand(prepared)
1211
+
1212
+ if (mode === 'one-shot') {
1213
+ command.push(
1214
+ '-p',
1215
+ '--output-format',
1216
+ 'stream-json',
1217
+ '--verbose',
1218
+ )
1219
+ }
1220
+
1221
+ command.push(INITIAL_PROMPT)
1222
+ return command
1223
+ }
1224
+
1133
1225
  async function resolveRepositoryRoot(cwd: string): Promise<string> {
1134
1226
  try {
1135
1227
  return await getRepositoryRoot(cwd)
@@ -1176,12 +1268,19 @@ async function validateRequiredFiles(repoRoot: string): Promise<void> {
1176
1268
  export async function prepareLocalAgentRun(options: {
1177
1269
  cwd?: string
1178
1270
  commandExists?: typeof commandExists
1271
+ inspectClaudeCli?: typeof inspectClaudeCli
1272
+ runCommand?: typeof runCommand
1179
1273
  } = {}): Promise<PreparedLocalAgentRun> {
1180
1274
  const cwd = options.cwd ?? process.cwd()
1181
- const commandExistsFn = options.commandExists ?? commandExists
1182
1275
  const repoRoot = await resolveRepositoryRoot(cwd)
1276
+ const inspectClaudeCliFn = options.inspectClaudeCli ?? inspectClaudeCli
1277
+
1278
+ const claudeInspection = await inspectClaudeCliFn({
1279
+ commandExistsFn: options.commandExists,
1280
+ runCommandFn: options.runCommand,
1281
+ })
1183
1282
 
1184
- if (!await commandExistsFn('claude')) {
1283
+ if (!claudeInspection.available) {
1185
1284
  throw createUserFacingError({
1186
1285
  code: 'run_claude_missing',
1187
1286
  summary: 'Claude CLI is not available on PATH.',
@@ -1189,6 +1288,10 @@ export async function prepareLocalAgentRun(options: {
1189
1288
  })
1190
1289
  }
1191
1290
 
1291
+ if (!claudeInspection.supportsProjectScopedRun) {
1292
+ throw buildUnsupportedClaudeVersionError(claudeInspection.detail)
1293
+ }
1294
+
1192
1295
  await validateRequiredFiles(repoRoot)
1193
1296
 
1194
1297
  const [outputFormatContent, agentConfigContent] = await Promise.all([
@@ -1209,6 +1312,9 @@ export async function prepareLocalAgentRun(options: {
1209
1312
  config,
1210
1313
  resultKind,
1211
1314
  resultPath: join(repoRoot, 'output', resultKind === 'json' ? 'result.json' : 'result.md'),
1315
+ mcpConfigPath: existsSync(join(repoRoot, '.mcp.json'))
1316
+ ? join(repoRoot, '.mcp.json')
1317
+ : undefined,
1212
1318
  }
1213
1319
  }
1214
1320
 
@@ -1375,13 +1481,10 @@ async function runInteractiveAgent(
1375
1481
  writeLine(options.writeStdout, 'Launching Claude in interactive chat mode. Tela will resume after Claude exits.')
1376
1482
  }
1377
1483
 
1378
- const command = [
1379
- 'claude',
1380
- ...(prepared.config.model ? ['--model', prepared.config.model] : []),
1381
- INITIAL_PROMPT,
1382
- ]
1383
-
1384
- await runInteractiveCommandFn(command, { cwd: prepared.repoRoot })
1484
+ await runInteractiveCommandFn(buildClaudeCommand(prepared, 'chat'), {
1485
+ cwd: prepared.repoRoot,
1486
+ env: buildClaudeRunEnvironment(),
1487
+ })
1385
1488
  }
1386
1489
 
1387
1490
  async function runOneShotAgent(
@@ -1408,18 +1511,11 @@ async function runOneShotAgent(
1408
1511
  }
1409
1512
 
1410
1513
  let cancelled = false
1411
- const command = [
1412
- 'claude',
1413
- '-p',
1414
- '--output-format',
1415
- 'stream-json',
1416
- '--verbose',
1417
- ...(prepared.config.model ? ['--model', prepared.config.model] : []),
1418
- INITIAL_PROMPT,
1419
- ]
1514
+ const command = buildClaudeCommand(prepared, 'one-shot')
1420
1515
 
1421
1516
  const handle = spawnStreamingCommandFn(command, {
1422
1517
  cwd: prepared.repoRoot,
1518
+ env: buildClaudeRunEnvironment(),
1423
1519
  allowFailure: true,
1424
1520
  onStdoutLine: async (line) => {
1425
1521
  const trimmed = line.trim()
@@ -1509,6 +1605,8 @@ export async function runLocalAgentCommand(
1509
1605
  const prepared = options.contract ?? await prepareLocalAgentRun({
1510
1606
  cwd: options.cwd ?? process.cwd(),
1511
1607
  commandExists: options.commandExists,
1608
+ inspectClaudeCli: options.inspectClaudeCli,
1609
+ runCommand: options.runCommand,
1512
1610
  })
1513
1611
  const mode: RunMode = prepared.config.isMultiturn ? 'chat' : 'one-shot'
1514
1612
  const tracker = new RunStateTracker(prepared, mode)
@@ -52,7 +52,17 @@ export function renderStatusReport(report: StatusReport, options: RenderOptions
52
52
 
53
53
  const toolRows = [
54
54
  ['git', report.tools.git ? 'available' : 'missing', report.tools.git ? 'ready' : 'install git'],
55
- ['claude', report.tools.claude ? 'available' : 'missing', report.tools.claude ? 'ready' : 'required for `tela run`, `tela feedback`, and Claude commit messages'],
55
+ [
56
+ 'claude',
57
+ !report.tools.claude
58
+ ? 'missing'
59
+ : (report.tools.claudeProjectScopedRunSupported === false ? 'unsupported' : 'available'),
60
+ !report.tools.claude
61
+ ? 'required for `tela run`, `tela feedback`, and Claude commit messages'
62
+ : (report.tools.claudeProjectScopedRunSupported === false
63
+ ? (report.tools.claudeDetail ?? `version ${report.tools.claudeVersion ?? '<unknown>'}`)
64
+ : (report.tools.claudeVersion ? `version ${report.tools.claudeVersion}` : 'ready')),
65
+ ],
56
66
  [
57
67
  'agent-api-origin',
58
68
  report.tools.agentApiReachable === null
@@ -1,6 +1,11 @@
1
1
  import { accessSync, constants, existsSync, statSync } from 'node:fs'
2
2
  import { dirname } from 'node:path'
3
3
  import { buildAgentApiHeaders } from './auth.ts'
4
+ import {
5
+ formatClaudeCliVersion,
6
+ inspectClaudeCli,
7
+ MIN_CLAUDE_VERSION_FOR_PROJECT_SCOPED_RUN,
8
+ } from './claude.ts'
4
9
  import { inspectSavedConfig, redactSecret } from './config.ts'
5
10
  import { resolveSshPrivateKeyPath } from './forgejo.ts'
6
11
  import {
@@ -70,6 +75,9 @@ export interface StatusReport {
70
75
  tools: {
71
76
  git: boolean
72
77
  claude: boolean
78
+ claudeVersion?: string
79
+ claudeProjectScopedRunSupported?: boolean | null
80
+ claudeDetail?: string
73
81
  agentApiReachable: boolean | null
74
82
  agentApiCategory?: AgentApiReachabilityCategory
75
83
  agentApiStatus?: number
@@ -269,6 +277,7 @@ function buildStatusNextActions(
269
277
  hasForgejoOrganization: boolean,
270
278
  hasForgejoAccess: boolean,
271
279
  hasGitSshKey: boolean,
280
+ claudeFix?: string,
272
281
  ): string[] {
273
282
  const actions: string[] = []
274
283
 
@@ -300,6 +309,10 @@ function buildStatusNextActions(
300
309
  actions.push('Review local changes before syncing, or use `tela push` to stage and commit them.')
301
310
  }
302
311
 
312
+ if (claudeFix) {
313
+ actions.push(claudeFix)
314
+ }
315
+
303
316
  if (warnings.length === 0) {
304
317
  actions.push('Run `tela sync --current`, `tela run`, or `tela push` when you are ready.')
305
318
  }
@@ -311,15 +324,16 @@ export async function createStatusReport(
311
324
  cwd = process.cwd(),
312
325
  fetchFn: typeof fetch = fetch,
313
326
  ): Promise<StatusReport> {
314
- const [configInspection, gitAvailable, claudeAvailable, repo] = await Promise.all([
327
+ const [configInspection, gitAvailable, claudeInspection, repo] = await Promise.all([
315
328
  Promise.resolve(inspectSavedConfig()),
316
329
  commandExists('git'),
317
- commandExists('claude'),
330
+ inspectClaudeCli(),
318
331
  inspectCurrentRepository(cwd),
319
332
  ])
320
333
 
321
334
  const warnings: string[] = []
322
335
  let reachability: AgentApiReachability | undefined
336
+ let claudeFix: string | undefined
323
337
 
324
338
  if (!configInspection.exists) {
325
339
  warnings.push('Tela CLI is not configured yet.')
@@ -354,8 +368,18 @@ export async function createStatusReport(
354
368
  warnings.push('`git` is not available on PATH.')
355
369
  }
356
370
 
357
- if (!claudeAvailable) {
371
+ if (!claudeInspection.available) {
358
372
  warnings.push('`claude` is not available on PATH.')
373
+ claudeFix = 'Install the Claude CLI before using `tela run`, `tela push --claude`, or `tela feedback`.'
374
+ }
375
+ else if (!claudeInspection.supportsProjectScopedRun) {
376
+ const requiredVersion = formatClaudeCliVersion(MIN_CLAUDE_VERSION_FOR_PROJECT_SCOPED_RUN)
377
+ warnings.push(
378
+ claudeInspection.problem === 'unparseable'
379
+ ? `Claude CLI version could not be determined for project-scoped runs: ${claudeInspection.detail ?? 'unknown output'}`
380
+ : `Claude CLI must be updated to ${requiredVersion} or newer for project-scoped \`tela run\`.`,
381
+ )
382
+ claudeFix = `Update Claude CLI to version ${requiredVersion} or newer before using \`tela run\`.`
359
383
  }
360
384
 
361
385
  if (repo.detached) {
@@ -382,6 +406,7 @@ export async function createStatusReport(
382
406
  Boolean(configInspection.config?.forgejoOrganization),
383
407
  Boolean(configInspection.config?.forgejoUserRegistered),
384
408
  Boolean(configInspection.config?.gitSshKey),
409
+ claudeFix,
385
410
  )
386
411
 
387
412
  return {
@@ -410,7 +435,12 @@ export async function createStatusReport(
410
435
  repo,
411
436
  tools: {
412
437
  git: gitAvailable,
413
- claude: claudeAvailable,
438
+ claude: claudeInspection.available,
439
+ claudeVersion: claudeInspection.version,
440
+ claudeProjectScopedRunSupported: claudeInspection.available
441
+ ? claudeInspection.supportsProjectScopedRun
442
+ : null,
443
+ claudeDetail: claudeInspection.detail,
414
444
  agentApiReachable: reachability?.reachable ?? null,
415
445
  agentApiCategory: reachability?.category,
416
446
  agentApiStatus: reachability?.statusCode,
@@ -632,10 +662,10 @@ export async function createDoctorReport(
632
662
  cwd = process.cwd(),
633
663
  fetchFn: typeof fetch = fetch,
634
664
  ): Promise<DoctorReport> {
635
- const [configInspection, gitAvailable, claudeAvailable, repo] = await Promise.all([
665
+ const [configInspection, gitAvailable, claudeInspection, repo] = await Promise.all([
636
666
  Promise.resolve(inspectSavedConfig()),
637
667
  commandExists('git'),
638
- commandExists('claude'),
668
+ inspectClaudeCli(),
639
669
  inspectCurrentRepository(cwd),
640
670
  ])
641
671
 
@@ -675,9 +705,22 @@ export async function createDoctorReport(
675
705
  {
676
706
  id: 'tool-claude',
677
707
  group: 'tools',
678
- status: claudeAvailable ? 'pass' : 'warn',
679
- summary: claudeAvailable ? '`claude` is available.' : '`claude` is not available on PATH.',
680
- fixes: claudeAvailable ? ['No action required.'] : ['Install the Claude CLI before using `tela run`, `tela push --claude`, or `tela feedback`.'],
708
+ status: !claudeInspection.available
709
+ ? 'warn'
710
+ : (claudeInspection.supportsProjectScopedRun ? 'pass' : 'fail'),
711
+ summary: !claudeInspection.available
712
+ ? '`claude` is not available on PATH.'
713
+ : (claudeInspection.supportsProjectScopedRun
714
+ ? `Claude CLI is available (${claudeInspection.version ?? 'unknown version'}) and supports project-scoped runs.`
715
+ : (claudeInspection.problem === 'unparseable'
716
+ ? 'Claude CLI version could not be determined for project-scoped runs.'
717
+ : `Claude CLI ${claudeInspection.version ?? '<unknown>'} is too old for project-scoped runs.`)),
718
+ fixes: !claudeInspection.available
719
+ ? ['Install the Claude CLI before using `tela run`, `tela push --claude`, or `tela feedback`.']
720
+ : (claudeInspection.supportsProjectScopedRun
721
+ ? ['No action required.']
722
+ : [`Update Claude CLI to version ${formatClaudeCliVersion(MIN_CLAUDE_VERSION_FOR_PROJECT_SCOPED_RUN)} or newer before using \`tela run\`.`]),
723
+ evidence: claudeInspection.available && claudeInspection.detail ? [claudeInspection.detail] : undefined,
681
724
  },
682
725
  ...buildRepoChecks(repo),
683
726
  ]
package/src/git.ts CHANGED
@@ -23,16 +23,33 @@ export interface GitStatusCounts {
23
23
  total: number
24
24
  }
25
25
 
26
+ type GitSshHostKeyPolicy = 'default' | 'accept-new'
27
+
26
28
  interface GitRunOptions extends Omit<RunCommandOptions, 'env'> {
27
29
  requireConfiguredSsh?: boolean
30
+ sshHostKeyPolicy?: GitSshHostKeyPolicy
28
31
  }
29
32
 
30
33
  function quoteForShell(value: string): string {
31
34
  return `'${value.replaceAll('\'', `'"'"'`)}'`
32
35
  }
33
36
 
34
- export function buildGitSshCommand(privateKeyPath: string): string {
35
- return `ssh -i ${quoteForShell(privateKeyPath)} -o IdentitiesOnly=yes`
37
+ export function buildGitSshCommand(
38
+ privateKeyPath: string,
39
+ options: {
40
+ hostKeyPolicy?: GitSshHostKeyPolicy
41
+ } = {},
42
+ ): string {
43
+ const args = [
44
+ `-i ${quoteForShell(privateKeyPath)}`,
45
+ '-o IdentitiesOnly=yes',
46
+ ]
47
+
48
+ if (options.hostKeyPolicy === 'accept-new') {
49
+ args.push('-o StrictHostKeyChecking=accept-new')
50
+ }
51
+
52
+ return `ssh ${args.join(' ')}`
36
53
  }
37
54
 
38
55
  export function isSshRemoteUrl(remoteUrl: string): boolean {
@@ -108,14 +125,19 @@ function resolveConfiguredGitSshKey(requireConfiguredSsh: boolean): TelaCliGitSs
108
125
  }
109
126
  }
110
127
 
111
- function buildGitEnv(requireConfiguredSsh: boolean): Record<string, string> | undefined {
128
+ function buildGitEnv(
129
+ requireConfiguredSsh: boolean,
130
+ sshHostKeyPolicy: GitSshHostKeyPolicy = 'default',
131
+ ): Record<string, string> | undefined {
112
132
  const gitSshKey = resolveConfiguredGitSshKey(requireConfiguredSsh)
113
133
  if (!gitSshKey) {
114
134
  return undefined
115
135
  }
116
136
 
117
137
  return {
118
- GIT_SSH_COMMAND: buildGitSshCommand(gitSshKey.privateKeyPath),
138
+ GIT_SSH_COMMAND: buildGitSshCommand(gitSshKey.privateKeyPath, {
139
+ hostKeyPolicy: sshHostKeyPolicy,
140
+ }),
119
141
  }
120
142
  }
121
143
 
@@ -123,7 +145,10 @@ async function runGitCommand(
123
145
  args: string[],
124
146
  options: GitRunOptions = {},
125
147
  ): Promise<CommandResult> {
126
- const env = buildGitEnv(Boolean(options.requireConfiguredSsh))
148
+ const env = buildGitEnv(
149
+ Boolean(options.requireConfiguredSsh),
150
+ options.sshHostKeyPolicy,
151
+ )
127
152
 
128
153
  return await runCommand(
129
154
  ['git', ...args],
@@ -215,6 +240,8 @@ export async function assertGitRepository(cwd: string): Promise<void> {
215
240
  export async function cloneRepository(remoteUrl: string, targetDir: string): Promise<void> {
216
241
  await runGitCommand(['clone', remoteUrl, targetDir], {
217
242
  requireConfiguredSsh: isSshRemoteUrl(remoteUrl),
243
+ // Sync accepts first-seen SSH host keys automatically, but changed host keys still fail.
244
+ sshHostKeyPolicy: 'accept-new',
218
245
  })
219
246
  }
220
247
 
@@ -227,6 +254,7 @@ export async function fetchOrigin(cwd: string): Promise<void> {
227
254
  await runGitCommand(['fetch', 'origin', '--prune'], {
228
255
  cwd,
229
256
  requireConfiguredSsh: await remoteRequiresConfiguredSsh(cwd, 'origin'),
257
+ sshHostKeyPolicy: 'accept-new',
230
258
  })
231
259
  }
232
260
 
@@ -234,6 +262,7 @@ export async function fetchAllRemotes(cwd: string): Promise<void> {
234
262
  await runGitCommand(['fetch', '--all', '--prune'], {
235
263
  cwd,
236
264
  requireConfiguredSsh: await anyRemoteRequiresConfiguredSsh(cwd),
265
+ sshHostKeyPolicy: 'accept-new',
237
266
  })
238
267
  }
239
268
 
@@ -280,6 +309,7 @@ export async function pullCurrentBranch(cwd: string): Promise<void> {
280
309
  await runGitCommand(['pull', '--ff-only'], {
281
310
  cwd,
282
311
  requireConfiguredSsh: await upstreamRequiresConfiguredSsh(cwd),
312
+ sshHostKeyPolicy: 'accept-new',
283
313
  })
284
314
  }
285
315
 
@@ -287,6 +317,7 @@ export async function pullDefaultBranch(cwd: string, defaultBranch: string): Pro
287
317
  await runGitCommand(['pull', '--ff-only', 'origin', defaultBranch], {
288
318
  cwd,
289
319
  requireConfiguredSsh: await remoteRequiresConfiguredSsh(cwd, 'origin'),
320
+ sshHostKeyPolicy: 'accept-new',
290
321
  })
291
322
  }
292
323
 
package/src/program.ts CHANGED
@@ -12,9 +12,9 @@ import { RunChatHandoffScreen, RunScreen } from './ink/screens/run-screen.tsx'
12
12
  import { SetupScreen, type SetupScreenResult } from './ink/screens/setup/setup-screen.tsx'
13
13
  import { SyncScreen, type SyncProgressItem } from './ink/screens/sync-screen.tsx'
14
14
  import { renderCommandScreen } from './ink/runtime.tsx'
15
- import { listOrganizations } from './agent-api.ts'
15
+ import { listAvailableRepositories, listOrganizations } from './agent-api.ts'
16
16
  import { authenticateWithDeviceFlow, type SetupAuthEvent } from './auth.ts'
17
- import { createUserFacingError } from './errors.ts'
17
+ import { createUserFacingError, toUserFacingError } from './errors.ts'
18
18
  import {
19
19
  getCommandMetadata,
20
20
  listCommandMetadata,
@@ -90,6 +90,7 @@ import {
90
90
  import { commandExists, runInteractiveCommand, runStreamingCommand } from './process.ts'
91
91
  import { CommandRuntime } from './runtime.ts'
92
92
  import type {
93
+ AvailableRepositorySummary,
93
94
  BatchSyncFailure,
94
95
  SyncResult,
95
96
  TelaCliAuthState,
@@ -125,6 +126,7 @@ interface CliDeps {
125
126
  syncAllRepositories?: typeof syncAllRepositories
126
127
  syncCurrentRepository?: typeof syncCurrentRepository
127
128
  listCloneRootRepositories?: typeof listCloneRootRepositories
129
+ listAvailableRepositories?: typeof listAvailableRepositories
128
130
  previewPush?: typeof previewPush
129
131
  pushRepository?: typeof pushRepository
130
132
  runLocalAgentCommand?: typeof runLocalAgentCommand
@@ -266,11 +268,13 @@ function renderRootHelp(program: Command): string {
266
268
  return joinBlocks([
267
269
  bootstrapPane('Tela CLI', [
268
270
  'One-shot workflows for setting up Tela, syncing repositories, running local agents, preparing pushes, and applying commit feedback.',
271
+ 'Run `tela` with no subcommand to list workspace repositories available to sync.',
269
272
  '',
270
273
  'First successful path:',
271
274
  stepList([
272
275
  'tela setup',
273
276
  'tela status',
277
+ 'tela',
274
278
  'tela sync <repo> or tela sync --current',
275
279
  'tela run',
276
280
  'tela push',
@@ -762,6 +766,26 @@ function syncCategoryFixes(category: SyncFailureCategory): string[] {
762
766
  }
763
767
  }
764
768
 
769
+ function buildSyncFailureError(error: unknown): ReturnType<typeof createUserFacingError> {
770
+ const category = categorizeSyncError(error)
771
+ const userError = toUserFacingError(error)
772
+ const stderrDetails = userError.evidence
773
+ .find(line => line.startsWith('stderr: '))
774
+ ?.replace(/^stderr: /, '')
775
+ const stdoutDetails = userError.evidence
776
+ .find(line => line.startsWith('stdout: '))
777
+ ?.replace(/^stdout: /, '')
778
+
779
+ return createUserFacingError({
780
+ code: `sync_${category}`,
781
+ summary: error instanceof Error ? error.message : String(error),
782
+ details: stderrDetails || stdoutDetails || userError.details,
783
+ fixes: syncCategoryFixes(category),
784
+ evidence: userError.evidence,
785
+ cause: error,
786
+ })
787
+ }
788
+
765
789
  function renderSyncPlan(input: {
766
790
  mode: SyncCommandResult['mode']
767
791
  target: string
@@ -1025,13 +1049,7 @@ async function runSyncInk(
1025
1049
  }
1026
1050
  }
1027
1051
  catch (error) {
1028
- const category = categorizeSyncError(error)
1029
- throw createUserFacingError({
1030
- code: `sync_${category}`,
1031
- summary: error instanceof Error ? error.message : String(error),
1032
- fixes: syncCategoryFixes(category),
1033
- cause: error,
1034
- })
1052
+ throw buildSyncFailureError(error)
1035
1053
  }
1036
1054
  },
1037
1055
  }))
@@ -1203,13 +1221,7 @@ export async function runSync(
1203
1221
  return result
1204
1222
  }
1205
1223
  catch (error) {
1206
- const category = categorizeSyncError(error)
1207
- throw createUserFacingError({
1208
- code: `sync_${category}`,
1209
- summary: error instanceof Error ? error.message : String(error),
1210
- fixes: syncCategoryFixes(category),
1211
- cause: error,
1212
- })
1224
+ throw buildSyncFailureError(error)
1213
1225
  }
1214
1226
  }
1215
1227
 
@@ -1857,8 +1869,10 @@ export async function runDoctor(
1857
1869
  }
1858
1870
 
1859
1871
  export interface ListRepositoriesResult {
1860
- cloneRoot: string
1861
- repositories: string[]
1872
+ success: boolean
1873
+ repositories: AvailableRepositorySummary[]
1874
+ message?: string
1875
+ nextActions?: string[]
1862
1876
  }
1863
1877
 
1864
1878
  export async function runListRepositories(
@@ -1867,35 +1881,76 @@ export async function runListRepositories(
1867
1881
  ): Promise<ListRepositoriesResult> {
1868
1882
  const runtime = createRuntime(deps, options)
1869
1883
  const inspection = (deps.inspectSavedConfig ?? inspectSavedConfig)()
1870
- const cloneRoot = inspection.config?.cloneRoot ?? getDefaultCloneRoot()
1871
- const listRepos = deps.listCloneRootRepositories ?? listCloneRootRepositories
1872
- const repositories = listRepos(cloneRoot)
1873
- const result: ListRepositoriesResult = { cloneRoot, repositories }
1884
+ const decorate = runtime.decorate
1885
+ const setupRequiredNextActions = [
1886
+ 'Run `tela setup` to configure Tela CLI and sign in.',
1887
+ 'Run `tela --help` to see all available commands.',
1888
+ ]
1889
+ const setupRequiredMessage = 'Setup and sign-in are required before Tela can list repositories available to sync.'
1890
+
1891
+ if (!inspection.valid || !inspection.config?.auth) {
1892
+ const result: ListRepositoriesResult = {
1893
+ success: false,
1894
+ repositories: [],
1895
+ message: setupRequiredMessage,
1896
+ nextActions: setupRequiredNextActions,
1897
+ }
1898
+
1899
+ if (options.json) {
1900
+ runtime.emitJson(result)
1901
+ return result
1902
+ }
1903
+
1904
+ emitBootstrap(runtime, 'Tela CLI', 'Workspace repositories available to sync.')
1905
+ runtime.emit(joinBlocks([
1906
+ section('Repositories', [
1907
+ setupRequiredMessage,
1908
+ '',
1909
+ 'These repositories will appear here after setup is complete.',
1910
+ ], { decorate }),
1911
+ nextActions(setupRequiredNextActions, { decorate }),
1912
+ ]))
1913
+
1914
+ return result
1915
+ }
1916
+
1917
+ const listRepos = deps.listAvailableRepositories ?? listAvailableRepositories
1918
+ const repositories = await listRepos(inspection.config, deps.fetchFn ?? fetch)
1919
+ const result: ListRepositoriesResult = {
1920
+ success: true,
1921
+ repositories,
1922
+ }
1874
1923
 
1875
1924
  if (options.json) {
1876
1925
  runtime.emitJson(result)
1877
1926
  return result
1878
1927
  }
1879
1928
 
1880
- emitBootstrap(runtime, 'Tela CLI', 'Workspace repositories under the configured clone root.')
1881
- const decorate = runtime.decorate
1929
+ emitBootstrap(runtime, 'Tela CLI', 'Workspace repositories available to sync.')
1882
1930
  const body = repositories.length > 0
1883
- ? section('Repositories', bulletList(repositories), { decorate })
1931
+ ? section(
1932
+ 'Repositories',
1933
+ table(
1934
+ ['Organization', 'Repository', 'Sync'],
1935
+ repositories.map(repository => [
1936
+ repository.organization.name,
1937
+ repository.agentRepo.repository,
1938
+ repository.syncStatus,
1939
+ ]),
1940
+ { decorate },
1941
+ ),
1942
+ { decorate },
1943
+ )
1884
1944
  : section('Repositories', [
1885
- `No repositories found under ${cloneRoot}.`,
1945
+ 'No workspace repositories are currently registered for this workspace.',
1886
1946
  '',
1887
- 'Next steps:',
1888
- bulletList([
1889
- 'Run `tela setup` to configure the clone root.',
1890
- 'Run `tela sync <repo>` to clone a workspace repository.',
1891
- ]),
1947
+ 'There is nothing to sync yet.',
1892
1948
  ], { decorate })
1893
1949
 
1894
1950
  runtime.emit(joinBlocks([
1895
1951
  body,
1896
- section('Clone root', cloneRoot, { decorate }),
1897
1952
  nextActions([
1898
- 'Run `tela sync <repo>` to clone or refresh a repository.',
1953
+ 'Run `tela sync <repo>` to clone or refresh an available repository.',
1899
1954
  'Run `tela --help` to see all available commands.',
1900
1955
  ], { decorate }),
1901
1956
  ]))
package/src/types.ts CHANGED
@@ -82,6 +82,21 @@ export interface RepositoryLookupResponse {
82
82
  error?: string
83
83
  }
84
84
 
85
+ export interface RepositoryListItemResponse {
86
+ organization: OrganizationSummary
87
+ agentRepo: AgentRepoSummary
88
+ }
89
+
90
+ export interface RepositoryListResponse {
91
+ success: boolean
92
+ repositories?: RepositoryListItemResponse[]
93
+ error?: string
94
+ }
95
+
96
+ export interface AvailableRepositorySummary extends RepositoryListItemResponse {
97
+ syncStatus: 'available'
98
+ }
99
+
85
100
  export interface OrganizationListResponse {
86
101
  success: boolean
87
102
  organizations?: TelaCliForgejoOrganization[]