tamfis-code 0.2.3__py3-none-any.whl

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.
tamfis_code/cli.py ADDED
@@ -0,0 +1,2351 @@
1
+ """tamfis-code command-line entry point.
2
+
3
+ Command surface includes login, workspace/session management, conversational
4
+ chat, read-only audit, durable plan/list/execute-plan workflows, full agent
5
+ execution, explicit shell commands, approvals, diffs/revert, retries,
6
+ background tasks, attach/logs, and the bare `tamfis-code` interactive mode.
7
+ Deliberately deferred: JSON/jsonl/sarif output modes,
8
+ shell completion, @file/@stdin references, TAMFIS.md hierarchical
9
+ instructions, --server/--session remote-session mode, compact as a distinct
10
+ command, the network-outage retry state machine, and a durable multi-
11
+ channel notification outbox (separate follow-up -- see project memory).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import asyncio
17
+ import functools
18
+ import getpass
19
+ import os
20
+ import re
21
+ import sys
22
+ from pathlib import Path
23
+ from typing import Optional
24
+
25
+ import click
26
+ import httpx
27
+ from rich.console import Console
28
+ from rich.markdown import Markdown
29
+ from rich.table import Table
30
+
31
+ from . import __version__, state as local_state
32
+ from .api_client import (
33
+ AuthRequiredError, RemoteAPIClient, RemoteAPIError, clear_secure_credentials as clear_credentials,
34
+ credential_storage_backend, load_secure_credentials as load_credentials,
35
+ save_secure_credentials as save_credentials,
36
+ )
37
+ from .config import APPROVAL_MODES, CONFIG_DIR, Config, Credentials, load_config
38
+ from .doctor import run_doctor
39
+ from .render import StreamRenderer, print_banner, print_error, print_recent_thread, print_unified_diff
40
+ from .runner import (
41
+ ACTIVE_TASK_STATUSES,
42
+ attach_and_stream, follow_session_logs, retry_task_and_stream,
43
+ run_ai_task_and_stream, run_shell_command, submit_ai_task_background,
44
+ )
45
+ from .tasks import find_recent_task
46
+ from .workspace import WorkspaceContext, blocking_dirty_files, context_from_session, discover_local_repository, find_resumable_session, resolve_workspace
47
+
48
+ EXIT_OK = 0
49
+ EXIT_TASK_FAILED = 1
50
+ EXIT_INVALID_ARGS = 2
51
+ EXIT_AUTH_FAILED = 3
52
+ EXIT_RUNTIME_UNAVAILABLE = 6
53
+ EXIT_INTERRUPTED = 7
54
+ EXIT_LOCAL_STATE_ERROR = 8
55
+
56
+
57
+ def _print_bg_hint(console: Console, session_id: int, task_id: str) -> None:
58
+ console.print(f"[green]backgrounded[/green] · session {session_id} · task {task_id}")
59
+ console.print()
60
+ console.print(" tamfis-code agents")
61
+ console.print(f" tamfis-code attach {session_id}")
62
+ console.print(f" tamfis-code logs {session_id}")
63
+ console.print(f" tamfis-code stop {session_id}")
64
+
65
+
66
+ def _run_async(coro):
67
+ try:
68
+ return asyncio.run(coro)
69
+ except KeyboardInterrupt:
70
+ raise SystemExit(EXIT_INTERRUPTED)
71
+
72
+
73
+ def _use_remote(config: Config, remote_flag: bool) -> bool:
74
+ """A per-command --remote flag always wins; otherwise fall back to the
75
+ persistent config.toml/env `default_backend` setting (see config.py) --
76
+ this is what lets a paid TamfisGPT tenant set it once instead of typing
77
+ --remote on every command."""
78
+ return remote_flag or config.default_backend == "remote"
79
+
80
+
81
+ def async_command(fn):
82
+ @functools.wraps(fn)
83
+ def wrapper(*args, **kwargs):
84
+ return _run_async(fn(*args, **kwargs))
85
+ return wrapper
86
+
87
+
88
+ @click.group(invoke_without_command=True)
89
+ @click.option("--debug", is_flag=True, default=False, help="Show structured event and tool diagnostics.")
90
+ @click.option("--approval", "approval_policy", type=click.Choice(APPROVAL_MODES), default=None, help="Override the configured approval policy for this invocation.")
91
+ @click.option("--api-base", "api_base", default=None, help="Override the configured Remote API base URL.")
92
+ @click.option("--cwd", "cwd_override", type=click.Path(exists=True, file_okay=False), default=None, help="Treat this directory as the workspace instead of the current directory.")
93
+ @click.option("--provider", default="auto", help="hf, nvidia, openrouter, ollama, or auto (default) -- which provider the bare (no-subcommand) interactive REPL calls directly.")
94
+ @click.option("--model", default=None, help="Provider-specific model id for the bare interactive REPL; defaults to that provider's default model.")
95
+ @click.option("--remote", is_flag=True, default=False, help="Use the legacy TamfisGPT Remote Workspace backend for the bare interactive REPL instead of calling a provider directly.")
96
+ @click.version_option(__version__, prog_name="tamfis-code")
97
+ @click.pass_context
98
+ def cli(
99
+ ctx: click.Context, debug: bool, approval_policy: Optional[str], api_base: Optional[str],
100
+ cwd_override: Optional[str], provider: str, model: Optional[str], remote: bool,
101
+ ):
102
+ """TamfisGPT Code -- a standalone terminal coding agent."""
103
+ workspace_root = Path(cwd_override).resolve() if cwd_override else Path.cwd()
104
+ config = load_config(project_root=workspace_root)
105
+ if os.environ.get("NO_COLOR") is not None:
106
+ config.colour = False
107
+ config.sources["colour"] = "env NO_COLOR"
108
+ elif os.environ.get("TERM", "").lower() == "dumb":
109
+ config.colour = False
110
+ config.sources["colour"] = "env TERM=dumb"
111
+ elif (os.environ.get("CI") or not sys.stdout.isatty()) and os.environ.get("FORCE_COLOR") is None:
112
+ config.colour = False
113
+ config.sources["colour"] = "non-interactive output"
114
+ if approval_policy:
115
+ config.approval_policy = approval_policy
116
+ config.sources["approval_policy"] = "--approval flag"
117
+ if api_base:
118
+ config.api_base = api_base
119
+ config.sources["api_base"] = "--api-base flag"
120
+ if debug:
121
+ config.debug = True
122
+ config.sources["debug"] = "--debug flag"
123
+ os.environ["TAMFIS_CODE_DEBUG"] = "1"
124
+
125
+ ctx.ensure_object(dict)
126
+ ctx.obj["config"] = config
127
+ ctx.obj["workspace_root"] = workspace_root
128
+
129
+ if ctx.invoked_subcommand is None:
130
+ _run_async(_interactive_entry(config, workspace_root, provider, model, remote))
131
+
132
+
133
+ async def _interactive_entry(
134
+ config: Config, workspace_root: Path, provider: str = "auto",
135
+ model: Optional[str] = None, remote: bool = False,
136
+ ) -> None:
137
+ from .interactive import run_interactive
138
+
139
+ console = Console(no_color=not config.colour)
140
+
141
+ if not _use_remote(config, remote):
142
+ from .workspace import resolve_local_workspace
143
+
144
+ workspace = resolve_local_workspace(workspace_root)
145
+ await run_interactive(None, config, workspace, provider=provider, model=model)
146
+ return
147
+
148
+ creds = load_credentials()
149
+ if creds is None:
150
+ print_error(console, "Not authenticated -- run `tamfis-code login` first, or omit --remote for the standalone REPL.")
151
+ raise SystemExit(EXIT_AUTH_FAILED)
152
+
153
+ async with RemoteAPIClient(config, creds) as client:
154
+ try:
155
+ workspace = await resolve_workspace(client, workspace_root)
156
+ except AuthRequiredError:
157
+ print_error(console, "Not authenticated -- run `tamfis-code login` first.")
158
+ raise SystemExit(EXIT_AUTH_FAILED)
159
+ except (RemoteAPIError, httpx.HTTPError) as e:
160
+ print_error(console, f"Could not reach TamfisGPT Remote runtime: {e}")
161
+ raise SystemExit(EXIT_RUNTIME_UNAVAILABLE)
162
+
163
+ await run_interactive(client, config, workspace)
164
+
165
+
166
+ # -- login / logout ------------------------------------------------------
167
+
168
+ @cli.command()
169
+ @click.option("--email", default=None)
170
+ @click.option("--token", "existing_token", default=None, envvar="TAMFIS_CODE_LOGIN_TOKEN",
171
+ help="Use an existing TamfisGPT access token (prefer the environment variable to shell history).")
172
+ @click.pass_context
173
+ def login(ctx: click.Context, email: Optional[str], existing_token: Optional[str]):
174
+ """Authenticate against the TamfisGPT account system (only needed for --remote commands -- standalone mode never requires login)."""
175
+ config: Config = ctx.obj["config"]
176
+ console = Console(no_color=not config.colour)
177
+
178
+ if not email and not existing_token:
179
+ console.print("1. Sign in with email and password")
180
+ console.print("2. Use an existing access token")
181
+ console.print("3. Exit")
182
+ choice = click.prompt("Select an option", type=click.Choice(["1", "2", "3"]), default="1")
183
+ if choice == "3":
184
+ return
185
+ if choice == "2":
186
+ existing_token = getpass.getpass("Access token (input hidden): ")
187
+
188
+ if existing_token:
189
+ async def _verify_token():
190
+ creds = Credentials(access_token=existing_token)
191
+ async with RemoteAPIClient(config, creds) as client:
192
+ data = await client.me()
193
+ user = data.get("user") or {}
194
+ if not data.get("authenticated", True):
195
+ raise RemoteAPIError(401, "Token is not authenticated")
196
+ creds.user_id = user.get("id")
197
+ creds.email = user.get("email")
198
+ backend = save_credentials(creds)
199
+ return user, backend
200
+
201
+ try:
202
+ user, backend = _run_async(_verify_token())
203
+ except (RemoteAPIError, httpx.HTTPError) as e:
204
+ print_error(console, f"Token login failed: {e}")
205
+ raise SystemExit(EXIT_AUTH_FAILED)
206
+ console.print(f"[green]Logged in[/green] as {user.get('email', 'TamfisGPT user')} · storage={backend}")
207
+ return
208
+
209
+ email = email or click.prompt("Email")
210
+ password = getpass.getpass("Password: ")
211
+
212
+ async def _do_login():
213
+ async with RemoteAPIClient(config, credentials=None) as client:
214
+ data = await client.login(email, password)
215
+ user = data.get("user") or {}
216
+ creds = Credentials(
217
+ access_token=data["access_token"],
218
+ refresh_token=data.get("refresh_token"),
219
+ user_id=user.get("id"),
220
+ email=user.get("email", email),
221
+ )
222
+ backend = save_credentials(creds)
223
+ return user, backend
224
+
225
+ try:
226
+ user, backend = _run_async(_do_login())
227
+ except RemoteAPIError as e:
228
+ print_error(console, f"Login failed: {e}")
229
+ raise SystemExit(EXIT_AUTH_FAILED)
230
+
231
+ console.print(f"[green]Logged in[/green] as {user.get('email', email)} (plan: {user.get('plan', 'unknown')}) · storage={backend}")
232
+
233
+
234
+ @cli.command()
235
+ @click.pass_context
236
+ def logout(ctx: click.Context):
237
+ """End the backend browser session where applicable and remove local credentials."""
238
+ config: Config = ctx.obj["config"]
239
+ console = Console(no_color=not config.colour)
240
+ creds = load_credentials()
241
+ if creds is not None:
242
+ async def _server_logout():
243
+ async with RemoteAPIClient(config, creds) as client:
244
+ try:
245
+ await client.logout()
246
+ except (RemoteAPIError, httpx.HTTPError):
247
+ pass # local token removal must still succeed offline
248
+ _run_async(_server_logout())
249
+ if clear_credentials():
250
+ console.print("[green]Logged out.[/green]")
251
+ else:
252
+ console.print("[dim]Not logged in.[/dim]")
253
+
254
+
255
+ # -- workspace / diagnostics ---------------------------------------------
256
+
257
+ def _session_for_primary(root: Path) -> Optional[int]:
258
+ resolved = str(root.resolve())
259
+ matches = [
260
+ sid for sid in local_state.all_known_session_ids()
261
+ if local_state.get_session_state(sid).primary_workspace == resolved
262
+ or local_state.get_session_state(sid).workspace_root == resolved
263
+ ]
264
+ return matches[-1] if matches else None
265
+
266
+
267
+ _ABS_PATH_RE = re.compile(r"(?<![\w.])(/[A-Za-z0-9_./+@%:=-]+)")
268
+
269
+
270
+ def _explicit_absolute_paths(objective: str) -> list[Path]:
271
+ return [Path(raw.rstrip(".,;:)]}")) for raw in _ABS_PATH_RE.findall(objective)]
272
+
273
+
274
+ def _project_root_for_target(target: Path) -> Path:
275
+ start = target if target.is_dir() else target.parent
276
+ for candidate in (start, *start.parents):
277
+ if (candidate / ".git").exists():
278
+ return candidate.resolve()
279
+ return start.resolve()
280
+
281
+
282
+ @cli.group(name="workspace")
283
+ def workspace_group():
284
+ """Manage filesystem roots approved for the current session."""
285
+
286
+
287
+ @workspace_group.command(name="list")
288
+ @click.pass_context
289
+ def workspace_list(ctx: click.Context):
290
+ console = Console(no_color=not ctx.obj["config"].colour)
291
+ session_id = _session_for_primary(ctx.obj["workspace_root"])
292
+ if session_id is None:
293
+ print_error(console, "No known session for this workspace; run `tamfis-code init` first.")
294
+ raise SystemExit(EXIT_TASK_FAILED)
295
+ state = local_state.get_session_state(session_id)
296
+ for path in state.allowed_workspaces or [state.workspace_root]:
297
+ marker = " (current)" if path == state.current_working_directory else ""
298
+ console.print(f"{path}{marker}")
299
+
300
+
301
+ @workspace_group.command(name="add")
302
+ @click.argument("path", type=click.Path(exists=True, file_okay=False, path_type=Path))
303
+ @click.pass_context
304
+ def workspace_add(ctx: click.Context, path: Path):
305
+ console = Console(no_color=not ctx.obj["config"].colour)
306
+ session_id = _session_for_primary(ctx.obj["workspace_root"])
307
+ if session_id is None:
308
+ print_error(console, "No known session for this workspace; run `tamfis-code init` first.")
309
+ raise SystemExit(EXIT_TASK_FAILED)
310
+ approved = str(path.resolve())
311
+ state = local_state.get_session_state(session_id)
312
+ allowed = list(dict.fromkeys([*(state.allowed_workspaces or [state.workspace_root]), approved]))
313
+ local_state.save_session_state(session_id, allowed_workspaces=allowed)
314
+ console.print(f"[green]Workspace approved for this session:[/green] {approved}")
315
+
316
+
317
+ @workspace_group.command(name="remove")
318
+ @click.argument("path", type=click.Path(path_type=Path))
319
+ @click.pass_context
320
+ def workspace_remove(ctx: click.Context, path: Path):
321
+ console = Console(no_color=not ctx.obj["config"].colour)
322
+ session_id = _session_for_primary(ctx.obj["workspace_root"])
323
+ if session_id is None:
324
+ print_error(console, "No known session for this workspace; run `tamfis-code init` first.")
325
+ raise SystemExit(EXIT_TASK_FAILED)
326
+ state = local_state.get_session_state(session_id)
327
+ target = str(path.expanduser().resolve())
328
+ if target == state.primary_workspace:
329
+ raise click.UsageError("The primary workspace cannot be removed.")
330
+ local_state.save_session_state(
331
+ session_id, allowed_workspaces=[item for item in state.allowed_workspaces if item != target],
332
+ )
333
+ console.print(f"[green]Workspace removed:[/green] {target}")
334
+
335
+
336
+ @cli.command(name="cwd")
337
+ @click.argument("path", required=False, type=click.Path(exists=True, file_okay=False, path_type=Path))
338
+ @click.option("--remote", is_flag=True, default=False, help="Update the working directory on the legacy TamfisGPT Remote Workspace backend instead of the local session.")
339
+ @click.pass_context
340
+ @async_command
341
+ async def cwd_command(ctx: click.Context, path: Optional[Path], remote: bool):
342
+ config: Config = ctx.obj["config"]
343
+ console = Console(no_color=not config.colour)
344
+ session_id = _session_for_primary(ctx.obj["workspace_root"])
345
+ if session_id is None:
346
+ print_error(console, "No known session for this workspace; run `tamfis-code init` first.")
347
+ raise SystemExit(EXIT_TASK_FAILED)
348
+ state = local_state.get_session_state(session_id)
349
+ if path is None:
350
+ console.print(state.current_working_directory or state.workspace_root)
351
+ return
352
+ target = str(path.resolve())
353
+ if not any(target == allowed or target.startswith(allowed.rstrip("/") + "/") for allowed in state.allowed_workspaces):
354
+ console.print(
355
+ f"Access to this path requires workspace approval:\n\n{target}\n\n"
356
+ f"Approve adding it with:\n tamfis-code workspace add {target}"
357
+ )
358
+ raise SystemExit(EXIT_TASK_FAILED)
359
+
360
+ if not _use_remote(config, remote):
361
+ local_state.save_session_state(session_id, current_working_directory=target)
362
+ discover_local_repository(session_id, Path(target), force=True)
363
+ console.print(f"[green]Working directory:[/green] {target}")
364
+ return
365
+
366
+ creds = load_credentials()
367
+ if creds is None:
368
+ print_error(console, "Not authenticated -- run `tamfis-code login` first, or omit --remote to update the local session.")
369
+ raise SystemExit(EXIT_AUTH_FAILED)
370
+ async with RemoteAPIClient(config, creds) as client:
371
+ updated = await client.set_session_cwd(session_id, target)
372
+ resolved = str(updated.get("working_directory") or target)
373
+ local_state.save_session_state(session_id, current_working_directory=resolved)
374
+ discover_local_repository(session_id, Path(resolved), force=True)
375
+ console.print(f"[green]Working directory:[/green] {resolved}")
376
+
377
+ @cli.command()
378
+ @click.option("--remote", is_flag=True, default=False, help="Register/reuse a session on the legacy TamfisGPT Remote Workspace backend instead of a local one.")
379
+ @click.pass_context
380
+ @async_command
381
+ async def init(ctx: click.Context, remote: bool):
382
+ """Open (or reuse) a session for this directory."""
383
+ config: Config = ctx.obj["config"]
384
+ workspace_root: Path = ctx.obj["workspace_root"]
385
+ console = Console(no_color=not config.colour)
386
+
387
+ if not _use_remote(config, remote):
388
+ from .workspace import resolve_local_workspace
389
+
390
+ workspace = resolve_local_workspace(workspace_root)
391
+ console.print(f"[green]Ready.[/green] session_id={workspace.session_id} (standalone, local session)")
392
+ console.print(f"workspace_root={workspace.workspace_root}")
393
+ return
394
+
395
+ creds = load_credentials()
396
+ if creds is None:
397
+ print_error(console, "Not authenticated -- run `tamfis-code login` first, or omit --remote for a standalone local session.")
398
+ raise SystemExit(EXIT_AUTH_FAILED)
399
+
400
+ async with RemoteAPIClient(config, creds) as client:
401
+ try:
402
+ workspace = await resolve_workspace(client, workspace_root)
403
+ except AuthRequiredError:
404
+ print_error(console, "Not authenticated -- run `tamfis-code login` first.")
405
+ raise SystemExit(EXIT_AUTH_FAILED)
406
+ except RemoteAPIError as e:
407
+ print_error(console, str(e))
408
+ raise SystemExit(EXIT_RUNTIME_UNAVAILABLE)
409
+
410
+ console.print(f"[green]Ready.[/green] session_id={workspace.session_id} server_id={workspace.server_id}")
411
+ console.print(f"workspace_root={workspace.workspace_root}")
412
+
413
+
414
+ @cli.command()
415
+ @click.option("--provider", default="auto", help="hf, nvidia, openrouter, ollama, or auto (default).")
416
+ @click.option("--remote", is_flag=True, default=False, help="Check the legacy TamfisGPT Remote Workspace backend instead of local provider connectivity.")
417
+ @click.pass_context
418
+ @async_command
419
+ async def doctor(ctx: click.Context, provider: str, remote: bool):
420
+ """Validate provider connectivity (or, with --remote, the legacy backend)."""
421
+ config: Config = ctx.obj["config"]
422
+ workspace_root: Path = ctx.obj["workspace_root"]
423
+ console = Console(no_color=not config.colour)
424
+
425
+ if not _use_remote(config, remote):
426
+ from .local_chat import resolve_provider_type
427
+ from .providers import get_provider_status
428
+ from .workspace import resolve_local_workspace
429
+
430
+ try:
431
+ provider_type = resolve_provider_type(provider)
432
+ except ValueError as exc:
433
+ raise click.UsageError(str(exc))
434
+ status = get_provider_status()
435
+ table = Table(show_header=True, header_style="bold")
436
+ for column in ("PROVIDER", "CONFIGURED", "KEY"):
437
+ table.add_column(column)
438
+ any_configured = False
439
+ for name, info in status["config"].items():
440
+ configured = bool(info["api_key_set"]) or name == "ollama"
441
+ any_configured = any_configured or configured
442
+ table.add_row(name, "[green]yes[/green]" if configured else "[dim]no[/dim]", info["key_preview"])
443
+ console.print(table)
444
+ console.print(f"[dim]Currently selected: {provider_type.value} · auto would pick: {status['default']}[/dim]")
445
+ workspace = resolve_local_workspace(workspace_root, discover=False)
446
+ console.print(f"[green]Local session ready[/green] session_id={workspace.session_id} workspace_root={workspace.workspace_root}")
447
+ if not any_configured:
448
+ print_error(console, "No provider is configured (set HF_TOKEN / NVIDIA_API_KEY / OPENROUTER_API_KEY, or run Ollama locally).")
449
+ raise SystemExit(EXIT_RUNTIME_UNAVAILABLE)
450
+ return
451
+
452
+ # Best-effort: if this directory already has (or can idempotently reuse)
453
+ # a session, run_doctor's session/workspace-snapshot/event-replay checks
454
+ # run too -- same resolve_workspace() every other command already calls,
455
+ # so this isn't a new side effect class, just reusing the existing one.
456
+ # Any failure here (no creds, API down) falls through to run_doctor
457
+ # running its own checks and reporting those failures properly instead.
458
+ session_id = None
459
+ creds = load_credentials()
460
+ if creds is not None:
461
+ async with RemoteAPIClient(config, creds) as client:
462
+ try:
463
+ workspace = await resolve_workspace(client, workspace_root)
464
+ session_id = workspace.session_id
465
+ except (AuthRequiredError, RemoteAPIError):
466
+ pass
467
+
468
+ ok = await run_doctor(config, console, workspace_root, session_id=session_id)
469
+ if not ok:
470
+ raise SystemExit(EXIT_RUNTIME_UNAVAILABLE)
471
+
472
+
473
+ @cli.command(name="config")
474
+ @click.pass_context
475
+ def config_command(ctx: click.Context):
476
+ """Show resolved configuration and where each value came from."""
477
+ config: Config = ctx.obj["config"]
478
+ console = Console(no_color=not config.colour)
479
+ table = Table(show_header=True, header_style="bold")
480
+ table.add_column("Setting")
481
+ table.add_column("Value")
482
+ table.add_column("Source", style="dim")
483
+ for key, value in config.as_dict().items():
484
+ table.add_row(key, str(value), config.sources.get(key, "default"))
485
+ table.add_row("credential_storage", credential_storage_backend(), "platform capability")
486
+ console.print(table)
487
+
488
+
489
+ @cli.command()
490
+ @click.option("--remote", is_flag=True, default=False, help="List sessions on the legacy TamfisGPT Remote Workspace backend instead of known local sessions.")
491
+ @click.pass_context
492
+ @async_command
493
+ async def sessions(ctx: click.Context, remote: bool):
494
+ """List known sessions (local by default, or --remote)."""
495
+ config: Config = ctx.obj["config"]
496
+ console = Console(no_color=not config.colour)
497
+
498
+ if not _use_remote(config, remote):
499
+ table = Table(show_header=True, header_style="bold")
500
+ for col in ("ID", "Workspace Root"):
501
+ table.add_column(col)
502
+ for sid in local_state.all_known_session_ids():
503
+ sess_state = local_state.get_session_state(sid)
504
+ table.add_row(str(sid), sess_state.workspace_root or sess_state.primary_workspace)
505
+ console.print(table)
506
+ return
507
+
508
+ creds = load_credentials()
509
+ if creds is None:
510
+ print_error(console, "Not authenticated -- run `tamfis-code login` first, or omit --remote to list local sessions.")
511
+ raise SystemExit(EXIT_AUTH_FAILED)
512
+
513
+ async with RemoteAPIClient(config, creds) as client:
514
+ try:
515
+ rows = await client.list_sessions()
516
+ except AuthRequiredError:
517
+ print_error(console, "Not authenticated -- run `tamfis-code login` first.")
518
+ raise SystemExit(EXIT_AUTH_FAILED)
519
+ except RemoteAPIError as e:
520
+ print_error(console, str(e))
521
+ raise SystemExit(EXIT_RUNTIME_UNAVAILABLE)
522
+
523
+ table = Table(show_header=True, header_style="bold")
524
+ for col in ("ID", "Server", "Status", "Working Directory", "Commands"):
525
+ table.add_column(col)
526
+ for row in rows:
527
+ table.add_row(str(row.get("id")), str(row.get("server_name")), str(row.get("status")), str(row.get("working_directory") or ""), str(row.get("command_count")))
528
+ console.print(table)
529
+
530
+
531
+ @cli.command()
532
+ @click.option("--remote", is_flag=True, default=False, help="Show status from the legacy TamfisGPT Remote Workspace backend instead of the local session.")
533
+ @click.pass_context
534
+ @async_command
535
+ async def status(ctx: click.Context, remote: bool):
536
+ """Show session, task, CWD, and approval status for this workspace."""
537
+ config: Config = ctx.obj["config"]
538
+ workspace_root: Path = ctx.obj["workspace_root"]
539
+ console = Console(no_color=not config.colour)
540
+
541
+ if not _use_remote(config, remote):
542
+ from .workspace import resolve_local_workspace
543
+
544
+ workspace = resolve_local_workspace(workspace_root, discover=False)
545
+ state = local_state.get_session_state(workspace.session_id)
546
+ console.print(f"session_id={workspace.session_id} (standalone, local session) phase={state.current_phase}")
547
+ console.print(f"workspace_root={workspace.workspace_root}")
548
+ console.print(f"repository_root={state.repository_root or '(not a Git repository)'} branch={state.active_branch or '-'}")
549
+ console.print(f"approval_policy={config.approval_policy}")
550
+ running = state.running_action or {}
551
+ console.print(f"running_action={running.get('purpose', 'none')} queued={sum(1 for item in state.queued_user_instructions if item.get('status') == 'queued')}")
552
+ console.print(f"modified_files={len(state.modified_files)} validations={len(state.validation_results)} unresolved={len(state.unresolved_issues)}")
553
+ console.print(f"saved_plans={len(state.saved_plans)} active_plan={state.active_plan_id or '-'}")
554
+ return
555
+
556
+ creds = load_credentials()
557
+ if creds is None:
558
+ print_error(console, "Not authenticated -- run `tamfis-code login` first, or omit --remote for local status.")
559
+ raise SystemExit(EXIT_AUTH_FAILED)
560
+
561
+ state = None
562
+ task_detail = None
563
+ async with RemoteAPIClient(config, creds) as client:
564
+ try:
565
+ workspace = await resolve_workspace(client, workspace_root)
566
+ session_detail = await client.get_session(workspace.session_id)
567
+ state = local_state.get_session_state(workspace.session_id)
568
+ if state.last_task_id:
569
+ try:
570
+ task_detail = await client.get_task(state.last_task_id)
571
+ except RemoteAPIError as exc:
572
+ if exc.status_code != 404:
573
+ raise
574
+ except AuthRequiredError:
575
+ print_error(console, "Not authenticated -- run `tamfis-code login` first.")
576
+ raise SystemExit(EXIT_AUTH_FAILED)
577
+ except RemoteAPIError as e:
578
+ print_error(console, str(e))
579
+ raise SystemExit(EXIT_RUNTIME_UNAVAILABLE)
580
+
581
+ if (
582
+ task_detail
583
+ and str(task_detail.get("status")) in {"completed", "failed", "cancelled", "denied"}
584
+ and state.running_action
585
+ ):
586
+ terminal_status = str(task_detail.get("status"))
587
+ local_state.finish_action(
588
+ workspace.session_id,
589
+ str(state.running_action.get("id")),
590
+ status=terminal_status,
591
+ summary=str(task_detail.get("final_answer") or task_detail.get("error") or ""),
592
+ )
593
+ local_state.save_session_state(
594
+ workspace.session_id,
595
+ active_task=None,
596
+ current_phase="report",
597
+ execution_status="idle",
598
+ )
599
+ state = local_state.get_session_state(workspace.session_id)
600
+ console.print(f"session_id={workspace.session_id} status={session_detail.get('status')} phase={state.current_phase}")
601
+ console.print(f"workspace_root={workspace.workspace_root}")
602
+ console.print(f"repository_root={state.repository_root or '(not a Git repository)'} branch={state.active_branch or '-'}")
603
+ console.print(f"approval_policy={config.approval_policy} api_base={config.api_base}")
604
+ running = state.running_action or {}
605
+ console.print(f"running_action={running.get('purpose', 'none')} queued={sum(1 for item in state.queued_user_instructions if item.get('status') == 'queued')}")
606
+ console.print(f"modified_files={len(state.modified_files)} validations={len(state.validation_results)} unresolved={len(state.unresolved_issues)}")
607
+ console.print(f"saved_plans={len(state.saved_plans)} active_plan={state.active_plan_id or '-'}")
608
+
609
+
610
+ @cli.command(name="context")
611
+ @click.option("--refresh", is_flag=True, help="Force a fresh bounded repository index.")
612
+ @click.pass_context
613
+ def context_command(ctx: click.Context, refresh: bool):
614
+ """Show the durable, secret-free repository and task context."""
615
+ config: Config = ctx.obj["config"]
616
+ root: Path = ctx.obj["workspace_root"]
617
+ console = Console(no_color=not config.colour)
618
+ matching = [sid for sid in local_state.all_known_session_ids()
619
+ if local_state.get_session_state(sid).workspace_root == str(root)]
620
+ if not matching:
621
+ console.print("[dim]No local session context yet; run `tamfis-code init` first.[/dim]")
622
+ return
623
+ session_id = matching[-1]
624
+ context = discover_local_repository(session_id, root, force=refresh)
625
+ state = local_state.get_session_state(session_id)
626
+ console.print(f"Repository {context.get('repository_root')}")
627
+ console.print(f"CWD {context.get('working_directory')}")
628
+ console.print(f"Branch {context.get('branch') or '-'}")
629
+ console.print(f"Worktree {'modified' if context.get('dirty') else 'clean'}")
630
+ console.print(f"Task {(state.active_task or {}).get('objective') or state.conversation_summary or '-'}")
631
+ console.print(f"Phase {state.current_phase} ({state.execution_status})")
632
+ console.print(f"Indexed {context.get('indexed_file_count', 0)} files")
633
+ for path in context.get("instruction_files", []):
634
+ console.print(f" instruction: {path}")
635
+
636
+
637
+ @cli.command(name="reports")
638
+ @click.pass_context
639
+ def reports_command(ctx: click.Context):
640
+ """Show reports discovered for this repository and verification status."""
641
+ config: Config = ctx.obj["config"]
642
+ root: Path = ctx.obj["workspace_root"]
643
+ console = Console(no_color=not config.colour)
644
+ matching = [sid for sid in local_state.all_known_session_ids()
645
+ if local_state.get_session_state(sid).workspace_root == str(root)]
646
+ if not matching:
647
+ console.print("[dim]No report index yet; run `tamfis-code init` first.[/dim]")
648
+ return
649
+ state = local_state.get_session_state(matching[-1])
650
+ if not state.discovered_reports:
651
+ console.print("[dim]No matching report files discovered in this repository.[/dim]")
652
+ return
653
+ table = Table(show_header=True, header_style="bold")
654
+ for name in ("Modified", "Status", "Title", "Path"):
655
+ table.add_column(name)
656
+ for report in state.discovered_reports:
657
+ table.add_row(str(report.get("modified_at", ""))[:10], str(report.get("verification", "unverified")),
658
+ str(report.get("title", "")), str(report.get("path", "")))
659
+ console.print(table)
660
+
661
+
662
+ @cli.command(name="plans")
663
+ @click.argument("plan_id", required=False)
664
+ @click.pass_context
665
+ def plans_command(ctx: click.Context, plan_id: Optional[str]):
666
+ """List saved plans, or show one plan by id/prefix."""
667
+ config: Config = ctx.obj["config"]
668
+ root: Path = ctx.obj["workspace_root"]
669
+ console = Console(no_color=not config.colour)
670
+ matching = [sid for sid in local_state.all_known_session_ids()
671
+ if local_state.get_session_state(sid).workspace_root == str(root)]
672
+ if not matching:
673
+ console.print("[dim]No local session context yet; run `tamfis-code init` first.[/dim]")
674
+ return
675
+ session_id = matching[-1]
676
+ state = local_state.get_session_state(session_id)
677
+ if plan_id:
678
+ plan = local_state.get_plan(session_id, plan_id)
679
+ if plan is None:
680
+ print_error(console, "Plan not found or prefix is ambiguous.")
681
+ raise SystemExit(EXIT_TASK_FAILED)
682
+ console.print(
683
+ f"[bold]{plan.get('id')}[/bold] · {plan.get('status', 'ready')}\n"
684
+ f"[dim]Objective:[/dim] {plan.get('objective', '')}"
685
+ )
686
+ console.print(Markdown(str(plan.get("content") or "")))
687
+ return
688
+ if not state.saved_plans:
689
+ console.print("[dim]No saved plans yet. Run `tamfis-code plan <objective>`.[/dim]")
690
+ return
691
+ table = Table(show_header=True, header_style="bold")
692
+ for column in ("ID", "STATUS", "OBJECTIVE", "CREATED"):
693
+ table.add_column(column)
694
+ for item in reversed(state.saved_plans):
695
+ marker = " *" if item.get("id") == state.active_plan_id else ""
696
+ table.add_row(
697
+ f"{item.get('id')}{marker}", str(item.get("status") or "ready"),
698
+ str(item.get("objective") or "")[:80], str(item.get("created_at") or "")[:19],
699
+ )
700
+ console.print(table)
701
+
702
+
703
+ async def _push_live_instruction(config: Config, task_id: str, text: str, classification: str) -> None:
704
+ creds = load_credentials()
705
+ if creds is None:
706
+ raise AuthRequiredError(401, "Not authenticated -- run `tamfis-code login` first.")
707
+ async with RemoteAPIClient(config, creds) as client:
708
+ await client.add_task_instruction(task_id, text, classification)
709
+
710
+
711
+ @cli.command(name="queue")
712
+ @click.argument("instruction", nargs=-1)
713
+ @click.option("--classification", type=click.Choice(["append", "reprioritise", "pause", "cancel", "replace", "follow_up", "clarification"]), default="append")
714
+ @click.option("--priority", type=int, default=100)
715
+ @click.pass_context
716
+ def queue_command(ctx: click.Context, instruction: tuple[str, ...], classification: str, priority: int):
717
+ """Show or enqueue an instruction for this workspace's active session."""
718
+ config: Config = ctx.obj["config"]
719
+ root: Path = ctx.obj["workspace_root"]
720
+ console = Console(no_color=not config.colour)
721
+ matching = [sid for sid in local_state.all_known_session_ids()
722
+ if local_state.get_session_state(sid).workspace_root == str(root)]
723
+ if not matching:
724
+ print_error(console, "No known session for this workspace; run `tamfis-code init` first.")
725
+ raise SystemExit(EXIT_TASK_FAILED)
726
+ session_id = matching[-1]
727
+ if instruction[:1] == ("remove",):
728
+ if len(instruction) != 2 or not local_state.update_instruction(session_id, instruction[1], "removed"):
729
+ raise click.UsageError("Use `tamfis-code queue remove <queue-id>` with an existing id.")
730
+ console.print(f"[green]Removed queued request:[/green] {instruction[1]}")
731
+ elif instruction:
732
+ text = " ".join(instruction)
733
+ item = local_state.enqueue_instruction(session_id, text, classification=classification, priority=priority)
734
+ queued_now = [entry for entry in local_state.get_session_state(session_id).queued_user_instructions if entry.get("status") == "queued"]
735
+ position = next((index for index, entry in enumerate(queued_now, 1) if entry.get("id") == item.id), len(queued_now))
736
+ console.print(f"[cyan]Queued request:[/cyan] {item.id} · position {position}")
737
+
738
+ # "cancel"/"replace"/"reprioritise" already reach a running task by
739
+ # interrupting it (runner.py's watch_instruction_queue, polling this
740
+ # same on-disk queue from whichever process is streaming the task).
741
+ # These three classifications are the live-branch case: guidance
742
+ # that should reach the SAME running task -- possibly streaming in
743
+ # a different terminal -- without killing it. Best-effort: if
744
+ # nothing is running, or the push fails, the instruction still sits
745
+ # in the local queue above for the next REPL turn.
746
+ if classification in {"append", "follow_up", "clarification"}:
747
+ active_task = local_state.get_session_state(session_id).active_task
748
+ task_id = active_task.get("id") if active_task else None
749
+ # A remote-mode active_task can be a concurrently-streaming task
750
+ # in another terminal, reachable via the Remote backend below --
751
+ # a standalone local turn is always synchronous within one
752
+ # process, so there's nothing "live" to push into; the queue
753
+ # just gets consumed on the next turn, silently and correctly.
754
+ if task_id and load_credentials() is not None:
755
+ try:
756
+ _run_async(_push_live_instruction(config, task_id, text, classification))
757
+ local_state.update_instruction(session_id, item.id, "running")
758
+ console.print(f"[green]Sent live to running task[/green] {task_id}")
759
+ except (AuthRequiredError, RemoteAPIError) as e:
760
+ print_error(console, f"Could not reach the running task ({e}); it will run on the next turn instead.")
761
+ queued = local_state.get_session_state(session_id).queued_user_instructions
762
+ if not queued:
763
+ console.print("[dim]Queue is empty.[/dim]")
764
+ return
765
+ for item in queued:
766
+ console.print(f" {item.get('id')} p={item.get('priority')} {item.get('status')} {item.get('classification')} {item.get('text')}")
767
+
768
+
769
+ # -- AI task commands ------------------------------------------------------
770
+
771
+ async def _run_ai_command(
772
+ config: Config, workspace_root: Path, objective: str, mode: str,
773
+ background: bool = False, model: str = "auto", provider: Optional[str] = None,
774
+ attachment_paths: tuple[str, ...] = (),
775
+ ) -> int:
776
+ console = Console(no_color=not config.colour)
777
+ creds = load_credentials()
778
+ if creds is None:
779
+ print_error(console, "Not authenticated -- run `tamfis-code login` first.")
780
+ return EXIT_AUTH_FAILED
781
+
782
+ async with RemoteAPIClient(config, creds) as client:
783
+ try:
784
+ workspace = await resolve_workspace(client, workspace_root, discover=mode != "chat")
785
+ except AuthRequiredError:
786
+ print_error(console, "Not authenticated -- run `tamfis-code login` first.")
787
+ return EXIT_AUTH_FAILED
788
+ except (RemoteAPIError, httpx.HTTPError) as e:
789
+ print_error(console, f"Could not reach TamfisGPT Remote runtime: {e}")
790
+ return EXIT_RUNTIME_UNAVAILABLE
791
+
792
+ attachments = []
793
+ try:
794
+ for raw_path in attachment_paths:
795
+ attachment_path = Path(raw_path).expanduser().resolve()
796
+ if not attachment_path.is_file():
797
+ print_error(console, f"Attachment not found: {attachment_path}")
798
+ return EXIT_INVALID_ARGS
799
+ if attachment_path.stat().st_size > 10 * 1024 * 1024:
800
+ print_error(console, f"Remote task attachments are limited to 10 MB: {attachment_path}")
801
+ return EXIT_INVALID_ARGS
802
+ attachments.append(await client.upload_attachment(attachment_path))
803
+ except (RemoteAPIError, httpx.HTTPError, OSError) as e:
804
+ print_error(console, f"Could not upload attachment: {e}")
805
+ return EXIT_RUNTIME_UNAVAILABLE
806
+
807
+ state = local_state.get_session_state(workspace.session_id)
808
+
809
+ # Single-shot commands (`ask`/`exec`/`agent`/...) can be used to
810
+ # continue a paused/background task from a fresh invocation, so a
811
+ # bare "ok"/"yes"/"1"/"step 2" needs the same contextual expansion
812
+ # the interactive REPL applies -- and it must happen before the
813
+ # objective is used for anything else, never rejected for being short.
814
+ from .interactive import contextualize_short_reply
815
+ objective = contextualize_short_reply(
816
+ objective,
817
+ has_context=bool(state.last_task_id or state.conversation_summary or state.active_plan_id),
818
+ )
819
+
820
+ for requested_path in _explicit_absolute_paths(objective):
821
+ # Explicit local paths are checked factually before the model is
822
+ # involved. This prevents an out-of-scope shell rejection from
823
+ # being misreported as ENOENT (or vice versa).
824
+ try:
825
+ requested_path.lstat()
826
+ except FileNotFoundError:
827
+ console.print(f"File not found: {requested_path}")
828
+ return EXIT_TASK_FAILED
829
+ except PermissionError:
830
+ console.print(f"Permission denied: {requested_path}")
831
+ return EXIT_TASK_FAILED
832
+ if requested_path.is_dir():
833
+ continue
834
+ approved = any(
835
+ str(requested_path.resolve()) == root_path
836
+ or str(requested_path.resolve()).startswith(root_path.rstrip("/") + "/")
837
+ for root_path in state.allowed_workspaces
838
+ )
839
+ if approved:
840
+ continue
841
+ expansion_root = _project_root_for_target(requested_path)
842
+ console.print(
843
+ f"Access to this path requires workspace approval:\n\n{expansion_root}\n\n"
844
+ "Approve adding it to this session's allowed workspaces?"
845
+ )
846
+ if not sys.stdin.isatty() or not click.confirm("Approve", default=False):
847
+ console.print(f"\n tamfis-code workspace add {expansion_root}")
848
+ return EXIT_TASK_FAILED
849
+ # Grants an ADDITIONAL allowed root (e.g. /tmp) alongside the
850
+ # existing primary workspace -- previously this called
851
+ # set_session_cwd, which instead REPLACES working_directory
852
+ # session-wide, silently abandoning the original repo root for
853
+ # every later command until manually switched back. The server
854
+ # now has a real, separate concept for "also allow this path"
855
+ # (RemoteSession.allowed_workspace_roots), matching what
856
+ # "approve workspace expansion" actually promises the user.
857
+ try:
858
+ expanded = await client.expand_session_workspace(workspace.session_id, str(expansion_root))
859
+ except (AuthRequiredError, RemoteAPIError) as e:
860
+ print_error(console, f"Could not approve workspace expansion: {e}")
861
+ return EXIT_TASK_FAILED
862
+ allowed = list(dict.fromkeys([
863
+ *state.allowed_workspaces,
864
+ *(expanded.get("allowed_workspace_roots") or [str(expansion_root)]),
865
+ ]))
866
+ local_state.save_session_state(workspace.session_id, allowed_workspaces=allowed)
867
+ state = local_state.get_session_state(workspace.session_id)
868
+ if mode in {"coding", "agent", "execute"}:
869
+ repeated_failure = next(
870
+ (
871
+ issue for issue in state.unresolved_issues
872
+ if issue.get("type") == "repeated_action_failure" and issue.get("purpose") == objective
873
+ ),
874
+ None,
875
+ )
876
+ if repeated_failure:
877
+ console.print(
878
+ f"[yellow]▲ This exact objective has already failed {repeated_failure.get('attempts')} "
879
+ "times in a row.[/yellow]\n"
880
+ "[dim]Continuing anyway -- consider `tamfis-code plan` to reconsider the approach "
881
+ "instead of retrying it unchanged.[/dim]"
882
+ )
883
+
884
+ dirty_files = state.repository_context.get("dirty_files") or []
885
+ protected_dirty_files = blocking_dirty_files(dirty_files)
886
+ if mode in {"coding", "agent", "execute"} and protected_dirty_files:
887
+ console.print("[yellow]▲ Existing uncommitted changes detected[/yellow]")
888
+ for path in protected_dirty_files[:20]:
889
+ console.print(f" {path}")
890
+ console.print("[dim]Execution was not started because action-scoped rollback cannot safely distinguish overlapping user edits. Commit/stash them yourself, or use audit/plan mode.[/dim]")
891
+ state.unresolved_issues.append({
892
+ "type": "pre_existing_changes", "status": "blocked",
893
+ "detail": f"{len(protected_dirty_files)} protected dirty paths detected before execute",
894
+ })
895
+ local_state.save_session_state(workspace.session_id, unresolved_issues=state.unresolved_issues[-100:])
896
+ return EXIT_TASK_FAILED
897
+
898
+ if background:
899
+ # The task is durable and server-side the instant this call
900
+ # returns (see submit_ai_task_background's docstring) -- this
901
+ # process's own lifetime from here on is irrelevant to the
902
+ # task's. No streaming, no blocking, no approval-answering: use
903
+ # `attach`/`logs` and `approve`/`reject` to interact with it.
904
+ try:
905
+ task = await submit_ai_task_background(
906
+ client, session_id=workspace.session_id, objective=objective, mode=mode,
907
+ model=model, provider=provider,
908
+ attachments=attachments,
909
+ )
910
+ except AuthRequiredError:
911
+ print_error(console, "Not authenticated -- run `tamfis-code login` first.")
912
+ return EXIT_AUTH_FAILED
913
+ except (RemoteAPIError, httpx.HTTPError) as e:
914
+ print_error(console, str(e))
915
+ return EXIT_RUNTIME_UNAVAILABLE
916
+ _print_bg_hint(console, workspace.session_id, str(task["task_id"]))
917
+ return EXIT_OK
918
+
919
+ print_banner(console, host=config.api_base, workspace_root=workspace.workspace_root, mode=mode, approval_policy=config.approval_policy)
920
+ renderer = StreamRenderer(console)
921
+ try:
922
+ outcome = await run_ai_task_and_stream(
923
+ client, renderer, console,
924
+ session_id=workspace.session_id, objective=objective, mode=mode,
925
+ approval_policy=config.approval_policy, interactive=False, # one-shot commands never block on a human prompt
926
+ model=model, provider=provider,
927
+ attachments=attachments,
928
+ )
929
+ except AuthRequiredError:
930
+ print_error(console, "Not authenticated -- run `tamfis-code login` first.")
931
+ return EXIT_AUTH_FAILED
932
+ except (RemoteAPIError, httpx.HTTPError) as e:
933
+ print_error(console, str(e))
934
+ return EXIT_RUNTIME_UNAVAILABLE
935
+
936
+ if outcome.status == "completed":
937
+ if outcome.summary and not renderer.streamed_final_text:
938
+ console.print(outcome.summary)
939
+ return EXIT_OK
940
+ if outcome.status == "cancelled":
941
+ print_error(console, "Interrupted.")
942
+ return EXIT_INTERRUPTED
943
+ if outcome.status == "detached":
944
+ _print_bg_hint(console, workspace.session_id, outcome.summary or "")
945
+ return EXIT_OK
946
+ print_error(console, outcome.error or f"task {outcome.status}")
947
+ return EXIT_TASK_FAILED
948
+
949
+
950
+ async def _run_local_ai_command(
951
+ config: Config, workspace_root: Path, objective: str, mode: str,
952
+ model: str, provider: Optional[str], attachment_paths: tuple[str, ...],
953
+ ) -> int:
954
+ """Standalone equivalent of _run_ai_command -- calls a provider directly
955
+ and runs the tool-calling loop locally (runner_local.py), no
956
+ RemoteAPIClient/tamgpt6 backend involved. Reuses every piece of
957
+ _run_ai_command's logic that was already purely local (contextualize_short_reply,
958
+ explicit-path workspace approval, repeated-failure warning, dirty-tree
959
+ guard) -- only the remote-specific pieces (auth, attachment upload,
960
+ remote workspace-expansion RPC, background execution) are replaced or
961
+ dropped, since none of them have a meaningful standalone equivalent yet.
962
+ """
963
+ from .local_chat import resolve_provider_type
964
+ from .providers import ProviderManager
965
+ from .runner_local import run_local_agent_turn
966
+ from .safety import READ_ONLY_TOOLS
967
+ from .workspace import resolve_local_workspace
968
+
969
+ console = Console(no_color=not config.colour)
970
+
971
+ if attachment_paths:
972
+ print_error(console, "--attach is not yet supported in standalone (local provider) mode.")
973
+ return EXIT_INVALID_ARGS
974
+
975
+ try:
976
+ provider_type = resolve_provider_type(provider)
977
+ except ValueError as exc:
978
+ raise click.UsageError(str(exc))
979
+
980
+ workspace = resolve_local_workspace(workspace_root, discover=mode != "chat")
981
+ state = local_state.get_session_state(workspace.session_id)
982
+
983
+ from .interactive import contextualize_short_reply
984
+ objective = contextualize_short_reply(
985
+ objective, has_context=bool(state.last_task_id or state.conversation_summary or state.active_plan_id),
986
+ )
987
+
988
+ for requested_path in _explicit_absolute_paths(objective):
989
+ try:
990
+ requested_path.lstat()
991
+ except FileNotFoundError:
992
+ console.print(f"File not found: {requested_path}")
993
+ return EXIT_TASK_FAILED
994
+ except PermissionError:
995
+ console.print(f"Permission denied: {requested_path}")
996
+ return EXIT_TASK_FAILED
997
+ if requested_path.is_dir():
998
+ continue
999
+ approved = any(
1000
+ str(requested_path.resolve()) == root_path
1001
+ or str(requested_path.resolve()).startswith(root_path.rstrip("/") + "/")
1002
+ for root_path in state.allowed_workspaces
1003
+ )
1004
+ if approved:
1005
+ continue
1006
+ expansion_root = _project_root_for_target(requested_path)
1007
+ console.print(
1008
+ f"Access to this path requires workspace approval:\n\n{expansion_root}\n\n"
1009
+ "Approve adding it to this session's allowed workspaces?"
1010
+ )
1011
+ if not sys.stdin.isatty() or not click.confirm("Approve", default=False):
1012
+ console.print(f"\n tamfis-code workspace add {expansion_root}")
1013
+ return EXIT_TASK_FAILED
1014
+ # No remote session to notify -- allowed_workspaces is (and always
1015
+ # was) purely a local state.py concept for this client.
1016
+ allowed = list(dict.fromkeys([*state.allowed_workspaces, str(expansion_root)]))
1017
+ local_state.save_session_state(workspace.session_id, allowed_workspaces=allowed)
1018
+ state = local_state.get_session_state(workspace.session_id)
1019
+
1020
+ if mode in {"coding", "agent", "execute"}:
1021
+ repeated_failure = next(
1022
+ (
1023
+ issue for issue in state.unresolved_issues
1024
+ if issue.get("type") == "repeated_action_failure" and issue.get("purpose") == objective
1025
+ ),
1026
+ None,
1027
+ )
1028
+ if repeated_failure:
1029
+ console.print(
1030
+ f"[yellow]▲ This exact objective has already failed {repeated_failure.get('attempts')} "
1031
+ "times in a row.[/yellow]\n"
1032
+ "[dim]Continuing anyway -- consider `tamfis-code plan` to reconsider the approach "
1033
+ "instead of retrying it unchanged.[/dim]"
1034
+ )
1035
+
1036
+ dirty_files = state.repository_context.get("dirty_files") or []
1037
+ protected_dirty_files = blocking_dirty_files(dirty_files)
1038
+ if mode in {"coding", "agent", "execute"} and protected_dirty_files:
1039
+ console.print("[yellow]▲ Existing uncommitted changes detected[/yellow]")
1040
+ for path in protected_dirty_files[:20]:
1041
+ console.print(f" {path}")
1042
+ console.print("[dim]Execution was not started because action-scoped rollback cannot safely distinguish overlapping user edits. Commit/stash them yourself, or use audit/plan mode.[/dim]")
1043
+ state.unresolved_issues.append({
1044
+ "type": "pre_existing_changes", "status": "blocked",
1045
+ "detail": f"{len(protected_dirty_files)} protected dirty paths detected before execute",
1046
+ })
1047
+ local_state.save_session_state(workspace.session_id, unresolved_issues=state.unresolved_issues[-100:])
1048
+ return EXIT_TASK_FAILED
1049
+
1050
+ read_only_mode = mode in {"chat", "audit", "plan"}
1051
+ # Persisted (not cleared on completion, unlike the remote flow's
1052
+ # active_task) so a later, separate `tamfis-code retry` invocation can
1053
+ # recover both the objective AND the mode that was actually used --
1054
+ # without this, retry had no way to know whether the last turn was
1055
+ # e.g. "agent" vs "chat" and always guessed "coding".
1056
+ local_state.save_session_state(workspace.session_id, active_task={"objective": objective, "mode": mode})
1057
+ print_banner(console, host=f"local:{provider_type.value}", workspace_root=workspace.workspace_root, mode=mode, approval_policy=config.approval_policy)
1058
+ renderer = StreamRenderer(console)
1059
+ manager = ProviderManager()
1060
+ outcome = await run_local_agent_turn(
1061
+ manager, provider_type, model if model != "auto" else None, [{"role": "user", "content": objective}],
1062
+ console, renderer,
1063
+ workspace_root=workspace.workspace_root, session_id=workspace.session_id,
1064
+ approval_policy=config.approval_policy, interactive=False, read_only=read_only_mode,
1065
+ )
1066
+ renderer.finish()
1067
+
1068
+ if outcome.status == "completed":
1069
+ if outcome.summary and not renderer.streamed_final_text:
1070
+ console.print(outcome.summary)
1071
+ if outcome.summary:
1072
+ local_state.save_session_state(workspace.session_id, conversation_summary=outcome.summary[-4000:])
1073
+ if mode == "plan" and outcome.summary:
1074
+ saved = local_state.save_plan(workspace.session_id, objective=objective, content=outcome.summary)
1075
+ console.print(
1076
+ f"[green]Plan saved[/green] · {saved.id} · run `/execute-plan {saved.id}` "
1077
+ f"in the REPL or `tamfis-code execute-plan {saved.id}` from the shell"
1078
+ )
1079
+ return EXIT_OK
1080
+ print_error(console, outcome.error or f"task {outcome.status}")
1081
+ return EXIT_TASK_FAILED
1082
+
1083
+
1084
+ def _ai_command(mode: str, help_text: str):
1085
+ @click.argument("objective", required=False)
1086
+ @click.option("--stdin", "read_stdin", is_flag=True, default=False, help="Read the objective from standard input (recommended for very large pasted text).")
1087
+ @click.option("--prompt-file", type=click.Path(exists=True, dir_okay=False, path_type=Path), default=None, help="Read the objective from a UTF-8 text file.")
1088
+ @click.option("--attach", "attachment_paths", multiple=True, type=click.Path(exists=True, dir_okay=False), help="Attach an image or document (repeatable; up to 10 files, 10 MB each).")
1089
+ @click.option("--bg", "background", is_flag=True, default=False, help="Submit and return immediately; the task keeps running server-side. Use `tamfis-code agents`/`attach`/`logs` to check on it.")
1090
+ @click.option("--model", default="auto", show_default=True, help="Catalog model id, or auto.")
1091
+ @click.option("--mode", "mode_override", type=click.Choice(["auto", "coding", "chat", "audit", "plan", "agent", "execute"]), default=None, help="Override this command's task mode.")
1092
+ @click.option("--provider", type=click.Choice(["auto", "hf", "huggingface", "or", "openrouter", "ollama", "nvidia", "nvidia_nim", "gemini", "apiframe"]), default=None, help="Pin this task to a specific provider.")
1093
+ @click.option("--remote", is_flag=True, default=False, help="Use the legacy TamfisGPT Remote Workspace backend (tamgpt6) instead of calling a provider directly. Deprecated -- standalone (the default) is the supported path going forward.")
1094
+ @click.pass_context
1095
+ def command(ctx: click.Context, objective: Optional[str], read_stdin: bool, prompt_file: Optional[Path], attachment_paths: tuple[str, ...], background: bool, model: str, mode_override: Optional[str], provider: Optional[str], remote: bool):
1096
+ config: Config = ctx.obj["config"]
1097
+ workspace_root: Path = ctx.obj["workspace_root"]
1098
+ sources = int(bool(objective and objective != "-")) + int(read_stdin or objective == "-") + int(prompt_file is not None)
1099
+ if sources != 1:
1100
+ raise click.UsageError("Provide exactly one objective, --stdin (or '-'), or --prompt-file.")
1101
+ if len(attachment_paths) > 10:
1102
+ raise click.UsageError("At most 10 --attach files are allowed per task.")
1103
+ effective_mode = mode_override or mode
1104
+ if effective_mode == "plan" and background:
1105
+ raise click.UsageError(
1106
+ "Plan creation must stay attached so the completed plan can be saved locally; omit --bg."
1107
+ )
1108
+ if background and not _use_remote(config, remote):
1109
+ raise click.UsageError(
1110
+ "--bg requires --remote (or default_backend = \"remote\" in config.toml): a standalone "
1111
+ "local run has no server to keep the task alive once this process exits."
1112
+ )
1113
+ if prompt_file is not None:
1114
+ objective_text = prompt_file.read_text(encoding="utf-8")
1115
+ elif read_stdin or objective == "-":
1116
+ objective_text = sys.stdin.read()
1117
+ else:
1118
+ objective_text = objective or ""
1119
+ if len(objective_text) > 1_000_000:
1120
+ raise click.UsageError("Objective exceeds the 1,000,000 character safety limit.")
1121
+ if _use_remote(config, remote):
1122
+ exit_code = _run_async(_run_ai_command(
1123
+ config, workspace_root, objective_text, effective_mode, background, model, provider, attachment_paths,
1124
+ ))
1125
+ else:
1126
+ exit_code = _run_async(_run_local_ai_command(
1127
+ config, workspace_root, objective_text, effective_mode, model, provider, attachment_paths,
1128
+ ))
1129
+ if exit_code != EXIT_OK:
1130
+ raise SystemExit(exit_code)
1131
+
1132
+ command.__doc__ = help_text
1133
+ return command
1134
+
1135
+
1136
+ cli.command(name="ask")(_ai_command("coding", "Run a CWD-scoped coding-agent task."))
1137
+ cli.command(name="chat")(_ai_command("chat", "Use conversational, read-only coding assistance."))
1138
+ cli.command(name="audit")(_ai_command("audit", "Run a read-only repository audit."))
1139
+ cli.command(name="plan")(_ai_command("plan", "Produce and save an executable plan without modifying files."))
1140
+ cli.command(name="agent")(_ai_command("agent", "Run the full coding-agent loop: inspect, edit, and verify."))
1141
+ cli.command(name="exec")(_ai_command("execute", "Run a tool-using engineering task subject to approval policy."))
1142
+
1143
+
1144
+ @cli.command(name="execute-plan")
1145
+ @click.argument("plan_id", required=False)
1146
+ @click.option("--bg", "background", is_flag=True, default=False, help="Execute the plan server-side and return immediately (requires --remote).")
1147
+ @click.option("--model", default="auto", show_default=True)
1148
+ @click.option("--provider", type=click.Choice(["auto", "hf", "huggingface", "or", "openrouter", "ollama", "nvidia", "nvidia_nim", "gemini", "apiframe"]), default=None)
1149
+ @click.option("--remote", is_flag=True, default=False, help="Use the legacy TamfisGPT Remote Workspace backend instead of calling a provider directly.")
1150
+ @click.pass_context
1151
+ def execute_plan_command(
1152
+ ctx: click.Context, plan_id: Optional[str], background: bool,
1153
+ model: str, provider: Optional[str], remote: bool,
1154
+ ):
1155
+ """Execute a saved plan (latest/active plan when no id is supplied)."""
1156
+ config: Config = ctx.obj["config"]
1157
+ root: Path = ctx.obj["workspace_root"]
1158
+ console = Console(no_color=not config.colour)
1159
+ if background and not _use_remote(config, remote):
1160
+ print_error(console, "--bg requires --remote (or default_backend = \"remote\" in config.toml): a standalone local run has no server to keep the task alive once this process exits.")
1161
+ raise SystemExit(EXIT_INVALID_ARGS)
1162
+ matching = [sid for sid in local_state.all_known_session_ids()
1163
+ if local_state.get_session_state(sid).workspace_root == str(root)]
1164
+ if not matching:
1165
+ print_error(console, "No known session for this workspace; run `tamfis-code init` first.")
1166
+ raise SystemExit(EXIT_TASK_FAILED)
1167
+ session_id = matching[-1]
1168
+ plan = local_state.get_plan(session_id, plan_id)
1169
+ if plan is None:
1170
+ print_error(console, "Plan not found or prefix is ambiguous. Use `tamfis-code plans`.")
1171
+ raise SystemExit(EXIT_TASK_FAILED)
1172
+ selected_id = str(plan["id"])
1173
+ local_state.update_plan(session_id, selected_id, status="executing")
1174
+ objective = local_state.plan_execution_objective(plan)
1175
+ if _use_remote(config, remote):
1176
+ exit_code = _run_async(_run_ai_command(config, root, objective, "execute", background, model, provider))
1177
+ else:
1178
+ exit_code = _run_async(_run_local_ai_command(config, root, objective, "execute", model, provider, ()))
1179
+ refreshed = local_state.get_session_state(session_id)
1180
+ local_state.update_plan(
1181
+ session_id, selected_id,
1182
+ status="executing" if background and exit_code == EXIT_OK else (
1183
+ "completed" if exit_code == EXIT_OK else "failed"
1184
+ ),
1185
+ execution_task_id=refreshed.last_task_id,
1186
+ )
1187
+ if exit_code != EXIT_OK:
1188
+ raise SystemExit(exit_code)
1189
+
1190
+
1191
+ @cli.command()
1192
+ @click.argument("command")
1193
+ @click.option("--bg", "background", is_flag=True, default=False, help="Submit and return immediately; the command keeps running server-side.")
1194
+ @click.option("--remote", is_flag=True, default=False, help="Use the legacy TamfisGPT Remote Workspace backend instead of running the command directly.")
1195
+ @click.pass_context
1196
+ @async_command
1197
+ async def run(ctx: click.Context, command: str, background: bool, remote: bool):
1198
+ """Run an explicit shell command (locally by default, or --remote)."""
1199
+ config: Config = ctx.obj["config"]
1200
+ workspace_root: Path = ctx.obj["workspace_root"]
1201
+ console = Console(no_color=not config.colour)
1202
+
1203
+ if not _use_remote(config, remote):
1204
+ if background:
1205
+ print_error(console, "--bg requires --remote: a standalone local run has no server to keep the command alive once this process exits.")
1206
+ raise SystemExit(EXIT_INVALID_ARGS)
1207
+ from .runner_local import run_local_shell_command
1208
+ from .workspace import resolve_local_workspace
1209
+
1210
+ workspace = resolve_local_workspace(workspace_root, discover=False)
1211
+ outcome = await run_local_shell_command(
1212
+ console, workspace_root=workspace.workspace_root, session_id=workspace.session_id,
1213
+ command=command, approval_policy=config.approval_policy, interactive=False,
1214
+ )
1215
+ if outcome.status != "completed":
1216
+ raise SystemExit(EXIT_TASK_FAILED)
1217
+ return
1218
+
1219
+ creds = load_credentials()
1220
+ if creds is None:
1221
+ print_error(console, "Not authenticated -- run `tamfis-code login` first, or omit --remote to run locally.")
1222
+ raise SystemExit(EXIT_AUTH_FAILED)
1223
+
1224
+ async with RemoteAPIClient(config, creds) as client:
1225
+ try:
1226
+ workspace = await resolve_workspace(client, workspace_root)
1227
+ if background:
1228
+ cmd = await client.submit_command(workspace.session_id, command)
1229
+ console.print(f"[green]backgrounded[/green] · session {workspace.session_id} · command {cmd['id']}")
1230
+ console.print(f" tamfis-code logs {workspace.session_id}")
1231
+ console.print(f" tamfis-code stop {workspace.session_id}")
1232
+ return
1233
+ outcome = await run_shell_command(
1234
+ client, console,
1235
+ session_id=workspace.session_id, command=command,
1236
+ approval_policy=config.approval_policy, interactive=False, # one-shot commands never block on a human prompt
1237
+ )
1238
+ except AuthRequiredError:
1239
+ print_error(console, "Not authenticated -- run `tamfis-code login` first.")
1240
+ raise SystemExit(EXIT_AUTH_FAILED)
1241
+ except (RemoteAPIError, httpx.HTTPError) as e:
1242
+ print_error(console, str(e))
1243
+ raise SystemExit(EXIT_RUNTIME_UNAVAILABLE)
1244
+
1245
+ if outcome.status != "completed":
1246
+ raise SystemExit(EXIT_TASK_FAILED)
1247
+
1248
+
1249
+ @cli.command()
1250
+ @click.argument("session_id", type=int, required=False, default=None)
1251
+ @click.option("--provider", default="auto", help="hf, nvidia, openrouter, ollama, or auto (default).")
1252
+ @click.option("--model", default=None, help="Provider-specific model id; defaults to that provider's default model.")
1253
+ @click.option("--remote", is_flag=True, default=False, help="Resume a session on the legacy TamfisGPT Remote Workspace backend instead of a local one.")
1254
+ @click.pass_context
1255
+ @async_command
1256
+ async def resume(ctx: click.Context, session_id: Optional[int], provider: str, model: Optional[str], remote: bool):
1257
+ """Resume an interrupted or previous session (most recent if no id given), then continue interactively."""
1258
+ from .interactive import run_interactive
1259
+
1260
+ config: Config = ctx.obj["config"]
1261
+ console = Console(no_color=not config.colour)
1262
+
1263
+ if not _use_remote(config, remote):
1264
+ known = local_state.all_known_session_ids()
1265
+ if session_id is not None:
1266
+ if session_id not in known:
1267
+ print_error(console, f"No known local session {session_id}. Use `tamfis-code sessions` to list known sessions.")
1268
+ raise SystemExit(EXIT_TASK_FAILED)
1269
+ target_id = session_id
1270
+ else:
1271
+ if not known:
1272
+ print_error(console, "No sessions to resume.")
1273
+ raise SystemExit(EXIT_TASK_FAILED)
1274
+ target_id = known[-1]
1275
+ target_state = local_state.get_session_state(target_id)
1276
+ workspace = WorkspaceContext(session_id=target_id, workspace_root=target_state.workspace_root or target_state.primary_workspace)
1277
+ console.print(f"[green]Resumed session {workspace.session_id}[/green] workspace_root={workspace.workspace_root}")
1278
+ if target_state.conversation_summary:
1279
+ console.print(f"[dim]{target_state.conversation_summary[-1000:]}[/dim]")
1280
+ await run_interactive(None, config, workspace, provider=provider, model=model)
1281
+ return
1282
+
1283
+ creds = load_credentials()
1284
+ if creds is None:
1285
+ print_error(console, "Not authenticated -- run `tamfis-code login` first, or omit --remote to resume a local session.")
1286
+ raise SystemExit(EXIT_AUTH_FAILED)
1287
+
1288
+ async with RemoteAPIClient(config, creds) as client:
1289
+ try:
1290
+ if session_id is not None:
1291
+ workspace = await context_from_session(client, session_id)
1292
+ else:
1293
+ target = await find_resumable_session(client)
1294
+ if target is None:
1295
+ print_error(console, "No sessions to resume.")
1296
+ raise SystemExit(EXIT_TASK_FAILED)
1297
+ workspace = await context_from_session(client, target["id"])
1298
+ except AuthRequiredError:
1299
+ print_error(console, "Not authenticated -- run `tamfis-code login` first.")
1300
+ raise SystemExit(EXIT_AUTH_FAILED)
1301
+ except (RemoteAPIError, httpx.HTTPError) as e:
1302
+ print_error(console, str(e))
1303
+ raise SystemExit(EXIT_RUNTIME_UNAVAILABLE)
1304
+
1305
+ console.print(f"[green]Resumed session {workspace.session_id}[/green] workspace_root={workspace.workspace_root}")
1306
+ try:
1307
+ thread = await client.get_thread(workspace.session_id)
1308
+ print_recent_thread(console, thread.get("messages") or [])
1309
+ except (AuthRequiredError, RemoteAPIError):
1310
+ pass
1311
+
1312
+ await run_interactive(client, config, workspace)
1313
+
1314
+
1315
+ @cli.command()
1316
+ @click.argument("task_id", required=False, default=None)
1317
+ @click.option("--provider", default="auto", help="hf, nvidia, openrouter, ollama, or auto (default).")
1318
+ @click.option("--model", default=None, help="Provider-specific model id; defaults to that provider's default model.")
1319
+ @click.option("--remote", is_flag=True, default=False, help="Retry a task on the legacy TamfisGPT Remote Workspace backend instead of resending locally.")
1320
+ @click.pass_context
1321
+ @async_command
1322
+ async def retry(ctx: click.Context, task_id: Optional[str], provider: str, model: Optional[str], remote: bool):
1323
+ """Retry the last turn in this workspace (standalone), or a specific --remote task."""
1324
+ config: Config = ctx.obj["config"]
1325
+ workspace_root: Path = ctx.obj["workspace_root"]
1326
+ console = Console(no_color=not config.colour)
1327
+
1328
+ if not _use_remote(config, remote):
1329
+ if task_id is not None:
1330
+ raise click.UsageError("A specific task_id only applies with --remote; standalone retry resends the session's last turn.")
1331
+ from .local_chat import resolve_provider_type
1332
+ from .providers import ProviderManager
1333
+ from .workspace import resolve_local_workspace
1334
+
1335
+ try:
1336
+ provider_type = resolve_provider_type(provider)
1337
+ except ValueError as exc:
1338
+ raise click.UsageError(str(exc))
1339
+ workspace = resolve_local_workspace(workspace_root, discover=False)
1340
+ state = local_state.get_session_state(workspace.session_id)
1341
+ if not state.conversation_summary and not state.active_task:
1342
+ print_error(console, "No previous turn in this session to retry.")
1343
+ raise SystemExit(EXIT_TASK_FAILED)
1344
+ objective = (state.active_task or {}).get("objective") or state.conversation_summary
1345
+ mode = (state.active_task or {}).get("mode") or "coding"
1346
+ exit_code = _run_async(_run_local_ai_command(config, workspace_root, objective, mode, model, provider, ()))
1347
+ if exit_code != EXIT_OK:
1348
+ raise SystemExit(exit_code)
1349
+ return
1350
+
1351
+ creds = load_credentials()
1352
+ if creds is None:
1353
+ print_error(console, "Not authenticated -- run `tamfis-code login` first, or omit --remote to retry locally.")
1354
+ raise SystemExit(EXIT_AUTH_FAILED)
1355
+
1356
+ async with RemoteAPIClient(config, creds) as client:
1357
+ try:
1358
+ workspace = await resolve_workspace(client, workspace_root)
1359
+ if task_id is None:
1360
+ failed = await find_recent_task(client, workspace.session_id, only_status={"failed", "cancelled"})
1361
+ if failed is None:
1362
+ print_error(console, "No recent failed task to retry in this workspace.")
1363
+ raise SystemExit(EXIT_TASK_FAILED)
1364
+ task_id = failed["id"]
1365
+
1366
+ print_banner(console, host=config.api_base, workspace_root=workspace.workspace_root, mode="retry", approval_policy=config.approval_policy)
1367
+ renderer = StreamRenderer(console)
1368
+ outcome = await retry_task_and_stream(
1369
+ client, renderer, console,
1370
+ session_id=workspace.session_id, task_id=task_id, mode=None,
1371
+ approval_policy=config.approval_policy, interactive=False, # one-shot commands never block on a human prompt
1372
+ )
1373
+ except AuthRequiredError:
1374
+ print_error(console, "Not authenticated -- run `tamfis-code login` first.")
1375
+ raise SystemExit(EXIT_AUTH_FAILED)
1376
+ except (RemoteAPIError, httpx.HTTPError) as e:
1377
+ print_error(console, str(e))
1378
+ raise SystemExit(EXIT_RUNTIME_UNAVAILABLE)
1379
+
1380
+ if outcome.status == "completed":
1381
+ if outcome.summary and not renderer.streamed_final_text:
1382
+ console.print(outcome.summary)
1383
+ return
1384
+ print_error(console, outcome.error or f"task {outcome.status}")
1385
+ raise SystemExit(EXIT_TASK_FAILED)
1386
+
1387
+
1388
+ @cli.command()
1389
+ @click.argument("n", type=int, required=False, default=10)
1390
+ @click.option("--remote", is_flag=True, default=False, help="List mutations from the legacy TamfisGPT Remote Workspace backend instead of the local ledger.")
1391
+ @click.pass_context
1392
+ @async_command
1393
+ async def diffs(ctx: click.Context, n: int, remote: bool):
1394
+ """List the last N file mutations (write_file/edit_file) in this workspace's session."""
1395
+ config: Config = ctx.obj["config"]
1396
+ workspace_root: Path = ctx.obj["workspace_root"]
1397
+ console = Console(no_color=not config.colour)
1398
+
1399
+ if not _use_remote(config, remote):
1400
+ from .workspace import resolve_local_workspace
1401
+
1402
+ workspace = resolve_local_workspace(workspace_root, discover=False)
1403
+ mutations = list(reversed(local_state.get_session_state(workspace.session_id).modified_files[-n:]))
1404
+ if not mutations:
1405
+ console.print("[dim]No file mutations recorded yet in this session.[/dim]")
1406
+ return
1407
+ table = Table(show_header=True, header_style="bold")
1408
+ for col in ("ID", "OP", "PATH", "+/-", "STATUS"):
1409
+ table.add_column(col)
1410
+ for m in mutations:
1411
+ table.add_row(
1412
+ str(m.get("mutation_id")), str(m.get("operation")), str(m.get("path")),
1413
+ f"+{m.get('lines_added')}/-{m.get('lines_removed')}", str(m.get("revert_status")),
1414
+ )
1415
+ console.print(table)
1416
+ return
1417
+
1418
+ creds = load_credentials()
1419
+ if creds is None:
1420
+ print_error(console, "Not authenticated -- run `tamfis-code login` first, or omit --remote for the local ledger.")
1421
+ raise SystemExit(EXIT_AUTH_FAILED)
1422
+
1423
+ async with RemoteAPIClient(config, creds) as client:
1424
+ try:
1425
+ workspace = await resolve_workspace(client, workspace_root)
1426
+ result = await client.list_file_mutations(workspace.session_id, limit=n)
1427
+ except AuthRequiredError:
1428
+ print_error(console, "Not authenticated -- run `tamfis-code login` first.")
1429
+ raise SystemExit(EXIT_AUTH_FAILED)
1430
+ except (RemoteAPIError, httpx.HTTPError) as e:
1431
+ print_error(console, str(e))
1432
+ raise SystemExit(EXIT_RUNTIME_UNAVAILABLE)
1433
+
1434
+ mutations = result.get("mutations") or []
1435
+ if not mutations:
1436
+ console.print("[dim]No file mutations recorded yet in this session.[/dim]")
1437
+ return
1438
+ table = Table(show_header=True, header_style="bold")
1439
+ for col in ("ID", "OP", "PATH", "+/-", "STATUS"):
1440
+ table.add_column(col)
1441
+ for m in mutations:
1442
+ table.add_row(
1443
+ str(m.get("id")), str(m.get("operation")), str(m.get("path")),
1444
+ f"+{m.get('lines_added')}/-{m.get('lines_removed')}", str(m.get("revert_status")),
1445
+ )
1446
+ console.print(table)
1447
+
1448
+
1449
+ @cli.command(name="diff")
1450
+ @click.argument("mutation_id", required=False)
1451
+ @click.option("--remote", is_flag=True, default=False, help="Show a diff from the legacy TamfisGPT Remote Workspace backend instead of the local ledger.")
1452
+ @click.pass_context
1453
+ @async_command
1454
+ async def diff_command(ctx: click.Context, mutation_id: Optional[str], remote: bool):
1455
+ """Show an agent-recorded unified diff (latest mutation by default)."""
1456
+ config: Config = ctx.obj["config"]
1457
+ workspace_root: Path = ctx.obj["workspace_root"]
1458
+ console = Console(no_color=not config.colour)
1459
+
1460
+ if not _use_remote(config, remote):
1461
+ from .workspace import resolve_local_workspace
1462
+
1463
+ workspace = resolve_local_workspace(workspace_root, discover=False)
1464
+ mutations = local_state.get_session_state(workspace.session_id).modified_files
1465
+ selected = next((item for item in mutations if item.get("mutation_id") == mutation_id), None) if mutation_id else (mutations[-1] if mutations else None)
1466
+ if selected is None:
1467
+ print_error(console, "Mutation not found in this session." if mutation_id else "No file mutations recorded yet.")
1468
+ raise SystemExit(EXIT_TASK_FAILED)
1469
+ print_unified_diff(console, str(selected.get("unified_diff") or ""), title=f"{selected.get('path')} · {selected.get('mutation_id')}")
1470
+ return
1471
+
1472
+ creds = load_credentials()
1473
+ if creds is None:
1474
+ print_error(console, "Not authenticated -- run `tamfis-code login` first, or omit --remote for the local ledger.")
1475
+ raise SystemExit(EXIT_AUTH_FAILED)
1476
+ async with RemoteAPIClient(config, creds) as client:
1477
+ try:
1478
+ workspace = await resolve_workspace(client, workspace_root)
1479
+ result = await client.list_file_mutations(workspace.session_id, limit=200 if mutation_id else 1)
1480
+ except AuthRequiredError:
1481
+ raise SystemExit(EXIT_AUTH_FAILED)
1482
+ except (RemoteAPIError, httpx.HTTPError) as exc:
1483
+ print_error(console, str(exc))
1484
+ raise SystemExit(EXIT_RUNTIME_UNAVAILABLE)
1485
+ mutations = result.get("mutations") or []
1486
+ selected = next((item for item in mutations if str(item.get("id")) == mutation_id), None) if mutation_id else (mutations[0] if mutations else None)
1487
+ if selected is None:
1488
+ print_error(console, "Mutation not found in this session." if mutation_id else "No file mutations recorded yet.")
1489
+ raise SystemExit(EXIT_TASK_FAILED)
1490
+ print_unified_diff(console, str(selected.get("unified_diff") or ""), title=f"{selected.get('path')} · {selected.get('id')}")
1491
+
1492
+
1493
+ @cli.command(name="changes")
1494
+ @click.pass_context
1495
+ def changes_command(ctx: click.Context):
1496
+ """Alias for a concise list of agent-recorded file changes."""
1497
+ ctx.invoke(diffs, n=25)
1498
+
1499
+
1500
+ @cli.command()
1501
+ @click.argument("mutation_id")
1502
+ @click.option("--remote", is_flag=True, default=False, help="Revert a mutation on the legacy TamfisGPT Remote Workspace backend instead of the local ledger.")
1503
+ @click.pass_context
1504
+ @async_command
1505
+ async def revert(ctx: click.Context, mutation_id: str, remote: bool):
1506
+ """Revert one file mutation by id (see `tamfis-code diffs`) -- restores the file to its content before that change, or deletes it if that mutation created the file."""
1507
+ config: Config = ctx.obj["config"]
1508
+ workspace_root: Path = ctx.obj["workspace_root"]
1509
+ console = Console(no_color=not config.colour)
1510
+
1511
+ if not _use_remote(config, remote):
1512
+ from .safety import revert_mutation
1513
+ from .workspace import resolve_local_workspace
1514
+
1515
+ workspace = resolve_local_workspace(workspace_root, discover=False)
1516
+ try:
1517
+ result = revert_mutation(workspace.session_id, mutation_id)
1518
+ except ValueError as exc:
1519
+ print_error(console, str(exc))
1520
+ raise SystemExit(EXIT_TASK_FAILED)
1521
+ console.print(f"[green]Reverted[/green] {result.get('path')}")
1522
+ return
1523
+
1524
+ creds = load_credentials()
1525
+ if creds is None:
1526
+ print_error(console, "Not authenticated -- run `tamfis-code login` first, or omit --remote for the local ledger.")
1527
+ raise SystemExit(EXIT_AUTH_FAILED)
1528
+
1529
+ async with RemoteAPIClient(config, creds) as client:
1530
+ try:
1531
+ workspace = await resolve_workspace(client, workspace_root)
1532
+ result = await client.revert_file_mutation(workspace.session_id, mutation_id)
1533
+ except AuthRequiredError:
1534
+ print_error(console, "Not authenticated -- run `tamfis-code login` first.")
1535
+ raise SystemExit(EXIT_AUTH_FAILED)
1536
+ except (RemoteAPIError, httpx.HTTPError) as e:
1537
+ print_error(console, str(e))
1538
+ raise SystemExit(EXIT_RUNTIME_UNAVAILABLE)
1539
+
1540
+ console.print(f"[green]Reverted[/green] {result.get('path')}")
1541
+
1542
+
1543
+ # -- background-session management ----------------------------------------
1544
+
1545
+ @cli.command()
1546
+ @click.option("--remote", is_flag=True, default=False, help="Show running/backgrounded/approval-pending status from the legacy TamfisGPT Remote Workspace backend -- standalone sessions are always synchronous within one process, so this concept only applies remotely.")
1547
+ @click.pass_context
1548
+ @async_command
1549
+ async def agents(ctx: click.Context, remote: bool):
1550
+ """List sessions and each one's most recent task/status -- what's running, backgrounded, or waiting on approval."""
1551
+ config: Config = ctx.obj["config"]
1552
+ console = Console(no_color=not config.colour)
1553
+
1554
+ if not _use_remote(config, remote):
1555
+ console.print("[dim]Standalone sessions have no background/approval-pending concept (each run is synchronous). Showing known local sessions -- see `tamfis-code sessions`.[/dim]")
1556
+ ctx.invoke(sessions)
1557
+ return
1558
+
1559
+ creds = load_credentials()
1560
+ if creds is None:
1561
+ print_error(console, "Not authenticated -- run `tamfis-code login` first, or omit --remote to list local sessions.")
1562
+ raise SystemExit(EXIT_AUTH_FAILED)
1563
+
1564
+ async with RemoteAPIClient(config, creds) as client:
1565
+ try:
1566
+ sessions_list = await client.list_sessions()
1567
+ except AuthRequiredError:
1568
+ print_error(console, "Not authenticated -- run `tamfis-code login` first.")
1569
+ raise SystemExit(EXIT_AUTH_FAILED)
1570
+ except (RemoteAPIError, httpx.HTTPError) as e:
1571
+ print_error(console, str(e))
1572
+ raise SystemExit(EXIT_RUNTIME_UNAVAILABLE)
1573
+
1574
+ table = Table(show_header=True, header_style="bold")
1575
+ for col in ("SESSION", "STATUS", "TASK", "WORKSPACE"):
1576
+ table.add_column(col)
1577
+
1578
+ for sess in sessions_list:
1579
+ session_id = sess.get("id")
1580
+ workspace_dir = str(sess.get("working_directory") or "")
1581
+ try:
1582
+ latest = await find_recent_task(client, session_id, only_status=None, lookback=1)
1583
+ except (AuthRequiredError, RemoteAPIError):
1584
+ latest = None
1585
+ if latest is None:
1586
+ task_col = "[dim](no tasks)[/dim]"
1587
+ status_col = str(sess.get("status", ""))
1588
+ else:
1589
+ task_status = str(latest.get("status", ""))
1590
+ task_col = f"{latest.get('id')} ({task_status})"
1591
+ status_col = "running" if task_status in ACTIVE_TASK_STATUSES else task_status
1592
+ table.add_row(str(session_id), status_col, task_col, workspace_dir)
1593
+
1594
+ console.print(table)
1595
+ console.print(
1596
+ "[dim]tamfis-code attach <session_id> · tamfis-code logs <session_id> · "
1597
+ "tamfis-code stop <session_id>[/dim]"
1598
+ )
1599
+
1600
+
1601
+ @cli.command()
1602
+ @click.argument("session_id", type=int)
1603
+ @click.pass_context
1604
+ @async_command
1605
+ async def attach(ctx: click.Context, session_id: int):
1606
+ """Reattach to a session's live (or most recent) task stream (legacy Remote Workspace backend only -- a standalone run is always synchronous within one process, so there's nothing to reattach to). The task is not owned by this connection -- Ctrl+C detaches, it does not stop the task."""
1607
+ config: Config = ctx.obj["config"]
1608
+ console = Console(no_color=not config.colour)
1609
+ creds = load_credentials()
1610
+ if creds is None:
1611
+ print_error(console, "Not authenticated -- run `tamfis-code login` first.")
1612
+ raise SystemExit(EXIT_AUTH_FAILED)
1613
+
1614
+ async with RemoteAPIClient(config, creds) as client:
1615
+ try:
1616
+ workspace = await context_from_session(client, session_id)
1617
+ task = await find_recent_task(client, session_id, only_status=ACTIVE_TASK_STATUSES, lookback=1)
1618
+ if task is None:
1619
+ task = await find_recent_task(client, session_id, only_status=None, lookback=1)
1620
+ if task is None:
1621
+ print_error(console, f"Session {session_id} has no tasks to attach to.")
1622
+ raise SystemExit(EXIT_TASK_FAILED)
1623
+
1624
+ print_banner(console, host=config.api_base, workspace_root=workspace.workspace_root, mode="attach", approval_policy=config.approval_policy)
1625
+ console.print(f"[dim]attached to task {task['id']} ({task.get('status')}) -- Ctrl+C to detach[/dim]")
1626
+ renderer = StreamRenderer(console)
1627
+ outcome = await attach_and_stream(
1628
+ client, renderer, console,
1629
+ session_id=session_id, task_id=str(task["id"]),
1630
+ approval_policy=config.approval_policy, interactive=True,
1631
+ )
1632
+ except AuthRequiredError:
1633
+ print_error(console, "Not authenticated -- run `tamfis-code login` first.")
1634
+ raise SystemExit(EXIT_AUTH_FAILED)
1635
+ except (RemoteAPIError, httpx.HTTPError) as e:
1636
+ print_error(console, str(e))
1637
+ raise SystemExit(EXIT_RUNTIME_UNAVAILABLE)
1638
+
1639
+ if outcome.status == "detached":
1640
+ console.print(f"[dim]Detached. The task keeps running.[/dim]")
1641
+ console.print(f" tamfis-code attach {session_id}")
1642
+ return
1643
+ if outcome.status == "completed":
1644
+ if outcome.summary and not renderer.streamed_final_text:
1645
+ console.print(outcome.summary)
1646
+ return
1647
+ print_error(console, outcome.error or f"task {outcome.status}")
1648
+ raise SystemExit(EXIT_TASK_FAILED)
1649
+
1650
+
1651
+ @cli.command()
1652
+ @click.pass_context
1653
+ def detach(ctx: click.Context):
1654
+ """Show the reattach command for this workspace's session -- Ctrl+C/Ctrl+D during `attach` is the actual detach action; this is informational."""
1655
+ config: Config = ctx.obj["config"]
1656
+ workspace_root: Path = ctx.obj["workspace_root"]
1657
+ console = Console(no_color=not config.colour)
1658
+ matches = [sid for sid in local_state.all_known_session_ids()
1659
+ if local_state.get_session_state(sid).workspace_root == str(workspace_root)]
1660
+ if not matches:
1661
+ console.print("[dim]No known session for this workspace yet -- run `tamfis-code init` or start a task first.[/dim]")
1662
+ return
1663
+ session_id = matches[-1]
1664
+ console.print(f"[dim]Nothing to disconnect locally (each command is its own short-lived process).[/dim]")
1665
+ console.print(f" tamfis-code attach {session_id}")
1666
+ console.print(f" tamfis-code logs {session_id}")
1667
+
1668
+
1669
+ @cli.command()
1670
+ @click.argument("session_id", type=int)
1671
+ @click.option("--follow", "follow", is_flag=True, default=False, help="Stream live output instead of printing recent history and exiting.")
1672
+ @click.option("--tail", "tail", type=int, default=6, help="Number of recent turns to show when not following.")
1673
+ @click.pass_context
1674
+ @async_command
1675
+ async def logs(ctx: click.Context, session_id: int, follow: bool, tail: int):
1676
+ """Show (or follow) a session's event history (legacy Remote Workspace backend only). Read-only -- never answers approvals; use `approve`/`reject` for that."""
1677
+ config: Config = ctx.obj["config"]
1678
+ console = Console(no_color=not config.colour)
1679
+ creds = load_credentials()
1680
+ if creds is None:
1681
+ print_error(console, "Not authenticated -- run `tamfis-code login` first.")
1682
+ raise SystemExit(EXIT_AUTH_FAILED)
1683
+
1684
+ async with RemoteAPIClient(config, creds) as client:
1685
+ try:
1686
+ if not follow:
1687
+ thread = await client.get_thread(session_id)
1688
+ print_recent_thread(console, thread.get("messages") or [], limit=tail)
1689
+ return
1690
+ from_event_id = local_state.get_session_state(session_id).last_event_id
1691
+ console.print(f"[dim]following session {session_id} from event {from_event_id} -- Ctrl+C to stop watching[/dim]")
1692
+ renderer = StreamRenderer(console)
1693
+ await follow_session_logs(client, renderer, console, session_id=session_id, from_event_id=from_event_id)
1694
+ except AuthRequiredError:
1695
+ print_error(console, "Not authenticated -- run `tamfis-code login` first.")
1696
+ raise SystemExit(EXIT_AUTH_FAILED)
1697
+ except (RemoteAPIError, httpx.HTTPError) as e:
1698
+ print_error(console, str(e))
1699
+ raise SystemExit(EXIT_RUNTIME_UNAVAILABLE)
1700
+
1701
+
1702
+ @cli.command()
1703
+ @click.argument("session_id", type=int)
1704
+ @click.pass_context
1705
+ @async_command
1706
+ async def stop(ctx: click.Context, session_id: int):
1707
+ """Cancel the active task in a session (legacy Remote Workspace backend only; server-side cancellation, not just local disconnect). Standalone runs can just be interrupted with Ctrl+C."""
1708
+ config: Config = ctx.obj["config"]
1709
+ console = Console(no_color=not config.colour)
1710
+ creds = load_credentials()
1711
+ if creds is None:
1712
+ print_error(console, "Not authenticated -- run `tamfis-code login` first.")
1713
+ raise SystemExit(EXIT_AUTH_FAILED)
1714
+
1715
+ async with RemoteAPIClient(config, creds) as client:
1716
+ try:
1717
+ task = await find_recent_task(client, session_id, only_status=ACTIVE_TASK_STATUSES, lookback=1)
1718
+ if task is None:
1719
+ console.print(f"[dim]No active task in session {session_id}.[/dim]")
1720
+ return
1721
+ await client.cancel_task(str(task["id"]))
1722
+ console.print(f"[green]Cancelled[/green] task {task['id']} in session {session_id}.")
1723
+ except AuthRequiredError:
1724
+ print_error(console, "Not authenticated -- run `tamfis-code login` first.")
1725
+ raise SystemExit(EXIT_AUTH_FAILED)
1726
+ except (RemoteAPIError, httpx.HTTPError) as e:
1727
+ print_error(console, str(e))
1728
+ raise SystemExit(EXIT_RUNTIME_UNAVAILABLE)
1729
+
1730
+
1731
+ async def _decide_approval(ctx: click.Context, approval_id: int, decision: str) -> None:
1732
+ config: Config = ctx.obj["config"]
1733
+ console = Console(no_color=not config.colour)
1734
+ creds = load_credentials()
1735
+ if creds is None:
1736
+ print_error(console, "Not authenticated -- run `tamfis-code login` first.")
1737
+ raise SystemExit(EXIT_AUTH_FAILED)
1738
+
1739
+ async with RemoteAPIClient(config, creds) as client:
1740
+ try:
1741
+ cmd = await client.approve_command(approval_id, decision)
1742
+ except AuthRequiredError:
1743
+ print_error(console, "Not authenticated -- run `tamfis-code login` first.")
1744
+ raise SystemExit(EXIT_AUTH_FAILED)
1745
+ except (RemoteAPIError, httpx.HTTPError) as e:
1746
+ print_error(console, str(e))
1747
+ raise SystemExit(EXIT_RUNTIME_UNAVAILABLE)
1748
+ verb = "Approved" if decision.startswith("approve") else "Rejected"
1749
+ console.print(f"[green]{verb}[/green] {approval_id} -> status={cmd.get('status')}")
1750
+
1751
+
1752
+ @cli.command()
1753
+ @click.argument("approval_id", type=int)
1754
+ @click.option("--once", "scope", flag_value="approve_once", default="approve_once", help="Approve this command only.")
1755
+ @click.option("--session", "scope", flag_value="approve_session", help="Approve this safety tier for the session.")
1756
+ @click.pass_context
1757
+ @async_command
1758
+ async def approve(ctx: click.Context, approval_id: int, scope: str):
1759
+ """Approve a pending command awaiting approval (legacy Remote Workspace backend only -- standalone mode prompts for approval inline, in the same process)."""
1760
+ await _decide_approval(ctx, approval_id, scope)
1761
+
1762
+
1763
+ @cli.command()
1764
+ @click.argument("approval_id", type=int)
1765
+ @click.option("--reason", default=None, help="Record a human-readable rejection reason in terminal history.")
1766
+ @click.pass_context
1767
+ @async_command
1768
+ async def reject(ctx: click.Context, approval_id: int, reason: Optional[str]):
1769
+ """Reject/deny a pending command awaiting approval (legacy Remote Workspace backend only -- standalone mode prompts for approval inline, in the same process)."""
1770
+ await _decide_approval(ctx, approval_id, "deny")
1771
+ if reason:
1772
+ Console(no_color=not ctx.obj["config"].colour).print(f"Reason: {reason}")
1773
+
1774
+
1775
+ @cli.command(name="inspect")
1776
+ @click.argument("command_id", type=int)
1777
+ @click.pass_context
1778
+ @async_command
1779
+ async def inspect_command(ctx: click.Context, command_id: int):
1780
+ """Show the exact command, CWD/risk metadata, and current status (legacy Remote Workspace backend only)."""
1781
+ config: Config = ctx.obj["config"]
1782
+ console = Console(no_color=not config.colour)
1783
+ creds = load_credentials()
1784
+ if creds is None:
1785
+ print_error(console, "Not authenticated -- run `tamfis-code login` first.")
1786
+ raise SystemExit(EXIT_AUTH_FAILED)
1787
+ async with RemoteAPIClient(config, creds) as client:
1788
+ cmd = await client.get_command(command_id)
1789
+ session_detail = await client.get_session(int(cmd["session_id"]))
1790
+ console.print(f"Command:\n{cmd.get('command_text', '')}\n")
1791
+ console.print(f"Working directory:\n{session_detail.get('working_directory') or '?'}\n")
1792
+ console.print(f"Reason:\n{cmd.get('safety_reason') or 'No reason recorded.'}\n")
1793
+ console.print(f"Risk:\n{cmd.get('safety_tier') or '?'}\n")
1794
+ console.print(f"Status:\n{cmd.get('status') or '?'}\n")
1795
+ console.print(f"Command ID:\n{command_id}")
1796
+
1797
+
1798
+ # ============== NEW COMMANDS ==============
1799
+
1800
+ @cli.command('completion')
1801
+ @click.argument('shell', type=click.Choice(['bash', 'zsh', 'fish', 'powershell']))
1802
+ def completion_cmd(shell: str):
1803
+ """Generate shell completion scripts"""
1804
+ from .completion import ShellCompleter
1805
+
1806
+ generators = {
1807
+ 'bash': ShellCompleter.generate_bash,
1808
+ 'zsh': ShellCompleter.generate_zsh,
1809
+ 'fish': ShellCompleter.generate_fish,
1810
+ 'powershell': ShellCompleter.generate_powershell,
1811
+ }
1812
+
1813
+ click.echo(generators[shell]())
1814
+
1815
+
1816
+ @cli.group()
1817
+ def session():
1818
+ """Manage sessions"""
1819
+ pass
1820
+
1821
+
1822
+ @session.command('list')
1823
+ @click.option('--limit', '-l', default=20, help='Number of sessions to list')
1824
+ def session_list(limit: int):
1825
+ """List all sessions"""
1826
+ from .sessions import SessionManager
1827
+ from datetime import datetime
1828
+
1829
+ manager = SessionManager()
1830
+ sessions = manager.list_sessions(limit)
1831
+
1832
+ if not sessions:
1833
+ click.echo("📋 No sessions found")
1834
+ return
1835
+
1836
+ click.echo("📋 Recent Sessions:")
1837
+ for s in sessions:
1838
+ age = (datetime.now() - s.updated_at).total_seconds()
1839
+ if age < 60:
1840
+ age_str = f"{int(age)}s ago"
1841
+ elif age < 3600:
1842
+ age_str = f"{int(age/60)}m ago"
1843
+ elif age < 86400:
1844
+ age_str = f"{int(age/3600)}h ago"
1845
+ else:
1846
+ age_str = f"{int(age/86400)}d ago"
1847
+
1848
+ msg_count = len(s.messages)
1849
+ status = "🟢" if s.is_active else "🔴"
1850
+ click.echo(f" {status} {s.id} | {s.name} | {msg_count} msgs | {age_str}")
1851
+
1852
+
1853
+ @session.command('resume')
1854
+ @click.argument('session_id')
1855
+ def session_resume(session_id: str):
1856
+ """Resume a session by ID"""
1857
+ from .sessions import SessionManager
1858
+
1859
+ manager = SessionManager()
1860
+ session = manager.load(session_id)
1861
+
1862
+ if not session:
1863
+ click.echo(f"❌ Session '{session_id}' not found")
1864
+ return
1865
+
1866
+ click.echo(f"🔄 Resuming session: {session.name}")
1867
+ # Pass to interactive mode
1868
+ from .interactive import run_interactive
1869
+ # This would need proper context - simplified for now
1870
+ click.echo("Interactive resume not fully implemented in this version")
1871
+
1872
+
1873
+ @session.command('delete')
1874
+ @click.argument('session_id')
1875
+ @click.option('--force', '-f', is_flag=True, help='Force delete without confirmation')
1876
+ def session_delete(session_id: str, force: bool):
1877
+ """Delete a session"""
1878
+ from .sessions import SessionManager
1879
+
1880
+ if not force:
1881
+ click.confirm(f"Delete session '{session_id}'?", abort=True)
1882
+
1883
+ manager = SessionManager()
1884
+ manager.delete(session_id)
1885
+ click.echo("✅ Session deleted")
1886
+
1887
+
1888
+ @session.command('fork')
1889
+ @click.argument('session_id')
1890
+ @click.option('--name', '-n', help='New session name')
1891
+ def session_fork(session_id: str, name: str):
1892
+ """Fork a session (create a copy)"""
1893
+ from .sessions import SessionManager
1894
+
1895
+ manager = SessionManager()
1896
+ new_session = manager.fork_session(session_id, name)
1897
+
1898
+ if not new_session:
1899
+ click.echo(f"❌ Session '{session_id}' not found")
1900
+ return
1901
+
1902
+ click.echo(f"✅ Forked to session: {new_session.id} ({new_session.name})")
1903
+
1904
+
1905
+ @session.command('clean')
1906
+ @click.option('--days', '-d', default=30, help='Delete sessions older than N days')
1907
+ def session_clean(days: int):
1908
+ """Clean old sessions"""
1909
+ from .sessions import SessionManager
1910
+
1911
+ manager = SessionManager()
1912
+ count = manager.delete_old(days)
1913
+ click.echo(f"✅ Deleted {count} sessions older than {days} days")
1914
+
1915
+
1916
+ @cli.command('agent-cmd')
1917
+ @click.argument('action', type=click.Choice(['list', 'run', 'info', 'delegate']))
1918
+ @click.option('--task', '-t', 'tasks', multiple=True, help='Task description (repeatable for delegate)')
1919
+ @click.option('--file', '-f', help='File to operate on')
1920
+ @click.option('--max-concurrency', default=1, show_default=True, help='Max concurrent delegated sub-tasks')
1921
+ @click.option('--provider', default="auto", help="hf, nvidia, openrouter, ollama, or auto (default).")
1922
+ @click.option('--model', default=None, help="Provider-specific model id; defaults to that provider's default model.")
1923
+ @click.pass_context
1924
+ def agent_cmd(ctx: click.Context, action: str, tasks: tuple[str, ...], file: str, max_concurrency: int, provider: str, model: Optional[str]):
1925
+ """Run subagents for various tasks"""
1926
+ import asyncio
1927
+ import json
1928
+ from .agents import AgentManager
1929
+
1930
+ manager = AgentManager()
1931
+ task = tasks[0] if tasks else None
1932
+
1933
+ if action == 'list':
1934
+ agents = manager.list_agents()
1935
+ click.echo("🤖 Available Agents:")
1936
+ for a in agents:
1937
+ click.echo(f" - {a['name']}: {a['description']}")
1938
+ click.echo(f" Capabilities: {', '.join(a['capabilities'])}")
1939
+ return
1940
+
1941
+ if action == 'info':
1942
+ agents = manager.list_agents()
1943
+ for a in agents:
1944
+ click.echo(f"\n📋 {a['name']}")
1945
+ click.echo(f" Description: {a['description']}")
1946
+ click.echo(f" Capabilities: {', '.join(a['capabilities'])}")
1947
+ return
1948
+
1949
+ if action == 'run':
1950
+ if not task:
1951
+ click.echo("❌ Please specify a task with --task")
1952
+ return
1953
+
1954
+ params = {}
1955
+ if file:
1956
+ params['file'] = file
1957
+
1958
+ result = asyncio.run(manager.execute_task(task, params))
1959
+ if 'error' in result:
1960
+ click.echo(f"❌ {result['error']}")
1961
+ else:
1962
+ click.echo(f"✅ Task completed by {result.get('agent', 'unknown')}")
1963
+ click.echo(json.dumps(result.get('result', {}), indent=2))
1964
+ return
1965
+
1966
+ if action == 'delegate':
1967
+ config: Config = ctx.obj["config"]
1968
+ if not config.enable_subagent_delegation:
1969
+ click.echo(
1970
+ "❌ Subagent delegation is disabled. Enable it with "
1971
+ "enable_subagent_delegation = true in config.toml, or "
1972
+ "TAMFIS_CODE_ENABLE_SUBAGENT_DELEGATION=1."
1973
+ )
1974
+ raise SystemExit(EXIT_INVALID_ARGS)
1975
+ if not tasks:
1976
+ click.echo("❌ Please specify at least one --task")
1977
+ raise SystemExit(EXIT_INVALID_ARGS)
1978
+
1979
+ from .local_chat import resolve_provider_type
1980
+ from .providers import ProviderManager
1981
+
1982
+ try:
1983
+ provider_type = resolve_provider_type(provider)
1984
+ except ValueError as exc:
1985
+ raise click.UsageError(str(exc))
1986
+
1987
+ workspace_root: Path = ctx.obj["workspace_root"]
1988
+ console = Console(no_color=not config.colour)
1989
+ provider_manager = ProviderManager()
1990
+
1991
+ results = _run_async(manager.execute_tasks(
1992
+ list(tasks), manager=provider_manager, provider=provider_type, model=model,
1993
+ console=console, workspace_root=str(workspace_root),
1994
+ approval_policy=config.approval_policy, max_concurrency=max_concurrency,
1995
+ ))
1996
+ for r in results:
1997
+ marker = "✅" if r["status"] == "completed" else "❌"
1998
+ click.echo(f"{marker} {r['description']}")
1999
+ summary = (r.get("result") or {}).get("summary") or (r.get("result") or {}).get("error")
2000
+ if summary:
2001
+ click.echo(f" {summary}")
2002
+ return
2003
+
2004
+
2005
+ @cli.command('tools')
2006
+ @click.argument('action', type=click.Choice(['list', 'call']))
2007
+ @click.option('--name', '-n', help='Tool name')
2008
+ @click.option('--params', '-p', help='Parameters as JSON')
2009
+ def tools_cmd(action: str, name: str, params: str):
2010
+ """List and call MCP tools"""
2011
+ import asyncio
2012
+ import json
2013
+ from .mcp import MCPServer
2014
+
2015
+ server = MCPServer()
2016
+
2017
+ if action == 'list':
2018
+ tools = asyncio.run(server.list_tools_async())
2019
+ click.echo("🔧 Available Tools:")
2020
+ for t in tools:
2021
+ click.echo(f" - {t['name']}: {t['description']}")
2022
+ return
2023
+
2024
+ if action == 'call':
2025
+ if not name:
2026
+ click.echo("❌ Please specify a tool with --name")
2027
+ return
2028
+
2029
+ if name in {"write_file", "execute_command"}:
2030
+ click.echo(
2031
+ "Approval required: direct mutation through 'tools call' is disabled. "
2032
+ "Use 'tamfis-code ask' or 'tamfis-code chat' so the command, working "
2033
+ "directory, risk, and approval record are shown first."
2034
+ )
2035
+ return
2036
+
2037
+ params_dict = json.loads(params) if params else {}
2038
+ result = asyncio.run(server.call_tool(name, params_dict))
2039
+ if result.get('success'):
2040
+ click.echo(json.dumps(result.get('result', {}), indent=2))
2041
+ else:
2042
+ click.echo(f"❌ {result.get('error', 'Unknown error')}")
2043
+
2044
+
2045
+ @cli.command('index')
2046
+ @click.argument('path', required=False, default='.')
2047
+ @click.option('--search', '-s', help='Search for symbols')
2048
+ @click.option('--kind', '-k', help='Filter by symbol kind')
2049
+ @click.option('--stats', is_flag=True, help='Show index statistics')
2050
+ def index_cmd(path: str, search: str, kind: str, stats: bool):
2051
+ """Index and search code"""
2052
+ import json
2053
+ from .indexer import CodeIndexer
2054
+ from pathlib import Path
2055
+
2056
+ root_path = Path(path)
2057
+ if not root_path.exists():
2058
+ click.echo(f"❌ Path '{path}' not found")
2059
+ return
2060
+
2061
+ indexer = CodeIndexer(root_path)
2062
+
2063
+ if search:
2064
+ indexer.load_index()
2065
+ results = indexer.search_symbol(search, kind)
2066
+ if not results:
2067
+ click.echo(f"No symbols found matching '{search}'")
2068
+ return
2069
+
2070
+ click.echo(f"📋 Found {len(results)} symbols:")
2071
+ for r in results:
2072
+ click.echo(f" {r.name} ({r.kind}) - {r.file_path}:{r.line_start}")
2073
+ return
2074
+
2075
+ if stats:
2076
+ indexer.load_index()
2077
+ stats_data = indexer.get_stats()
2078
+ click.echo("📊 Index Statistics:")
2079
+ click.echo(f" Files: {stats_data['files']}")
2080
+ click.echo(f" Total Symbols: {stats_data['total_symbols']}")
2081
+ click.echo(f" Languages: {stats_data['languages']}")
2082
+ click.echo(f" Symbol Kinds: {stats_data['symbol_kinds']}")
2083
+ return
2084
+
2085
+ # Index the path
2086
+ click.echo(f"📁 Indexing: {path}")
2087
+ count = indexer.index()
2088
+ click.echo(f"✅ Indexed {count} files")
2089
+
2090
+
2091
+ # The standalone `metrics` command used to start an empty MetricsTracker and
2092
+ # sleep 60s printing `\r`-overwritten zeros -- nothing ever called .record()
2093
+ # on it, since it was disconnected from the real streaming path. Superseded
2094
+ # by StreamRenderer's live token/rate display (render.py), which records real
2095
+ # per-task estimates as assistant_delta events arrive.
2096
+
2097
+
2098
+ def main() -> None:
2099
+ try:
2100
+ cli()
2101
+ except PermissionError as exc:
2102
+ # Without this, a permission/ownership mismatch on CONFIG_DIR (e.g. it
2103
+ # was created by a different user) surfaces as a raw traceback on the
2104
+ # very first local state write of every single invocation -- the CLI
2105
+ # "dies on the same step every time" with no actionable message, and
2106
+ # since nothing ever got persisted, the next invocation looks like a
2107
+ # fresh start and re-proposes the same plan instead of progressing.
2108
+ click.echo(
2109
+ f"✗ Permission denied writing local session state: {exc}\n"
2110
+ f" {CONFIG_DIR} (or a file inside it) is likely owned by a different "
2111
+ "user than the one running tamfis-code. Check `ls -la "
2112
+ f"{CONFIG_DIR}` and fix its ownership, then retry.",
2113
+ err=True,
2114
+ )
2115
+ raise SystemExit(EXIT_LOCAL_STATE_ERROR)
2116
+
2117
+
2118
+ if __name__ == "__main__":
2119
+ cli()
2120
+
2121
+ @session.command('create')
2122
+ @click.argument('name', required=False)
2123
+ def session_create(name: Optional[str]):
2124
+ """Create a new session"""
2125
+ from .sessions import SessionManager
2126
+
2127
+ manager = SessionManager()
2128
+ session = manager.create_session(name)
2129
+ click.echo(f"✅ Session created: {session.id} - {session.name}")
2130
+
2131
+ @cli.command('providers')
2132
+ @click.pass_context
2133
+ def providers_command(ctx: click.Context):
2134
+ """Show available AI providers and their status"""
2135
+ from .providers import get_provider_status
2136
+ from rich.table import Table
2137
+ from rich.console import Console
2138
+
2139
+ config: Config = ctx.obj["config"]
2140
+ console = Console(no_color=not config.colour)
2141
+
2142
+ status = get_provider_status()
2143
+
2144
+ table = Table(show_header=True, header_style="bold")
2145
+ table.add_column("Provider")
2146
+ table.add_column("Status")
2147
+ table.add_column("Default Model")
2148
+ table.add_column("Priority")
2149
+ table.add_column("Reasoning")
2150
+
2151
+ for p in status.get("available", []):
2152
+ status_str = "🟢 Available" if p.get("available") else "🔴 Unavailable"
2153
+ reasoning_str = "✅" if p.get("reasoning_supported") else "❌"
2154
+ table.add_row(
2155
+ p.get("name", "Unknown"),
2156
+ status_str,
2157
+ p.get("default_model", "-"),
2158
+ str(p.get("weight", 0)),
2159
+ reasoning_str
2160
+ )
2161
+
2162
+ console.print(table)
2163
+ console.print(f"[dim]Default provider: {status.get('default', 'none')}[/dim]")
2164
+
2165
+
2166
+ @cli.command('local')
2167
+ @click.argument('objective', required=False)
2168
+ @click.option('--provider', default="auto", help="hf, nvidia, openrouter, ollama, or auto (default).")
2169
+ @click.option('--model', default=None, help="Provider-specific model id; defaults to that provider's default model.")
2170
+ @click.option('--no-tools', 'no_tools', is_flag=True, default=False, help="Disable read-only repo tools (read_file/list_directory/search_code/get_git_info) for this turn.")
2171
+ @click.option('--agent', 'full_agent', is_flag=True, default=False, help="Full read/write/execute tool access (write_file/edit_file/execute_command) via the local risk/approval/mutation-ledger layer, instead of read-only Q&A. Standalone -- no TamfisGPT backend involved.")
2172
+ @click.option('--repl', 'run_repl', is_flag=True, default=False, help="Start an interactive local chat loop instead of a single turn.")
2173
+ @click.pass_context
2174
+ def local_command(ctx: click.Context, objective: Optional[str], provider: str, model: Optional[str], no_tools: bool, full_agent: bool, run_repl: bool):
2175
+ """Offline chat with a directly-configured LLM provider -- no TamfisGPT
2176
+ account, login, or network round-trip to the backend at all. Ollama
2177
+ needs no API key and runs fully on-device; HF/NVIDIA/OpenRouter are
2178
+ available if you've set your own key in the environment.
2179
+
2180
+ Read-only repo tools (read_file/list_directory/search_code/get_git_info)
2181
+ are available so the model can answer questions about this directory.
2182
+ Pass --agent for full read/write/execute capability -- that path has its
2183
+ own local risk classifier, approval prompts (per --approval-policy /
2184
+ config.toml), and file-mutation ledger (see `tamfis-code diffs`/`revert`
2185
+ once wired to it), since there's no server backing it anymore.
2186
+ """
2187
+ from .local_chat import resolve_provider_type, run_local_turn, stream_local_turn
2188
+ from .providers import ProviderManager
2189
+
2190
+ config: Config = ctx.obj["config"]
2191
+ console = Console(no_color=not config.colour)
2192
+
2193
+ try:
2194
+ provider_type = resolve_provider_type(provider)
2195
+ except ValueError as exc:
2196
+ raise click.UsageError(str(exc))
2197
+
2198
+ manager = ProviderManager()
2199
+ use_tools = not no_tools
2200
+
2201
+ if full_agent:
2202
+ from .render import StreamRenderer
2203
+ from .runner_local import run_local_agent_turn
2204
+ from .workspace import resolve_local_workspace
2205
+
2206
+ workspace = resolve_local_workspace(ctx.obj["workspace_root"])
2207
+
2208
+ async def _one_agent_turn(messages: list) -> str:
2209
+ renderer = StreamRenderer(console)
2210
+ outcome = await run_local_agent_turn(
2211
+ manager, provider_type, model, messages, console, renderer,
2212
+ workspace_root=workspace.workspace_root, session_id=workspace.session_id,
2213
+ approval_policy=config.approval_policy, interactive=sys.stdin.isatty(),
2214
+ )
2215
+ renderer.finish()
2216
+ if outcome.status != "completed":
2217
+ raise RuntimeError(outcome.error or "Task did not complete")
2218
+ return outcome.summary or ""
2219
+
2220
+ if run_repl:
2221
+ console.print(f"[dim]Local standalone agent mode (session {workspace.session_id}, {workspace.workspace_root}) -- Ctrl+D or /exit to quit.[/dim]")
2222
+ history: list = []
2223
+ while True:
2224
+ try:
2225
+ text = console.input("[bold cyan]you> [/bold cyan]").strip()
2226
+ except (EOFError, KeyboardInterrupt):
2227
+ console.print()
2228
+ break
2229
+ if not text or text in {"/exit", "/quit"}:
2230
+ break
2231
+ history.append({"role": "user", "content": text})
2232
+ try:
2233
+ answer = _run_async(_one_agent_turn(history))
2234
+ except Exception as exc:
2235
+ print_error(console, str(exc))
2236
+ history.pop()
2237
+ continue
2238
+ history.append({"role": "assistant", "content": answer})
2239
+ return
2240
+
2241
+ if not objective:
2242
+ raise click.UsageError("Provide an objective, or pass --repl for an interactive loop.")
2243
+ try:
2244
+ _run_async(_one_agent_turn([{"role": "user", "content": objective}]))
2245
+ except Exception as exc:
2246
+ print_error(console, str(exc))
2247
+ raise SystemExit(EXIT_TASK_FAILED)
2248
+ return
2249
+
2250
+ async def _one_turn(messages: list) -> str:
2251
+ if use_tools:
2252
+ answer = await run_local_turn(manager, provider_type, messages, model, console, use_tools=True)
2253
+ console.print(answer)
2254
+ return answer
2255
+ parts: list[str] = []
2256
+ async for chunk in stream_local_turn(manager, provider_type, messages, model):
2257
+ console.print(chunk, end="")
2258
+ parts.append(chunk)
2259
+ console.print()
2260
+ return "".join(parts)
2261
+
2262
+ if run_repl:
2263
+ console.print("[dim]Local/offline mode -- Ctrl+D or /exit to quit.[/dim]")
2264
+ history: list = []
2265
+ while True:
2266
+ try:
2267
+ text = console.input("[bold cyan]you> [/bold cyan]").strip()
2268
+ except (EOFError, KeyboardInterrupt):
2269
+ console.print()
2270
+ break
2271
+ if not text or text in {"/exit", "/quit"}:
2272
+ break
2273
+ history.append({"role": "user", "content": text})
2274
+ try:
2275
+ answer = _run_async(_one_turn(history))
2276
+ except Exception as exc:
2277
+ print_error(console, str(exc))
2278
+ history.pop()
2279
+ continue
2280
+ history.append({"role": "assistant", "content": answer})
2281
+ return
2282
+
2283
+ if not objective:
2284
+ raise click.UsageError("Provide an objective, or pass --repl for an interactive loop.")
2285
+ try:
2286
+ _run_async(_one_turn([{"role": "user", "content": objective}]))
2287
+ except Exception as exc:
2288
+ print_error(console, str(exc))
2289
+ raise SystemExit(EXIT_TASK_FAILED)
2290
+
2291
+
2292
+ @cli.command('screenshot')
2293
+ @click.argument('url_or_path')
2294
+ @click.option('--width', '-w', default=1920, help='Screenshot width')
2295
+ @click.option('--height', '-h', default=1080, help='Screenshot height')
2296
+ @click.option('--quality', '-q', default=90, help='JPEG quality (1-100)')
2297
+ @click.option('--format', '-f', default='png', help='Output format (png/jpeg/webp)')
2298
+ @click.option('--full-page', '-F', is_flag=True, help='Capture full page')
2299
+ @click.option('--output', '-o', help='Output filename')
2300
+ @click.pass_context
2301
+ def screenshot_cmd(ctx: click.Context, url_or_path: str, width: int, height: int,
2302
+ quality: int, format: str, full_page: bool, output: Optional[str]):
2303
+ """Take a real Playwright screenshot of a URL (or copy/render a local image)."""
2304
+ config: Config = ctx.obj["config"]
2305
+ console = Console(no_color=not config.colour)
2306
+
2307
+ try:
2308
+ if url_or_path.startswith(("http://", "https://")):
2309
+ if format.lower() != "png":
2310
+ raise ValueError("Live browser screenshots use PNG; pass --format png.")
2311
+ from .mcp import get_browser_tool_class
2312
+
2313
+ result = get_browser_tool_class()().execute(
2314
+ url=url_or_path,
2315
+ action="screenshot",
2316
+ viewport_width=width,
2317
+ viewport_height=height,
2318
+ full_page=full_page,
2319
+ screenshot_name=output,
2320
+ _trusted_source_client="tamfis-code",
2321
+ _trusted_workspace_root=str(ctx.obj["workspace_root"]),
2322
+ _trusted_task_id="cli-screenshot",
2323
+ _trusted_mode="audit",
2324
+ )
2325
+ if not result.get("success"):
2326
+ raise RuntimeError(result.get("error") or "Browser screenshot failed")
2327
+ console.print(f"[green]Screenshot saved:[/green] {result['screenshot_path']}")
2328
+ console.print(f"[green]URL:[/green] {result['screenshot_url']}")
2329
+ else:
2330
+ from .screenshot import ScreenshotOptions, ScreenshotTaker
2331
+
2332
+ taker = ScreenshotTaker()
2333
+ options = ScreenshotOptions(
2334
+ width=width, height=height, quality=quality,
2335
+ format=format, full_page=full_page,
2336
+ )
2337
+ result = taker.take_screenshot(url_or_path, filename=output, options=options)
2338
+ console.print(f"[green]Screenshot saved:[/green] {result}")
2339
+ except Exception as e:
2340
+ console.print(f"[red]Screenshot failed: {e}[/red]")
2341
+ raise SystemExit(EXIT_TASK_FAILED)
2342
+
2343
+ # Import enforcer
2344
+ from .enforcer import TestEnforcer, run_enforcer, add_enforcer_command
2345
+
2346
+ # `add_enforcer_command` registers a real `enforce` command backed by methods
2347
+ # that actually exist on TestEnforcer (--python/--node/--frontend). A previous
2348
+ # `enforce_cmd` defined directly here duplicated this with --shell/--type
2349
+ # flags wired to _run_shell_checks/_run_type_checks, neither of which exist on
2350
+ # TestEnforcer -- using those flags raised AttributeError at runtime.
2351
+ add_enforcer_command(cli)