llmcode-cli 1.0.0__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.
- llm_code/__init__.py +2 -0
- llm_code/analysis/__init__.py +6 -0
- llm_code/analysis/cache.py +33 -0
- llm_code/analysis/engine.py +256 -0
- llm_code/analysis/go_rules.py +114 -0
- llm_code/analysis/js_rules.py +84 -0
- llm_code/analysis/python_rules.py +311 -0
- llm_code/analysis/rules.py +140 -0
- llm_code/analysis/rust_rules.py +108 -0
- llm_code/analysis/universal_rules.py +111 -0
- llm_code/api/__init__.py +0 -0
- llm_code/api/client.py +90 -0
- llm_code/api/errors.py +73 -0
- llm_code/api/openai_compat.py +390 -0
- llm_code/api/provider.py +35 -0
- llm_code/api/sse.py +52 -0
- llm_code/api/types.py +140 -0
- llm_code/cli/__init__.py +0 -0
- llm_code/cli/commands.py +70 -0
- llm_code/cli/image.py +122 -0
- llm_code/cli/render.py +214 -0
- llm_code/cli/status_line.py +79 -0
- llm_code/cli/streaming.py +92 -0
- llm_code/cli/tui_main.py +220 -0
- llm_code/computer_use/__init__.py +11 -0
- llm_code/computer_use/app_detect.py +49 -0
- llm_code/computer_use/app_tier.py +57 -0
- llm_code/computer_use/coordinator.py +99 -0
- llm_code/computer_use/input_control.py +71 -0
- llm_code/computer_use/screenshot.py +93 -0
- llm_code/cron/__init__.py +13 -0
- llm_code/cron/parser.py +145 -0
- llm_code/cron/scheduler.py +135 -0
- llm_code/cron/storage.py +126 -0
- llm_code/enterprise/__init__.py +1 -0
- llm_code/enterprise/audit.py +59 -0
- llm_code/enterprise/auth.py +26 -0
- llm_code/enterprise/oidc.py +95 -0
- llm_code/enterprise/rbac.py +65 -0
- llm_code/harness/__init__.py +5 -0
- llm_code/harness/config.py +33 -0
- llm_code/harness/engine.py +129 -0
- llm_code/harness/guides.py +41 -0
- llm_code/harness/sensors.py +68 -0
- llm_code/harness/templates.py +84 -0
- llm_code/hida/__init__.py +1 -0
- llm_code/hida/classifier.py +187 -0
- llm_code/hida/engine.py +49 -0
- llm_code/hida/profiles.py +95 -0
- llm_code/hida/types.py +28 -0
- llm_code/ide/__init__.py +1 -0
- llm_code/ide/bridge.py +80 -0
- llm_code/ide/detector.py +76 -0
- llm_code/ide/server.py +169 -0
- llm_code/logging.py +29 -0
- llm_code/lsp/__init__.py +0 -0
- llm_code/lsp/client.py +298 -0
- llm_code/lsp/detector.py +42 -0
- llm_code/lsp/manager.py +56 -0
- llm_code/lsp/tools.py +288 -0
- llm_code/marketplace/__init__.py +0 -0
- llm_code/marketplace/builtin_registry.py +102 -0
- llm_code/marketplace/installer.py +162 -0
- llm_code/marketplace/plugin.py +78 -0
- llm_code/marketplace/registry.py +360 -0
- llm_code/mcp/__init__.py +0 -0
- llm_code/mcp/bridge.py +87 -0
- llm_code/mcp/client.py +117 -0
- llm_code/mcp/health.py +120 -0
- llm_code/mcp/manager.py +214 -0
- llm_code/mcp/oauth.py +219 -0
- llm_code/mcp/transport.py +254 -0
- llm_code/mcp/types.py +53 -0
- llm_code/remote/__init__.py +0 -0
- llm_code/remote/client.py +136 -0
- llm_code/remote/protocol.py +22 -0
- llm_code/remote/server.py +275 -0
- llm_code/remote/ssh_proxy.py +56 -0
- llm_code/runtime/__init__.py +0 -0
- llm_code/runtime/auto_commit.py +56 -0
- llm_code/runtime/auto_diagnose.py +62 -0
- llm_code/runtime/checkpoint.py +70 -0
- llm_code/runtime/checkpoint_recovery.py +142 -0
- llm_code/runtime/compaction.py +35 -0
- llm_code/runtime/compressor.py +415 -0
- llm_code/runtime/config.py +533 -0
- llm_code/runtime/context.py +49 -0
- llm_code/runtime/conversation.py +921 -0
- llm_code/runtime/cost_tracker.py +126 -0
- llm_code/runtime/dream.py +127 -0
- llm_code/runtime/file_protection.py +150 -0
- llm_code/runtime/hardware.py +85 -0
- llm_code/runtime/hooks.py +223 -0
- llm_code/runtime/indexer.py +230 -0
- llm_code/runtime/knowledge_compiler.py +232 -0
- llm_code/runtime/memory.py +132 -0
- llm_code/runtime/memory_layers.py +467 -0
- llm_code/runtime/memory_lint.py +252 -0
- llm_code/runtime/model_aliases.py +37 -0
- llm_code/runtime/ollama.py +93 -0
- llm_code/runtime/overlay.py +124 -0
- llm_code/runtime/permissions.py +200 -0
- llm_code/runtime/plan.py +45 -0
- llm_code/runtime/prompt.py +238 -0
- llm_code/runtime/repo_map.py +174 -0
- llm_code/runtime/sandbox.py +116 -0
- llm_code/runtime/session.py +268 -0
- llm_code/runtime/skill_resolver.py +61 -0
- llm_code/runtime/skills.py +133 -0
- llm_code/runtime/speculative.py +75 -0
- llm_code/runtime/streaming_executor.py +216 -0
- llm_code/runtime/telemetry.py +196 -0
- llm_code/runtime/token_budget.py +26 -0
- llm_code/runtime/vcr.py +142 -0
- llm_code/runtime/vision.py +102 -0
- llm_code/swarm/__init__.py +1 -0
- llm_code/swarm/backend_subprocess.py +108 -0
- llm_code/swarm/backend_tmux.py +103 -0
- llm_code/swarm/backend_worktree.py +306 -0
- llm_code/swarm/checkpoint.py +74 -0
- llm_code/swarm/coordinator.py +236 -0
- llm_code/swarm/mailbox.py +88 -0
- llm_code/swarm/manager.py +202 -0
- llm_code/swarm/memory_sync.py +80 -0
- llm_code/swarm/recovery.py +21 -0
- llm_code/swarm/team.py +67 -0
- llm_code/swarm/types.py +31 -0
- llm_code/task/__init__.py +16 -0
- llm_code/task/diagnostics.py +93 -0
- llm_code/task/manager.py +162 -0
- llm_code/task/types.py +112 -0
- llm_code/task/verifier.py +104 -0
- llm_code/tools/__init__.py +0 -0
- llm_code/tools/agent.py +145 -0
- llm_code/tools/agent_roles.py +82 -0
- llm_code/tools/base.py +94 -0
- llm_code/tools/bash.py +565 -0
- llm_code/tools/computer_use_tools.py +278 -0
- llm_code/tools/coordinator_tool.py +75 -0
- llm_code/tools/cron_create.py +90 -0
- llm_code/tools/cron_delete.py +49 -0
- llm_code/tools/cron_list.py +51 -0
- llm_code/tools/deferred.py +92 -0
- llm_code/tools/dump.py +116 -0
- llm_code/tools/edit_file.py +282 -0
- llm_code/tools/git_tools.py +531 -0
- llm_code/tools/glob_search.py +112 -0
- llm_code/tools/grep_search.py +144 -0
- llm_code/tools/ide_diagnostics.py +59 -0
- llm_code/tools/ide_open.py +58 -0
- llm_code/tools/ide_selection.py +52 -0
- llm_code/tools/memory_tools.py +138 -0
- llm_code/tools/multi_edit.py +143 -0
- llm_code/tools/notebook_edit.py +107 -0
- llm_code/tools/notebook_read.py +81 -0
- llm_code/tools/parsing.py +63 -0
- llm_code/tools/read_file.py +154 -0
- llm_code/tools/registry.py +58 -0
- llm_code/tools/search_backends/__init__.py +56 -0
- llm_code/tools/search_backends/brave.py +56 -0
- llm_code/tools/search_backends/duckduckgo.py +129 -0
- llm_code/tools/search_backends/searxng.py +71 -0
- llm_code/tools/search_backends/tavily.py +73 -0
- llm_code/tools/swarm_create.py +109 -0
- llm_code/tools/swarm_delete.py +95 -0
- llm_code/tools/swarm_list.py +44 -0
- llm_code/tools/swarm_message.py +109 -0
- llm_code/tools/task_close.py +79 -0
- llm_code/tools/task_plan.py +79 -0
- llm_code/tools/task_verify.py +90 -0
- llm_code/tools/tool_search.py +65 -0
- llm_code/tools/web_common.py +258 -0
- llm_code/tools/web_fetch.py +223 -0
- llm_code/tools/web_search.py +280 -0
- llm_code/tools/write_file.py +118 -0
- llm_code/tui/__init__.py +1 -0
- llm_code/tui/app.py +2432 -0
- llm_code/tui/chat_view.py +82 -0
- llm_code/tui/chat_widgets.py +309 -0
- llm_code/tui/header_bar.py +46 -0
- llm_code/tui/input_bar.py +349 -0
- llm_code/tui/keybindings.py +142 -0
- llm_code/tui/marketplace.py +210 -0
- llm_code/tui/status_bar.py +72 -0
- llm_code/tui/theme.py +96 -0
- llm_code/utils/__init__.py +0 -0
- llm_code/utils/diff.py +111 -0
- llm_code/utils/errors.py +70 -0
- llm_code/utils/hyperlink.py +73 -0
- llm_code/utils/notebook.py +179 -0
- llm_code/utils/search.py +69 -0
- llm_code/utils/text_normalize.py +28 -0
- llm_code/utils/version_check.py +62 -0
- llm_code/vim/__init__.py +4 -0
- llm_code/vim/engine.py +51 -0
- llm_code/vim/motions.py +172 -0
- llm_code/vim/operators.py +183 -0
- llm_code/vim/text_objects.py +139 -0
- llm_code/vim/transitions.py +279 -0
- llm_code/vim/types.py +68 -0
- llm_code/voice/__init__.py +1 -0
- llm_code/voice/languages.py +43 -0
- llm_code/voice/recorder.py +136 -0
- llm_code/voice/stt.py +36 -0
- llm_code/voice/stt_anthropic.py +66 -0
- llm_code/voice/stt_google.py +32 -0
- llm_code/voice/stt_whisper.py +52 -0
- llmcode_cli-1.0.0.dist-info/METADATA +524 -0
- llmcode_cli-1.0.0.dist-info/RECORD +212 -0
- llmcode_cli-1.0.0.dist-info/WHEEL +4 -0
- llmcode_cli-1.0.0.dist-info/entry_points.txt +2 -0
- llmcode_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Incremental Markdown renderer for streaming LLM output."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.markdown import Markdown
|
|
8
|
+
from rich.syntax import Syntax
|
|
9
|
+
|
|
10
|
+
_CODE_BLOCK_RE = re.compile(r"^```(\w*)\n(.*?)```\s*$", re.DOTALL)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class IncrementalMarkdownRenderer:
|
|
14
|
+
"""Renders streaming token output incrementally using Rich.
|
|
15
|
+
|
|
16
|
+
Strategy:
|
|
17
|
+
- Accumulate tokens in a buffer.
|
|
18
|
+
- After each feed, attempt to flush completed blocks:
|
|
19
|
+
* Code block: text between opening ``` and closing ```.
|
|
20
|
+
* Paragraph / heading / list: text terminated by \\n\\n.
|
|
21
|
+
- finish() flushes whatever remains.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, console: Console) -> None:
|
|
25
|
+
self._console = console
|
|
26
|
+
self._buffer = ""
|
|
27
|
+
self._in_code_block = False
|
|
28
|
+
|
|
29
|
+
def feed(self, token: str) -> None:
|
|
30
|
+
"""Accumulate a token and try to flush completed blocks."""
|
|
31
|
+
self._buffer += token
|
|
32
|
+
self._try_flush()
|
|
33
|
+
|
|
34
|
+
def finish(self) -> None:
|
|
35
|
+
"""Flush all remaining buffered content."""
|
|
36
|
+
if self._buffer.strip():
|
|
37
|
+
self._render_block(self._buffer)
|
|
38
|
+
self._buffer = ""
|
|
39
|
+
self._in_code_block = False
|
|
40
|
+
|
|
41
|
+
def _try_flush(self) -> None:
|
|
42
|
+
"""Detect and render complete blocks from the buffer."""
|
|
43
|
+
while True:
|
|
44
|
+
if self._in_code_block:
|
|
45
|
+
# Look for closing ```
|
|
46
|
+
close_idx = self._buffer.find("```", 3) # skip opening ```
|
|
47
|
+
if close_idx == -1:
|
|
48
|
+
break # code block not yet closed
|
|
49
|
+
# Include everything up to and including the closing ```
|
|
50
|
+
end = close_idx + 3
|
|
51
|
+
# Consume optional trailing newlines
|
|
52
|
+
while end < len(self._buffer) and self._buffer[end] in ("\n",):
|
|
53
|
+
end += 1
|
|
54
|
+
block = self._buffer[:end]
|
|
55
|
+
self._buffer = self._buffer[end:]
|
|
56
|
+
self._in_code_block = False
|
|
57
|
+
self._render_block(block)
|
|
58
|
+
else:
|
|
59
|
+
# Check if we're entering a code block
|
|
60
|
+
if self._buffer.startswith("```"):
|
|
61
|
+
self._in_code_block = True
|
|
62
|
+
continue # re-check with in_code_block=True
|
|
63
|
+
|
|
64
|
+
# Look for paragraph boundary (\n\n)
|
|
65
|
+
para_idx = self._buffer.find("\n\n")
|
|
66
|
+
if para_idx == -1:
|
|
67
|
+
break # no complete paragraph yet
|
|
68
|
+
|
|
69
|
+
block = self._buffer[: para_idx + 2]
|
|
70
|
+
self._buffer = self._buffer[para_idx + 2 :]
|
|
71
|
+
|
|
72
|
+
# After consuming a paragraph, the remainder might start a code block
|
|
73
|
+
if self._buffer.startswith("```"):
|
|
74
|
+
self._in_code_block = True
|
|
75
|
+
|
|
76
|
+
stripped = block.strip()
|
|
77
|
+
if stripped:
|
|
78
|
+
self._render_block(stripped)
|
|
79
|
+
|
|
80
|
+
def _render_block(self, block: str) -> None:
|
|
81
|
+
"""Render a single block — code block as Syntax, else as Markdown."""
|
|
82
|
+
stripped = block.strip()
|
|
83
|
+
if not stripped:
|
|
84
|
+
return
|
|
85
|
+
|
|
86
|
+
m = _CODE_BLOCK_RE.match(stripped)
|
|
87
|
+
if m:
|
|
88
|
+
lang = m.group(1) or "text"
|
|
89
|
+
code = m.group(2)
|
|
90
|
+
self._console.print(Syntax(code, lang, theme="monokai"))
|
|
91
|
+
else:
|
|
92
|
+
self._console.print(Markdown(stripped))
|
llm_code/cli/tui_main.py
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""Entry point for llm-code."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
_PERMISSION_CHOICES = ["prompt", "auto_accept", "read_only", "workspace_write", "full_access"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@click.command()
|
|
13
|
+
@click.argument("prompt", required=False)
|
|
14
|
+
@click.option("--model", "-m", default=None, help="Model name to use")
|
|
15
|
+
@click.option("--api", default=None, help="API base URL")
|
|
16
|
+
@click.option("--api-key", default=None, help="API key (or set LLM_API_KEY env var)")
|
|
17
|
+
@click.option("--provider", type=click.Choice(["ollama"]), default=None, help="LLM provider shortcut")
|
|
18
|
+
@click.option(
|
|
19
|
+
"--permission",
|
|
20
|
+
type=click.Choice(_PERMISSION_CHOICES),
|
|
21
|
+
default=None,
|
|
22
|
+
help="Permission mode",
|
|
23
|
+
)
|
|
24
|
+
@click.option("--budget", type=int, default=None, help="Token budget target")
|
|
25
|
+
@click.option("--verbose", "-v", is_flag=True, help="Enable verbose logging")
|
|
26
|
+
@click.option("--serve", is_flag=True, help="Start as remote server")
|
|
27
|
+
@click.option("--port", type=int, default=8765, help="Server port (for --serve)")
|
|
28
|
+
@click.option("--connect", default=None, help="Connect to remote server (host:port)")
|
|
29
|
+
@click.option("--ssh", default=None, help="SSH to remote host and connect (user@host)")
|
|
30
|
+
@click.option("--replay", default=None, help="Replay a VCR recording file (.jsonl)")
|
|
31
|
+
@click.option("--replay-speed", type=float, default=1.0, help="Playback speed for --replay (0 = instant)")
|
|
32
|
+
@click.option("--resume", default=None, help="Resume from a checkpoint (session_id or 'last')")
|
|
33
|
+
def main(
|
|
34
|
+
prompt: str | None,
|
|
35
|
+
model: str | None,
|
|
36
|
+
api: str | None,
|
|
37
|
+
api_key: str | None,
|
|
38
|
+
provider: str | None,
|
|
39
|
+
permission: str | None,
|
|
40
|
+
budget: int | None,
|
|
41
|
+
verbose: bool = False,
|
|
42
|
+
serve: bool = False,
|
|
43
|
+
port: int = 8765,
|
|
44
|
+
connect: str | None = None,
|
|
45
|
+
ssh: str | None = None,
|
|
46
|
+
replay: str | None = None,
|
|
47
|
+
replay_speed: float = 1.0,
|
|
48
|
+
resume: str | None = None,
|
|
49
|
+
) -> None:
|
|
50
|
+
"""llm-code: AI coding assistant CLI."""
|
|
51
|
+
from llm_code.logging import setup_logging
|
|
52
|
+
from llm_code.runtime.config import load_config
|
|
53
|
+
|
|
54
|
+
setup_logging(verbose=verbose)
|
|
55
|
+
cwd = Path.cwd()
|
|
56
|
+
|
|
57
|
+
# Build CLI overrides
|
|
58
|
+
cli_overrides: dict = {}
|
|
59
|
+
if model:
|
|
60
|
+
cli_overrides["model"] = model
|
|
61
|
+
if api:
|
|
62
|
+
cli_overrides.setdefault("provider", {})["base_url"] = api
|
|
63
|
+
if api_key:
|
|
64
|
+
os.environ["LLM_API_KEY"] = api_key
|
|
65
|
+
if permission:
|
|
66
|
+
cli_overrides.setdefault("permissions", {})["mode"] = permission
|
|
67
|
+
|
|
68
|
+
# Ollama provider setup
|
|
69
|
+
if provider == "ollama":
|
|
70
|
+
ollama_result = _run_ollama_setup(
|
|
71
|
+
api_override=api,
|
|
72
|
+
model_override=model,
|
|
73
|
+
)
|
|
74
|
+
if ollama_result is None:
|
|
75
|
+
click.echo("Error: Cannot connect to Ollama at localhost:11434", err=True)
|
|
76
|
+
click.echo("Make sure Ollama is running: ollama serve", err=True)
|
|
77
|
+
raise SystemExit(1)
|
|
78
|
+
selected_model, base_url = ollama_result
|
|
79
|
+
cli_overrides["model"] = selected_model
|
|
80
|
+
cli_overrides.setdefault("provider", {})["base_url"] = base_url
|
|
81
|
+
|
|
82
|
+
user_dir = Path.home() / ".llm-code"
|
|
83
|
+
config = load_config(
|
|
84
|
+
user_dir=user_dir,
|
|
85
|
+
project_dir=cwd,
|
|
86
|
+
local_path=cwd / ".llm-code" / "config.json",
|
|
87
|
+
cli_overrides=cli_overrides,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
import asyncio
|
|
91
|
+
|
|
92
|
+
if replay:
|
|
93
|
+
from llm_code.runtime.vcr import VCRPlayer
|
|
94
|
+
player = VCRPlayer(Path(replay))
|
|
95
|
+
summary = player.summary()
|
|
96
|
+
print(f"Replaying: {replay}")
|
|
97
|
+
print(f" events={summary['event_count']} duration={summary['duration']:.1f}s")
|
|
98
|
+
print()
|
|
99
|
+
for event in player.replay(speed=replay_speed):
|
|
100
|
+
print(f"[{event.type:15s}] {event.data}")
|
|
101
|
+
return
|
|
102
|
+
|
|
103
|
+
if serve:
|
|
104
|
+
from llm_code.remote.server import RemoteServer
|
|
105
|
+
server = RemoteServer(host="0.0.0.0", port=port, config=config)
|
|
106
|
+
asyncio.run(server.start())
|
|
107
|
+
return
|
|
108
|
+
|
|
109
|
+
if connect:
|
|
110
|
+
from llm_code.remote.client import RemoteClient
|
|
111
|
+
client = RemoteClient(connect)
|
|
112
|
+
asyncio.run(client.connect())
|
|
113
|
+
return
|
|
114
|
+
|
|
115
|
+
if ssh:
|
|
116
|
+
from llm_code.remote.ssh_proxy import ssh_connect
|
|
117
|
+
asyncio.run(ssh_connect(ssh, port=port))
|
|
118
|
+
return
|
|
119
|
+
|
|
120
|
+
# Resolve resume session if requested
|
|
121
|
+
resume_session = None
|
|
122
|
+
if resume:
|
|
123
|
+
from llm_code.runtime.checkpoint_recovery import CheckpointRecovery
|
|
124
|
+
checkpoints_dir = Path.home() / ".llm-code" / "checkpoints"
|
|
125
|
+
recovery = CheckpointRecovery(checkpoints_dir)
|
|
126
|
+
if resume == "last":
|
|
127
|
+
resume_session = recovery.detect_last_checkpoint()
|
|
128
|
+
else:
|
|
129
|
+
resume_session = recovery.load_checkpoint(resume)
|
|
130
|
+
if resume_session is None:
|
|
131
|
+
print(f"[warning] No checkpoint found for: {resume}")
|
|
132
|
+
else:
|
|
133
|
+
print(f"Resuming session {resume_session.id} ({len(resume_session.messages)} messages)")
|
|
134
|
+
|
|
135
|
+
# Textual fullscreen TUI (default and only UI mode)
|
|
136
|
+
from llm_code.tui.app import LLMCodeTUI
|
|
137
|
+
app = LLMCodeTUI(config=config, cwd=cwd, budget=budget)
|
|
138
|
+
app.run()
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
_OLLAMA_DEFAULT_URL = "http://localhost:11434"
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _run_ollama_setup(
|
|
145
|
+
api_override: str | None = None,
|
|
146
|
+
model_override: str | None = None,
|
|
147
|
+
) -> tuple[str, str] | None:
|
|
148
|
+
"""Probe Ollama, optionally select model. Returns (model, base_url) or None."""
|
|
149
|
+
import asyncio as _asyncio
|
|
150
|
+
|
|
151
|
+
base_url = api_override or _OLLAMA_DEFAULT_URL
|
|
152
|
+
|
|
153
|
+
async def _setup() -> tuple[str, str] | None:
|
|
154
|
+
from llm_code.runtime.ollama import OllamaClient, sort_models_for_selection
|
|
155
|
+
from llm_code.runtime.hardware import detect_vram_gb
|
|
156
|
+
|
|
157
|
+
client = OllamaClient(base_url=base_url)
|
|
158
|
+
try:
|
|
159
|
+
if not await client.probe():
|
|
160
|
+
return None
|
|
161
|
+
|
|
162
|
+
if model_override:
|
|
163
|
+
return (model_override, f"{base_url}/v1")
|
|
164
|
+
|
|
165
|
+
models = await client.list_models()
|
|
166
|
+
if not models:
|
|
167
|
+
click.echo("No models found in Ollama. Download one first:", err=True)
|
|
168
|
+
click.echo(" ollama pull qwen3:1.7b", err=True)
|
|
169
|
+
return None
|
|
170
|
+
|
|
171
|
+
if len(models) == 1:
|
|
172
|
+
click.echo(f"Using Ollama model: {models[0].name}")
|
|
173
|
+
return (models[0].name, f"{base_url}/v1")
|
|
174
|
+
|
|
175
|
+
vram_gb = detect_vram_gb()
|
|
176
|
+
sorted_models = sort_models_for_selection(models, vram_gb)
|
|
177
|
+
output = _format_model_list(sorted_models, vram_gb)
|
|
178
|
+
click.echo(output)
|
|
179
|
+
|
|
180
|
+
choice = click.prompt("Select model", default="1")
|
|
181
|
+
try:
|
|
182
|
+
idx = int(choice) - 1
|
|
183
|
+
if 0 <= idx < len(sorted_models):
|
|
184
|
+
selected = sorted_models[idx]
|
|
185
|
+
else:
|
|
186
|
+
selected = sorted_models[0]
|
|
187
|
+
except ValueError:
|
|
188
|
+
selected = sorted_models[0]
|
|
189
|
+
|
|
190
|
+
click.echo(f"Using: {selected.name}")
|
|
191
|
+
return (selected.name, f"{base_url}/v1")
|
|
192
|
+
finally:
|
|
193
|
+
await client.close()
|
|
194
|
+
|
|
195
|
+
return _asyncio.run(_setup())
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _format_model_list(
|
|
199
|
+
models: list,
|
|
200
|
+
vram_gb: float | None,
|
|
201
|
+
) -> str:
|
|
202
|
+
"""Format models as a numbered list with VRAM annotations."""
|
|
203
|
+
lines = ["\nAvailable Ollama models:\n"]
|
|
204
|
+
|
|
205
|
+
for i, model in enumerate(models, 1):
|
|
206
|
+
size_str = f"~{model.estimated_vram_gb:.0f}GB"
|
|
207
|
+
prefix = " "
|
|
208
|
+
suffix = ""
|
|
209
|
+
|
|
210
|
+
if vram_gb is not None:
|
|
211
|
+
if model.is_recommended(vram_gb):
|
|
212
|
+
prefix = "★ "
|
|
213
|
+
suffix = " Recommended"
|
|
214
|
+
elif not model.fits_in_vram(vram_gb):
|
|
215
|
+
suffix = " ⚠️ May exceed available VRAM"
|
|
216
|
+
|
|
217
|
+
lines.append(f" {prefix}{i}) {model.name:<20s} ({size_str}){suffix}")
|
|
218
|
+
|
|
219
|
+
lines.append("")
|
|
220
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Computer use — GUI automation for llm-code."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def is_available() -> bool:
|
|
5
|
+
"""Return True if pyautogui and Pillow are importable."""
|
|
6
|
+
try:
|
|
7
|
+
import pyautogui # noqa: F401
|
|
8
|
+
import PIL # noqa: F401
|
|
9
|
+
return True
|
|
10
|
+
except ImportError:
|
|
11
|
+
return False
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Detect the frontmost application on macOS."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import subprocess
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class AppInfo:
|
|
11
|
+
"""Information about a running application."""
|
|
12
|
+
name: str
|
|
13
|
+
bundle_id: str
|
|
14
|
+
pid: int
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _get_via_osascript() -> AppInfo:
|
|
18
|
+
"""Use osascript to get frontmost app info."""
|
|
19
|
+
script = (
|
|
20
|
+
'tell application "System Events" to '
|
|
21
|
+
'set fp to first process whose frontmost is true\n'
|
|
22
|
+
'set n to name of fp\n'
|
|
23
|
+
'set b to bundle identifier of fp\n'
|
|
24
|
+
'set p to unix id of fp\n'
|
|
25
|
+
'return n & "|" & b & "|" & (p as text)'
|
|
26
|
+
)
|
|
27
|
+
result = subprocess.run(
|
|
28
|
+
["osascript", "-e", script],
|
|
29
|
+
capture_output=True, text=True, timeout=5,
|
|
30
|
+
)
|
|
31
|
+
if result.returncode != 0:
|
|
32
|
+
raise RuntimeError(f"osascript failed: {result.stderr}")
|
|
33
|
+
parts = result.stdout.strip().split("|")
|
|
34
|
+
if len(parts) < 3:
|
|
35
|
+
raise RuntimeError(f"Unexpected osascript output: {result.stdout}")
|
|
36
|
+
return AppInfo(name=parts[0], bundle_id=parts[1], pid=int(parts[2]))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def get_frontmost_app_sync() -> AppInfo:
|
|
40
|
+
"""Get frontmost app, with fallback to Unknown on any error."""
|
|
41
|
+
try:
|
|
42
|
+
return _get_via_osascript()
|
|
43
|
+
except Exception:
|
|
44
|
+
return AppInfo(name="Unknown", bundle_id="", pid=0)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
async def get_frontmost_app() -> AppInfo:
|
|
48
|
+
"""Async wrapper for get_frontmost_app_sync."""
|
|
49
|
+
return await asyncio.to_thread(get_frontmost_app_sync)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""App-aware tier classification and permission enforcement for computer use."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import fnmatch
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from llm_code.computer_use.app_detect import AppInfo
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class AppTierRule:
|
|
12
|
+
"""Maps a bundle_id glob pattern to a tier."""
|
|
13
|
+
pattern: str
|
|
14
|
+
tier: str
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
DEFAULT_RULES: tuple[AppTierRule, ...] = (
|
|
18
|
+
AppTierRule("com.apple.Safari*", "read"),
|
|
19
|
+
AppTierRule("com.google.Chrome*", "read"),
|
|
20
|
+
AppTierRule("org.mozilla.firefox*", "read"),
|
|
21
|
+
AppTierRule("company.thebrowser.Browser*", "read"),
|
|
22
|
+
AppTierRule("com.microsoft.edgemac*", "read"),
|
|
23
|
+
AppTierRule("com.apple.Terminal*", "click"),
|
|
24
|
+
AppTierRule("com.googlecode.iterm2*", "click"),
|
|
25
|
+
AppTierRule("com.microsoft.VSCode*", "click"),
|
|
26
|
+
AppTierRule("com.jetbrains.*", "click"),
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
TIER_PERMISSIONS: dict[str, frozenset[str]] = {
|
|
31
|
+
"read": frozenset({"screenshot", "get_frontmost_app"}),
|
|
32
|
+
"click": frozenset({"screenshot", "get_frontmost_app", "left_click", "scroll"}),
|
|
33
|
+
"full": frozenset({
|
|
34
|
+
"screenshot", "get_frontmost_app", "left_click", "right_click",
|
|
35
|
+
"double_click", "drag", "scroll", "type", "key", "hotkey",
|
|
36
|
+
}),
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class AppTierDenied(Exception):
|
|
41
|
+
def __init__(self, app: str, tier: str, action: str, hint: str = "") -> None:
|
|
42
|
+
self.app = app
|
|
43
|
+
self.tier = tier
|
|
44
|
+
self.action = action
|
|
45
|
+
self.hint = hint
|
|
46
|
+
super().__init__(f"Action '{action}' denied for app '{app}' (tier='{tier}'). {hint}")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class AppTierClassifier:
|
|
51
|
+
rules: tuple[AppTierRule, ...]
|
|
52
|
+
|
|
53
|
+
def classify(self, app: AppInfo) -> str:
|
|
54
|
+
for rule in self.rules:
|
|
55
|
+
if fnmatch.fnmatch(app.bundle_id, rule.pattern):
|
|
56
|
+
return rule.tier
|
|
57
|
+
return "full"
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Coordinator that composes screenshot + input for tool actions."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import time
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from llm_code.runtime.config import ComputerUseConfig
|
|
9
|
+
|
|
10
|
+
from llm_code.computer_use.app_detect import get_frontmost_app_sync
|
|
11
|
+
from llm_code.computer_use.app_tier import (
|
|
12
|
+
DEFAULT_RULES,
|
|
13
|
+
TIER_PERMISSIONS,
|
|
14
|
+
AppTierClassifier,
|
|
15
|
+
AppTierDenied,
|
|
16
|
+
AppTierRule,
|
|
17
|
+
)
|
|
18
|
+
from llm_code.computer_use.input_control import (
|
|
19
|
+
keyboard_hotkey,
|
|
20
|
+
keyboard_type,
|
|
21
|
+
mouse_click,
|
|
22
|
+
mouse_drag,
|
|
23
|
+
scroll,
|
|
24
|
+
)
|
|
25
|
+
from llm_code.computer_use.screenshot import take_screenshot_base64
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ComputerUseCoordinator:
|
|
29
|
+
"""Orchestrates GUI actions with follow-up screenshots and app-aware tier enforcement."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, config: "ComputerUseConfig") -> None:
|
|
32
|
+
self._config = config
|
|
33
|
+
user_rules = tuple(
|
|
34
|
+
AppTierRule(pattern=r["pattern"], tier=r["tier"])
|
|
35
|
+
for r in self._config.app_tiers
|
|
36
|
+
if isinstance(r, dict) and "pattern" in r and "tier" in r
|
|
37
|
+
)
|
|
38
|
+
self._classifier = AppTierClassifier(rules=user_rules + DEFAULT_RULES)
|
|
39
|
+
|
|
40
|
+
def _ensure_enabled(self) -> None:
|
|
41
|
+
if not self._config.enabled:
|
|
42
|
+
raise RuntimeError("Computer use is not enabled. Set computer_use.enabled=true in config.")
|
|
43
|
+
|
|
44
|
+
def _check_tier(self, action: str) -> None:
|
|
45
|
+
app = get_frontmost_app_sync()
|
|
46
|
+
tier = self._classifier.classify(app)
|
|
47
|
+
if action not in TIER_PERMISSIONS[tier]:
|
|
48
|
+
hint = ""
|
|
49
|
+
if tier == "read":
|
|
50
|
+
hint = "Use MCP browser tools (chrome-devtools) instead."
|
|
51
|
+
elif tier == "click" and action in ("type", "key", "hotkey"):
|
|
52
|
+
hint = "Use the Bash tool instead for terminal input."
|
|
53
|
+
raise AppTierDenied(app=app.name, tier=tier, action=action, hint=hint)
|
|
54
|
+
|
|
55
|
+
def _delay_then_screenshot(self) -> str:
|
|
56
|
+
if self._config.screenshot_delay > 0:
|
|
57
|
+
time.sleep(self._config.screenshot_delay)
|
|
58
|
+
return take_screenshot_base64()
|
|
59
|
+
|
|
60
|
+
def screenshot(self) -> dict:
|
|
61
|
+
self._ensure_enabled()
|
|
62
|
+
self._check_tier("screenshot")
|
|
63
|
+
img = self._delay_then_screenshot()
|
|
64
|
+
return {"screenshot_base64": img}
|
|
65
|
+
|
|
66
|
+
def click_and_observe(self, x: int, y: int, button: str = "left") -> dict:
|
|
67
|
+
self._ensure_enabled()
|
|
68
|
+
self._check_tier("left_click")
|
|
69
|
+
mouse_click(x, y, button=button)
|
|
70
|
+
img = self._delay_then_screenshot()
|
|
71
|
+
return {"action": "click", "x": x, "y": y, "button": button, "screenshot_base64": img}
|
|
72
|
+
|
|
73
|
+
def type_and_observe(self, text: str) -> dict:
|
|
74
|
+
self._ensure_enabled()
|
|
75
|
+
self._check_tier("type")
|
|
76
|
+
keyboard_type(text)
|
|
77
|
+
img = self._delay_then_screenshot()
|
|
78
|
+
return {"action": "type", "text": text, "screenshot_base64": img}
|
|
79
|
+
|
|
80
|
+
def hotkey_and_observe(self, *keys: str) -> dict:
|
|
81
|
+
self._ensure_enabled()
|
|
82
|
+
self._check_tier("hotkey")
|
|
83
|
+
keyboard_hotkey(*keys)
|
|
84
|
+
img = self._delay_then_screenshot()
|
|
85
|
+
return {"action": "hotkey", "keys": list(keys), "screenshot_base64": img}
|
|
86
|
+
|
|
87
|
+
def scroll_and_observe(self, clicks: int, x: int | None = None, y: int | None = None) -> dict:
|
|
88
|
+
self._ensure_enabled()
|
|
89
|
+
self._check_tier("scroll")
|
|
90
|
+
scroll(clicks, x=x, y=y)
|
|
91
|
+
img = self._delay_then_screenshot()
|
|
92
|
+
return {"action": "scroll", "clicks": clicks, "screenshot_base64": img}
|
|
93
|
+
|
|
94
|
+
def drag_and_observe(self, start_x: int, start_y: int, offset_x: int, offset_y: int, duration: float = 0.5) -> dict:
|
|
95
|
+
self._ensure_enabled()
|
|
96
|
+
self._check_tier("drag")
|
|
97
|
+
mouse_drag(start_x, start_y, offset_x, offset_y, duration=duration)
|
|
98
|
+
img = self._delay_then_screenshot()
|
|
99
|
+
return {"action": "drag", "start_x": start_x, "start_y": start_y, "offset_x": offset_x, "offset_y": offset_y, "screenshot_base64": img}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Mouse and keyboard control via pyautogui (lazy import)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
_DEFAULT_DELAY = 0.05 # 50ms between actions
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _get_pyautogui():
|
|
8
|
+
"""Lazy import pyautogui with clear error on missing dep."""
|
|
9
|
+
try:
|
|
10
|
+
import pyautogui
|
|
11
|
+
return pyautogui
|
|
12
|
+
except ImportError as exc:
|
|
13
|
+
raise RuntimeError(
|
|
14
|
+
"pyautogui is required for input control. "
|
|
15
|
+
"Install with: pip install llm-code[computer-use]"
|
|
16
|
+
) from exc
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def mouse_move(x: int, y: int) -> None:
|
|
20
|
+
"""Move mouse cursor to (x, y)."""
|
|
21
|
+
pag = _get_pyautogui()
|
|
22
|
+
pag.moveTo(x, y, duration=_DEFAULT_DELAY)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def mouse_click(x: int, y: int, button: str = "left") -> None:
|
|
26
|
+
"""Click at (x, y) with the given button."""
|
|
27
|
+
pag = _get_pyautogui()
|
|
28
|
+
pag.click(x, y, button=button)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def mouse_double_click(x: int, y: int) -> None:
|
|
32
|
+
"""Double-click at (x, y)."""
|
|
33
|
+
pag = _get_pyautogui()
|
|
34
|
+
pag.doubleClick(x, y)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def mouse_drag(
|
|
38
|
+
start_x: int,
|
|
39
|
+
start_y: int,
|
|
40
|
+
offset_x: int,
|
|
41
|
+
offset_y: int,
|
|
42
|
+
duration: float = 0.5,
|
|
43
|
+
button: str = "left",
|
|
44
|
+
) -> None:
|
|
45
|
+
"""Drag from (start_x, start_y) by (offset_x, offset_y)."""
|
|
46
|
+
pag = _get_pyautogui()
|
|
47
|
+
pag.moveTo(start_x, start_y, duration=_DEFAULT_DELAY)
|
|
48
|
+
pag.drag(offset_x, offset_y, duration=duration, button=button)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def keyboard_type(text: str) -> None:
|
|
52
|
+
"""Type the given text string character by character."""
|
|
53
|
+
pag = _get_pyautogui()
|
|
54
|
+
pag.typewrite(text, interval=_DEFAULT_DELAY)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def keyboard_hotkey(*keys: str) -> None:
|
|
58
|
+
"""Press a keyboard shortcut (e.g., keyboard_hotkey('ctrl', 'c'))."""
|
|
59
|
+
pag = _get_pyautogui()
|
|
60
|
+
pag.hotkey(*keys)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def scroll(clicks: int, x: int | None = None, y: int | None = None) -> None:
|
|
64
|
+
"""Scroll the mouse wheel. Positive = up, negative = down."""
|
|
65
|
+
pag = _get_pyautogui()
|
|
66
|
+
kwargs: dict = {}
|
|
67
|
+
if x is not None:
|
|
68
|
+
kwargs["x"] = x
|
|
69
|
+
if y is not None:
|
|
70
|
+
kwargs["y"] = y
|
|
71
|
+
pag.scroll(clicks, **kwargs)
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Cross-platform screenshot capture."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import base64
|
|
5
|
+
import platform
|
|
6
|
+
import subprocess
|
|
7
|
+
import tempfile
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Tuple
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def take_screenshot(region: Tuple[int, int, int, int] | None = None) -> bytes:
|
|
13
|
+
"""Capture the screen and return raw PNG bytes.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
region: Optional (x, y, width, height) crop region.
|
|
17
|
+
|
|
18
|
+
Returns:
|
|
19
|
+
PNG image bytes.
|
|
20
|
+
|
|
21
|
+
Platform strategy:
|
|
22
|
+
macOS -> screencapture CLI
|
|
23
|
+
Linux -> scrot CLI
|
|
24
|
+
Windows -> mss library (lazy import)
|
|
25
|
+
"""
|
|
26
|
+
system = platform.system()
|
|
27
|
+
|
|
28
|
+
if system == "Darwin":
|
|
29
|
+
return _capture_macos(region)
|
|
30
|
+
elif system == "Linux":
|
|
31
|
+
return _capture_linux(region)
|
|
32
|
+
elif system == "Windows":
|
|
33
|
+
return _capture_windows(region)
|
|
34
|
+
else:
|
|
35
|
+
raise RuntimeError(f"Unsupported platform for screenshots: {system}")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def take_screenshot_base64(region: Tuple[int, int, int, int] | None = None) -> str:
|
|
39
|
+
"""Capture screen and return as a base64-encoded string."""
|
|
40
|
+
raw = take_screenshot(region)
|
|
41
|
+
return base64.b64encode(raw).decode("ascii")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _capture_macos(region: Tuple[int, int, int, int] | None) -> bytes:
|
|
45
|
+
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp:
|
|
46
|
+
tmp_path = tmp.name
|
|
47
|
+
|
|
48
|
+
cmd = ["screencapture", "-x"]
|
|
49
|
+
if region:
|
|
50
|
+
x, y, w, h = region
|
|
51
|
+
cmd.extend(["-R", f"{x},{y},{w},{h}"])
|
|
52
|
+
cmd.append(tmp_path)
|
|
53
|
+
|
|
54
|
+
subprocess.run(cmd, check=True, timeout=10)
|
|
55
|
+
data = Path(tmp_path).read_bytes()
|
|
56
|
+
Path(tmp_path).unlink(missing_ok=True)
|
|
57
|
+
return data
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _capture_linux(region: Tuple[int, int, int, int] | None) -> bytes:
|
|
61
|
+
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp:
|
|
62
|
+
tmp_path = tmp.name
|
|
63
|
+
|
|
64
|
+
cmd = ["scrot"]
|
|
65
|
+
if region:
|
|
66
|
+
x, y, w, h = region
|
|
67
|
+
cmd.extend(["-a", f"{x},{y},{w},{h}"])
|
|
68
|
+
cmd.append(tmp_path)
|
|
69
|
+
|
|
70
|
+
subprocess.run(cmd, check=True, timeout=10)
|
|
71
|
+
data = Path(tmp_path).read_bytes()
|
|
72
|
+
Path(tmp_path).unlink(missing_ok=True)
|
|
73
|
+
return data
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _capture_windows(region: Tuple[int, int, int, int] | None) -> bytes:
|
|
77
|
+
try:
|
|
78
|
+
import mss
|
|
79
|
+
import mss.tools
|
|
80
|
+
except ImportError as exc:
|
|
81
|
+
raise RuntimeError(
|
|
82
|
+
"mss is required for Windows screenshots. "
|
|
83
|
+
"Install with: pip install llm-code[computer-use]"
|
|
84
|
+
) from exc
|
|
85
|
+
|
|
86
|
+
with mss.mss() as sct:
|
|
87
|
+
if region:
|
|
88
|
+
x, y, w, h = region
|
|
89
|
+
monitor = {"top": y, "left": x, "width": w, "height": h}
|
|
90
|
+
else:
|
|
91
|
+
monitor = sct.monitors[1] # Primary monitor
|
|
92
|
+
img = sct.grab(monitor)
|
|
93
|
+
return mss.tools.to_png(img.rgb, img.size)
|