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/__init__.py +58 -0
- tamfis_code/__main__.py +4 -0
- tamfis_code/agents.py +371 -0
- tamfis_code/api_client.py +461 -0
- tamfis_code/cli.py +2351 -0
- tamfis_code/completion.py +137 -0
- tamfis_code/config.py +199 -0
- tamfis_code/doctor.py +173 -0
- tamfis_code/enforcer.py +318 -0
- tamfis_code/indexer.py +567 -0
- tamfis_code/instructions.py +121 -0
- tamfis_code/interactive.py +985 -0
- tamfis_code/local_chat.py +117 -0
- tamfis_code/local_tools.py +93 -0
- tamfis_code/mcp.py +538 -0
- tamfis_code/metrics.py +105 -0
- tamfis_code/providers.py +423 -0
- tamfis_code/references.py +336 -0
- tamfis_code/render.py +575 -0
- tamfis_code/runner.py +635 -0
- tamfis_code/runner_local.py +364 -0
- tamfis_code/safety.py +164 -0
- tamfis_code/screenshot.py +382 -0
- tamfis_code/sessions.py +253 -0
- tamfis_code/state.py +449 -0
- tamfis_code/tasks.py +42 -0
- tamfis_code/workspace.py +329 -0
- tamfis_code-0.2.3.dist-info/METADATA +78 -0
- tamfis_code-0.2.3.dist-info/RECORD +32 -0
- tamfis_code-0.2.3.dist-info/WHEEL +5 -0
- tamfis_code-0.2.3.dist-info/entry_points.txt +4 -0
- tamfis_code-0.2.3.dist-info/top_level.txt +1 -0
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)
|