oadson 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.
- oadson/__init__.py +2 -0
- oadson/agent.py +246 -0
- oadson/cli.py +431 -0
- oadson/config.py +73 -0
- oadson/context.py +144 -0
- oadson/executor.py +367 -0
- oadson-1.0.0.dist-info/METADATA +118 -0
- oadson-1.0.0.dist-info/RECORD +11 -0
- oadson-1.0.0.dist-info/WHEEL +5 -0
- oadson-1.0.0.dist-info/entry_points.txt +2 -0
- oadson-1.0.0.dist-info/top_level.txt +1 -0
oadson/config.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""
|
|
2
|
+
OADSON config — reads/writes ~/.oadson/config.json
|
|
3
|
+
"""
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
CONFIG_DIR = Path.home() / ".oadson"
|
|
9
|
+
CONFIG_FILE = CONFIG_DIR / "config.json"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def get_config() -> dict:
|
|
13
|
+
if CONFIG_FILE.exists():
|
|
14
|
+
try:
|
|
15
|
+
return json.loads(CONFIG_FILE.read_text())
|
|
16
|
+
except Exception:
|
|
17
|
+
return {}
|
|
18
|
+
return {}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def save_config(cfg: dict):
|
|
22
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
23
|
+
CONFIG_FILE.write_text(json.dumps(cfg, indent=2))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def get_backend_url() -> str:
|
|
27
|
+
return get_config().get("backend_url", "").rstrip("/")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def get_token() -> str:
|
|
31
|
+
return get_config().get("token", "")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def get_permissions() -> dict:
|
|
35
|
+
"""
|
|
36
|
+
Permission system for local tool execution.
|
|
37
|
+
|
|
38
|
+
auto_allow — run without prompting
|
|
39
|
+
confirm — show diff/plan, wait for y/n
|
|
40
|
+
always_backup — copy to .oadson_backup/ before touching
|
|
41
|
+
never_allow — block outright (no override)
|
|
42
|
+
"""
|
|
43
|
+
defaults = {
|
|
44
|
+
"auto_allow": [
|
|
45
|
+
"read_file", "list_dir", "git_status",
|
|
46
|
+
"git_diff", "search_files", "run_read_only",
|
|
47
|
+
"pwd", "ls", "cat", "find", "grep",
|
|
48
|
+
"head", "tail", "wc", "echo", "which",
|
|
49
|
+
],
|
|
50
|
+
"confirm": [
|
|
51
|
+
"write_file", "create_file", "run_bash",
|
|
52
|
+
"git_commit", "git_push", "pip_install",
|
|
53
|
+
"npm_install",
|
|
54
|
+
],
|
|
55
|
+
"always_backup": [
|
|
56
|
+
"delete_file", "overwrite_file",
|
|
57
|
+
],
|
|
58
|
+
"never_allow": [
|
|
59
|
+
"rm -rf /", "rm -rf ~", ":(){ :|: & };:",
|
|
60
|
+
"dd if=", "mkfs", "format",
|
|
61
|
+
],
|
|
62
|
+
}
|
|
63
|
+
cfg_perms = get_config().get("permissions", {})
|
|
64
|
+
# Merge user overrides with defaults
|
|
65
|
+
for key in defaults:
|
|
66
|
+
if key in cfg_perms:
|
|
67
|
+
defaults[key] = cfg_perms[key]
|
|
68
|
+
return defaults
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def is_configured() -> bool:
|
|
72
|
+
cfg = get_config()
|
|
73
|
+
return bool(cfg.get("backend_url") and cfg.get("token"))
|
oadson/context.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""
|
|
2
|
+
context.py — Auto shell context collector.
|
|
3
|
+
|
|
4
|
+
Runs silently on every request. OADSON sees your shell state
|
|
5
|
+
without you having to describe it. Mirrors how Claude Code works —
|
|
6
|
+
it already knows your cwd, git branch, last error before you type.
|
|
7
|
+
"""
|
|
8
|
+
import os
|
|
9
|
+
import subprocess
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _run(cmd: str, timeout: int = 5) -> str:
|
|
14
|
+
try:
|
|
15
|
+
r = subprocess.run(
|
|
16
|
+
cmd, shell=True, capture_output=True,
|
|
17
|
+
text=True, timeout=timeout
|
|
18
|
+
)
|
|
19
|
+
return r.stdout.strip()
|
|
20
|
+
except Exception:
|
|
21
|
+
return ""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _run_err(cmd: str, timeout: int = 5) -> str:
|
|
25
|
+
try:
|
|
26
|
+
r = subprocess.run(
|
|
27
|
+
cmd, shell=True, capture_output=True,
|
|
28
|
+
text=True, timeout=timeout
|
|
29
|
+
)
|
|
30
|
+
return (r.stdout + r.stderr).strip()
|
|
31
|
+
except Exception:
|
|
32
|
+
return ""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def collect(last_exit_code: int = 0, last_command: str = "") -> dict:
|
|
36
|
+
"""
|
|
37
|
+
Collect shell context. Called before every backend request.
|
|
38
|
+
Fast — all subprocesses have a 5s timeout and failures are silent.
|
|
39
|
+
"""
|
|
40
|
+
cwd = os.getcwd()
|
|
41
|
+
home = str(Path.home())
|
|
42
|
+
|
|
43
|
+
# Git context
|
|
44
|
+
git_branch = _run("git rev-parse --abbrev-ref HEAD 2>/dev/null")
|
|
45
|
+
git_status = _run("git status --short 2>/dev/null")
|
|
46
|
+
git_diff_stat = _run("git diff --stat HEAD 2>/dev/null | tail -1")
|
|
47
|
+
|
|
48
|
+
# Project type detection
|
|
49
|
+
project_type = _detect_project_type(cwd)
|
|
50
|
+
|
|
51
|
+
# Recent files changed (git-aware)
|
|
52
|
+
recent_files = _run("git diff --name-only HEAD 2>/dev/null | head -10")
|
|
53
|
+
if not recent_files:
|
|
54
|
+
# Fallback: recently modified files
|
|
55
|
+
recent_files = _run(
|
|
56
|
+
"find . -maxdepth 2 -newer . -type f "
|
|
57
|
+
"! -path './.git/*' ! -path './node_modules/*' "
|
|
58
|
+
"! -path './__pycache__/*' 2>/dev/null | head -10"
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
# Directory tree (shallow)
|
|
62
|
+
tree = _run(
|
|
63
|
+
"find . -maxdepth 2 -not -path './.git/*' "
|
|
64
|
+
"-not -path './node_modules/*' "
|
|
65
|
+
"-not -path './__pycache__/*' "
|
|
66
|
+
"-not -name '*.pyc' 2>/dev/null | head -40"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
ctx = {
|
|
70
|
+
"cwd": cwd.replace(home, "~"),
|
|
71
|
+
"shell": os.environ.get("SHELL", "bash"),
|
|
72
|
+
"os": _run("uname -s") or "Linux",
|
|
73
|
+
"project_type": project_type,
|
|
74
|
+
"last_exit_code": last_exit_code,
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if last_command:
|
|
78
|
+
ctx["last_command"] = last_command
|
|
79
|
+
|
|
80
|
+
if git_branch:
|
|
81
|
+
ctx["git"] = {
|
|
82
|
+
"branch": git_branch,
|
|
83
|
+
"status": git_status or "clean",
|
|
84
|
+
"diff_stat": git_diff_stat or "",
|
|
85
|
+
"changed_files": recent_files or "",
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if tree:
|
|
89
|
+
ctx["directory_tree"] = tree
|
|
90
|
+
|
|
91
|
+
return ctx
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _detect_project_type(cwd: str) -> str:
|
|
95
|
+
p = Path(cwd)
|
|
96
|
+
checks = [
|
|
97
|
+
(p / "package.json", "node"),
|
|
98
|
+
(p / "pyproject.toml", "python"),
|
|
99
|
+
(p / "requirements.txt", "python"),
|
|
100
|
+
(p / "setup.py", "python"),
|
|
101
|
+
(p / "Cargo.toml", "rust"),
|
|
102
|
+
(p / "go.mod", "go"),
|
|
103
|
+
(p / "pom.xml", "java"),
|
|
104
|
+
(p / "Dockerfile", "docker"),
|
|
105
|
+
(p / "docker-compose.yml", "docker"),
|
|
106
|
+
(p / "main.py", "python"),
|
|
107
|
+
(p / "index.js", "node"),
|
|
108
|
+
(p / "index.ts", "typescript"),
|
|
109
|
+
]
|
|
110
|
+
for path, kind in checks:
|
|
111
|
+
if path.exists():
|
|
112
|
+
return kind
|
|
113
|
+
return "unknown"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def format_for_prompt(ctx: dict) -> str:
|
|
117
|
+
"""
|
|
118
|
+
Format context into a compact system prompt block.
|
|
119
|
+
Injected into every request so OADSON knows your shell state.
|
|
120
|
+
"""
|
|
121
|
+
lines = [
|
|
122
|
+
"=== SHELL CONTEXT ===",
|
|
123
|
+
f"cwd: {ctx['cwd']}",
|
|
124
|
+
f"project: {ctx.get('project_type', 'unknown')}",
|
|
125
|
+
f"shell: {ctx.get('shell', 'bash')}",
|
|
126
|
+
f"os: {ctx.get('os', 'Linux')}",
|
|
127
|
+
f"last_exit_code: {ctx.get('last_exit_code', 0)}",
|
|
128
|
+
]
|
|
129
|
+
|
|
130
|
+
if ctx.get("last_command"):
|
|
131
|
+
lines.append(f"last_command: {ctx['last_command']}")
|
|
132
|
+
|
|
133
|
+
if ctx.get("git"):
|
|
134
|
+
g = ctx["git"]
|
|
135
|
+
lines.append(f"git_branch: {g['branch']}")
|
|
136
|
+
lines.append(f"git_status: {g['status']}")
|
|
137
|
+
if g.get("changed_files"):
|
|
138
|
+
lines.append(f"changed_files:\n{g['changed_files']}")
|
|
139
|
+
|
|
140
|
+
if ctx.get("directory_tree"):
|
|
141
|
+
lines.append(f"directory_tree:\n{ctx['directory_tree']}")
|
|
142
|
+
|
|
143
|
+
lines.append("=== END CONTEXT ===")
|
|
144
|
+
return "\n".join(lines)
|
oadson/executor.py
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
"""
|
|
2
|
+
executor.py — Local tool executor.
|
|
3
|
+
|
|
4
|
+
This is the Claude Code equivalent for OADSON.
|
|
5
|
+
Tools are executed locally on YOUR machine, not on Railway.
|
|
6
|
+
Railway sends the plan; this file carries it out — with your permission.
|
|
7
|
+
|
|
8
|
+
Permission tiers:
|
|
9
|
+
auto_allow → runs silently, no prompt
|
|
10
|
+
confirm → shows what will change, waits for y/n
|
|
11
|
+
always_backup → copies file first, then confirms
|
|
12
|
+
never_allow → blocked, no override
|
|
13
|
+
"""
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import shutil
|
|
17
|
+
import subprocess
|
|
18
|
+
from datetime import datetime
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Optional
|
|
21
|
+
|
|
22
|
+
from rich.console import Console
|
|
23
|
+
from rich.syntax import Syntax
|
|
24
|
+
from rich.panel import Panel
|
|
25
|
+
from rich.prompt import Confirm
|
|
26
|
+
|
|
27
|
+
from oadson.config import get_permissions
|
|
28
|
+
|
|
29
|
+
console = Console()
|
|
30
|
+
|
|
31
|
+
BACKUP_DIR = Path.home() / ".oadson" / "backups"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ── TOOL REGISTRY ──
|
|
35
|
+
# Maps tool names the AI can call → local handler functions.
|
|
36
|
+
|
|
37
|
+
def _backup_file(path: str) -> Optional[str]:
|
|
38
|
+
"""Copy file to ~/.oadson/backups/ before touching it."""
|
|
39
|
+
src = Path(path)
|
|
40
|
+
if not src.exists():
|
|
41
|
+
return None
|
|
42
|
+
BACKUP_DIR.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
44
|
+
dest = BACKUP_DIR / f"{src.name}.{ts}.bak"
|
|
45
|
+
shutil.copy2(src, dest)
|
|
46
|
+
return str(dest)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _show_diff(path: str, new_content: str):
|
|
50
|
+
"""Show a unified diff of current file vs proposed content."""
|
|
51
|
+
src = Path(path)
|
|
52
|
+
if src.exists():
|
|
53
|
+
old_lines = src.read_text().splitlines(keepends=True)
|
|
54
|
+
else:
|
|
55
|
+
old_lines = []
|
|
56
|
+
new_lines = new_content.splitlines(keepends=True)
|
|
57
|
+
|
|
58
|
+
import difflib
|
|
59
|
+
diff = list(difflib.unified_diff(
|
|
60
|
+
old_lines, new_lines,
|
|
61
|
+
fromfile=f"current: {path}",
|
|
62
|
+
tofile=f"proposed: {path}",
|
|
63
|
+
lineterm=""
|
|
64
|
+
))
|
|
65
|
+
if diff:
|
|
66
|
+
diff_text = "\n".join(diff)
|
|
67
|
+
console.print(Syntax(diff_text, "diff", theme="monokai"))
|
|
68
|
+
else:
|
|
69
|
+
console.print("[dim]No changes.[/dim]")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _is_never_allowed(command: str, perms: dict) -> bool:
|
|
73
|
+
return any(blocked in command for blocked in perms.get("never_allow", []))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _get_tier(tool_name: str, command: str, perms: dict) -> str:
|
|
77
|
+
"""Determine permission tier for this tool call."""
|
|
78
|
+
# Check never_allow first
|
|
79
|
+
if command and _is_never_allowed(command, perms):
|
|
80
|
+
return "never"
|
|
81
|
+
|
|
82
|
+
for item in perms.get("never_allow", []):
|
|
83
|
+
if item in tool_name:
|
|
84
|
+
return "never"
|
|
85
|
+
|
|
86
|
+
for item in perms.get("always_backup", []):
|
|
87
|
+
if item in tool_name:
|
|
88
|
+
return "backup"
|
|
89
|
+
|
|
90
|
+
for item in perms.get("confirm", []):
|
|
91
|
+
if item in tool_name:
|
|
92
|
+
return "confirm"
|
|
93
|
+
|
|
94
|
+
for item in perms.get("auto_allow", []):
|
|
95
|
+
if item in tool_name:
|
|
96
|
+
return "auto"
|
|
97
|
+
|
|
98
|
+
# Default: confirm anything unknown
|
|
99
|
+
return "confirm"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# ── INDIVIDUAL TOOL HANDLERS ──
|
|
103
|
+
|
|
104
|
+
def tool_read_file(args: dict) -> dict:
|
|
105
|
+
path = args.get("path", "")
|
|
106
|
+
try:
|
|
107
|
+
content = Path(path).read_text()
|
|
108
|
+
return {"status": "success", "content": content, "path": path}
|
|
109
|
+
except FileNotFoundError:
|
|
110
|
+
return {"status": "error", "reason": f"File not found: {path}"}
|
|
111
|
+
except Exception as e:
|
|
112
|
+
return {"status": "error", "reason": str(e)}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def tool_write_file(args: dict) -> dict:
|
|
116
|
+
path = args.get("path", "")
|
|
117
|
+
content = args.get("content", "")
|
|
118
|
+
perms = get_permissions()
|
|
119
|
+
tier = _get_tier("write_file", "", perms)
|
|
120
|
+
|
|
121
|
+
if tier == "never":
|
|
122
|
+
return {"status": "blocked", "reason": "write_file is in never_allow"}
|
|
123
|
+
|
|
124
|
+
# Show diff
|
|
125
|
+
console.print(f"\n[bold yellow]⚡ OADSON wants to write:[/bold yellow] {path}")
|
|
126
|
+
_show_diff(path, content)
|
|
127
|
+
|
|
128
|
+
if tier == "backup":
|
|
129
|
+
backup = _backup_file(path)
|
|
130
|
+
if backup:
|
|
131
|
+
console.print(f"[dim]Backup saved to: {backup}[/dim]")
|
|
132
|
+
|
|
133
|
+
if tier in ("confirm", "backup"):
|
|
134
|
+
if not Confirm.ask("[bold]Apply this change?[/bold]"):
|
|
135
|
+
return {"status": "cancelled", "reason": "User declined"}
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
p = Path(path)
|
|
139
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
140
|
+
p.write_text(content)
|
|
141
|
+
return {"status": "success", "path": path, "bytes": len(content)}
|
|
142
|
+
except Exception as e:
|
|
143
|
+
return {"status": "error", "reason": str(e)}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def tool_create_file(args: dict) -> dict:
|
|
147
|
+
"""Same as write_file but makes intent explicit."""
|
|
148
|
+
path = args.get("path", "")
|
|
149
|
+
content = args.get("content", "")
|
|
150
|
+
perms = get_permissions()
|
|
151
|
+
|
|
152
|
+
if Path(path).exists():
|
|
153
|
+
console.print(f"[bold yellow]⚠ File exists:[/bold yellow] {path} — treating as overwrite")
|
|
154
|
+
return tool_write_file(args)
|
|
155
|
+
|
|
156
|
+
console.print(f"\n[bold green]✨ OADSON wants to create:[/bold green] {path}")
|
|
157
|
+
if content:
|
|
158
|
+
lang = _detect_lang(path)
|
|
159
|
+
console.print(Panel(
|
|
160
|
+
Syntax(content, lang, theme="monokai", line_numbers=True),
|
|
161
|
+
title=f"New file: {path}",
|
|
162
|
+
border_style="green"
|
|
163
|
+
))
|
|
164
|
+
|
|
165
|
+
tier = _get_tier("create_file", "", perms)
|
|
166
|
+
if tier in ("confirm", "backup"):
|
|
167
|
+
if not Confirm.ask("[bold]Create this file?[/bold]"):
|
|
168
|
+
return {"status": "cancelled", "reason": "User declined"}
|
|
169
|
+
|
|
170
|
+
try:
|
|
171
|
+
p = Path(path)
|
|
172
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
173
|
+
p.write_text(content)
|
|
174
|
+
return {"status": "success", "path": path, "created": True}
|
|
175
|
+
except Exception as e:
|
|
176
|
+
return {"status": "error", "reason": str(e)}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def tool_delete_file(args: dict) -> dict:
|
|
180
|
+
path = args.get("path", "")
|
|
181
|
+
perms = get_permissions()
|
|
182
|
+
|
|
183
|
+
if not Path(path).exists():
|
|
184
|
+
return {"status": "error", "reason": f"File not found: {path}"}
|
|
185
|
+
|
|
186
|
+
# Always backup before delete
|
|
187
|
+
backup = _backup_file(path)
|
|
188
|
+
console.print(f"\n[bold red]🗑 OADSON wants to DELETE:[/bold red] {path}")
|
|
189
|
+
if backup:
|
|
190
|
+
console.print(f"[dim]Backup saved to: {backup}[/dim]")
|
|
191
|
+
|
|
192
|
+
if not Confirm.ask("[bold red]Confirm DELETE?[/bold red]"):
|
|
193
|
+
return {"status": "cancelled", "reason": "User declined"}
|
|
194
|
+
|
|
195
|
+
try:
|
|
196
|
+
Path(path).unlink()
|
|
197
|
+
return {"status": "success", "path": path, "deleted": True, "backup": backup}
|
|
198
|
+
except Exception as e:
|
|
199
|
+
return {"status": "error", "reason": str(e)}
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def tool_list_dir(args: dict) -> dict:
|
|
203
|
+
path = args.get("path", ".")
|
|
204
|
+
try:
|
|
205
|
+
p = Path(path)
|
|
206
|
+
items = []
|
|
207
|
+
for item in sorted(p.iterdir()):
|
|
208
|
+
items.append({
|
|
209
|
+
"name": item.name,
|
|
210
|
+
"type": "dir" if item.is_dir() else "file",
|
|
211
|
+
"size": item.stat().st_size if item.is_file() else None,
|
|
212
|
+
})
|
|
213
|
+
return {"status": "success", "path": str(p.resolve()), "items": items}
|
|
214
|
+
except Exception as e:
|
|
215
|
+
return {"status": "error", "reason": str(e)}
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def tool_run_bash(args: dict) -> dict:
|
|
219
|
+
"""
|
|
220
|
+
Run a bash command locally.
|
|
221
|
+
Always confirms unless the command is in auto_allow patterns.
|
|
222
|
+
Shows exactly what will run before running it.
|
|
223
|
+
"""
|
|
224
|
+
command = args.get("command", "").strip()
|
|
225
|
+
if not command:
|
|
226
|
+
return {"status": "error", "reason": "No command provided"}
|
|
227
|
+
|
|
228
|
+
perms = get_permissions()
|
|
229
|
+
tier = _get_tier("run_bash", command, perms)
|
|
230
|
+
|
|
231
|
+
if tier == "never":
|
|
232
|
+
return {"status": "blocked", "reason": f"Command blocked by never_allow: {command}"}
|
|
233
|
+
|
|
234
|
+
# Check auto_allow patterns
|
|
235
|
+
if tier == "auto":
|
|
236
|
+
# Double-check: if it has destructive patterns, force confirm anyway
|
|
237
|
+
destructive = ["rm ", "rmdir", "mv ", "chmod 777", "> /", "dd if"]
|
|
238
|
+
if any(d in command for d in destructive):
|
|
239
|
+
tier = "confirm"
|
|
240
|
+
|
|
241
|
+
if tier in ("confirm", "backup"):
|
|
242
|
+
console.print(f"\n[bold yellow]⚡ OADSON wants to run:[/bold yellow]")
|
|
243
|
+
console.print(Panel(
|
|
244
|
+
Syntax(command, "bash", theme="monokai"),
|
|
245
|
+
border_style="yellow"
|
|
246
|
+
))
|
|
247
|
+
if not Confirm.ask("[bold]Run this command?[/bold]"):
|
|
248
|
+
return {"status": "cancelled", "reason": "User declined"}
|
|
249
|
+
|
|
250
|
+
try:
|
|
251
|
+
result = subprocess.run(
|
|
252
|
+
command,
|
|
253
|
+
shell=True,
|
|
254
|
+
capture_output=True,
|
|
255
|
+
text=True,
|
|
256
|
+
timeout=60,
|
|
257
|
+
cwd=os.getcwd(),
|
|
258
|
+
)
|
|
259
|
+
return {
|
|
260
|
+
"status": "success" if result.returncode == 0 else "error",
|
|
261
|
+
"command": command,
|
|
262
|
+
"stdout": result.stdout,
|
|
263
|
+
"stderr": result.stderr or None,
|
|
264
|
+
"return_code": result.returncode,
|
|
265
|
+
}
|
|
266
|
+
except subprocess.TimeoutExpired:
|
|
267
|
+
return {"status": "timeout", "command": command, "reason": "Command exceeded 60s"}
|
|
268
|
+
except Exception as e:
|
|
269
|
+
return {"status": "error", "command": command, "reason": str(e)}
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def tool_search_files(args: dict) -> dict:
|
|
273
|
+
pattern = args.get("pattern", "")
|
|
274
|
+
path = args.get("path", ".")
|
|
275
|
+
try:
|
|
276
|
+
result = subprocess.run(
|
|
277
|
+
f"grep -rn {repr(pattern)} {path} --include='*.py' --include='*.js' "
|
|
278
|
+
f"--include='*.ts' --include='*.json' --include='*.md' "
|
|
279
|
+
f"--exclude-dir=node_modules --exclude-dir=.git 2>/dev/null | head -50",
|
|
280
|
+
shell=True, capture_output=True, text=True, timeout=10
|
|
281
|
+
)
|
|
282
|
+
return {"status": "success", "matches": result.stdout}
|
|
283
|
+
except Exception as e:
|
|
284
|
+
return {"status": "error", "reason": str(e)}
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def tool_git_status(args: dict) -> dict:
|
|
288
|
+
try:
|
|
289
|
+
status = subprocess.run("git status", shell=True, capture_output=True, text=True).stdout
|
|
290
|
+
branch = subprocess.run("git branch --show-current", shell=True, capture_output=True, text=True).stdout.strip()
|
|
291
|
+
return {"status": "success", "branch": branch, "output": status}
|
|
292
|
+
except Exception as e:
|
|
293
|
+
return {"status": "error", "reason": str(e)}
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def tool_git_diff(args: dict) -> dict:
|
|
297
|
+
path = args.get("path", "")
|
|
298
|
+
cmd = f"git diff {path}" if path else "git diff"
|
|
299
|
+
try:
|
|
300
|
+
result = subprocess.run(cmd, shell=True, capture_output=True, text=True)
|
|
301
|
+
return {"status": "success", "diff": result.stdout}
|
|
302
|
+
except Exception as e:
|
|
303
|
+
return {"status": "error", "reason": str(e)}
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def tool_http_request(args: dict) -> dict:
|
|
307
|
+
"""Fire HTTP requests — useful for testing your Railway API."""
|
|
308
|
+
import httpx
|
|
309
|
+
method = args.get("method", "GET").upper()
|
|
310
|
+
url = args.get("url", "")
|
|
311
|
+
headers = args.get("headers", {})
|
|
312
|
+
body = args.get("body", None)
|
|
313
|
+
|
|
314
|
+
console.print(f"\n[bold cyan]🌐 OADSON wants to call:[/bold cyan] {method} {url}")
|
|
315
|
+
if not Confirm.ask("[bold]Send request?[/bold]"):
|
|
316
|
+
return {"status": "cancelled"}
|
|
317
|
+
|
|
318
|
+
try:
|
|
319
|
+
with httpx.Client(timeout=30) as client:
|
|
320
|
+
resp = client.request(method, url, headers=headers, json=body)
|
|
321
|
+
return {
|
|
322
|
+
"status": "success",
|
|
323
|
+
"http_status": resp.status_code,
|
|
324
|
+
"headers": dict(resp.headers),
|
|
325
|
+
"body": resp.text[:4000],
|
|
326
|
+
}
|
|
327
|
+
except Exception as e:
|
|
328
|
+
return {"status": "error", "reason": str(e)}
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
# ── TOOL DISPATCH TABLE ──
|
|
332
|
+
TOOLS = {
|
|
333
|
+
"read_file": tool_read_file,
|
|
334
|
+
"write_file": tool_write_file,
|
|
335
|
+
"create_file": tool_create_file,
|
|
336
|
+
"delete_file": tool_delete_file,
|
|
337
|
+
"list_dir": tool_list_dir,
|
|
338
|
+
"run_bash": tool_run_bash,
|
|
339
|
+
"search_files": tool_search_files,
|
|
340
|
+
"git_status": tool_git_status,
|
|
341
|
+
"git_diff": tool_git_diff,
|
|
342
|
+
"http_request": tool_http_request,
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def execute_tool(tool_name: str, args: dict) -> dict:
|
|
347
|
+
"""
|
|
348
|
+
Dispatch a tool call from the AI to the local handler.
|
|
349
|
+
Returns result dict always — never raises.
|
|
350
|
+
"""
|
|
351
|
+
handler = TOOLS.get(tool_name)
|
|
352
|
+
if not handler:
|
|
353
|
+
return {"status": "error", "reason": f"Unknown tool: {tool_name}"}
|
|
354
|
+
try:
|
|
355
|
+
return handler(args)
|
|
356
|
+
except Exception as e:
|
|
357
|
+
return {"status": "error", "reason": f"Tool {tool_name} crashed: {e}"}
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _detect_lang(path: str) -> str:
|
|
361
|
+
ext = Path(path).suffix.lstrip(".")
|
|
362
|
+
return {
|
|
363
|
+
"py": "python", "js": "javascript", "ts": "typescript",
|
|
364
|
+
"json": "json", "md": "markdown", "sh": "bash",
|
|
365
|
+
"html": "html", "css": "css", "rs": "rust",
|
|
366
|
+
"go": "go", "yaml": "yaml", "yml": "yaml",
|
|
367
|
+
}.get(ext, "text")
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: oadson
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: OADSON Terminal — AI coding agent for your local shell
|
|
5
|
+
Author-email: Goodness Olayinka <goodnessolayinka2@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/goodnessolayinka/Oadson_v2
|
|
8
|
+
Project-URL: Repository, https://github.com/goodnessolayinka/Oadson_v2
|
|
9
|
+
Keywords: ai,cli,coding-agent,terminal,oadson
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
19
|
+
Classifier: Topic :: Terminals
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
Requires-Dist: httpx>=0.27
|
|
23
|
+
Requires-Dist: rich>=13
|
|
24
|
+
Requires-Dist: typer>=0.12
|
|
25
|
+
Requires-Dist: prompt_toolkit>=3.0
|
|
26
|
+
|
|
27
|
+
# oadson
|
|
28
|
+
|
|
29
|
+
**OADSON Terminal** — AI coding agent for your local shell.
|
|
30
|
+
|
|
31
|
+
Powered by your Railway OADSON V2 backend.
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## Install
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install oadson
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Setup
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
oadson setup
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Enter your Railway backend URL and API token when prompted.
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## Usage
|
|
52
|
+
|
|
53
|
+
### One-shot
|
|
54
|
+
```bash
|
|
55
|
+
oadson "fix the import error in main.py"
|
|
56
|
+
oadson "write a README for this project"
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### Interactive REPL
|
|
60
|
+
```bash
|
|
61
|
+
oadson
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### Pipe mode
|
|
65
|
+
```bash
|
|
66
|
+
cat error.log | oadson
|
|
67
|
+
cat file.py | oadson "refactor this"
|
|
68
|
+
python main.py 2>&1 | oadson "what's wrong"
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### Session management
|
|
72
|
+
```bash
|
|
73
|
+
oadson session new "auth refactor"
|
|
74
|
+
oadson session list
|
|
75
|
+
oadson session use 2
|
|
76
|
+
oadson session delete 1
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
## REPL commands
|
|
82
|
+
|
|
83
|
+
| Command | Description |
|
|
84
|
+
|---------|-------------|
|
|
85
|
+
| `/new [name]` | Start fresh session |
|
|
86
|
+
| `/sessions` | List all sessions |
|
|
87
|
+
| `/delete N` | Delete session #N |
|
|
88
|
+
| `/clear` | Clear screen |
|
|
89
|
+
| `/help` | Show help |
|
|
90
|
+
| `/exit` | Quit |
|
|
91
|
+
|
|
92
|
+
---
|
|
93
|
+
|
|
94
|
+
## Permission system
|
|
95
|
+
|
|
96
|
+
OADSON always shows what it's about to do before doing it.
|
|
97
|
+
|
|
98
|
+
| Action | Behaviour |
|
|
99
|
+
|--------|-----------|
|
|
100
|
+
| Read files, git status, ls | Auto — no prompt |
|
|
101
|
+
| Write/create files | Shows diff → asks y/n |
|
|
102
|
+
| Delete files | Auto-backup → asks y/n |
|
|
103
|
+
| Run bash commands | Shows command → asks y/n |
|
|
104
|
+
| Destructive patterns (`rm -rf /`) | Blocked always |
|
|
105
|
+
|
|
106
|
+
Customize permissions in `~/.oadson/config.json`.
|
|
107
|
+
|
|
108
|
+
---
|
|
109
|
+
|
|
110
|
+
## Config location
|
|
111
|
+
|
|
112
|
+
```
|
|
113
|
+
~/.oadson/
|
|
114
|
+
config.json ← backend URL, token, permissions
|
|
115
|
+
sessions.json ← session history
|
|
116
|
+
history ← REPL input history
|
|
117
|
+
backups/ ← auto-backups before delete/overwrite
|
|
118
|
+
```
|