crowe-logic 0.1.0
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.
- package/.dockerignore +14 -0
- package/.env.example +24 -0
- package/Dockerfile +62 -0
- package/cli/__init__.py +0 -0
- package/cli/branding.py +105 -0
- package/cli/crowe_logic.py +268 -0
- package/cli/icon.png +0 -0
- package/config/__init__.py +0 -0
- package/config/agent_config.py +54 -0
- package/npm/bin.js +34 -0
- package/package.json +32 -0
- package/pyproject.toml +70 -0
- package/requirements.txt +11 -0
- package/scripts/__init__.py +0 -0
- package/scripts/create_agent.py +163 -0
- package/scripts/fine_tune.py +271 -0
- package/scripts/npm-publish.sh +15 -0
- package/scripts/orchestrator.applescript +162 -0
- package/scripts/test_agent.py +200 -0
- package/setup.py +24 -0
- package/tools/__init__.py +57 -0
- package/tools/applescript.py +58 -0
- package/tools/browser.py +79 -0
- package/tools/filesystem.py +115 -0
- package/tools/git_ops.py +107 -0
- package/tools/playwright_browser.py +101 -0
- package/tools/quantum.py +89 -0
- package/tools/search.py +108 -0
- package/tools/shell.py +47 -0
- package/tools/talon_music.py +112 -0
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Crowe Logic Agent — Test Harness
|
|
4
|
+
|
|
5
|
+
Runs a suite of test prompts across all domains to verify the agent
|
|
6
|
+
works correctly with all tools. Tests auto-tool-selection, streaming,
|
|
7
|
+
and multi-step reasoning.
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
python scripts/test_agent.py
|
|
11
|
+
python scripts/test_agent.py --quick # Just 3 core tests
|
|
12
|
+
python scripts/test_agent.py --verbose # Show full responses
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
import sys
|
|
17
|
+
import json
|
|
18
|
+
import time
|
|
19
|
+
import argparse
|
|
20
|
+
|
|
21
|
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
22
|
+
|
|
23
|
+
from azure.ai.agents import AgentsClient
|
|
24
|
+
from azure.ai.agents.models import (
|
|
25
|
+
FunctionTool, ToolSet, CodeInterpreterTool,
|
|
26
|
+
MessageDeltaChunk, ThreadMessage, ThreadRun, AgentStreamEvent,
|
|
27
|
+
ListSortOrder, MessageRole,
|
|
28
|
+
)
|
|
29
|
+
from azure.identity import DefaultAzureCredential
|
|
30
|
+
|
|
31
|
+
from config.agent_config import PROJECT_ENDPOINT
|
|
32
|
+
from tools import user_functions
|
|
33
|
+
|
|
34
|
+
# ──────────────────────────────────────────────
|
|
35
|
+
# Test prompts — each exercises a different tool
|
|
36
|
+
# ──────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
TESTS = [
|
|
39
|
+
{
|
|
40
|
+
"name": "filesystem_read",
|
|
41
|
+
"prompt": "Read the file /Users/crowelogic/Projects/crowe-logic-foundry/requirements.txt and list the packages.",
|
|
42
|
+
"expect_tool": "read_file",
|
|
43
|
+
"domain": "filesystem",
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
"name": "directory_list",
|
|
47
|
+
"prompt": "List all Python files in /Users/crowelogic/Projects/crowe-logic-foundry/tools/",
|
|
48
|
+
"expect_tool": "list_directory",
|
|
49
|
+
"domain": "filesystem",
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
"name": "shell_command",
|
|
53
|
+
"prompt": "What version of Python is installed? Run python3 --version",
|
|
54
|
+
"expect_tool": "execute_shell",
|
|
55
|
+
"domain": "shell",
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
"name": "grep_search",
|
|
59
|
+
"prompt": "Search for all occurrences of 'def ' in /Users/crowelogic/Projects/crowe-logic-foundry/tools/filesystem.py",
|
|
60
|
+
"expect_tool": "grep_search",
|
|
61
|
+
"domain": "search",
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
"name": "web_search",
|
|
65
|
+
"prompt": "Search the web for 'gpt-oss-120b benchmark results'",
|
|
66
|
+
"expect_tool": "web_search",
|
|
67
|
+
"domain": "web",
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
"name": "browse_url",
|
|
71
|
+
"prompt": "Fetch the content of https://httpbin.org/json and tell me what it contains",
|
|
72
|
+
"expect_tool": "browse_url",
|
|
73
|
+
"domain": "web",
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
"name": "code_interpreter",
|
|
77
|
+
"prompt": "Use code interpreter to calculate the first 20 Fibonacci numbers and return them as a list.",
|
|
78
|
+
"expect_tool": "code_interpreter",
|
|
79
|
+
"domain": "code",
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
"name": "multi_step",
|
|
83
|
+
"prompt": "List the Python files in /Users/crowelogic/Projects/crowe-logic-foundry/tools/, then read each one and count the total number of functions defined across all files.",
|
|
84
|
+
"expect_tool": "multi_step",
|
|
85
|
+
"domain": "reasoning",
|
|
86
|
+
},
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
QUICK_TESTS = ["filesystem_read", "shell_command", "web_search"]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def run_test(client, agent_id: str, test: dict, verbose: bool = False) -> dict:
|
|
93
|
+
"""Run a single test and return results."""
|
|
94
|
+
thread = client.threads.create()
|
|
95
|
+
client.messages.create(thread_id=thread.id, role="user", content=test["prompt"])
|
|
96
|
+
|
|
97
|
+
start = time.time()
|
|
98
|
+
full_response = ""
|
|
99
|
+
|
|
100
|
+
# Use create_and_process for simpler test execution
|
|
101
|
+
run = client.runs.create_and_process(thread_id=thread.id, agent_id=agent_id)
|
|
102
|
+
elapsed = time.time() - start
|
|
103
|
+
|
|
104
|
+
# Get the response
|
|
105
|
+
messages = client.messages.list(thread_id=thread.id, order=ListSortOrder.ASCENDING)
|
|
106
|
+
for msg in messages:
|
|
107
|
+
if msg.role == MessageRole.AGENT and msg.text_messages:
|
|
108
|
+
full_response = msg.text_messages[-1].text.value
|
|
109
|
+
|
|
110
|
+
passed = len(full_response) > 10 and "error" not in full_response.lower()[:100]
|
|
111
|
+
|
|
112
|
+
result = {
|
|
113
|
+
"name": test["name"],
|
|
114
|
+
"domain": test["domain"],
|
|
115
|
+
"passed": passed,
|
|
116
|
+
"elapsed": round(elapsed, 2),
|
|
117
|
+
"response_length": len(full_response),
|
|
118
|
+
"run_status": run.status,
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if verbose:
|
|
122
|
+
result["response_preview"] = full_response[:500]
|
|
123
|
+
|
|
124
|
+
return result
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def main():
|
|
128
|
+
parser = argparse.ArgumentParser(description="Test the Crowe Logic agent")
|
|
129
|
+
parser.add_argument("--quick", action="store_true", help="Run only 3 core tests")
|
|
130
|
+
parser.add_argument("--verbose", "-v", action="store_true", help="Show response previews")
|
|
131
|
+
args = parser.parse_args()
|
|
132
|
+
|
|
133
|
+
# Load agent ID
|
|
134
|
+
agent_file = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), ".agent_id")
|
|
135
|
+
if not os.path.exists(agent_file):
|
|
136
|
+
print("ERROR: No agent deployed. Run: python scripts/create_agent.py")
|
|
137
|
+
sys.exit(1)
|
|
138
|
+
|
|
139
|
+
with open(agent_file) as f:
|
|
140
|
+
agent_id = json.load(f)["agent_id"]
|
|
141
|
+
|
|
142
|
+
# Connect
|
|
143
|
+
print(f"\n{'='*60}")
|
|
144
|
+
print(f" CROWE LOGIC AGENT — TEST SUITE")
|
|
145
|
+
print(f"{'='*60}\n")
|
|
146
|
+
|
|
147
|
+
client = AgentsClient(
|
|
148
|
+
endpoint=PROJECT_ENDPOINT,
|
|
149
|
+
credential=DefaultAzureCredential(),
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
# Setup toolset
|
|
153
|
+
toolset = ToolSet()
|
|
154
|
+
toolset.add(FunctionTool(user_functions))
|
|
155
|
+
toolset.add(CodeInterpreterTool())
|
|
156
|
+
client.enable_auto_function_calls(toolset)
|
|
157
|
+
|
|
158
|
+
# Select tests
|
|
159
|
+
tests = TESTS
|
|
160
|
+
if args.quick:
|
|
161
|
+
tests = [t for t in TESTS if t["name"] in QUICK_TESTS]
|
|
162
|
+
|
|
163
|
+
print(f" Running {len(tests)} tests against agent {agent_id[:20]}...\n")
|
|
164
|
+
|
|
165
|
+
# Run tests
|
|
166
|
+
results = []
|
|
167
|
+
for i, test in enumerate(tests, 1):
|
|
168
|
+
print(f" [{i}/{len(tests)}] {test['name']:25s} ({test['domain']}) ... ", end="", flush=True)
|
|
169
|
+
try:
|
|
170
|
+
result = run_test(client, agent_id, test, verbose=args.verbose)
|
|
171
|
+
status = "PASS" if result["passed"] else "FAIL"
|
|
172
|
+
color = "" # no ANSI in basic print
|
|
173
|
+
print(f"{status} ({result['elapsed']}s, {result['response_length']} chars)")
|
|
174
|
+
if args.verbose and "response_preview" in result:
|
|
175
|
+
print(f" Response: {result['response_preview'][:200]}...")
|
|
176
|
+
print()
|
|
177
|
+
except Exception as e:
|
|
178
|
+
result = {"name": test["name"], "domain": test["domain"], "passed": False, "error": str(e)}
|
|
179
|
+
print(f"ERROR ({str(e)[:60]})")
|
|
180
|
+
results.append(result)
|
|
181
|
+
|
|
182
|
+
# Summary
|
|
183
|
+
passed = sum(1 for r in results if r.get("passed"))
|
|
184
|
+
failed = len(results) - passed
|
|
185
|
+
print(f"\n{'='*60}")
|
|
186
|
+
print(f" Results: {passed} passed, {failed} failed, {len(results)} total")
|
|
187
|
+
print(f"{'='*60}\n")
|
|
188
|
+
|
|
189
|
+
if failed > 0:
|
|
190
|
+
print(" Failed tests:")
|
|
191
|
+
for r in results:
|
|
192
|
+
if not r.get("passed"):
|
|
193
|
+
print(f" - {r['name']}: {r.get('error', r.get('run_status', 'unknown'))}")
|
|
194
|
+
print()
|
|
195
|
+
|
|
196
|
+
sys.exit(0 if failed == 0 else 1)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
if __name__ == "__main__":
|
|
200
|
+
main()
|
package/setup.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from setuptools import setup, find_packages
|
|
2
|
+
|
|
3
|
+
setup(
|
|
4
|
+
name="crowe-logic",
|
|
5
|
+
version="0.1.0",
|
|
6
|
+
packages=find_packages(),
|
|
7
|
+
install_requires=[
|
|
8
|
+
"azure-ai-agents>=1.1.0",
|
|
9
|
+
"azure-identity>=1.17.0",
|
|
10
|
+
"click>=8.1.0",
|
|
11
|
+
"rich>=13.0.0",
|
|
12
|
+
"prompt-toolkit>=3.0.0",
|
|
13
|
+
"python-dotenv>=1.0.0",
|
|
14
|
+
"httpx>=0.27.0",
|
|
15
|
+
"beautifulsoup4>=4.12.0",
|
|
16
|
+
],
|
|
17
|
+
entry_points={
|
|
18
|
+
"console_scripts": [
|
|
19
|
+
"crowe-logic=cli.crowe_logic:main",
|
|
20
|
+
],
|
|
21
|
+
},
|
|
22
|
+
author="Michael Crowe",
|
|
23
|
+
description="Crowe Logic — Universal AI Agent powered by gpt-oss-120b on Azure AI Foundry",
|
|
24
|
+
)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Crowe Logic Agent — Complete Tool Suite
|
|
3
|
+
|
|
4
|
+
All functions are registered as FunctionTool with Azure AI Agent Service.
|
|
5
|
+
The agent auto-selects and executes them based on the conversation.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
# Core tools (filesystem, shell, web)
|
|
9
|
+
from tools.filesystem import read_file, write_file, edit_file, list_directory
|
|
10
|
+
from tools.shell import execute_shell
|
|
11
|
+
from tools.search import web_search, grep_search
|
|
12
|
+
from tools.browser import browse_url
|
|
13
|
+
|
|
14
|
+
# macOS automation
|
|
15
|
+
from tools.applescript import run_applescript, open_application, send_notification
|
|
16
|
+
|
|
17
|
+
# Git operations
|
|
18
|
+
from tools.git_ops import git_status, git_diff, git_log, git_commit, git_clone
|
|
19
|
+
|
|
20
|
+
# Browser automation (Playwright)
|
|
21
|
+
from tools.playwright_browser import (
|
|
22
|
+
browser_navigate, browser_click, browser_type_text,
|
|
23
|
+
browser_snapshot, browser_screenshot,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
# Talon Music Engine
|
|
27
|
+
from tools.talon_music import (
|
|
28
|
+
talon_compose, talon_import_midi, talon_analyze,
|
|
29
|
+
talon_transform, talon_export,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
# Quantum computing
|
|
33
|
+
from tools.quantum import run_quantum_circuit, synapse_evaluate, qubit_flow_execute
|
|
34
|
+
|
|
35
|
+
# All user-facing functions the agent can call
|
|
36
|
+
user_functions = {
|
|
37
|
+
# Filesystem
|
|
38
|
+
read_file, write_file, edit_file, list_directory,
|
|
39
|
+
# Shell
|
|
40
|
+
execute_shell,
|
|
41
|
+
# Search
|
|
42
|
+
web_search, grep_search,
|
|
43
|
+
# Web browsing
|
|
44
|
+
browse_url,
|
|
45
|
+
# macOS
|
|
46
|
+
run_applescript, open_application, send_notification,
|
|
47
|
+
# Git
|
|
48
|
+
git_status, git_diff, git_log, git_commit, git_clone,
|
|
49
|
+
# Playwright browser
|
|
50
|
+
browser_navigate, browser_click, browser_type_text,
|
|
51
|
+
browser_snapshot, browser_screenshot,
|
|
52
|
+
# Talon Music
|
|
53
|
+
talon_compose, talon_import_midi, talon_analyze,
|
|
54
|
+
talon_transform, talon_export,
|
|
55
|
+
# Quantum
|
|
56
|
+
run_quantum_circuit, synapse_evaluate, qubit_flow_execute,
|
|
57
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AppleScript tool — macOS automation via osascript.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import subprocess
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def run_applescript(script: str) -> str:
|
|
10
|
+
"""
|
|
11
|
+
Execute an AppleScript and return the result. Can automate any macOS
|
|
12
|
+
application — Finder, Safari, Terminal, System Events, etc.
|
|
13
|
+
|
|
14
|
+
:param script: The AppleScript code to execute.
|
|
15
|
+
:return: JSON with the script output or error.
|
|
16
|
+
:rtype: str
|
|
17
|
+
"""
|
|
18
|
+
try:
|
|
19
|
+
result = subprocess.run(
|
|
20
|
+
["osascript", "-e", script],
|
|
21
|
+
capture_output=True,
|
|
22
|
+
text=True,
|
|
23
|
+
timeout=30,
|
|
24
|
+
)
|
|
25
|
+
return json.dumps({
|
|
26
|
+
"stdout": result.stdout.strip(),
|
|
27
|
+
"stderr": result.stderr.strip() if result.stderr else "",
|
|
28
|
+
"return_code": result.returncode,
|
|
29
|
+
})
|
|
30
|
+
except subprocess.TimeoutExpired:
|
|
31
|
+
return json.dumps({"error": "AppleScript timed out after 30s"})
|
|
32
|
+
except Exception as e:
|
|
33
|
+
return json.dumps({"error": str(e)})
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def open_application(app_name: str) -> str:
|
|
37
|
+
"""
|
|
38
|
+
Open a macOS application by name. Works with any installed app.
|
|
39
|
+
|
|
40
|
+
:param app_name: Name of the application (e.g. "Ableton Live", "Safari", "Terminal").
|
|
41
|
+
:return: JSON confirmation.
|
|
42
|
+
:rtype: str
|
|
43
|
+
"""
|
|
44
|
+
script = f'tell application "{app_name}" to activate'
|
|
45
|
+
return run_applescript(script)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def send_notification(title: str, message: str) -> str:
|
|
49
|
+
"""
|
|
50
|
+
Send a macOS notification with a title and message.
|
|
51
|
+
|
|
52
|
+
:param title: The notification title.
|
|
53
|
+
:param message: The notification body text.
|
|
54
|
+
:return: JSON confirmation.
|
|
55
|
+
:rtype: str
|
|
56
|
+
"""
|
|
57
|
+
script = f'display notification "{message}" with title "{title}"'
|
|
58
|
+
return run_applescript(script)
|
package/tools/browser.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Browser tool — fetch and extract content from web pages.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def browse_url(url: str, extract_mode: str = "text") -> str:
|
|
9
|
+
"""
|
|
10
|
+
Fetch a web page and extract its content. Supports text extraction
|
|
11
|
+
and raw HTML modes.
|
|
12
|
+
|
|
13
|
+
:param url: The URL to fetch (must start with http:// or https://).
|
|
14
|
+
:param extract_mode: "text" for readable text, "html" for raw HTML, "links" for all links.
|
|
15
|
+
:return: JSON with the extracted page content.
|
|
16
|
+
:rtype: str
|
|
17
|
+
"""
|
|
18
|
+
import httpx
|
|
19
|
+
from bs4 import BeautifulSoup
|
|
20
|
+
|
|
21
|
+
if not url.startswith(("http://", "https://")):
|
|
22
|
+
url = "https://" + url
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
response = httpx.get(
|
|
26
|
+
url,
|
|
27
|
+
headers={
|
|
28
|
+
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) CroweLogic/0.1",
|
|
29
|
+
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
30
|
+
},
|
|
31
|
+
timeout=20,
|
|
32
|
+
follow_redirects=True,
|
|
33
|
+
)
|
|
34
|
+
response.raise_for_status()
|
|
35
|
+
|
|
36
|
+
content_type = response.headers.get("content-type", "")
|
|
37
|
+
if "json" in content_type:
|
|
38
|
+
return json.dumps({
|
|
39
|
+
"url": str(response.url),
|
|
40
|
+
"status": response.status_code,
|
|
41
|
+
"content_type": "json",
|
|
42
|
+
"body": response.json(),
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
soup = BeautifulSoup(response.text, "html.parser")
|
|
46
|
+
|
|
47
|
+
# Remove script, style, nav, footer noise
|
|
48
|
+
for tag in soup(["script", "style", "nav", "footer", "header", "aside"]):
|
|
49
|
+
tag.decompose()
|
|
50
|
+
|
|
51
|
+
if extract_mode == "html":
|
|
52
|
+
body = str(soup)[:100000]
|
|
53
|
+
return json.dumps({"url": str(response.url), "status": response.status_code, "html": body})
|
|
54
|
+
|
|
55
|
+
if extract_mode == "links":
|
|
56
|
+
links = []
|
|
57
|
+
for a in soup.find_all("a", href=True):
|
|
58
|
+
links.append({"text": a.get_text(strip=True)[:200], "href": a["href"]})
|
|
59
|
+
return json.dumps({"url": str(response.url), "status": response.status_code, "links": links[:200]})
|
|
60
|
+
|
|
61
|
+
# Default: text extraction
|
|
62
|
+
title = soup.title.string.strip() if soup.title and soup.title.string else ""
|
|
63
|
+
text = soup.get_text(separator="\n", strip=True)
|
|
64
|
+
# Collapse multiple blank lines
|
|
65
|
+
lines = [line for line in text.splitlines() if line.strip()]
|
|
66
|
+
text = "\n".join(lines)
|
|
67
|
+
if len(text) > 80000:
|
|
68
|
+
text = text[:80000] + "\n... (content truncated)"
|
|
69
|
+
|
|
70
|
+
return json.dumps({
|
|
71
|
+
"url": str(response.url),
|
|
72
|
+
"status": response.status_code,
|
|
73
|
+
"title": title,
|
|
74
|
+
"text": text,
|
|
75
|
+
})
|
|
76
|
+
except httpx.HTTPStatusError as e:
|
|
77
|
+
return json.dumps({"error": f"HTTP {e.response.status_code}: {str(e)}", "url": url})
|
|
78
|
+
except Exception as e:
|
|
79
|
+
return json.dumps({"error": str(e), "url": url})
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Filesystem tools — read, write, edit, and list files.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def read_file(file_path: str, offset: int = 0, limit: int = 0) -> str:
|
|
11
|
+
"""
|
|
12
|
+
Read the contents of a file. Returns the file content as a string.
|
|
13
|
+
|
|
14
|
+
:param file_path: Absolute path to the file to read.
|
|
15
|
+
:param offset: Line number to start reading from (0-based, default 0).
|
|
16
|
+
:param limit: Maximum number of lines to return (0 = unlimited).
|
|
17
|
+
:return: File contents with line numbers prefixed.
|
|
18
|
+
:rtype: str
|
|
19
|
+
"""
|
|
20
|
+
path = Path(file_path).expanduser().resolve()
|
|
21
|
+
if not path.exists():
|
|
22
|
+
return json.dumps({"error": f"File not found: {file_path}"})
|
|
23
|
+
if not path.is_file():
|
|
24
|
+
return json.dumps({"error": f"Not a file: {file_path}"})
|
|
25
|
+
try:
|
|
26
|
+
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
27
|
+
if offset > 0:
|
|
28
|
+
lines = lines[offset:]
|
|
29
|
+
if limit > 0:
|
|
30
|
+
lines = lines[:limit]
|
|
31
|
+
numbered = [f"{i + offset + 1:>6}\t{line}" for i, line in enumerate(lines)]
|
|
32
|
+
return "\n".join(numbered)
|
|
33
|
+
except Exception as e:
|
|
34
|
+
return json.dumps({"error": str(e)})
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def write_file(file_path: str, content: str) -> str:
|
|
38
|
+
"""
|
|
39
|
+
Write content to a file, creating it if it doesn't exist.
|
|
40
|
+
Overwrites existing content.
|
|
41
|
+
|
|
42
|
+
:param file_path: Absolute path to the file to write.
|
|
43
|
+
:param content: The full content to write to the file.
|
|
44
|
+
:return: Confirmation message with bytes written.
|
|
45
|
+
:rtype: str
|
|
46
|
+
"""
|
|
47
|
+
path = Path(file_path).expanduser().resolve()
|
|
48
|
+
try:
|
|
49
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
path.write_text(content, encoding="utf-8")
|
|
51
|
+
return json.dumps({"success": True, "path": str(path), "bytes": len(content.encode("utf-8"))})
|
|
52
|
+
except Exception as e:
|
|
53
|
+
return json.dumps({"error": str(e)})
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def edit_file(file_path: str, old_string: str, new_string: str) -> str:
|
|
57
|
+
"""
|
|
58
|
+
Replace an exact string in a file. The old_string must appear exactly once
|
|
59
|
+
in the file for the replacement to succeed.
|
|
60
|
+
|
|
61
|
+
:param file_path: Absolute path to the file to edit.
|
|
62
|
+
:param old_string: The exact text to find and replace.
|
|
63
|
+
:param new_string: The replacement text.
|
|
64
|
+
:return: Confirmation or error message.
|
|
65
|
+
:rtype: str
|
|
66
|
+
"""
|
|
67
|
+
path = Path(file_path).expanduser().resolve()
|
|
68
|
+
if not path.exists():
|
|
69
|
+
return json.dumps({"error": f"File not found: {file_path}"})
|
|
70
|
+
try:
|
|
71
|
+
text = path.read_text(encoding="utf-8")
|
|
72
|
+
count = text.count(old_string)
|
|
73
|
+
if count == 0:
|
|
74
|
+
return json.dumps({"error": "old_string not found in file"})
|
|
75
|
+
if count > 1:
|
|
76
|
+
return json.dumps({"error": f"old_string found {count} times — must be unique. Provide more context."})
|
|
77
|
+
new_text = text.replace(old_string, new_string, 1)
|
|
78
|
+
path.write_text(new_text, encoding="utf-8")
|
|
79
|
+
return json.dumps({"success": True, "path": str(path)})
|
|
80
|
+
except Exception as e:
|
|
81
|
+
return json.dumps({"error": str(e)})
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def list_directory(directory_path: str, pattern: str = "*", recursive: bool = False) -> str:
|
|
85
|
+
"""
|
|
86
|
+
List files and directories at the given path. Supports glob patterns.
|
|
87
|
+
|
|
88
|
+
:param directory_path: Absolute path to the directory to list.
|
|
89
|
+
:param pattern: Glob pattern to filter results (default "*").
|
|
90
|
+
:param recursive: If true, search recursively using "**/" prefix.
|
|
91
|
+
:return: JSON list of file/directory entries with type and size.
|
|
92
|
+
:rtype: str
|
|
93
|
+
"""
|
|
94
|
+
path = Path(directory_path).expanduser().resolve()
|
|
95
|
+
if not path.exists():
|
|
96
|
+
return json.dumps({"error": f"Directory not found: {directory_path}"})
|
|
97
|
+
if not path.is_dir():
|
|
98
|
+
return json.dumps({"error": f"Not a directory: {directory_path}"})
|
|
99
|
+
try:
|
|
100
|
+
if recursive:
|
|
101
|
+
entries = sorted(path.rglob(pattern))
|
|
102
|
+
else:
|
|
103
|
+
entries = sorted(path.glob(pattern))
|
|
104
|
+
|
|
105
|
+
results = []
|
|
106
|
+
for entry in entries[:500]: # Cap at 500 to avoid huge outputs
|
|
107
|
+
results.append({
|
|
108
|
+
"name": entry.name,
|
|
109
|
+
"path": str(entry),
|
|
110
|
+
"type": "dir" if entry.is_dir() else "file",
|
|
111
|
+
"size": entry.stat().st_size if entry.is_file() else None,
|
|
112
|
+
})
|
|
113
|
+
return json.dumps({"count": len(results), "entries": results})
|
|
114
|
+
except Exception as e:
|
|
115
|
+
return json.dumps({"error": str(e)})
|
package/tools/git_ops.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Git operations tool — repo management, commits, branches, diffs.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import subprocess
|
|
7
|
+
import os
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def git_status(repo_path: str) -> str:
|
|
11
|
+
"""
|
|
12
|
+
Show the working tree status of a git repository.
|
|
13
|
+
|
|
14
|
+
:param repo_path: Absolute path to the git repository.
|
|
15
|
+
:return: JSON with status output (staged, unstaged, untracked files).
|
|
16
|
+
:rtype: str
|
|
17
|
+
"""
|
|
18
|
+
return _git(repo_path, ["status", "--porcelain=v2", "--branch"])
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def git_diff(repo_path: str, staged: bool = False) -> str:
|
|
22
|
+
"""
|
|
23
|
+
Show changes in the working directory or staging area.
|
|
24
|
+
|
|
25
|
+
:param repo_path: Absolute path to the git repository.
|
|
26
|
+
:param staged: If true, show staged changes. Default shows unstaged.
|
|
27
|
+
:return: JSON with diff output.
|
|
28
|
+
:rtype: str
|
|
29
|
+
"""
|
|
30
|
+
cmd = ["diff", "--stat"]
|
|
31
|
+
if staged:
|
|
32
|
+
cmd.append("--cached")
|
|
33
|
+
return _git(repo_path, cmd)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def git_log(repo_path: str, count: int = 10) -> str:
|
|
37
|
+
"""
|
|
38
|
+
Show recent commit history.
|
|
39
|
+
|
|
40
|
+
:param repo_path: Absolute path to the git repository.
|
|
41
|
+
:param count: Number of commits to show (default 10).
|
|
42
|
+
:return: JSON with commit history (hash, author, date, message).
|
|
43
|
+
:rtype: str
|
|
44
|
+
"""
|
|
45
|
+
count = min(count, 50)
|
|
46
|
+
return _git(repo_path, ["log", f"-{count}", "--oneline", "--decorate"])
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def git_commit(repo_path: str, message: str, files: str = ".") -> str:
|
|
50
|
+
"""
|
|
51
|
+
Stage files and create a commit.
|
|
52
|
+
|
|
53
|
+
:param repo_path: Absolute path to the git repository.
|
|
54
|
+
:param message: The commit message.
|
|
55
|
+
:param files: Files to stage (default "." for all changes).
|
|
56
|
+
:return: JSON with commit result.
|
|
57
|
+
:rtype: str
|
|
58
|
+
"""
|
|
59
|
+
add_result = _git(repo_path, ["add", files])
|
|
60
|
+
if "error" in add_result:
|
|
61
|
+
return add_result
|
|
62
|
+
return _git(repo_path, ["commit", "-m", message])
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def git_clone(url: str, target_path: str) -> str:
|
|
66
|
+
"""
|
|
67
|
+
Clone a git repository.
|
|
68
|
+
|
|
69
|
+
:param url: The repository URL to clone.
|
|
70
|
+
:param target_path: Local path to clone into.
|
|
71
|
+
:return: JSON with clone result.
|
|
72
|
+
:rtype: str
|
|
73
|
+
"""
|
|
74
|
+
try:
|
|
75
|
+
result = subprocess.run(
|
|
76
|
+
["git", "clone", url, target_path],
|
|
77
|
+
capture_output=True, text=True, timeout=120,
|
|
78
|
+
)
|
|
79
|
+
return json.dumps({
|
|
80
|
+
"stdout": result.stdout.strip(),
|
|
81
|
+
"stderr": result.stderr.strip(),
|
|
82
|
+
"return_code": result.returncode,
|
|
83
|
+
})
|
|
84
|
+
except Exception as e:
|
|
85
|
+
return json.dumps({"error": str(e)})
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _git(repo_path: str, args: list) -> str:
|
|
89
|
+
"""Run a git command in the given repo."""
|
|
90
|
+
repo = os.path.expanduser(repo_path)
|
|
91
|
+
if not os.path.isdir(os.path.join(repo, ".git")):
|
|
92
|
+
return json.dumps({"error": f"Not a git repository: {repo_path}"})
|
|
93
|
+
try:
|
|
94
|
+
result = subprocess.run(
|
|
95
|
+
["git"] + args,
|
|
96
|
+
capture_output=True, text=True, timeout=30, cwd=repo,
|
|
97
|
+
)
|
|
98
|
+
output = result.stdout.strip()
|
|
99
|
+
if len(output) > 50000:
|
|
100
|
+
output = output[:50000] + "\n... (truncated)"
|
|
101
|
+
return json.dumps({
|
|
102
|
+
"output": output,
|
|
103
|
+
"stderr": result.stderr.strip() if result.stderr else "",
|
|
104
|
+
"return_code": result.returncode,
|
|
105
|
+
})
|
|
106
|
+
except Exception as e:
|
|
107
|
+
return json.dumps({"error": str(e)})
|