limbo-code 0.1.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.
- limbo/__init__.py +3 -0
- limbo/__main__.py +6 -0
- limbo/agent.py +456 -0
- limbo/app.py +107 -0
- limbo/config.py +99 -0
- limbo/history.py +90 -0
- limbo/llm/__init__.py +1 -0
- limbo/llm/anthropic_client.py +320 -0
- limbo/llm/catalog.py +234 -0
- limbo/llm/client.py +21 -0
- limbo/llm/factory.py +46 -0
- limbo/llm/openai_client.py +261 -0
- limbo/models.py +81 -0
- limbo/sessions.py +258 -0
- limbo/skills.py +89 -0
- limbo/tools/__init__.py +1 -0
- limbo/tools/base.py +102 -0
- limbo/tools/bash.py +172 -0
- limbo/tools/edit.py +70 -0
- limbo/tools/find.py +71 -0
- limbo/tools/grep.py +182 -0
- limbo/tools/ignore.py +111 -0
- limbo/tools/ls.py +41 -0
- limbo/tools/read.py +105 -0
- limbo/tools/registry.py +66 -0
- limbo/tools/write.py +34 -0
- limbo/trace.py +100 -0
- limbo/ui/__init__.py +1 -0
- limbo/ui/app.py +52 -0
- limbo/ui/app.tcss +174 -0
- limbo/ui/banner.py +83 -0
- limbo/ui/commands.py +61 -0
- limbo/ui/screens/__init__.py +1 -0
- limbo/ui/screens/game2048.py +213 -0
- limbo/ui/screens/main.py +369 -0
- limbo/ui/screens/session_picker.py +59 -0
- limbo/ui/widgets/__init__.py +1 -0
- limbo/ui/widgets/chat.py +149 -0
- limbo/ui/widgets/command_menu.py +45 -0
- limbo/ui/widgets/input.py +199 -0
- limbo/ui/widgets/status_bar.py +32 -0
- limbo/ui/widgets/tool_card.py +179 -0
- limbo_code-0.1.0.dist-info/METADATA +16 -0
- limbo_code-0.1.0.dist-info/RECORD +46 -0
- limbo_code-0.1.0.dist-info/WHEEL +4 -0
- limbo_code-0.1.0.dist-info/entry_points.txt +2 -0
limbo/skills.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Skill support: discover and load SKILL.md skills.
|
|
2
|
+
|
|
3
|
+
A skill is a directory containing a ``SKILL.md`` file with YAML-ish
|
|
4
|
+
frontmatter (``name``, ``description``) followed by the instruction body —
|
|
5
|
+
the same convention used by Claude Code and pi.
|
|
6
|
+
|
|
7
|
+
Discovery paths (later sources win on name collisions):
|
|
8
|
+
- user: ``~/.limbo/skills/<name>/SKILL.md``
|
|
9
|
+
- project: ``<workdir>/.agents/skills/<name>/SKILL.md``
|
|
10
|
+
|
|
11
|
+
Skills are invoked through slash commands: ``/<name> [args]`` injects the
|
|
12
|
+
skill body (plus args) as the turn's prompt.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
DEFAULT_USER_SKILLS_DIR = Path.home() / ".limbo" / "skills"
|
|
21
|
+
PROJECT_SKILLS_DIR = ".agents/skills"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class Skill:
|
|
26
|
+
name: str
|
|
27
|
+
description: str
|
|
28
|
+
body: str
|
|
29
|
+
path: Path
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def parse_skill_md(text: str, fallback_name: str) -> tuple[str, str, str]:
|
|
33
|
+
"""Parse SKILL.md content into (name, description, body).
|
|
34
|
+
|
|
35
|
+
Frontmatter is a simple ``---`` delimited block of ``key: value`` lines.
|
|
36
|
+
Without frontmatter, the fallback name is used and the whole file is the
|
|
37
|
+
body.
|
|
38
|
+
"""
|
|
39
|
+
if text.startswith("---"):
|
|
40
|
+
end = text.find("\n---", 3)
|
|
41
|
+
if end != -1:
|
|
42
|
+
header = text[3:end]
|
|
43
|
+
body = text[end + 4 :].lstrip("\n")
|
|
44
|
+
name = fallback_name
|
|
45
|
+
description = ""
|
|
46
|
+
for line in header.splitlines():
|
|
47
|
+
key, sep, value = line.partition(":")
|
|
48
|
+
if not sep:
|
|
49
|
+
continue
|
|
50
|
+
key = key.strip()
|
|
51
|
+
value = value.strip()
|
|
52
|
+
if key == "name" and value:
|
|
53
|
+
name = value
|
|
54
|
+
elif key == "description":
|
|
55
|
+
description = value
|
|
56
|
+
return name, description, body
|
|
57
|
+
return fallback_name, "", text
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _load_skills_from(skills_dir: Path) -> list[Skill]:
|
|
61
|
+
skills: list[Skill] = []
|
|
62
|
+
if not skills_dir.is_dir():
|
|
63
|
+
return skills
|
|
64
|
+
for path in sorted(skills_dir.glob("*/SKILL.md")):
|
|
65
|
+
try:
|
|
66
|
+
text = path.read_text(encoding="utf-8")
|
|
67
|
+
except OSError:
|
|
68
|
+
continue
|
|
69
|
+
name, description, body = parse_skill_md(text, fallback_name=path.parent.name)
|
|
70
|
+
skills.append(
|
|
71
|
+
Skill(name=name, description=description, body=body, path=path)
|
|
72
|
+
)
|
|
73
|
+
return skills
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def discover_skills(
|
|
77
|
+
workdir: Path, user_dir: Path | None = None
|
|
78
|
+
) -> list[Skill]:
|
|
79
|
+
"""Discover skills from the user dir and the project (.agents/skills).
|
|
80
|
+
|
|
81
|
+
Project skills override user skills with the same name. Sorted by name.
|
|
82
|
+
"""
|
|
83
|
+
user_dir = user_dir if user_dir is not None else DEFAULT_USER_SKILLS_DIR
|
|
84
|
+
by_name: dict[str, Skill] = {}
|
|
85
|
+
for skill in _load_skills_from(user_dir):
|
|
86
|
+
by_name[skill.name] = skill
|
|
87
|
+
for skill in _load_skills_from(workdir / PROJECT_SKILLS_DIR):
|
|
88
|
+
by_name[skill.name] = skill
|
|
89
|
+
return [by_name[name] for name in sorted(by_name)]
|
limbo/tools/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Limbo tools package."""
|
limbo/tools/base.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Base class and shared helpers for tools.
|
|
2
|
+
|
|
3
|
+
The base module owns the rituals every tool used to repeat: workdir-safe
|
|
4
|
+
path resolution (raising ``ToolError`` instead of returning union types)
|
|
5
|
+
and the output truncation policy.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from abc import ABC, abstractmethod
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from limbo.models import ToolResult
|
|
15
|
+
|
|
16
|
+
MAX_OUTPUT_BYTES = 512 * 1024
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ToolError(Exception):
|
|
20
|
+
"""Raised by tool helpers; ``BaseTool.execute`` turns it into an error result."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class BaseTool(ABC):
|
|
24
|
+
"""Abstract base class for all Limbo tools.
|
|
25
|
+
|
|
26
|
+
Subclasses implement ``run``; ``execute`` is the public entry point and
|
|
27
|
+
converts ``ToolError`` into an error ``ToolResult`` uniformly.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
name: str
|
|
31
|
+
description: str
|
|
32
|
+
parameters: dict[str, Any]
|
|
33
|
+
|
|
34
|
+
def __init__(self, workdir: Path):
|
|
35
|
+
self.workdir = workdir.resolve()
|
|
36
|
+
|
|
37
|
+
def execute(self, arguments: dict[str, Any]) -> ToolResult:
|
|
38
|
+
try:
|
|
39
|
+
return self.run(arguments)
|
|
40
|
+
except ToolError as e:
|
|
41
|
+
return ToolResult(success=False, error=str(e))
|
|
42
|
+
|
|
43
|
+
@abstractmethod
|
|
44
|
+
def run(self, arguments: dict[str, Any]) -> ToolResult:
|
|
45
|
+
...
|
|
46
|
+
|
|
47
|
+
# -- path resolution -------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
def resolve(self, raw_path: str, *, strict: bool = True) -> Path:
|
|
50
|
+
"""Resolve ``raw_path`` under the workdir, enforcing the boundary.
|
|
51
|
+
|
|
52
|
+
Raises ``ToolError`` for paths outside the workdir, unresolvable
|
|
53
|
+
paths, and (when ``strict``) broken symlinks.
|
|
54
|
+
"""
|
|
55
|
+
raw = self.workdir / raw_path
|
|
56
|
+
try:
|
|
57
|
+
target = raw.resolve(strict=False)
|
|
58
|
+
except (OSError, RuntimeError) as e:
|
|
59
|
+
raise ToolError(f"Invalid path: {e}") from e
|
|
60
|
+
|
|
61
|
+
if not is_within_workdir(target, self.workdir):
|
|
62
|
+
raise ToolError("Path is outside working directory.")
|
|
63
|
+
|
|
64
|
+
if strict and raw.is_symlink() and not target.exists():
|
|
65
|
+
raise ToolError("Invalid path: broken symlink")
|
|
66
|
+
|
|
67
|
+
return target
|
|
68
|
+
|
|
69
|
+
def resolve_existing(
|
|
70
|
+
self, raw_path: str, *, noun: str = "Path", kind: str = "any"
|
|
71
|
+
) -> Path:
|
|
72
|
+
"""Resolve a path that must exist. ``kind``: any | file | dir."""
|
|
73
|
+
target = self.resolve(raw_path)
|
|
74
|
+
if not target.exists():
|
|
75
|
+
raise ToolError(f"{noun} not found: {raw_path}")
|
|
76
|
+
if kind == "file" and not target.is_file():
|
|
77
|
+
raise ToolError(f"Not a file: {raw_path}")
|
|
78
|
+
if kind == "dir" and not target.is_dir():
|
|
79
|
+
raise ToolError(f"Not a directory: {raw_path}")
|
|
80
|
+
return target
|
|
81
|
+
|
|
82
|
+
def resolve_creatable(self, raw_path: str) -> Path:
|
|
83
|
+
"""Resolve a path that may not exist yet (e.g. for writing)."""
|
|
84
|
+
return self.resolve(raw_path, strict=False)
|
|
85
|
+
|
|
86
|
+
def is_within_workdir(path: Path, workdir: Path) -> bool:
|
|
87
|
+
"""Return True if resolved path is inside or equal to workdir."""
|
|
88
|
+
try:
|
|
89
|
+
path.resolve().relative_to(workdir.resolve())
|
|
90
|
+
return True
|
|
91
|
+
except ValueError:
|
|
92
|
+
return False
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def truncate_output(
|
|
96
|
+
output: str, *, limit: int = MAX_OUTPUT_BYTES, suffix: str = "\n[Output truncated.]"
|
|
97
|
+
) -> str:
|
|
98
|
+
"""Truncate ``output`` to ``limit`` bytes (UTF-8 safe) and append ``suffix``."""
|
|
99
|
+
encoded = output.encode("utf-8", errors="replace")
|
|
100
|
+
if len(encoded) <= limit:
|
|
101
|
+
return output
|
|
102
|
+
return encoded[:limit].decode("utf-8", errors="replace") + suffix
|
limbo/tools/bash.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Execute bash commands tool."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import shlex
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from limbo.config import DEFAULT_DANGEROUS_COMMANDS
|
|
13
|
+
from limbo.models import ToolResult
|
|
14
|
+
from limbo.tools.base import MAX_OUTPUT_BYTES, BaseTool, truncate_output
|
|
15
|
+
|
|
16
|
+
DEFAULT_TIMEOUT = 30.0
|
|
17
|
+
MAX_TIMEOUT = 300.0
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class BashTool(BaseTool):
|
|
21
|
+
name = "bash"
|
|
22
|
+
description = (
|
|
23
|
+
"Execute a bash command in the current working directory. Returns stdout and stderr. "
|
|
24
|
+
"WARNING: bash is not sandboxed and can access files outside the workdir. "
|
|
25
|
+
"Commands matching dangerous patterns (e.g. rm, git reset --hard) are "
|
|
26
|
+
"rejected outright. The filter is heuristic only: "
|
|
27
|
+
"subshells, command substitution, variable indirection, options before the command "
|
|
28
|
+
"name, and variable assignments before the command name "
|
|
29
|
+
"(e.g. 'bash -c rm -rf /', '$(rm ...)', 'git -C /foo reset --hard', "
|
|
30
|
+
"or 'VAR=1 rm -rf /') can bypass it."
|
|
31
|
+
)
|
|
32
|
+
parameters = {
|
|
33
|
+
"type": "object",
|
|
34
|
+
"properties": {
|
|
35
|
+
"command": {"type": "string", "description": "Bash command to execute"},
|
|
36
|
+
"timeout": {
|
|
37
|
+
"type": "number",
|
|
38
|
+
"description": "Timeout in seconds (optional, default 30, maximum 300)",
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
"required": ["command"],
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
def __init__(self, workdir: Path, dangerous_patterns: list[str] | None = None):
|
|
45
|
+
super().__init__(workdir)
|
|
46
|
+
self.dangerous_patterns = dangerous_patterns or list(
|
|
47
|
+
DEFAULT_DANGEROUS_COMMANDS
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
def run(self, arguments: dict[str, Any]) -> ToolResult:
|
|
51
|
+
command = arguments.get("command", "")
|
|
52
|
+
timeout = arguments.get("timeout", DEFAULT_TIMEOUT)
|
|
53
|
+
|
|
54
|
+
if not command:
|
|
55
|
+
return ToolResult(success=False, error="No command provided.")
|
|
56
|
+
|
|
57
|
+
if is_dangerous(command, self.dangerous_patterns):
|
|
58
|
+
return ToolResult(
|
|
59
|
+
success=False,
|
|
60
|
+
error=f"Command blocked by safety policy: {command}",
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
# Cap the effective timeout so a huge value cannot block the worker
|
|
64
|
+
# indefinitely. The cap is documented in the tool schema above.
|
|
65
|
+
try:
|
|
66
|
+
requested_timeout = float(timeout)
|
|
67
|
+
except (TypeError, ValueError):
|
|
68
|
+
return ToolResult(success=False, error=f"Invalid timeout: {timeout}")
|
|
69
|
+
effective_timeout = min(requested_timeout, MAX_TIMEOUT)
|
|
70
|
+
|
|
71
|
+
shell = shutil.which("bash") or "/bin/bash"
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
proc = subprocess.run(
|
|
75
|
+
[shell, "-c", command],
|
|
76
|
+
capture_output=True,
|
|
77
|
+
text=True,
|
|
78
|
+
errors="replace",
|
|
79
|
+
timeout=effective_timeout,
|
|
80
|
+
cwd=str(self.workdir),
|
|
81
|
+
)
|
|
82
|
+
except subprocess.TimeoutExpired:
|
|
83
|
+
return ToolResult(
|
|
84
|
+
success=False,
|
|
85
|
+
error=f"Command timed out after {effective_timeout}s.",
|
|
86
|
+
)
|
|
87
|
+
except Exception as e: # noqa: BLE001
|
|
88
|
+
return ToolResult(success=False, error=f"Execution failed: {e}")
|
|
89
|
+
|
|
90
|
+
output = proc.stdout
|
|
91
|
+
if proc.stderr:
|
|
92
|
+
output += ("\n" if output else "") + f"[stderr]\n{proc.stderr}"
|
|
93
|
+
|
|
94
|
+
output = truncate_output(
|
|
95
|
+
output,
|
|
96
|
+
suffix=(
|
|
97
|
+
f"\n\n[output truncated: exceeded {MAX_OUTPUT_BYTES} byte limit]"
|
|
98
|
+
),
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
if proc.returncode != 0:
|
|
102
|
+
exit_msg = f"Command failed with exit code {proc.returncode}."
|
|
103
|
+
output = f"{exit_msg}\n{output}" if output else exit_msg
|
|
104
|
+
return ToolResult(
|
|
105
|
+
success=False,
|
|
106
|
+
output=output,
|
|
107
|
+
error=exit_msg,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
return ToolResult(success=True, output=output or "")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
_CONTROL_OPERATOR_RE = re.compile(r"(;|&&|&|\|\||\|)")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _tokenize_command(command: str) -> list[str]:
|
|
117
|
+
"""Split a command into tokens, treating shell control operators as separate tokens."""
|
|
118
|
+
raw_tokens = shlex.split(command)
|
|
119
|
+
tokens: list[str] = []
|
|
120
|
+
for raw in raw_tokens:
|
|
121
|
+
for part in _CONTROL_OPERATOR_RE.split(raw):
|
|
122
|
+
if part:
|
|
123
|
+
tokens.append(part)
|
|
124
|
+
return tokens
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def is_dangerous(command: str, patterns: list[str]) -> bool:
|
|
128
|
+
"""Return True if command matches a dangerous pattern.
|
|
129
|
+
|
|
130
|
+
Single-token patterns are matched only in command-name positions: the first
|
|
131
|
+
token of the command or the first token after a shell control operator
|
|
132
|
+
(``;``, ``&&``, ``&``, ``||``, ``|``). Multi-token patterns are matched
|
|
133
|
+
against the leading tokens starting at each command position.
|
|
134
|
+
|
|
135
|
+
.. warning::
|
|
136
|
+
This check is heuristic only. It tokenizes the top-level command, so
|
|
137
|
+
subshells (``bash -c ...``), command substitution (``$(rm ...)``),
|
|
138
|
+
variable indirection, variable assignments before a command name
|
|
139
|
+
(``VAR=1 rm -rf /``), and options between the command name and the
|
|
140
|
+
matched tokens (``git -C /foo reset --hard``) can bypass it.
|
|
141
|
+
"""
|
|
142
|
+
tokens = _tokenize_command(command)
|
|
143
|
+
control_operators = {";", "&&", "&", "||", "|"}
|
|
144
|
+
command_starts = [0]
|
|
145
|
+
for i in range(1, len(tokens)):
|
|
146
|
+
if tokens[i - 1] in control_operators:
|
|
147
|
+
command_starts.append(i)
|
|
148
|
+
|
|
149
|
+
for pattern in patterns:
|
|
150
|
+
if not pattern:
|
|
151
|
+
continue
|
|
152
|
+
pattern_tokens = _tokenize_command(pattern)
|
|
153
|
+
for start in command_starts:
|
|
154
|
+
if start >= len(tokens):
|
|
155
|
+
continue
|
|
156
|
+
if len(pattern_tokens) == 1:
|
|
157
|
+
name = pattern_tokens[0]
|
|
158
|
+
token = tokens[start]
|
|
159
|
+
if token == name or Path(token).name == name:
|
|
160
|
+
return True
|
|
161
|
+
else:
|
|
162
|
+
first_pattern = pattern_tokens[0]
|
|
163
|
+
first_token = tokens[start]
|
|
164
|
+
first_matches = (
|
|
165
|
+
first_token == first_pattern
|
|
166
|
+
or Path(first_token).name == Path(first_pattern).name
|
|
167
|
+
)
|
|
168
|
+
if first_matches and tokens[
|
|
169
|
+
start + 1 : start + len(pattern_tokens)
|
|
170
|
+
] == pattern_tokens[1:]:
|
|
171
|
+
return True
|
|
172
|
+
return False
|
limbo/tools/edit.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Surgical file edit tool."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import difflib
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from limbo.models import ToolResult
|
|
9
|
+
from limbo.tools.base import BaseTool
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class EditTool(BaseTool):
|
|
13
|
+
name = "edit"
|
|
14
|
+
description = (
|
|
15
|
+
"Make surgical edits to a file by replacing exact text. old_text must match exactly. "
|
|
16
|
+
"The displayed diff may not clearly indicate trailing-newline changes."
|
|
17
|
+
)
|
|
18
|
+
parameters = {
|
|
19
|
+
"type": "object",
|
|
20
|
+
"properties": {
|
|
21
|
+
"path": {"type": "string", "description": "Path to the file"},
|
|
22
|
+
"old_text": {
|
|
23
|
+
"type": "string",
|
|
24
|
+
"description": "Exact text to find and replace",
|
|
25
|
+
},
|
|
26
|
+
"new_text": {
|
|
27
|
+
"type": "string",
|
|
28
|
+
"description": "New text to replace with",
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
"required": ["path", "old_text", "new_text"],
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
def run(self, arguments: dict[str, Any]) -> ToolResult:
|
|
35
|
+
raw_path = arguments.get("path", "")
|
|
36
|
+
old_text = arguments.get("old_text", "")
|
|
37
|
+
new_text = arguments.get("new_text", "")
|
|
38
|
+
target = self.resolve_existing(raw_path, noun="File")
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
content = target.read_text(encoding="utf-8")
|
|
42
|
+
except OSError as e:
|
|
43
|
+
return ToolResult(success=False, error=f"Could not read file: {e}")
|
|
44
|
+
|
|
45
|
+
if old_text == "":
|
|
46
|
+
return ToolResult(success=False, error="old_text cannot be empty.")
|
|
47
|
+
|
|
48
|
+
occurrences = content.count(old_text)
|
|
49
|
+
if occurrences == 0:
|
|
50
|
+
return ToolResult(success=False, error=f"old_text not found in {raw_path}.")
|
|
51
|
+
if occurrences > 1:
|
|
52
|
+
return ToolResult(success=False, error=f"Text must be unique in {raw_path}.")
|
|
53
|
+
|
|
54
|
+
new_content = content.replace(old_text, new_text, 1)
|
|
55
|
+
if new_content == content:
|
|
56
|
+
return ToolResult(success=False, error=f"No changes to {raw_path}.")
|
|
57
|
+
|
|
58
|
+
diff = self._make_diff(content, new_content)
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
target.write_text(new_content, encoding="utf-8")
|
|
62
|
+
except OSError as e:
|
|
63
|
+
return ToolResult(success=False, error=f"Could not write file: {e}")
|
|
64
|
+
|
|
65
|
+
return ToolResult(success=True, output=f"Edited {raw_path}.\n{diff}")
|
|
66
|
+
|
|
67
|
+
def _make_diff(self, old: str, new: str) -> str:
|
|
68
|
+
return "".join(
|
|
69
|
+
difflib.unified_diff(old.splitlines(), new.splitlines(), lineterm="\n")
|
|
70
|
+
)
|
limbo/tools/find.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Find files by glob pattern tool."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from limbo.models import ToolResult
|
|
8
|
+
from limbo.tools.base import (
|
|
9
|
+
BaseTool,
|
|
10
|
+
is_within_workdir,
|
|
11
|
+
truncate_output,
|
|
12
|
+
)
|
|
13
|
+
from limbo.tools.ignore import GitignoreMatcher
|
|
14
|
+
|
|
15
|
+
MAX_RESULTS = 1000
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class FindTool(BaseTool):
|
|
19
|
+
name = "find"
|
|
20
|
+
description = (
|
|
21
|
+
"Find files by glob pattern. Respects the root .gitignore only. "
|
|
22
|
+
"The built-in matcher is minimal and does not support nested .gitignore "
|
|
23
|
+
"files or advanced glob/negation rules."
|
|
24
|
+
)
|
|
25
|
+
parameters = {
|
|
26
|
+
"type": "object",
|
|
27
|
+
"properties": {
|
|
28
|
+
"pattern": {"type": "string", "description": "Glob pattern"},
|
|
29
|
+
"path": {"type": "string", "description": "Directory to search"},
|
|
30
|
+
"limit": {"type": "integer", "description": "Max results"},
|
|
31
|
+
},
|
|
32
|
+
"required": ["pattern"],
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
def run(self, arguments: dict[str, Any]) -> ToolResult:
|
|
36
|
+
pattern = arguments.get("pattern", "")
|
|
37
|
+
path = arguments.get("path", ".")
|
|
38
|
+
limit = arguments.get("limit", MAX_RESULTS)
|
|
39
|
+
|
|
40
|
+
target = self.resolve_existing(path)
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
# ``glob`` may traverse directory symlinks before we can filter them;
|
|
44
|
+
# we resolve and enforce the workdir boundary below.
|
|
45
|
+
matches = sorted(target.glob(pattern))
|
|
46
|
+
except ValueError as e:
|
|
47
|
+
return ToolResult(success=False, error=f"Invalid glob pattern: {e}")
|
|
48
|
+
|
|
49
|
+
matcher = GitignoreMatcher(self.workdir)
|
|
50
|
+
|
|
51
|
+
results = []
|
|
52
|
+
for p in matches:
|
|
53
|
+
try:
|
|
54
|
+
resolved = p.resolve()
|
|
55
|
+
except OSError:
|
|
56
|
+
continue
|
|
57
|
+
if not resolved.is_file():
|
|
58
|
+
continue
|
|
59
|
+
if not is_within_workdir(resolved, self.workdir):
|
|
60
|
+
continue
|
|
61
|
+
rel = str(p.relative_to(self.workdir))
|
|
62
|
+
if matcher.is_ignored(rel):
|
|
63
|
+
continue
|
|
64
|
+
results.append(rel)
|
|
65
|
+
if len(results) >= limit:
|
|
66
|
+
break
|
|
67
|
+
|
|
68
|
+
output = "\n".join(results)
|
|
69
|
+
output = truncate_output(output)
|
|
70
|
+
|
|
71
|
+
return ToolResult(success=True, output=output or "No matches.")
|
limbo/tools/grep.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Search file contents tool."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import fnmatch
|
|
6
|
+
import re
|
|
7
|
+
import subprocess
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from limbo.models import ToolResult
|
|
12
|
+
from limbo.tools.base import (
|
|
13
|
+
BaseTool,
|
|
14
|
+
is_within_workdir,
|
|
15
|
+
truncate_output,
|
|
16
|
+
)
|
|
17
|
+
from limbo.tools.ignore import GitignoreMatcher
|
|
18
|
+
|
|
19
|
+
MAX_MATCHES = 100
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class GrepTool(BaseTool):
|
|
23
|
+
name = "grep"
|
|
24
|
+
description = (
|
|
25
|
+
"Search file contents for a pattern. Respects .gitignore (basic rules). "
|
|
26
|
+
"Install ripgrep for full gitignore, glob, and context-line support. "
|
|
27
|
+
"The Python fallback does not support `**` recursive glob patterns."
|
|
28
|
+
)
|
|
29
|
+
parameters = {
|
|
30
|
+
"type": "object",
|
|
31
|
+
"properties": {
|
|
32
|
+
"pattern": {"type": "string", "description": "Search pattern"},
|
|
33
|
+
"path": {"type": "string", "description": "Directory or file to search"},
|
|
34
|
+
"glob": {
|
|
35
|
+
"type": "string",
|
|
36
|
+
"description": "Filter files by glob (Python fallback does not support `**`)",
|
|
37
|
+
},
|
|
38
|
+
"ignore_case": {"type": "boolean", "description": "Case-insensitive"},
|
|
39
|
+
"fixed_string": {"type": "boolean", "description": "Literal string"},
|
|
40
|
+
"context": {"type": "integer", "description": "Lines before/after match"},
|
|
41
|
+
"limit": {
|
|
42
|
+
"type": "integer",
|
|
43
|
+
"description": (
|
|
44
|
+
"Max matches (per file with ripgrep,"
|
|
45
|
+
" total across files in Python fallback)"
|
|
46
|
+
),
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
"required": ["pattern"],
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
def run(self, arguments: dict[str, Any]) -> ToolResult:
|
|
53
|
+
pattern = arguments.get("pattern", "")
|
|
54
|
+
path = arguments.get("path", ".")
|
|
55
|
+
glob = arguments.get("glob")
|
|
56
|
+
ignore_case = arguments.get("ignore_case", False)
|
|
57
|
+
fixed_string = arguments.get("fixed_string", False)
|
|
58
|
+
context = arguments.get("context", 0)
|
|
59
|
+
limit = arguments.get("limit", MAX_MATCHES)
|
|
60
|
+
|
|
61
|
+
target = self.resolve_existing(path)
|
|
62
|
+
|
|
63
|
+
rg = self._find_rg()
|
|
64
|
+
if rg:
|
|
65
|
+
return self._run_rg(
|
|
66
|
+
rg, target, pattern, glob, ignore_case, fixed_string, context, limit
|
|
67
|
+
)
|
|
68
|
+
return self._run_python_regex(
|
|
69
|
+
target, pattern, glob, ignore_case, fixed_string, context, limit
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
def _find_rg(self) -> str | None:
|
|
73
|
+
import shutil
|
|
74
|
+
|
|
75
|
+
return shutil.which("rg")
|
|
76
|
+
|
|
77
|
+
def _run_rg(
|
|
78
|
+
self,
|
|
79
|
+
rg: str,
|
|
80
|
+
target: Path,
|
|
81
|
+
pattern: str,
|
|
82
|
+
glob: str | None,
|
|
83
|
+
ignore_case: bool,
|
|
84
|
+
fixed_string: bool,
|
|
85
|
+
context: int,
|
|
86
|
+
limit: int,
|
|
87
|
+
) -> ToolResult:
|
|
88
|
+
cmd = [rg, "--line-number", "--no-heading", "--color=never", "--no-require-git"]
|
|
89
|
+
if ignore_case:
|
|
90
|
+
cmd.append("--ignore-case")
|
|
91
|
+
if fixed_string:
|
|
92
|
+
cmd.append("--fixed-strings")
|
|
93
|
+
if context:
|
|
94
|
+
cmd.extend(["-C", str(context)])
|
|
95
|
+
if glob:
|
|
96
|
+
cmd.extend(["-g", glob])
|
|
97
|
+
# Emit workdir-relative paths, consistent with the Python fallback and find.
|
|
98
|
+
rel_target = target.relative_to(self.workdir)
|
|
99
|
+
cmd.extend(["--max-count", str(limit), "--", pattern, str(rel_target)])
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
proc = subprocess.run(
|
|
103
|
+
cmd,
|
|
104
|
+
capture_output=True,
|
|
105
|
+
text=True,
|
|
106
|
+
timeout=30,
|
|
107
|
+
cwd=str(self.workdir),
|
|
108
|
+
)
|
|
109
|
+
except subprocess.TimeoutExpired:
|
|
110
|
+
return ToolResult(success=False, error="grep timed out.")
|
|
111
|
+
except FileNotFoundError:
|
|
112
|
+
return ToolResult(success=False, error="ripgrep not found.")
|
|
113
|
+
|
|
114
|
+
output = proc.stdout
|
|
115
|
+
output = truncate_output(output)
|
|
116
|
+
|
|
117
|
+
return ToolResult(success=True, output=output or "No matches.")
|
|
118
|
+
|
|
119
|
+
def _run_python_regex(
|
|
120
|
+
self,
|
|
121
|
+
target: Path,
|
|
122
|
+
pattern: str,
|
|
123
|
+
glob: str | None,
|
|
124
|
+
ignore_case: bool,
|
|
125
|
+
fixed_string: bool,
|
|
126
|
+
context: int,
|
|
127
|
+
limit: int,
|
|
128
|
+
) -> ToolResult:
|
|
129
|
+
# Fallback path: supports pattern/glob/ignore_case/fixed_string/limit.
|
|
130
|
+
# Context lines are only available through ripgrep.
|
|
131
|
+
if context:
|
|
132
|
+
return ToolResult(
|
|
133
|
+
success=False,
|
|
134
|
+
error="Context lines require ripgrep. Install ripgrep or omit 'context'.",
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
flags = re.IGNORECASE if ignore_case else 0
|
|
138
|
+
try:
|
|
139
|
+
compiled = re.compile(
|
|
140
|
+
re.escape(pattern) if fixed_string else pattern, flags
|
|
141
|
+
)
|
|
142
|
+
except re.error as e:
|
|
143
|
+
return ToolResult(success=False, error=f"Invalid regex: {e}")
|
|
144
|
+
|
|
145
|
+
matches = []
|
|
146
|
+
matcher = GitignoreMatcher(self.workdir)
|
|
147
|
+
# ``rglob`` may traverse directory symlinks before we can filter them;
|
|
148
|
+
# we resolve and enforce the workdir boundary for each candidate.
|
|
149
|
+
files = [target] if target.is_file() else target.rglob("*")
|
|
150
|
+
count = 0
|
|
151
|
+
for f in files:
|
|
152
|
+
# Resolve symlinks and enforce the workdir boundary before reading.
|
|
153
|
+
resolved = f.resolve()
|
|
154
|
+
if not is_within_workdir(resolved, self.workdir):
|
|
155
|
+
continue
|
|
156
|
+
if not resolved.is_file():
|
|
157
|
+
continue
|
|
158
|
+
try:
|
|
159
|
+
rel = resolved.relative_to(self.workdir)
|
|
160
|
+
except ValueError:
|
|
161
|
+
continue
|
|
162
|
+
rel_str = str(rel)
|
|
163
|
+
if matcher.is_ignored(rel_str):
|
|
164
|
+
continue
|
|
165
|
+
if glob is not None and not fnmatch.fnmatch(rel_str, glob):
|
|
166
|
+
continue
|
|
167
|
+
try:
|
|
168
|
+
text = resolved.read_text(encoding="utf-8", errors="replace")
|
|
169
|
+
except OSError:
|
|
170
|
+
continue
|
|
171
|
+
for lineno, line in enumerate(text.splitlines(), start=1):
|
|
172
|
+
if compiled.search(line):
|
|
173
|
+
matches.append(f"{rel}:{lineno}:{line}")
|
|
174
|
+
count += 1
|
|
175
|
+
if count >= limit:
|
|
176
|
+
break
|
|
177
|
+
if count >= limit:
|
|
178
|
+
break
|
|
179
|
+
|
|
180
|
+
output = "\n".join(matches)
|
|
181
|
+
output = truncate_output(output)
|
|
182
|
+
return ToolResult(success=True, output=output or "No matches.")
|