@meistrari/tela-cli 0.4.0 → 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.0",
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)
@@ -31,6 +31,9 @@ export function renderStatusReport(report: StatusReport, options: RenderOptions
31
31
  { label: 'Workspace', value: report.config.workspace ?? '<signed out>' },
32
32
  { label: 'Organization', value: report.config.forgejoOrganization ?? '<not selected>' },
33
33
  { label: 'Forgejo user', value: report.config.forgejoUserRegistered == null ? '<unknown>' : (report.config.forgejoUserRegistered ? 'registered' : 'pending') },
34
+ { label: 'Git SSH key', value: report.config.gitSshKeyTitle ?? '<missing>' },
35
+ { label: 'Git SSH public key', value: report.config.gitSshPublicKeyPath ?? '<missing>' },
36
+ { label: 'Git SSH private key', value: report.config.gitSshPrivateKeyPath ?? '<missing>' },
34
37
  { label: 'Access token', value: report.config.accessToken ?? '<missing>' },
35
38
  { label: 'Session expiry', value: report.config.sessionExpiresAt ? new Date(report.config.sessionExpiresAt).toISOString() : '<missing>' },
36
39
  { label: 'Refresh token', value: report.config.hasRefreshToken ? 'present' : 'missing' },
@@ -49,7 +52,17 @@ export function renderStatusReport(report: StatusReport, options: RenderOptions
49
52
 
50
53
  const toolRows = [
51
54
  ['git', report.tools.git ? 'available' : 'missing', report.tools.git ? 'ready' : 'install git'],
52
- ['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
+ ],
53
66
  [
54
67
  'agent-api-origin',
55
68
  report.tools.agentApiReachable === null
package/src/config.ts CHANGED
@@ -5,9 +5,13 @@ import { listOrganizations, provisionForgejoUser } from './agent-api.ts'
5
5
  import type { FetchLike } from './auth.ts'
6
6
  import { createUserFacingError } from './errors.ts'
7
7
  import {
8
+ createGitSshKey,
8
9
  ensureForgejoUserAccess,
10
+ inferSshPrivateKeyPath,
9
11
  listDiscoveredSshPublicKeys,
10
12
  loadSshPublicKey,
13
+ resolveSshPrivateKeyPath,
14
+ validateSshPrivateKeyPath,
11
15
  validateSshPublicKeyPath,
12
16
  type SelectedSshPublicKey,
13
17
  } from './forgejo.ts'
@@ -15,6 +19,7 @@ import type {
15
19
  TelaCliAuthState,
16
20
  TelaCliConfig,
17
21
  TelaCliForgejoOrganization,
22
+ TelaCliGitSshKey,
18
23
  TelaEnvironment,
19
24
  } from './types.ts'
20
25
 
@@ -213,6 +218,18 @@ function normalizeForgejoOrganization(
213
218
  }
214
219
  }
215
220
 
221
+ function normalizeGitSshKey(gitSshKey: TelaCliConfig['gitSshKey']): TelaCliGitSshKey | null {
222
+ if (!gitSshKey) {
223
+ return null
224
+ }
225
+
226
+ return {
227
+ title: ensureNonEmpty(String(gitSshKey.title ?? ''), 'Git SSH key title'),
228
+ publicKeyPath: expandHome(ensureNonEmpty(String(gitSshKey.publicKeyPath ?? ''), 'Git SSH public key path')),
229
+ privateKeyPath: expandHome(ensureNonEmpty(String(gitSshKey.privateKeyPath ?? ''), 'Git SSH private key path')),
230
+ }
231
+ }
232
+
216
233
  function normalizeConfig(config: TelaCliConfig): TelaCliConfig {
217
234
  const environment = ensureEnvironment(config.environment)
218
235
  const auth = normalizeAuth(config.auth)
@@ -229,6 +246,7 @@ function normalizeConfig(config: TelaCliConfig): TelaCliConfig {
229
246
  auth,
230
247
  forgejoOrganization: auth ? normalizeForgejoOrganization(config.forgejoOrganization) : null,
231
248
  forgejoUserRegistered: Boolean(config.forgejoUserRegistered),
249
+ gitSshKey: normalizeGitSshKey(config.gitSshKey),
232
250
  }
233
251
  }
234
252
 
@@ -257,6 +275,7 @@ function parseJsonConfig(input: string): TelaCliConfig {
257
275
  auth,
258
276
  forgejoOrganization: parsed.forgejoOrganization as TelaCliConfig['forgejoOrganization'] ?? null,
259
277
  forgejoUserRegistered: Boolean(parsed.forgejoUserRegistered ?? false),
278
+ gitSshKey: parsed.gitSshKey as TelaCliConfig['gitSshKey'] ?? null,
260
279
  })
261
280
  }
262
281
 
@@ -317,6 +336,25 @@ async function promptForSshPublicKey(prompts: PromptModule): Promise<SelectedSsh
317
336
  return loadSshPublicKey(promptValue(customPath))
318
337
  }
319
338
 
339
+ async function promptForSshPrivateKeyPath(
340
+ prompts: PromptModule,
341
+ sshKey: SelectedSshPublicKey,
342
+ ): Promise<string> {
343
+ const privateKeyPath = await prompts.text({
344
+ message: 'SSH private key path',
345
+ defaultValue: inferSshPrivateKeyPath(sshKey.path),
346
+ placeholder: sshKey.path.endsWith('.pub')
347
+ ? sshKey.path.slice(0, -4)
348
+ : '~/.ssh/id_ed25519',
349
+ validate: validateSshPrivateKeyPath,
350
+ })
351
+ if (prompts.isCancel(privateKeyPath)) {
352
+ cancelSetup(prompts)
353
+ }
354
+
355
+ return resolveSshPrivateKeyPath(promptValue(privateKeyPath))
356
+ }
357
+
320
358
  function getHomeDir(): string {
321
359
  return process.env.HOME || homedir()
322
360
  }
@@ -700,6 +738,7 @@ export async function setupConfigInteractive(options: {
700
738
  }
701
739
 
702
740
  const sshKey = await promptForSshPublicKey(prompts)
741
+ const privateKeyPath = await promptForSshPrivateKeyPath(prompts, sshKey)
703
742
 
704
743
  const resolvedSetup = {
705
744
  ...resolveEnvironmentConfigForPreset(environment, customValues),
@@ -718,6 +757,7 @@ export async function setupConfigInteractive(options: {
718
757
  auth,
719
758
  forgejoOrganization: null,
720
759
  forgejoUserRegistered: false,
760
+ gitSshKey: createGitSshKey(sshKey, privateKeyPath),
721
761
  })
722
762
  const organization = await resolveForgejoOrganizationInteractive({
723
763
  prompts,