itamar-agentcli 0.1.0__tar.gz
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.
- itamar_agentcli-0.1.0/PKG-INFO +9 -0
- itamar_agentcli-0.1.0/itamar_agentcli.egg-info/PKG-INFO +9 -0
- itamar_agentcli-0.1.0/itamar_agentcli.egg-info/SOURCES.txt +24 -0
- itamar_agentcli-0.1.0/itamar_agentcli.egg-info/dependency_links.txt +1 -0
- itamar_agentcli-0.1.0/itamar_agentcli.egg-info/entry_points.txt +2 -0
- itamar_agentcli-0.1.0/itamar_agentcli.egg-info/requires.txt +4 -0
- itamar_agentcli-0.1.0/itamar_agentcli.egg-info/top_level.txt +1 -0
- itamar_agentcli-0.1.0/localagent/__init__.py +0 -0
- itamar_agentcli-0.1.0/localagent/agent/__init__.py +0 -0
- itamar_agentcli-0.1.0/localagent/agent/planner.py +0 -0
- itamar_agentcli-0.1.0/localagent/agent_parser.py +22 -0
- itamar_agentcli-0.1.0/localagent/cli.py +36 -0
- itamar_agentcli-0.1.0/localagent/core/__init__.py +0 -0
- itamar_agentcli-0.1.0/localagent/core/context.py +0 -0
- itamar_agentcli-0.1.0/localagent/core/errors.py +0 -0
- itamar_agentcli-0.1.0/localagent/core/models.py +0 -0
- itamar_agentcli-0.1.0/localagent/gateway/__init__.py +0 -0
- itamar_agentcli-0.1.0/localagent/gateway/openai_compat.py +70 -0
- itamar_agentcli-0.1.0/localagent/safety/__init__.py +0 -0
- itamar_agentcli-0.1.0/localagent/safety/approval.py +75 -0
- itamar_agentcli-0.1.0/localagent/tools/__init__.py +0 -0
- itamar_agentcli-0.1.0/localagent/tools/exec.py +152 -0
- itamar_agentcli-0.1.0/localagent/tools/files.py +0 -0
- itamar_agentcli-0.1.0/localagent/tools/python_runner.py +0 -0
- itamar_agentcli-0.1.0/pyproject.toml +18 -0
- itamar_agentcli-0.1.0/setup.cfg +4 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
pyproject.toml
|
|
2
|
+
itamar_agentcli.egg-info/PKG-INFO
|
|
3
|
+
itamar_agentcli.egg-info/SOURCES.txt
|
|
4
|
+
itamar_agentcli.egg-info/dependency_links.txt
|
|
5
|
+
itamar_agentcli.egg-info/entry_points.txt
|
|
6
|
+
itamar_agentcli.egg-info/requires.txt
|
|
7
|
+
itamar_agentcli.egg-info/top_level.txt
|
|
8
|
+
localagent/__init__.py
|
|
9
|
+
localagent/agent_parser.py
|
|
10
|
+
localagent/cli.py
|
|
11
|
+
localagent/agent/__init__.py
|
|
12
|
+
localagent/agent/planner.py
|
|
13
|
+
localagent/core/__init__.py
|
|
14
|
+
localagent/core/context.py
|
|
15
|
+
localagent/core/errors.py
|
|
16
|
+
localagent/core/models.py
|
|
17
|
+
localagent/gateway/__init__.py
|
|
18
|
+
localagent/gateway/openai_compat.py
|
|
19
|
+
localagent/safety/__init__.py
|
|
20
|
+
localagent/safety/approval.py
|
|
21
|
+
localagent/tools/__init__.py
|
|
22
|
+
localagent/tools/exec.py
|
|
23
|
+
localagent/tools/files.py
|
|
24
|
+
localagent/tools/python_runner.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
localagent
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
def parse_command(text: str) -> str | None:
|
|
4
|
+
text = text.lower()
|
|
5
|
+
|
|
6
|
+
# install packages
|
|
7
|
+
if "nmap" in text:
|
|
8
|
+
return "sudo dnf install -y nmap"
|
|
9
|
+
|
|
10
|
+
if "ffmpeg" in text:
|
|
11
|
+
return "sudo dnf install -y ffmpeg --allowerasing"
|
|
12
|
+
|
|
13
|
+
if "yt-dlp" in text or "youtube" in text:
|
|
14
|
+
return "sudo dnf install -y yt-dlp"
|
|
15
|
+
|
|
16
|
+
if "update" in text or "עדכן" in text:
|
|
17
|
+
return "sudo dnf update -y"
|
|
18
|
+
|
|
19
|
+
if "נקה" in text or "clean" in text:
|
|
20
|
+
return "sudo dnf autoremove -y"
|
|
21
|
+
|
|
22
|
+
return None
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
|
|
6
|
+
from localagent.tools.exec import CommandExecutor
|
|
7
|
+
|
|
8
|
+
console = Console()
|
|
9
|
+
app = typer.Typer(add_completion=False)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@app.command()
|
|
13
|
+
def main(
|
|
14
|
+
command: str = typer.Argument(
|
|
15
|
+
...,
|
|
16
|
+
help='Command to run. Quote it if it contains spaces, e.g. "ls -la"',
|
|
17
|
+
),
|
|
18
|
+
yes: bool = typer.Option(
|
|
19
|
+
False,
|
|
20
|
+
"--yes",
|
|
21
|
+
help="Auto-approve ONLY whitelisted dangerous commands (dnf install/update/upgrade/remove).",
|
|
22
|
+
),
|
|
23
|
+
stream: bool = typer.Option(
|
|
24
|
+
False,
|
|
25
|
+
"--stream",
|
|
26
|
+
help="Stream stdout live while running.",
|
|
27
|
+
),
|
|
28
|
+
):
|
|
29
|
+
"""LocalAgent command runner (with approvals)."""
|
|
30
|
+
executor = CommandExecutor(console=console, auto_yes=yes)
|
|
31
|
+
result = executor.run(command=command, require_approval=True, stream=stream)
|
|
32
|
+
raise typer.Exit(code=result.exit_code)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
if __name__ == "__main__":
|
|
36
|
+
app()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, List, Optional
|
|
4
|
+
import requests
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ChatMessage(BaseModel):
|
|
9
|
+
role: str # "system" | "user" | "assistant" | "tool"
|
|
10
|
+
content: str
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class GatewayResponse(BaseModel):
|
|
14
|
+
content: str
|
|
15
|
+
raw: Dict[str, Any] = Field(default_factory=dict)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class OpenAICompatGateway:
|
|
19
|
+
"""
|
|
20
|
+
Minimal OpenAI-compatible Chat Completions gateway.
|
|
21
|
+
Intended for local LLM servers (Ollama OpenAI compatibility, vLLM, etc.).
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
base_url: str = "http://localhost:11434/v1",
|
|
27
|
+
api_key: str = "ollama", # often ignored by local servers
|
|
28
|
+
model: str = "llama3.1",
|
|
29
|
+
timeout_s: int = 120,
|
|
30
|
+
) -> None:
|
|
31
|
+
self.base_url = base_url.rstrip("/")
|
|
32
|
+
self.api_key = api_key
|
|
33
|
+
self.model = model
|
|
34
|
+
self.timeout_s = timeout_s
|
|
35
|
+
|
|
36
|
+
def chat(
|
|
37
|
+
self,
|
|
38
|
+
messages: List[ChatMessage],
|
|
39
|
+
temperature: float = 0.2,
|
|
40
|
+
max_tokens: Optional[int] = None,
|
|
41
|
+
extra: Optional[Dict[str, Any]] = None,
|
|
42
|
+
) -> GatewayResponse:
|
|
43
|
+
url = f"{self.base_url}/chat/completions"
|
|
44
|
+
payload: Dict[str, Any] = {
|
|
45
|
+
"model": self.model,
|
|
46
|
+
"messages": [m.model_dump() for m in messages],
|
|
47
|
+
"temperature": temperature,
|
|
48
|
+
}
|
|
49
|
+
if max_tokens is not None:
|
|
50
|
+
payload["max_tokens"] = max_tokens
|
|
51
|
+
if extra:
|
|
52
|
+
payload.update(extra)
|
|
53
|
+
|
|
54
|
+
headers = {
|
|
55
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
56
|
+
"Content-Type": "application/json",
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
r = requests.post(url, json=payload, headers=headers, timeout=self.timeout_s)
|
|
60
|
+
r.raise_for_status()
|
|
61
|
+
data = r.json()
|
|
62
|
+
|
|
63
|
+
# Standard OpenAI format: choices[0].message.content
|
|
64
|
+
content = (
|
|
65
|
+
data.get("choices", [{}])[0]
|
|
66
|
+
.get("message", {})
|
|
67
|
+
.get("content", "")
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
return GatewayResponse(content=content, raw=data)
|
|
File without changes
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Optional, Sequence
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class ApprovalDecision:
|
|
10
|
+
approved: bool
|
|
11
|
+
reason: str
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ApprovalPolicy:
|
|
15
|
+
"""
|
|
16
|
+
Heuristic-based approval for shell commands + a small whitelist mechanism
|
|
17
|
+
for auto-approval (e.g., dnf installs) when --yes is used.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
DEFAULT_DANGEROUS_PATTERNS: Sequence[str] = (
|
|
21
|
+
r"\brm\b.*\s-rf\b",
|
|
22
|
+
r"\brm\b.*\s--no-preserve-root\b",
|
|
23
|
+
r"\bdd\b",
|
|
24
|
+
r"\bmkfs\.",
|
|
25
|
+
r"\bchmod\b\s+777\b",
|
|
26
|
+
r"\bchown\b.*\s-R\b",
|
|
27
|
+
r"\bshutdown\b|\breboot\b",
|
|
28
|
+
r"\bsystemctl\b\s+(stop|disable|mask)\b",
|
|
29
|
+
r"\bdnf\b\s+(install|remove|upgrade|update)\b",
|
|
30
|
+
r"\byum\b\s+(install|remove|update|upgrade)\b",
|
|
31
|
+
r"\bpip\b\s+install\b",
|
|
32
|
+
r"\bcurl\b.*\|\s*(sh|bash)\b",
|
|
33
|
+
r"\bwget\b.*\|\s*(sh|bash)\b",
|
|
34
|
+
r"\bchmod\b.*\+x\b.*\.\w+",
|
|
35
|
+
r"\bsudo\b",
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
def __init__(self, extra_patterns: Optional[Sequence[str]] = None) -> None:
|
|
39
|
+
patterns = list(self.DEFAULT_DANGEROUS_PATTERNS)
|
|
40
|
+
if extra_patterns:
|
|
41
|
+
patterns.extend(extra_patterns)
|
|
42
|
+
self._compiled = [re.compile(p, re.IGNORECASE) for p in patterns]
|
|
43
|
+
|
|
44
|
+
def requires_approval(self, command: str) -> Optional[str]:
|
|
45
|
+
cmd = command.strip()
|
|
46
|
+
for rx in self._compiled:
|
|
47
|
+
if rx.search(cmd):
|
|
48
|
+
return f"Matched risky pattern: {rx.pattern}"
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
def is_autoapprove_whitelisted(self, command: str) -> bool:
|
|
52
|
+
"""
|
|
53
|
+
Commands allowed for auto-approval when --yes is used.
|
|
54
|
+
Keep this list tight and explicit.
|
|
55
|
+
"""
|
|
56
|
+
cmd = command.strip().lower()
|
|
57
|
+
|
|
58
|
+
# Allow these admin package operations to auto-approve
|
|
59
|
+
whitelist_prefixes = (
|
|
60
|
+
"sudo dnf install",
|
|
61
|
+
"sudo dnf update",
|
|
62
|
+
"sudo dnf upgrade",
|
|
63
|
+
"sudo dnf remove",
|
|
64
|
+
"dnf install",
|
|
65
|
+
"dnf update",
|
|
66
|
+
"dnf upgrade",
|
|
67
|
+
"dnf remove",
|
|
68
|
+
"sudo dnf autoremove",
|
|
69
|
+
"dnf autoremove",
|
|
70
|
+
"sudo dnf clean",
|
|
71
|
+
"dnf clean",
|
|
72
|
+
"sudo dnf makecache",
|
|
73
|
+
"dnf makecache",
|
|
74
|
+
)
|
|
75
|
+
return cmd.startswith(whitelist_prefixes)
|
|
File without changes
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import shlex
|
|
5
|
+
import subprocess
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Dict, List, Optional
|
|
8
|
+
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.panel import Panel
|
|
11
|
+
from rich.prompt import Confirm
|
|
12
|
+
|
|
13
|
+
from localagent.safety.approval import ApprovalPolicy
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class CommandResult:
|
|
18
|
+
command: str
|
|
19
|
+
cwd: str
|
|
20
|
+
exit_code: int
|
|
21
|
+
stdout: str
|
|
22
|
+
stderr: str
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class CommandExecutor:
|
|
26
|
+
"""
|
|
27
|
+
Executes shell commands with human-in-the-loop approval for risky commands.
|
|
28
|
+
If auto_yes=True, will auto-approve ONLY whitelisted risky commands.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
approval: Optional[ApprovalPolicy] = None,
|
|
34
|
+
console: Optional[Console] = None,
|
|
35
|
+
default_cwd: Optional[str] = None,
|
|
36
|
+
env_overrides: Optional[Dict[str, str]] = None,
|
|
37
|
+
auto_yes: bool = False,
|
|
38
|
+
) -> None:
|
|
39
|
+
self.approval = approval or ApprovalPolicy()
|
|
40
|
+
self.console = console or Console()
|
|
41
|
+
self.default_cwd = default_cwd or os.getcwd()
|
|
42
|
+
self.env_overrides = env_overrides or {}
|
|
43
|
+
self.auto_yes = auto_yes
|
|
44
|
+
|
|
45
|
+
def run(
|
|
46
|
+
self,
|
|
47
|
+
command: str,
|
|
48
|
+
cwd: Optional[str] = None,
|
|
49
|
+
timeout_s: int = 600,
|
|
50
|
+
check: bool = False,
|
|
51
|
+
require_approval: bool = True,
|
|
52
|
+
stream: bool = False,
|
|
53
|
+
) -> CommandResult:
|
|
54
|
+
cwd_final = cwd or self.default_cwd
|
|
55
|
+
|
|
56
|
+
# Approval step
|
|
57
|
+
if require_approval:
|
|
58
|
+
reason = self.approval.requires_approval(command)
|
|
59
|
+
if reason:
|
|
60
|
+
self.console.print(
|
|
61
|
+
Panel.fit(
|
|
62
|
+
f"[bold yellow]Approval required[/bold yellow]\n"
|
|
63
|
+
f"[bold]Reason:[/bold] {reason}\n\n"
|
|
64
|
+
f"[bold]Command:[/bold]\n{command}\n\n"
|
|
65
|
+
f"[dim]CWD:[/dim] {cwd_final}",
|
|
66
|
+
title="Dangerous Command Detected",
|
|
67
|
+
)
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
# Auto-approve only whitelisted commands when --yes is enabled
|
|
71
|
+
if self.auto_yes and self.approval.is_autoapprove_whitelisted(command):
|
|
72
|
+
self.console.print(
|
|
73
|
+
"[bold yellow]Auto-approval enabled (--yes + whitelist). Proceeding...[/bold yellow]"
|
|
74
|
+
)
|
|
75
|
+
else:
|
|
76
|
+
if not Confirm.ask("Execute this command?", default=False):
|
|
77
|
+
return CommandResult(
|
|
78
|
+
command=command,
|
|
79
|
+
cwd=cwd_final,
|
|
80
|
+
exit_code=130,
|
|
81
|
+
stdout="",
|
|
82
|
+
stderr="User rejected command execution.",
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
env = os.environ.copy()
|
|
86
|
+
env.update(self.env_overrides)
|
|
87
|
+
|
|
88
|
+
self.console.print(
|
|
89
|
+
Panel.fit(
|
|
90
|
+
f"[bold cyan]$[/bold cyan] {command}\n[dim]cwd:[/dim] {cwd_final}",
|
|
91
|
+
title="Executing",
|
|
92
|
+
)
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
args: List[str] = shlex.split(command)
|
|
96
|
+
|
|
97
|
+
if stream:
|
|
98
|
+
proc = subprocess.Popen(
|
|
99
|
+
args,
|
|
100
|
+
cwd=cwd_final,
|
|
101
|
+
env=env,
|
|
102
|
+
stdout=subprocess.PIPE,
|
|
103
|
+
stderr=subprocess.PIPE,
|
|
104
|
+
text=True,
|
|
105
|
+
bufsize=1,
|
|
106
|
+
)
|
|
107
|
+
out_lines: List[str] = []
|
|
108
|
+
err_lines: List[str] = []
|
|
109
|
+
|
|
110
|
+
assert proc.stdout is not None
|
|
111
|
+
assert proc.stderr is not None
|
|
112
|
+
|
|
113
|
+
for line in proc.stdout:
|
|
114
|
+
out_lines.append(line)
|
|
115
|
+
self.console.print(line.rstrip("\n"))
|
|
116
|
+
|
|
117
|
+
err_text = proc.stderr.read()
|
|
118
|
+
if err_text:
|
|
119
|
+
err_lines.append(err_text)
|
|
120
|
+
self.console.print(err_text, style="bold red")
|
|
121
|
+
|
|
122
|
+
exit_code = proc.wait(timeout=timeout_s)
|
|
123
|
+
stdout = "".join(out_lines)
|
|
124
|
+
stderr = "".join(err_lines)
|
|
125
|
+
else:
|
|
126
|
+
completed = subprocess.run(
|
|
127
|
+
args,
|
|
128
|
+
cwd=cwd_final,
|
|
129
|
+
env=env,
|
|
130
|
+
capture_output=True,
|
|
131
|
+
text=True,
|
|
132
|
+
timeout=timeout_s,
|
|
133
|
+
)
|
|
134
|
+
exit_code = completed.returncode
|
|
135
|
+
stdout = completed.stdout or ""
|
|
136
|
+
stderr = completed.stderr or ""
|
|
137
|
+
|
|
138
|
+
if stdout.strip():
|
|
139
|
+
self.console.print(Panel.fit(stdout.rstrip(), title="stdout"))
|
|
140
|
+
if stderr.strip():
|
|
141
|
+
self.console.print(Panel.fit(stderr.rstrip(), title="stderr", style="red"))
|
|
142
|
+
|
|
143
|
+
if check and exit_code != 0:
|
|
144
|
+
raise subprocess.CalledProcessError(exit_code, args, stdout, stderr)
|
|
145
|
+
|
|
146
|
+
return CommandResult(
|
|
147
|
+
command=command,
|
|
148
|
+
cwd=cwd_final,
|
|
149
|
+
exit_code=exit_code,
|
|
150
|
+
stdout=stdout,
|
|
151
|
+
stderr=stderr,
|
|
152
|
+
)
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "itamar-agentcli"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Local AI Agent CLI"
|
|
9
|
+
requires-python = ">=3.12"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"typer>=0.12",
|
|
12
|
+
"rich>=13",
|
|
13
|
+
"pydantic>=2",
|
|
14
|
+
"requests>=2",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[project.scripts]
|
|
18
|
+
agentcli = "localagent.cli:app"
|