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/__init__.py
ADDED
oadson/agent.py
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"""
|
|
2
|
+
agent.py — Local agent loop.
|
|
3
|
+
|
|
4
|
+
Sends your message + shell context to Railway.
|
|
5
|
+
Railway's AI responds with text and/or tool calls.
|
|
6
|
+
Tool calls are executed locally here, results fed back.
|
|
7
|
+
Loop repeats until AI has no more tool calls.
|
|
8
|
+
|
|
9
|
+
This is the core of OADSON terminal — same pattern as Claude Code.
|
|
10
|
+
"""
|
|
11
|
+
import json
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
from rich.console import Console
|
|
16
|
+
from rich.live import Live
|
|
17
|
+
from rich.spinner import Spinner
|
|
18
|
+
from rich.text import Text
|
|
19
|
+
|
|
20
|
+
from oadson.config import get_backend_url, get_token
|
|
21
|
+
from oadson.context import collect, format_for_prompt
|
|
22
|
+
from oadson.executor import execute_tool
|
|
23
|
+
|
|
24
|
+
console = Console()
|
|
25
|
+
|
|
26
|
+
MAX_ITERATIONS = 10 # safety cap on tool call loops
|
|
27
|
+
REQUEST_TIMEOUT = 90 # seconds — generous for Nigeria network
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class NetworkError(Exception):
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _headers() -> dict:
|
|
35
|
+
return {
|
|
36
|
+
"Authorization": f"Bearer {get_token()}",
|
|
37
|
+
"Content-Type": "application/json",
|
|
38
|
+
"X-OADSON-Surface": "terminal",
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _build_payload(
|
|
43
|
+
message: str,
|
|
44
|
+
session_id: str,
|
|
45
|
+
context: dict,
|
|
46
|
+
tool_results: Optional[list] = None,
|
|
47
|
+
) -> dict:
|
|
48
|
+
payload = {
|
|
49
|
+
"message": message,
|
|
50
|
+
"session_id": session_id,
|
|
51
|
+
"surface": "terminal",
|
|
52
|
+
"context": format_for_prompt(context),
|
|
53
|
+
}
|
|
54
|
+
if tool_results:
|
|
55
|
+
payload["tool_results"] = tool_results
|
|
56
|
+
return payload
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def run(
|
|
60
|
+
message: str,
|
|
61
|
+
session_id: str,
|
|
62
|
+
last_exit_code: int = 0,
|
|
63
|
+
last_command: str = "",
|
|
64
|
+
stream: bool = True,
|
|
65
|
+
) -> str:
|
|
66
|
+
"""
|
|
67
|
+
Main agent loop. Call this for every user message.
|
|
68
|
+
|
|
69
|
+
Returns the final text reply from the AI.
|
|
70
|
+
Exits cleanly on network error — no crash, just a message.
|
|
71
|
+
"""
|
|
72
|
+
backend = get_backend_url()
|
|
73
|
+
if not backend:
|
|
74
|
+
return "[bold red]⚠ Not configured.[/bold red] Run: oadson setup"
|
|
75
|
+
|
|
76
|
+
# Collect shell context
|
|
77
|
+
ctx = collect(last_exit_code=last_exit_code, last_command=last_command)
|
|
78
|
+
|
|
79
|
+
tool_results = []
|
|
80
|
+
final_reply = ""
|
|
81
|
+
iterations = 0
|
|
82
|
+
|
|
83
|
+
while iterations < MAX_ITERATIONS:
|
|
84
|
+
iterations += 1
|
|
85
|
+
payload = _build_payload(message, session_id, ctx, tool_results or None)
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
if stream:
|
|
89
|
+
final_reply = _stream_request(backend, payload)
|
|
90
|
+
else:
|
|
91
|
+
final_reply = _plain_request(backend, payload)
|
|
92
|
+
|
|
93
|
+
except NetworkError as e:
|
|
94
|
+
return f"[bold red]⚠ No network:[/bold red] {e}\nYour session is safe — retry when connected."
|
|
95
|
+
except Exception as e:
|
|
96
|
+
return f"[bold red]⚠ Error:[/bold red] {e}"
|
|
97
|
+
|
|
98
|
+
# Parse tool calls from response
|
|
99
|
+
calls = _extract_tool_calls(final_reply)
|
|
100
|
+
if not calls:
|
|
101
|
+
break # AI is done — no more tools needed
|
|
102
|
+
|
|
103
|
+
# Execute each tool locally
|
|
104
|
+
tool_results = []
|
|
105
|
+
for call in calls:
|
|
106
|
+
tool_name = call.get("tool")
|
|
107
|
+
args = call.get("args", {})
|
|
108
|
+
|
|
109
|
+
console.print(f"\n[dim]→ Tool: {tool_name}[/dim]")
|
|
110
|
+
result = execute_tool(tool_name, args)
|
|
111
|
+
|
|
112
|
+
tool_results.append({
|
|
113
|
+
"tool": tool_name,
|
|
114
|
+
"args": args,
|
|
115
|
+
"result": result,
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
# Show result summary
|
|
119
|
+
if result.get("status") == "success":
|
|
120
|
+
console.print(f"[green]✓ {tool_name}[/green]")
|
|
121
|
+
elif result.get("status") == "cancelled":
|
|
122
|
+
console.print(f"[yellow]↩ {tool_name} cancelled[/yellow]")
|
|
123
|
+
else:
|
|
124
|
+
console.print(f"[red]✗ {tool_name}: {result.get('reason', 'failed')}[/red]")
|
|
125
|
+
|
|
126
|
+
# Feed results back for next iteration
|
|
127
|
+
message = "" # subsequent turns are tool-result driven
|
|
128
|
+
|
|
129
|
+
return final_reply
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _stream_request(backend: str, payload: dict) -> str:
|
|
133
|
+
"""
|
|
134
|
+
Streaming request — prints words as they arrive.
|
|
135
|
+
Returns full text when done.
|
|
136
|
+
"""
|
|
137
|
+
url = f"{backend}/api/v2/chat/stream"
|
|
138
|
+
full_text = ""
|
|
139
|
+
|
|
140
|
+
try:
|
|
141
|
+
with httpx.Client(timeout=REQUEST_TIMEOUT) as client:
|
|
142
|
+
with client.stream("POST", url, json=payload, headers=_headers()) as resp:
|
|
143
|
+
if resp.status_code == 401:
|
|
144
|
+
raise NetworkError("Invalid token — run: oadson setup")
|
|
145
|
+
if resp.status_code == 404:
|
|
146
|
+
# Fallback to non-streaming
|
|
147
|
+
return _plain_request(backend, payload)
|
|
148
|
+
if resp.status_code != 200:
|
|
149
|
+
raise NetworkError(f"HTTP {resp.status_code}")
|
|
150
|
+
|
|
151
|
+
console.print() # newline before streaming
|
|
152
|
+
for chunk in resp.iter_text():
|
|
153
|
+
if chunk:
|
|
154
|
+
# Handle SSE format: "data: {...}\n"
|
|
155
|
+
for line in chunk.splitlines():
|
|
156
|
+
if line.startswith("data: "):
|
|
157
|
+
data_str = line[6:]
|
|
158
|
+
if data_str.strip() == "[DONE]":
|
|
159
|
+
break
|
|
160
|
+
try:
|
|
161
|
+
data = json.loads(data_str)
|
|
162
|
+
token = (
|
|
163
|
+
data.get("text") or
|
|
164
|
+
data.get("token") or
|
|
165
|
+
data.get("content") or
|
|
166
|
+
data.get("response") or ""
|
|
167
|
+
)
|
|
168
|
+
if token:
|
|
169
|
+
console.print(token, end="", highlight=False)
|
|
170
|
+
full_text += token
|
|
171
|
+
except json.JSONDecodeError:
|
|
172
|
+
# Plain text chunk
|
|
173
|
+
console.print(line, end="", highlight=False)
|
|
174
|
+
full_text += line
|
|
175
|
+
console.print() # newline after streaming
|
|
176
|
+
|
|
177
|
+
except httpx.ConnectError as e:
|
|
178
|
+
raise NetworkError(f"Cannot reach {backend} — {e}")
|
|
179
|
+
except httpx.TimeoutException:
|
|
180
|
+
raise NetworkError(f"Request timed out after {REQUEST_TIMEOUT}s")
|
|
181
|
+
|
|
182
|
+
return full_text
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _plain_request(backend: str, payload: dict) -> str:
|
|
186
|
+
"""
|
|
187
|
+
Non-streaming fallback — shows spinner, returns full reply.
|
|
188
|
+
"""
|
|
189
|
+
url = f"{backend}/api/v2/chat"
|
|
190
|
+
|
|
191
|
+
with Live(Spinner("dots", text="OADSON is thinking..."), console=console, refresh_per_second=10):
|
|
192
|
+
try:
|
|
193
|
+
with httpx.Client(timeout=REQUEST_TIMEOUT) as client:
|
|
194
|
+
resp = client.post(url, json=payload, headers=_headers())
|
|
195
|
+
except httpx.ConnectError as e:
|
|
196
|
+
raise NetworkError(f"Cannot reach {backend} — {e}")
|
|
197
|
+
except httpx.TimeoutException:
|
|
198
|
+
raise NetworkError(f"Request timed out after {REQUEST_TIMEOUT}s")
|
|
199
|
+
|
|
200
|
+
if resp.status_code == 401:
|
|
201
|
+
raise NetworkError("Invalid token — run: oadson setup")
|
|
202
|
+
if resp.status_code != 200:
|
|
203
|
+
raise NetworkError(f"HTTP {resp.status_code}: {resp.text[:200]}")
|
|
204
|
+
|
|
205
|
+
data = resp.json()
|
|
206
|
+
return (
|
|
207
|
+
data.get("response") or
|
|
208
|
+
data.get("text") or
|
|
209
|
+
data.get("message") or
|
|
210
|
+
str(data)
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _extract_tool_calls(text: str) -> list:
|
|
215
|
+
"""
|
|
216
|
+
Parse tool calls from AI response.
|
|
217
|
+
Railway returns them as JSON blocks: ```tool_call\n{...}\n```
|
|
218
|
+
or as a JSON array in the response body.
|
|
219
|
+
"""
|
|
220
|
+
calls = []
|
|
221
|
+
|
|
222
|
+
# Format 1: ```tool_call\n{"tool": ..., "args": ...}\n```
|
|
223
|
+
import re
|
|
224
|
+
blocks = re.findall(r"```tool_call\s*\n([\s\S]*?)\n```", text)
|
|
225
|
+
for block in blocks:
|
|
226
|
+
try:
|
|
227
|
+
call = json.loads(block.strip())
|
|
228
|
+
if isinstance(call, list):
|
|
229
|
+
calls.extend(call)
|
|
230
|
+
elif isinstance(call, dict) and "tool" in call:
|
|
231
|
+
calls.append(call)
|
|
232
|
+
except json.JSONDecodeError:
|
|
233
|
+
pass
|
|
234
|
+
|
|
235
|
+
# Format 2: JSON array anywhere in text
|
|
236
|
+
if not calls:
|
|
237
|
+
arr = re.findall(r"\[\s*\{[^]]*\"tool\"[^]]*\}\s*\]", text, re.DOTALL)
|
|
238
|
+
for match in arr:
|
|
239
|
+
try:
|
|
240
|
+
parsed = json.loads(match)
|
|
241
|
+
if isinstance(parsed, list):
|
|
242
|
+
calls.extend(parsed)
|
|
243
|
+
except json.JSONDecodeError:
|
|
244
|
+
pass
|
|
245
|
+
|
|
246
|
+
return calls
|
oadson/cli.py
ADDED
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
"""
|
|
2
|
+
cli.py — OADSON Terminal CLI
|
|
3
|
+
|
|
4
|
+
Modes:
|
|
5
|
+
oadson "fix the import error" → one-shot
|
|
6
|
+
oadson → interactive REPL
|
|
7
|
+
oadson setup → configure backend + token
|
|
8
|
+
oadson config → show current config
|
|
9
|
+
oadson session new → start fresh session
|
|
10
|
+
oadson session list → list past sessions
|
|
11
|
+
cat error.log | oadson → pipe mode
|
|
12
|
+
"""
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import sys
|
|
16
|
+
import uuid
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Optional
|
|
19
|
+
|
|
20
|
+
import typer
|
|
21
|
+
from rich.console import Console
|
|
22
|
+
from rich.panel import Panel
|
|
23
|
+
from rich.prompt import Prompt
|
|
24
|
+
from rich.table import Table
|
|
25
|
+
from prompt_toolkit import PromptSession
|
|
26
|
+
from prompt_toolkit.history import FileHistory
|
|
27
|
+
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
|
|
28
|
+
from prompt_toolkit.styles import Style
|
|
29
|
+
|
|
30
|
+
from oadson import agent
|
|
31
|
+
from oadson.config import (
|
|
32
|
+
get_config, save_config, get_backend_url,
|
|
33
|
+
get_token, is_configured,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
app = typer.Typer(
|
|
37
|
+
name="oadson",
|
|
38
|
+
help="OADSON Terminal — AI coding agent for your local shell",
|
|
39
|
+
add_completion=False,
|
|
40
|
+
no_args_is_help=False,
|
|
41
|
+
)
|
|
42
|
+
session_app = typer.Typer(help="Manage OADSON sessions")
|
|
43
|
+
app.add_typer(session_app, name="session")
|
|
44
|
+
|
|
45
|
+
console = Console()
|
|
46
|
+
|
|
47
|
+
SESSIONS_FILE = Path.home() / ".oadson" / "sessions.json"
|
|
48
|
+
HISTORY_FILE = Path.home() / ".oadson" / "history"
|
|
49
|
+
|
|
50
|
+
PROMPT_STYLE = Style.from_dict({
|
|
51
|
+
"prompt": "bold #00ff88",
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ── SESSION HELPERS ──
|
|
56
|
+
|
|
57
|
+
def _load_sessions() -> dict:
|
|
58
|
+
if SESSIONS_FILE.exists():
|
|
59
|
+
try:
|
|
60
|
+
return json.loads(SESSIONS_FILE.read_text())
|
|
61
|
+
except Exception:
|
|
62
|
+
return {}
|
|
63
|
+
return {}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _save_sessions(sessions: dict):
|
|
67
|
+
SESSIONS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
68
|
+
SESSIONS_FILE.write_text(json.dumps(sessions, indent=2))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _get_active_session() -> str:
|
|
72
|
+
sessions = _load_sessions()
|
|
73
|
+
active = sessions.get("active")
|
|
74
|
+
if active and active in sessions.get("history", {}):
|
|
75
|
+
return active
|
|
76
|
+
# Create default
|
|
77
|
+
return _create_session("default")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _create_session(name: str = "") -> str:
|
|
81
|
+
sessions = _load_sessions()
|
|
82
|
+
sid = f"terminal_{uuid.uuid4().hex[:8]}"
|
|
83
|
+
history = sessions.get("history", {})
|
|
84
|
+
history[sid] = {
|
|
85
|
+
"id": sid,
|
|
86
|
+
"name": name or sid,
|
|
87
|
+
"created": _now(),
|
|
88
|
+
"updated": _now(),
|
|
89
|
+
}
|
|
90
|
+
sessions["history"] = history
|
|
91
|
+
sessions["active"] = sid
|
|
92
|
+
_save_sessions(sessions)
|
|
93
|
+
return sid
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _now() -> str:
|
|
97
|
+
from datetime import datetime
|
|
98
|
+
return datetime.now().strftime("%Y-%m-%d %H:%M")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# ── COMMANDS ──
|
|
102
|
+
|
|
103
|
+
@app.callback(invoke_without_command=True)
|
|
104
|
+
def main(
|
|
105
|
+
ctx: typer.Context,
|
|
106
|
+
message: Optional[str] = typer.Argument(None, help="Message to send (one-shot mode)"),
|
|
107
|
+
no_stream: bool = typer.Option(False, "--no-stream", help="Disable streaming"),
|
|
108
|
+
):
|
|
109
|
+
"""
|
|
110
|
+
OADSON Terminal — AI coding agent.
|
|
111
|
+
|
|
112
|
+
One-shot: oadson "explain this error"
|
|
113
|
+
Interactive: oadson
|
|
114
|
+
Pipe: cat file.py | oadson
|
|
115
|
+
Setup: oadson setup
|
|
116
|
+
"""
|
|
117
|
+
if ctx.invoked_subcommand is not None:
|
|
118
|
+
return
|
|
119
|
+
|
|
120
|
+
if not is_configured():
|
|
121
|
+
console.print("[bold red]⚠ Not configured.[/bold red] Run: [bold]oadson setup[/bold]")
|
|
122
|
+
raise typer.Exit(1)
|
|
123
|
+
|
|
124
|
+
# Pipe mode: read from stdin
|
|
125
|
+
if not sys.stdin.isatty():
|
|
126
|
+
piped = sys.stdin.read().strip()
|
|
127
|
+
if message:
|
|
128
|
+
full = f"{piped}\n\n{message}"
|
|
129
|
+
else:
|
|
130
|
+
full = piped
|
|
131
|
+
_one_shot(full, stream=not no_stream)
|
|
132
|
+
return
|
|
133
|
+
|
|
134
|
+
# One-shot mode
|
|
135
|
+
if message:
|
|
136
|
+
_one_shot(message, stream=not no_stream)
|
|
137
|
+
return
|
|
138
|
+
|
|
139
|
+
# Interactive REPL
|
|
140
|
+
_repl(stream=not no_stream)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _one_shot(message: str, stream: bool = True):
|
|
144
|
+
sid = _get_active_session()
|
|
145
|
+
reply = agent.run(message, session_id=sid, stream=stream)
|
|
146
|
+
if not stream:
|
|
147
|
+
console.print(reply)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _repl(stream: bool = True):
|
|
151
|
+
"""Interactive REPL with readline history and multiline support."""
|
|
152
|
+
console.print(Panel(
|
|
153
|
+
"[bold #00ff88]OADSON Terminal[/bold #00ff88]\n"
|
|
154
|
+
"[dim]AI coding agent — runs in your shell[/dim]\n\n"
|
|
155
|
+
"[dim]Commands: /new /sessions /exit /help[/dim]",
|
|
156
|
+
border_style="#00ff88",
|
|
157
|
+
))
|
|
158
|
+
|
|
159
|
+
sid = _get_active_session()
|
|
160
|
+
sessions = _load_sessions()
|
|
161
|
+
session_name = sessions.get("history", {}).get(sid, {}).get("name", sid)
|
|
162
|
+
console.print(f"[dim]Session: {session_name}[/dim]\n")
|
|
163
|
+
|
|
164
|
+
HISTORY_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
165
|
+
ps = PromptSession(
|
|
166
|
+
history=FileHistory(str(HISTORY_FILE)),
|
|
167
|
+
auto_suggest=AutoSuggestFromHistory(),
|
|
168
|
+
style=PROMPT_STYLE,
|
|
169
|
+
multiline=False,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
last_exit = 0
|
|
173
|
+
last_cmd = ""
|
|
174
|
+
|
|
175
|
+
while True:
|
|
176
|
+
try:
|
|
177
|
+
user_input = ps.prompt("oadson> ").strip()
|
|
178
|
+
except (KeyboardInterrupt, EOFError):
|
|
179
|
+
console.print("\n[dim]Bye.[/dim]")
|
|
180
|
+
break
|
|
181
|
+
|
|
182
|
+
if not user_input:
|
|
183
|
+
continue
|
|
184
|
+
|
|
185
|
+
# ── REPL commands ──
|
|
186
|
+
if user_input.startswith("/"):
|
|
187
|
+
parts = user_input.split(None, 1)
|
|
188
|
+
cmd = parts[0].lower()
|
|
189
|
+
arg = parts[1] if len(parts) > 1 else ""
|
|
190
|
+
|
|
191
|
+
if cmd in ("/exit", "/quit", "/q"):
|
|
192
|
+
console.print("[dim]Bye.[/dim]")
|
|
193
|
+
break
|
|
194
|
+
|
|
195
|
+
elif cmd == "/new":
|
|
196
|
+
name = arg or ""
|
|
197
|
+
sid = _create_session(name)
|
|
198
|
+
console.print(f"[green]✓ New session started[/green]" + (f": {name}" if name else ""))
|
|
199
|
+
|
|
200
|
+
elif cmd in ("/sessions", "/history"):
|
|
201
|
+
_print_sessions()
|
|
202
|
+
|
|
203
|
+
elif cmd == "/delete":
|
|
204
|
+
_delete_session(arg)
|
|
205
|
+
|
|
206
|
+
elif cmd == "/help":
|
|
207
|
+
_print_help()
|
|
208
|
+
|
|
209
|
+
elif cmd == "/clear":
|
|
210
|
+
console.clear()
|
|
211
|
+
|
|
212
|
+
else:
|
|
213
|
+
console.print(f"[yellow]Unknown command: {cmd}[/yellow]")
|
|
214
|
+
|
|
215
|
+
continue
|
|
216
|
+
|
|
217
|
+
# ── Send to AI ──
|
|
218
|
+
reply = agent.run(
|
|
219
|
+
user_input,
|
|
220
|
+
session_id=sid,
|
|
221
|
+
last_exit_code=last_exit,
|
|
222
|
+
last_command=last_cmd,
|
|
223
|
+
stream=stream,
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
# Update session timestamp
|
|
227
|
+
_touch_session(sid)
|
|
228
|
+
|
|
229
|
+
last_cmd = user_input
|
|
230
|
+
last_exit = 0 # reset — we don't know actual exit code here
|
|
231
|
+
|
|
232
|
+
if not stream:
|
|
233
|
+
console.print(reply)
|
|
234
|
+
console.print()
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _touch_session(sid: str):
|
|
238
|
+
sessions = _load_sessions()
|
|
239
|
+
if sid in sessions.get("history", {}):
|
|
240
|
+
sessions["history"][sid]["updated"] = _now()
|
|
241
|
+
_save_sessions(sessions)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _print_sessions():
|
|
245
|
+
sessions = _load_sessions()
|
|
246
|
+
history = sessions.get("history", {})
|
|
247
|
+
active = sessions.get("active", "")
|
|
248
|
+
|
|
249
|
+
if not history:
|
|
250
|
+
console.print("[dim]No sessions yet.[/dim]")
|
|
251
|
+
return
|
|
252
|
+
|
|
253
|
+
table = Table(title="OADSON Sessions", border_style="dim")
|
|
254
|
+
table.add_column("#", style="dim", width=3)
|
|
255
|
+
table.add_column("Name")
|
|
256
|
+
table.add_column("ID", style="dim")
|
|
257
|
+
table.add_column("Updated")
|
|
258
|
+
table.add_column("", width=2)
|
|
259
|
+
|
|
260
|
+
for i, (sid, info) in enumerate(
|
|
261
|
+
sorted(history.items(), key=lambda x: x[1].get("updated", ""), reverse=True), 1
|
|
262
|
+
):
|
|
263
|
+
active_marker = "●" if sid == active else ""
|
|
264
|
+
table.add_row(
|
|
265
|
+
str(i),
|
|
266
|
+
info.get("name", sid),
|
|
267
|
+
sid,
|
|
268
|
+
info.get("updated", "—"),
|
|
269
|
+
f"[green]{active_marker}[/green]",
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
console.print(table)
|
|
273
|
+
console.print("[dim]/delete N to delete session #N[/dim]")
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _delete_session(arg: str):
|
|
277
|
+
sessions = _load_sessions()
|
|
278
|
+
history = sessions.get("history", {})
|
|
279
|
+
items = sorted(history.items(), key=lambda x: x[1].get("updated", ""), reverse=True)
|
|
280
|
+
|
|
281
|
+
try:
|
|
282
|
+
n = int(arg.strip())
|
|
283
|
+
sid, info = items[n - 1]
|
|
284
|
+
except (ValueError, IndexError):
|
|
285
|
+
console.print("[red]Usage: /delete N (e.g. /delete 1)[/red]")
|
|
286
|
+
return
|
|
287
|
+
|
|
288
|
+
from rich.prompt import Confirm
|
|
289
|
+
if not Confirm.ask(f"Delete session [bold]{info.get('name', sid)}[/bold]?"):
|
|
290
|
+
return
|
|
291
|
+
|
|
292
|
+
del history[sid]
|
|
293
|
+
if sessions.get("active") == sid:
|
|
294
|
+
# Switch to most recent remaining
|
|
295
|
+
if history:
|
|
296
|
+
sessions["active"] = sorted(
|
|
297
|
+
history.keys(),
|
|
298
|
+
key=lambda s: history[s].get("updated", ""), reverse=True
|
|
299
|
+
)[0]
|
|
300
|
+
else:
|
|
301
|
+
sessions.pop("active", None)
|
|
302
|
+
|
|
303
|
+
_save_sessions(sessions)
|
|
304
|
+
console.print(f"[green]✓ Deleted session: {info.get('name', sid)}[/green]")
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _print_help():
|
|
308
|
+
console.print(Panel(
|
|
309
|
+
"[bold]REPL Commands:[/bold]\n"
|
|
310
|
+
" /new [name] Start a fresh session\n"
|
|
311
|
+
" /sessions List all sessions\n"
|
|
312
|
+
" /delete N Delete session #N\n"
|
|
313
|
+
" /clear Clear screen\n"
|
|
314
|
+
" /help Show this\n"
|
|
315
|
+
" /exit Quit\n\n"
|
|
316
|
+
"[bold]Pipe mode:[/bold]\n"
|
|
317
|
+
" cat error.log | oadson\n"
|
|
318
|
+
" cat file.py | oadson 'refactor this'\n\n"
|
|
319
|
+
"[bold]One-shot:[/bold]\n"
|
|
320
|
+
' oadson "fix the broken import"\n'
|
|
321
|
+
' oadson "write tests for main.py"',
|
|
322
|
+
title="OADSON Help",
|
|
323
|
+
border_style="dim",
|
|
324
|
+
))
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
# ── SETUP COMMAND ──
|
|
328
|
+
|
|
329
|
+
@app.command()
|
|
330
|
+
def setup():
|
|
331
|
+
"""Configure OADSON with your Railway backend URL and token."""
|
|
332
|
+
console.print(Panel(
|
|
333
|
+
"[bold #00ff88]OADSON Setup[/bold #00ff88]",
|
|
334
|
+
border_style="#00ff88"
|
|
335
|
+
))
|
|
336
|
+
|
|
337
|
+
cfg = get_config()
|
|
338
|
+
|
|
339
|
+
backend = Prompt.ask(
|
|
340
|
+
"Railway backend URL",
|
|
341
|
+
default=cfg.get("backend_url", "https://oadsonv2-production.up.railway.app")
|
|
342
|
+
).strip().rstrip("/")
|
|
343
|
+
|
|
344
|
+
token = Prompt.ask(
|
|
345
|
+
"API token (from Railway env OADSON_SERVICE_TOKEN)",
|
|
346
|
+
password=True,
|
|
347
|
+
default=cfg.get("token", "")
|
|
348
|
+
).strip()
|
|
349
|
+
|
|
350
|
+
cfg["backend_url"] = backend
|
|
351
|
+
cfg["token"] = token
|
|
352
|
+
save_config(cfg)
|
|
353
|
+
|
|
354
|
+
# Test connection
|
|
355
|
+
console.print("\n[dim]Testing connection...[/dim]")
|
|
356
|
+
try:
|
|
357
|
+
import httpx
|
|
358
|
+
r = httpx.get(
|
|
359
|
+
f"{backend}/health",
|
|
360
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
361
|
+
timeout=10
|
|
362
|
+
)
|
|
363
|
+
if r.status_code in (200, 404):
|
|
364
|
+
console.print("[green]✓ Connected successfully[/green]")
|
|
365
|
+
else:
|
|
366
|
+
console.print(f"[yellow]⚠ HTTP {r.status_code} — check your URL and token[/yellow]")
|
|
367
|
+
except Exception as e:
|
|
368
|
+
console.print(f"[yellow]⚠ Could not connect: {e}[/yellow]")
|
|
369
|
+
console.print("[dim]Config saved anyway — retry when network is available[/dim]")
|
|
370
|
+
|
|
371
|
+
console.print(f"\n[bold]Config saved to:[/bold] ~/.oadson/config.json")
|
|
372
|
+
console.print("[bold green]✓ Ready. Run: oadson[/bold green]")
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
@app.command("config")
|
|
376
|
+
def show_config():
|
|
377
|
+
"""Show current configuration."""
|
|
378
|
+
cfg = get_config()
|
|
379
|
+
if not cfg:
|
|
380
|
+
console.print("[yellow]Not configured. Run: oadson setup[/yellow]")
|
|
381
|
+
return
|
|
382
|
+
|
|
383
|
+
table = Table(border_style="dim")
|
|
384
|
+
table.add_column("Key")
|
|
385
|
+
table.add_column("Value")
|
|
386
|
+
table.add_row("backend_url", cfg.get("backend_url", "—"))
|
|
387
|
+
table.add_row("token", ("*" * 8 + cfg.get("token", "")[-4:]) if cfg.get("token") else "—")
|
|
388
|
+
console.print(table)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
# ── SESSION SUBCOMMANDS ──
|
|
392
|
+
|
|
393
|
+
@session_app.command("new")
|
|
394
|
+
def session_new(name: str = typer.Argument("", help="Optional session name")):
|
|
395
|
+
"""Start a new session."""
|
|
396
|
+
sid = _create_session(name)
|
|
397
|
+
console.print(f"[green]✓ New session:[/green] {name or sid}")
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
@session_app.command("list")
|
|
401
|
+
def session_list():
|
|
402
|
+
"""List all sessions."""
|
|
403
|
+
_print_sessions()
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
@session_app.command("delete")
|
|
407
|
+
def session_delete(n: int = typer.Argument(..., help="Session number from list")):
|
|
408
|
+
"""Delete a session by number."""
|
|
409
|
+
_delete_session(str(n))
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
@session_app.command("use")
|
|
413
|
+
def session_use(n: int = typer.Argument(..., help="Session number to switch to")):
|
|
414
|
+
"""Switch to a session by number."""
|
|
415
|
+
sessions = _load_sessions()
|
|
416
|
+
history = sessions.get("history", {})
|
|
417
|
+
items = sorted(history.items(), key=lambda x: x[1].get("updated", ""), reverse=True)
|
|
418
|
+
|
|
419
|
+
try:
|
|
420
|
+
sid, info = items[n - 1]
|
|
421
|
+
except IndexError:
|
|
422
|
+
console.print(f"[red]No session #{n}[/red]")
|
|
423
|
+
return
|
|
424
|
+
|
|
425
|
+
sessions["active"] = sid
|
|
426
|
+
_save_sessions(sessions)
|
|
427
|
+
console.print(f"[green]✓ Switched to:[/green] {info.get('name', sid)}")
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
if __name__ == "__main__":
|
|
431
|
+
app()
|