ghostcode-canary 0.1.25-canary.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.
Files changed (50) hide show
  1. package/LICENSE +201 -0
  2. package/PAT.txt +1 -0
  3. package/README.md +95 -0
  4. package/cli.js +92 -0
  5. package/error/err1.txt +12 -0
  6. package/package.json +31 -0
  7. package/setup.js +67 -0
  8. package/src/ghostcode/__init__.py +3 -0
  9. package/src/ghostcode/__main__.py +3 -0
  10. package/src/ghostcode/_version.py +17 -0
  11. package/src/ghostcode/agents/__init__.py +20 -0
  12. package/src/ghostcode/agents/code.py +104 -0
  13. package/src/ghostcode/agents/explore.py +50 -0
  14. package/src/ghostcode/ai/__init__.py +0 -0
  15. package/src/ghostcode/ai/anthropic_client.py +8 -0
  16. package/src/ghostcode/ai/base.py +33 -0
  17. package/src/ghostcode/ai/factory.py +38 -0
  18. package/src/ghostcode/ai/groq_client.py +7 -0
  19. package/src/ghostcode/ai/nvidia_client.py +7 -0
  20. package/src/ghostcode/ai/ollama_client.py +7 -0
  21. package/src/ghostcode/ai/openai_client.py +7 -0
  22. package/src/ghostcode/ai/openai_compat.py +139 -0
  23. package/src/ghostcode/ai/opencode_go_client.py +7 -0
  24. package/src/ghostcode/ai/opencode_zen_client.py +7 -0
  25. package/src/ghostcode/ai/openrouter_client.py +7 -0
  26. package/src/ghostcode/ai/registry.py +169 -0
  27. package/src/ghostcode/app.py +148 -0
  28. package/src/ghostcode/config.py +24 -0
  29. package/src/ghostcode/core/__init__.py +5 -0
  30. package/src/ghostcode/core/commands.py +55 -0
  31. package/src/ghostcode/core/file_ops.py +127 -0
  32. package/src/ghostcode/core/hooks.py +122 -0
  33. package/src/ghostcode/core/memory.py +137 -0
  34. package/src/ghostcode/core/prompt.py +73 -0
  35. package/src/ghostcode/core/reminders.py +61 -0
  36. package/src/ghostcode/core/security.py +119 -0
  37. package/src/ghostcode/core/tasks.py +125 -0
  38. package/src/ghostcode/tools/__init__.py +2 -0
  39. package/src/ghostcode/tools/defs.py +325 -0
  40. package/src/ghostcode/tools/executor.py +261 -0
  41. package/src/ghostcode/tui/__init__.py +0 -0
  42. package/src/ghostcode/tui/app.py +12 -0
  43. package/src/ghostcode/tui/dialogs.py +241 -0
  44. package/src/ghostcode/tui/screens.py +799 -0
  45. package/src/ghostcode/tui/widgets.py +32 -0
  46. package/src/ghostcode/utils/__init__.py +0 -0
  47. package/src/ghostcode/utils/config_loader.py +102 -0
  48. package/src/ghostcode/utils/logger.py +16 -0
  49. package/src/pyproject.toml +18 -0
  50. package/token.txt +1 -0
@@ -0,0 +1,261 @@
1
+ import asyncio
2
+ import json
3
+ import os
4
+ import subprocess
5
+ from pathlib import Path
6
+
7
+ from ghostcode.core.hooks import hook_registry
8
+ from ghostcode.core.memory import save_memory, search_memory, delete_memory, list_memories
9
+ from ghostcode.core.security import is_action_denied, needs_confirmation, scan_for_secrets
10
+ from ghostcode.core.tasks import task_manager, TaskStatus
11
+
12
+
13
+ def needs_permission(tool_name: str, args: dict) -> bool:
14
+ if tool_name == "run_command":
15
+ return True
16
+ denied, _ = is_action_denied(tool_name, args)
17
+ if denied:
18
+ return True
19
+ needs, _ = needs_confirmation(tool_name, args)
20
+ if needs:
21
+ return True
22
+ path_str = args.get("file_path") or args.get("path") or ""
23
+ if not path_str:
24
+ return False
25
+ cwd = os.getcwd()
26
+ abs_path = os.path.abspath(path_str) if not os.path.isabs(path_str) else os.path.abspath(path_str)
27
+ return not abs_path.startswith(Path(cwd).resolve().as_posix())
28
+
29
+
30
+ async def execute_tool(tool_name: str, args: dict) -> str:
31
+ allowed, reason = hook_registry.run_before(tool_name, args)
32
+ if not allowed:
33
+ return json.dumps({"error": f"Hook blocked: {reason}"})
34
+
35
+ if tool_name == "read_file":
36
+ result = _exec_read_file(args)
37
+ elif tool_name == "write_file":
38
+ result = _exec_write_file(args)
39
+ elif tool_name == "edit_file":
40
+ result = _exec_edit_file(args)
41
+ elif tool_name == "run_command":
42
+ result = await _exec_run_command(args)
43
+ elif tool_name == "list_files":
44
+ result = _exec_list_files(args)
45
+ elif tool_name == "grep_search":
46
+ result = _exec_grep_search(args)
47
+ elif tool_name == "web_search":
48
+ result = json.dumps({"error": "web_search not available directly — use the provider's web search"})
49
+ elif tool_name == "web_fetch":
50
+ result = await _exec_web_fetch(args)
51
+ elif tool_name == "todo_write":
52
+ result = json.dumps({"ok": True, "note": "Todo list updated in conversation context"})
53
+ elif tool_name == "memory_save":
54
+ result = _exec_memory_save(args)
55
+ elif tool_name == "memory_search":
56
+ result = _exec_memory_search(args)
57
+ elif tool_name == "memory_delete":
58
+ result = _exec_memory_delete(args)
59
+ elif tool_name == "memory_list":
60
+ result = _exec_memory_list(args)
61
+ elif tool_name == "task_create":
62
+ result = _exec_task_create(args)
63
+ elif tool_name == "task_stop":
64
+ result = _exec_task_stop(args)
65
+ elif tool_name == "task_list":
66
+ result = _exec_task_list(args)
67
+ else:
68
+ return json.dumps({"error": f"Unknown tool: {tool_name}"})
69
+
70
+ result = hook_registry.run_after(tool_name, args, result)
71
+ return result
72
+
73
+
74
+ def _exec_read_file(args: dict) -> str:
75
+ path = args["file_path"]
76
+ offset = args.get("offset")
77
+ limit = args.get("limit")
78
+ try:
79
+ p = Path(path).expanduser().resolve()
80
+ if not p.exists():
81
+ return json.dumps({"error": f"File not found: {path}"})
82
+ lines = p.read_text(encoding="utf-8").splitlines()
83
+ total = len(lines)
84
+ if offset:
85
+ start = max(0, int(offset) - 1)
86
+ lines = lines[start:]
87
+ if limit:
88
+ lines = lines[:int(limit)]
89
+ if total > len(lines):
90
+ content = "\n".join(lines) + f"\n\n[Read {len(lines)} of {total} lines]"
91
+ else:
92
+ content = "\n".join(lines)
93
+ return content
94
+ except Exception as e:
95
+ return json.dumps({"error": str(e)})
96
+
97
+
98
+ def _exec_write_file(args: dict) -> str:
99
+ path = args["file_path"]
100
+ content = args["content"]
101
+ secrets = scan_for_secrets(content)
102
+ if secrets:
103
+ return json.dumps({"error": f"Refusing to write — potential secrets detected: {secrets[0][:20]}..."})
104
+ try:
105
+ p = Path(path).expanduser().resolve()
106
+ p.parent.mkdir(parents=True, exist_ok=True)
107
+ p.write_text(content, encoding="utf-8")
108
+ size = len(content.encode("utf-8"))
109
+ return json.dumps({"ok": True, "path": str(p), "bytes": size})
110
+ except Exception as e:
111
+ return json.dumps({"error": str(e)})
112
+
113
+
114
+ def _exec_edit_file(args: dict) -> str:
115
+ path = args["file_path"]
116
+ old = args["old_string"]
117
+ new = args["new_string"]
118
+ try:
119
+ p = Path(path).expanduser().resolve()
120
+ content = p.read_text(encoding="utf-8")
121
+ if old not in content:
122
+ return json.dumps({"error": "old_string not found in file"})
123
+ new_content = content.replace(old, new, 1)
124
+ p.write_text(new_content, encoding="utf-8")
125
+ return json.dumps({"ok": True, "path": str(p), "replaced": 1})
126
+ except Exception as e:
127
+ return json.dumps({"error": str(e)})
128
+
129
+
130
+ async def _exec_run_command(args: dict) -> str:
131
+ command = args["command"]
132
+ workdir = args.get("workdir") or os.getcwd()
133
+ timeout = args.get("timeout", 120000) / 1000
134
+ try:
135
+ proc = await asyncio.create_subprocess_shell(
136
+ command,
137
+ stdout=subprocess.PIPE,
138
+ stderr=subprocess.PIPE,
139
+ cwd=workdir,
140
+ )
141
+ stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
142
+ out = stdout.decode("utf-8", errors="replace")
143
+ err = stderr.decode("utf-8", errors="replace")
144
+ result = {"exit_code": proc.returncode, "stdout": out, "stderr": err}
145
+ return json.dumps(result)
146
+ except asyncio.TimeoutError:
147
+ return json.dumps({"error": "Command timed out"})
148
+ except Exception as e:
149
+ return json.dumps({"error": str(e)})
150
+
151
+
152
+ def _exec_list_files(args: dict) -> str:
153
+ path = args.get("path") or os.getcwd()
154
+ pattern = args.get("pattern")
155
+ try:
156
+ p = Path(path).expanduser().resolve()
157
+ if pattern:
158
+ matches = list(p.rglob(pattern))
159
+ return json.dumps({"files": [str(m.relative_to(p)) for m in sorted(matches) if m.is_file()]})
160
+ items = []
161
+ for entry in sorted(p.iterdir()):
162
+ name = entry.name + "/" if entry.is_dir() else entry.name
163
+ items.append(name)
164
+ return json.dumps({"files": items, "path": str(p)})
165
+ except Exception as e:
166
+ return json.dumps({"error": str(e)})
167
+
168
+
169
+ def _exec_grep_search(args: dict) -> str:
170
+ pattern = args["pattern"]
171
+ include = args.get("include")
172
+ path = args.get("path") or os.getcwd()
173
+ try:
174
+ import re
175
+ p = Path(path).expanduser().resolve()
176
+ if not p.exists():
177
+ return json.dumps({"error": f"Path not found: {path}"})
178
+ if p.is_file():
179
+ files = [p]
180
+ elif include:
181
+ files = []
182
+ for ext in include.split(","):
183
+ files.extend(p.rglob(ext.strip()))
184
+ else:
185
+ files = [f for f in p.rglob("*") if f.is_file() and not f.name.startswith(".")]
186
+ results = []
187
+ for f in files:
188
+ try:
189
+ text = f.read_text(encoding="utf-8", errors="replace")
190
+ for i, line in enumerate(text.splitlines(), 1):
191
+ if re.search(pattern, line):
192
+ results.append({"file": str(f.relative_to(p) if f.is_relative_to(p) else f), "line": i, "content": line.strip()})
193
+ if len(results) >= 200:
194
+ break
195
+ except Exception:
196
+ continue
197
+ if len(results) >= 200:
198
+ break
199
+ return json.dumps({"matches": results, "total": len(results)})
200
+ except Exception as e:
201
+ return json.dumps({"error": str(e)})
202
+
203
+
204
+ async def _exec_web_fetch(args: dict) -> str:
205
+ url = args["url"]
206
+ import httpx
207
+ try:
208
+ async with httpx.AsyncClient(timeout=30) as client:
209
+ resp = await client.get(url, follow_redirects=True)
210
+ resp.raise_for_status()
211
+ return resp.text[:50000]
212
+ except Exception as e:
213
+ return json.dumps({"error": str(e)})
214
+
215
+
216
+ def _exec_memory_save(args: dict) -> str:
217
+ name = args["name"]
218
+ content = args["content"]
219
+ mtype = args.get("type", "reference")
220
+ desc = args.get("description", "")
221
+ pinned = args.get("pinned", False)
222
+ result = save_memory(name, content, {"type": mtype, "description": desc, "pinned": pinned})
223
+ return json.dumps(result)
224
+
225
+
226
+ def _exec_memory_search(args: dict) -> str:
227
+ query = args["query"]
228
+ results = search_memory(query)
229
+ return json.dumps({"matches": results, "total": len(results)})
230
+
231
+
232
+ def _exec_memory_delete(args: dict) -> str:
233
+ name = args["name"]
234
+ ok = delete_memory(name)
235
+ return json.dumps({"ok": ok, "note": f"Memory '{name}' {'deleted' if ok else 'not found'}"})
236
+
237
+
238
+ def _exec_memory_list(args: dict) -> str:
239
+ mtype = args.get("type")
240
+ results = list_memories(mtype)
241
+ return json.dumps({"memories": results, "total": len(results)})
242
+
243
+
244
+ def _exec_task_create(args: dict) -> str:
245
+ desc = args["description"]
246
+ prompt = args["prompt"]
247
+ subagent_type = args.get("subagent_type", "worker")
248
+ task = task_manager.create(desc, prompt, subagent_type)
249
+ return json.dumps({"task_id": task.id, "status": "pending", "description": desc})
250
+
251
+
252
+ def _exec_task_stop(args: dict) -> str:
253
+ task_id = args["task_id"]
254
+ ok = task_manager.stop(task_id)
255
+ return json.dumps({"ok": ok, "task_id": task_id})
256
+
257
+
258
+ def _exec_task_list(args: dict) -> str:
259
+ status = args.get("status")
260
+ tasks = task_manager.list_tasks(status)
261
+ return json.dumps({"tasks": tasks, "total": len(tasks)})
File without changes
@@ -0,0 +1,12 @@
1
+ from textual.app import App
2
+
3
+ from ghostcode.tui.screens import ChatScreen
4
+
5
+
6
+ class GhostCodeApp(App):
7
+ TITLE = "GhostCode"
8
+ SUB_TITLE = "AI coding assistant"
9
+ SCREENS = {"chat": ChatScreen}
10
+
11
+ def on_mount(self):
12
+ self.push_screen("chat")
@@ -0,0 +1,241 @@
1
+ import textwrap
2
+
3
+ from textual import on
4
+ from textual.app import ComposeResult
5
+ from textual.binding import Binding
6
+ from textual.containers import Vertical, Horizontal
7
+ from textual.screen import ModalScreen
8
+ from textual.widgets import Button, Input, Label
9
+
10
+
11
+ class ApiKeyDialog(ModalScreen):
12
+ def __init__(self, provider_id: str, provider_name: str):
13
+ super().__init__()
14
+ self._pid = provider_id
15
+ self._pname = provider_name
16
+
17
+ def compose(self) -> ComposeResult:
18
+ with Vertical(id="dialog"):
19
+ yield Label(f"Enter API key for [bold]{self._pname}[/bold]:", id="dialog-title")
20
+ yield Input(id="key-input", placeholder="sk-... or nvapi-...", password=True)
21
+ yield Label("Press [bold]Enter[/bold] to save - [bold]Esc[/bold] to cancel", id="dialog-hint")
22
+ yield from ()
23
+
24
+ CSS = """
25
+ ApiKeyDialog {
26
+ align: center middle;
27
+ }
28
+
29
+ #dialog {
30
+ width: 50;
31
+ height: auto;
32
+ padding: 2 3;
33
+ background: $surface;
34
+ border: tall $primary;
35
+ }
36
+
37
+ #dialog-title {
38
+ margin-bottom: 1;
39
+ text-style: bold;
40
+ }
41
+
42
+ #key-input {
43
+ width: 100%;
44
+ margin-bottom: 1;
45
+ }
46
+
47
+ #dialog-hint {
48
+ color: $text-muted;
49
+ }
50
+ """
51
+
52
+ BINDINGS = [
53
+ Binding("escape", "cancel", "Cancel"),
54
+ ]
55
+
56
+ def action_cancel(self):
57
+ self.dismiss(None)
58
+
59
+ @on(Input.Submitted, "#key-input")
60
+ def on_key_submitted(self, event: Input.Submitted):
61
+ self.dismiss(event.value.strip())
62
+
63
+
64
+ class UpdateDialog(ModalScreen):
65
+ def __init__(self, current: str, latest: str):
66
+ super().__init__()
67
+ self._current = current
68
+ self._latest = latest
69
+ self._updating = False
70
+
71
+ def compose(self) -> ComposeResult:
72
+ with Vertical(id="dialog"):
73
+ yield Label("[bold yellow]Update available[/bold yellow]", id="dialog-title")
74
+ yield Label(f"Current: v{self._current}", id="label-current")
75
+ yield Label(f"Latest: [bold green]v{self._latest}[/bold green]", id="label-latest")
76
+ yield Label("")
77
+ yield Label("To update, run this in your terminal:")
78
+ yield Label("[bold cyan]ghostcode update[/bold cyan]", id="label-command")
79
+ yield Label("")
80
+ yield Button("Close", id="btn-close")
81
+
82
+ CSS = """
83
+ UpdateDialog {
84
+ align: center middle;
85
+ }
86
+
87
+ #dialog {
88
+ width: 56;
89
+ height: auto;
90
+ padding: 2 3;
91
+ background: $surface;
92
+ border: tall $primary;
93
+ }
94
+
95
+ #dialog-title {
96
+ margin-bottom: 1;
97
+ }
98
+
99
+ Label {
100
+ margin-bottom: 1;
101
+ }
102
+
103
+ #label-command {
104
+ padding: 1 2;
105
+ background: $background;
106
+ color: $primary;
107
+ text-style: bold;
108
+ }
109
+ """
110
+
111
+ BINDINGS = [
112
+ Binding("escape", "close", "Close"),
113
+ ]
114
+
115
+ @on(Button.Pressed, "#btn-close")
116
+ def action_close(self):
117
+ self.dismiss(None)
118
+
119
+
120
+ class DevMenu(ModalScreen):
121
+ def compose(self) -> ComposeResult:
122
+ with Vertical(id="dialog"):
123
+ yield Label("[bold]Dev Menu[/bold]", id="dialog-title")
124
+ yield Label("Test UI components:", id="dialog-subtitle")
125
+ yield Button("ApiKeyDialog", id="btn-apikey")
126
+ yield Button("UpdateDialog", id="btn-update")
127
+ yield Button("Close", id="btn-close", variant="primary")
128
+
129
+ CSS = """
130
+ DevMenu {
131
+ align: center middle;
132
+ }
133
+
134
+ #dialog {
135
+ width: 50;
136
+ height: auto;
137
+ padding: 2 3;
138
+ background: $surface;
139
+ border: tall $primary;
140
+ }
141
+
142
+ #dialog-title {
143
+ margin-bottom: 1;
144
+ text-style: bold;
145
+ }
146
+
147
+ #dialog-subtitle {
148
+ color: $text-muted;
149
+ margin-bottom: 1;
150
+ }
151
+
152
+ Button {
153
+ width: 100%;
154
+ margin-bottom: 1;
155
+ }
156
+ """
157
+
158
+ BINDINGS = [
159
+ Binding("escape", "close", "Close"),
160
+ ]
161
+
162
+ @on(Button.Pressed, "#btn-apikey")
163
+ def test_apikey(self):
164
+ self.app.push_screen(ApiKeyDialog("groq", "Groq (test)"))
165
+
166
+ @on(Button.Pressed, "#btn-update")
167
+ def test_update(self):
168
+ self.app.push_screen(UpdateDialog("0.1.0", "99.9.9"))
169
+
170
+ @on(Button.Pressed, "#btn-close")
171
+ def action_close(self):
172
+ self.dismiss(None)
173
+
174
+
175
+ class PermissionDialog(ModalScreen):
176
+ def __init__(self, tool_name: str, args: dict):
177
+ super().__init__()
178
+ self._tool_name = tool_name
179
+ self._args = args
180
+
181
+ def compose(self) -> ComposeResult:
182
+ args_str = str(self._args)
183
+ if len(args_str) > 70:
184
+ args_str = args_str[:67] + "..."
185
+ with Vertical(id="dialog"):
186
+ yield Label("[bold yellow]Permission required[/bold yellow]", id="dialog-title")
187
+ yield Label(f"Tool: [bold]{self._tool_name}[/bold]", id="label-tool")
188
+ yield Label(f"Args: [dim]{args_str}[/dim]", id="label-args")
189
+ yield Label("")
190
+ with Horizontal(id="perm-buttons"):
191
+ yield Button("Allow", id="btn-allow", variant="primary")
192
+ yield Button("Deny", id="btn-deny", variant="error")
193
+
194
+ CSS = """
195
+ PermissionDialog {
196
+ align: center middle;
197
+ }
198
+
199
+ #dialog {
200
+ width: 60;
201
+ height: auto;
202
+ padding: 2 3;
203
+ background: $surface;
204
+ border: tall $primary;
205
+ }
206
+
207
+ #dialog-title {
208
+ margin-bottom: 1;
209
+ }
210
+
211
+ #label-tool {
212
+ margin-bottom: 1;
213
+ }
214
+
215
+ #label-args {
216
+ margin-bottom: 1;
217
+ color: $text-muted;
218
+ }
219
+
220
+ #perm-buttons {
221
+ height: 3;
222
+ align: center middle;
223
+ }
224
+
225
+ #perm-buttons Button {
226
+ margin: 0 1;
227
+ width: 20;
228
+ }
229
+ """
230
+
231
+ BINDINGS = [
232
+ Binding("escape", "deny", "Deny"),
233
+ ]
234
+
235
+ @on(Button.Pressed, "#btn-allow")
236
+ def action_allow(self):
237
+ self.dismiss(True)
238
+
239
+ @on(Button.Pressed, "#btn-deny")
240
+ def action_deny(self):
241
+ self.dismiss(False)