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,799 @@
1
+ import asyncio
2
+ import os
3
+ from pathlib import Path
4
+
5
+ import httpx
6
+ from ghostcode._version import get_version
7
+ from rich.table import Table
8
+ from rich.text import Text
9
+ from textual import on
10
+ from textual.app import ComposeResult
11
+ from textual.binding import Binding
12
+ from textual.containers import Horizontal
13
+ from textual.reactive import reactive
14
+ from textual.screen import Screen
15
+ from textual.widgets import Footer, Input, RichLog, Static
16
+
17
+ from ghostcode.ai.factory import create_client
18
+ from ghostcode.ai.registry import PROVIDERS, provider_status
19
+ from ghostcode.agents import AGENTS
20
+ from ghostcode.core.file_ops import autosave_code_blocks
21
+ from ghostcode.core.prompt import build_messages, detect_greeting
22
+ from ghostcode.core.commands import dispatch as cmd_dispatch, list_commands
23
+ from ghostcode.core.memory import memory_summary, count_memories, list_memories, delete_memory
24
+ from ghostcode.core.security import list_deny_rules
25
+ from ghostcode.core.hooks import hook_registry
26
+ from ghostcode.core.tasks import task_manager
27
+ from ghostcode.tools import TOOL_DEFINITIONS
28
+ from ghostcode.tui.dialogs import ApiKeyDialog, DevMenu, PermissionDialog, UpdateDialog
29
+ from ghostcode.tui.widgets import dim_text, render_chat_content
30
+ from ghostcode.utils.config_loader import (
31
+ get_api_key,
32
+ get_default_provider,
33
+ get_last_model,
34
+ set_api_key,
35
+ set_default_provider,
36
+ set_last_model,
37
+ )
38
+
39
+
40
+ class StatusBar(Static):
41
+ provider = reactive("nvidia")
42
+ model = reactive("")
43
+ cwd = reactive("")
44
+ agent = reactive("code")
45
+
46
+ def __init__(self, provider: str = "nvidia", model: str = "", cwd: str = "", agent: str = "code"):
47
+ super().__init__()
48
+ self.provider = provider
49
+ self.model = model
50
+ self.cwd = cwd
51
+ self.agent = agent
52
+ self._build()
53
+
54
+ def _build(self):
55
+ t = Text()
56
+ t.append(" GhostCode ", style="bold cyan")
57
+ t.append("│", style="dim")
58
+ pid = self.provider
59
+ has = bool(get_api_key(pid))
60
+ status = provider_status(pid, has)
61
+ color = {"connected": "green", "needs key": "yellow", "coming soon": "dim"}.get(status, "dim")
62
+ t.append(f" {self.provider.upper()} ", style=f"bold {color}")
63
+ if has:
64
+ t.append("[key] ", style="dim")
65
+ t.append("│", style="dim")
66
+ t.append(f" {self.model} ", style="cyan")
67
+ t.append("│", style="dim")
68
+ ainfo = AGENTS.get(self.agent, {})
69
+ aname = ainfo.get("name", self.agent.upper())
70
+ t.append(f" {aname} ", style="bold magenta")
71
+ t.append("│", style="dim")
72
+ t.append(f" {self.cwd} ", style="dim")
73
+ self.update(t)
74
+
75
+ def watch_provider(self, val):
76
+ self._build()
77
+
78
+ def watch_model(self, val):
79
+ self._build()
80
+
81
+ def watch_cwd(self, val):
82
+ self._build()
83
+
84
+ def watch_agent(self, val):
85
+ self._build()
86
+
87
+ def refresh_status(self):
88
+ self._build()
89
+
90
+
91
+ class ChatScreen(Screen):
92
+ MAX_HISTORY = 100
93
+ TRIM_TO = 50
94
+
95
+ BINDINGS = [
96
+ Binding("ctrl+l", "clear", "Clear"),
97
+ Binding("ctrl+q", "quit", "Quit"),
98
+ Binding("escape", "cancel_stream", "Cancel"),
99
+ Binding("tab", "switch_agent", "Agent"),
100
+ ]
101
+
102
+ def __init__(self):
103
+ super().__init__()
104
+ self._provider = get_default_provider()
105
+ last_model = get_last_model(self._provider)
106
+ self._model = last_model
107
+ self._client = create_client(self._provider, self._model)
108
+ self._history: list[dict] = []
109
+ self._streaming = False
110
+ self._agent = "code"
111
+ self._agent_order = list(AGENTS.keys())
112
+ self._agent_index = 0
113
+ self._cmd_suggestions = [
114
+ ("/init", "create GHOST.md for this project"),
115
+ ("/api connect", "show / set API keys"),
116
+ ("/api connect <provider>", "enter API key for a provider"),
117
+ ("/api connect <provider> <key>", "set API key directly"),
118
+ ("/provider", "list providers"),
119
+ ("/provider <name>", "switch provider"),
120
+ ("/model", "list models for current provider"),
121
+ ("/model <name>", "switch model"),
122
+ ("/clear", "clear chat history"),
123
+ ("/help", "show this help"),
124
+ ("/memory", "list/manage persistent memories"),
125
+ ("/memory save <name> <content>", "save a memory"),
126
+ ("/memory search <query>", "search memories"),
127
+ ("/memory delete <name>", "delete a memory"),
128
+ ("/memory summary", "show memory stats"),
129
+ ("/tasks", "list subagent tasks"),
130
+ ("/tasks stop <id>", "stop a task"),
131
+ ("/deny", "list security deny rules"),
132
+ ("/deny add <pattern>", "add a deny rule"),
133
+ ("/deny remove <pattern>", "remove a deny rule"),
134
+ ("/hooks", "list active hooks"),
135
+ ("/plan", "enter plan mode"),
136
+ ("/review", "run code review"),
137
+ ("/summary", "summarize session"),
138
+ ("/ask", "ask a structured question"),
139
+ ("/brief", "toggle brief output mode"),
140
+ ]
141
+
142
+ def on_mount(self):
143
+ asyncio.create_task(self._check_update())
144
+
145
+ async def _check_update(self):
146
+ try:
147
+ async with httpx.AsyncClient() as client:
148
+ r = await client.get(
149
+ "https://registry.npmjs.org/ghostcode-canary/latest",
150
+ timeout=5,
151
+ )
152
+ if r.status_code == 200:
153
+ latest = r.json().get("version", "")
154
+ current = get_version()
155
+ if latest and self._is_newer(latest, current):
156
+ await self.app.push_screen(UpdateDialog(current, latest))
157
+ except Exception:
158
+ pass
159
+
160
+ @staticmethod
161
+ def _is_newer(latest: str, current: str) -> bool:
162
+ try:
163
+ lp = tuple(int(x) for x in latest.split("."))
164
+ cp = tuple(int(x) for x in current.split("."))
165
+ return lp > cp
166
+ except ValueError:
167
+ return False
168
+
169
+ @on(Input.Submitted, "#prompt-input")
170
+ async def on_input_submit(self):
171
+ await self.action_submit()
172
+
173
+ def _trim_history(self):
174
+ if len(self._history) <= self.MAX_HISTORY:
175
+ return
176
+ self._history = self._history[-self.TRIM_TO:]
177
+ try:
178
+ log = self.query_one("#chat-log", RichLog)
179
+ log.clear()
180
+ for msg in self._history:
181
+ role = msg["role"]
182
+ content = msg["content"]
183
+ label = "[bold cyan]▎ You[/bold cyan]" if role == "user" else "[bold green]▎ GhostCode[/bold green]"
184
+ log.write(f"\n{label}")
185
+ log.write(render_chat_content(content))
186
+ except Exception:
187
+ pass
188
+
189
+ def compose(self) -> ComposeResult:
190
+ yield StatusBar(
191
+ provider=self._provider,
192
+ model=self._client.model_name,
193
+ cwd=os.getcwd(),
194
+ agent=self._agent,
195
+ )
196
+ yield RichLog(id="chat-log", highlight=True, markup=True)
197
+ yield Static(id="stream-area")
198
+ with Horizontal(id="input-area"):
199
+ yield Input(id="prompt-input", placeholder="Type a message... (Enter to send)")
200
+ yield Static(id="cmd-suggestions")
201
+ yield Footer()
202
+
203
+ CSS = """
204
+ Screen {
205
+ background: $background;
206
+ }
207
+ StatusBar {
208
+ background: $surface;
209
+ padding: 1 2;
210
+ dock: top;
211
+ border-bottom: tall $primary;
212
+ }
213
+ #chat-log {
214
+ height: 1fr;
215
+ border: none;
216
+ background: $background;
217
+ padding: 1 2;
218
+ overflow-y: auto;
219
+ }
220
+ #input-area {
221
+ height: 4;
222
+ background: $surface;
223
+ border-top: tall $primary;
224
+ padding: 0 1;
225
+ align: center middle;
226
+ }
227
+ #prompt-input {
228
+ width: 1fr;
229
+ height: 3;
230
+ background: $surface;
231
+ color: $text;
232
+ border: round $primary;
233
+ margin: 0;
234
+ }
235
+ #prompt-input:focus {
236
+ border: round $accent;
237
+ }
238
+ #stream-area {
239
+ display: none;
240
+ height: auto;
241
+ max-height: 12;
242
+ background: $surface;
243
+ border-top: tall $primary;
244
+ padding: 1 2;
245
+ margin: 0;
246
+ overflow-y: auto;
247
+ }
248
+ #stream-area.visible {
249
+ display: block;
250
+ }
251
+ #cmd-suggestions {
252
+ display: none;
253
+ height: auto;
254
+ max-height: 10;
255
+ background: $surface;
256
+ border: tall $primary;
257
+ padding: 0 1;
258
+ margin: 0 1;
259
+ overflow-y: auto;
260
+ }
261
+ #cmd-suggestions.visible {
262
+ display: block;
263
+ }
264
+ Footer {
265
+ background: $surface;
266
+ dock: bottom;
267
+ }
268
+ """
269
+
270
+ def _log(self, text: str):
271
+ self.query_one("#chat-log", RichLog).write(text)
272
+
273
+ def _show_info(self, text: str):
274
+ self._log(f"\n[dim]{text}[/dim]")
275
+
276
+ def _persist_state(self):
277
+ set_default_provider(self._provider)
278
+ set_last_model(self._provider, self._client.model_name)
279
+
280
+ @on(Input.Changed, "#prompt-input")
281
+ def on_input_changed(self, event: Input.Changed):
282
+ widget = self.query_one("#cmd-suggestions", Static)
283
+ val = event.value.strip()
284
+ if not val.startswith("/"):
285
+ widget.classes = ""
286
+ return
287
+
288
+ query = val.lower()
289
+ lines = []
290
+ for cmd, desc in self._cmd_suggestions:
291
+ if query == "/" or cmd.lower().startswith(query):
292
+ lines.append(f" [bold cyan]{cmd}[/bold cyan] [dim]{desc}[/dim]")
293
+
294
+ if lines:
295
+ widget.update("\n".join(lines))
296
+ widget.classes = "visible"
297
+ else:
298
+ widget.classes = ""
299
+
300
+ def action_quit(self):
301
+ self.app.exit()
302
+
303
+ async def action_clear(self):
304
+ self._history.clear()
305
+ self._streaming = False
306
+ self.query_one("#chat-log", RichLog).clear()
307
+ self.query_one("#stream-area", Static).classes = ""
308
+ self._show_info("Chat cleared.")
309
+
310
+ async def action_submit(self):
311
+ if self._streaming:
312
+ return
313
+ inp = self.query_one("#prompt-input", Input)
314
+ prompt = inp.value.strip()
315
+ if not prompt:
316
+ return
317
+ inp.value = ""
318
+ self._streaming = True
319
+
320
+ if prompt.startswith("/"):
321
+ if prompt.strip() == "/":
322
+ self._show_help()
323
+ self._streaming = False
324
+ inp.focus()
325
+ return
326
+ await self._handle_command(prompt)
327
+ self._streaming = False
328
+ return
329
+
330
+ has = bool(get_api_key(self._provider))
331
+ info = PROVIDERS.get(self._provider, {})
332
+ if info.get("needs_key") and not has:
333
+ self._log(f"\n[bold yellow]Provider {self._provider.upper()} needs an API key.[/bold yellow]")
334
+ self._log("Type [bold]/api connect[/bold] to see options.")
335
+ self._streaming = False
336
+ return
337
+
338
+ self._history.append({"role": "user", "content": prompt})
339
+ self._trim_history()
340
+ self._log(f"\n[bold cyan]▎ You[/bold cyan]")
341
+ self._log(render_chat_content(prompt))
342
+
343
+ greeting = detect_greeting(prompt)
344
+ if greeting:
345
+ self._log(f"\n[bold green]▎ GhostCode[/bold green]")
346
+ self._log(greeting)
347
+ self._history.append({"role": "assistant", "content": greeting})
348
+ self._trim_history()
349
+ self._streaming = False
350
+ inp.focus()
351
+ return
352
+
353
+ self._log(f"\n[bold green]▎ GhostCode[/bold green]")
354
+
355
+ stream_widget = self.query_one("#stream-area", Static)
356
+ info = PROVIDERS.get(self._provider, {})
357
+ pname = info.get("name", self._provider.upper())
358
+ stream_widget.update(f"[bold cyan]{pname} · {self._client.model_name}[/bold cyan]\n[dim]...thinking[/dim]")
359
+ stream_widget.classes = "visible"
360
+ self._streaming = True
361
+
362
+ messages = build_messages(self._history[:-1], prompt, agent=self._agent)
363
+ full = ""
364
+ has_tools = self._client.supports_tools
365
+ status = ""
366
+
367
+ async def _permission_callback(tool_name: str, args: dict) -> bool:
368
+ result = await self.app.push_screen(PermissionDialog(tool_name, args))
369
+ return bool(result)
370
+
371
+ try:
372
+ gen = (
373
+ self._client.chat(messages, tools=TOOL_DEFINITIONS, permission_callback=_permission_callback)
374
+ if has_tools
375
+ else self._client.generate(messages, stream=True)
376
+ )
377
+
378
+ async for chunk in gen:
379
+ if chunk.startswith("[dim]"):
380
+ status = dim_text(chunk).strip()
381
+ stream_widget.update(
382
+ f"[bold cyan]{pname} · {self._client.model_name}[/bold cyan]\n[dim]{status}[/dim]"
383
+ )
384
+ continue
385
+ full += chunk
386
+ stream_widget.update(f"[bold green]{full}[/bold green]")
387
+ stream_widget.scroll_visible()
388
+
389
+ if full:
390
+ self._log(render_chat_content(full))
391
+ stream_widget.classes = ""
392
+ self._history.append({"role": "assistant", "content": full})
393
+ self._trim_history()
394
+ saved = autosave_code_blocks(full)
395
+ for s in saved:
396
+ self._log(f"\n[dim]auto-saved -> {s['path']} ({s['size']} B)[/dim]")
397
+ except Exception as e:
398
+ stream_widget.update(f"[bold red]Error: {e}[/bold red]")
399
+ finally:
400
+ self._streaming = False
401
+
402
+ inp.focus()
403
+
404
+ async def action_cancel_stream(self):
405
+ if self._streaming:
406
+ self._streaming = False
407
+ sw = self.query_one("#stream-area", Static)
408
+ sw.classes = ""
409
+ self._show_info("Cancelled.")
410
+
411
+ def action_switch_agent(self):
412
+ agents = self._agent_order
413
+ self._agent_index = (self._agent_index + 1) % len(agents)
414
+ self._agent = agents[self._agent_index]
415
+ sb = self.query_one(StatusBar)
416
+ sb.agent = self._agent
417
+ self._show_info(f"Agent → {AGENTS[self._agent]['name']}")
418
+
419
+ async def _cmd_init(self):
420
+ inp = self.query_one("#prompt-input", Input)
421
+ stream_widget = self.query_one("#stream-area", Static)
422
+ stream_widget.update("[bold cyan]Analyzing codebase for GHOST.md...[/bold cyan]")
423
+ stream_widget.classes = "visible"
424
+ self._streaming = True
425
+
426
+ init_prompt = (
427
+ "Analyze this codebase and create a GHOST.md file at the project root. "
428
+ "This file will be given to future AI coding assistants to help them operate in this repository.\n\n"
429
+ "What to include:\n"
430
+ "1. Commands: how to build, lint, test, run a single test, and develop.\n"
431
+ "2. Architecture: high-level code structure so future AIs can be productive quickly.\n"
432
+ "3. Key files: README, configs, and any existing documentation.\n\n"
433
+ "Rules:\n"
434
+ "- Do not repeat yourself or include obvious instructions.\n"
435
+ "- Avoid listing every component or file — focus on the big picture.\n"
436
+ "- Don't make up information. Only include what you find by reading files.\n"
437
+ "- If a GHOST.md already exists, suggest improvements instead of overwriting.\n\n"
438
+ "Prefix the file with:\n"
439
+ "# GHOST.md\n\n"
440
+ "This file provides guidance to GhostCode when working with code in this repository."
441
+ )
442
+
443
+ messages = build_messages([], init_prompt, agent=self._agent)
444
+ full = ""
445
+ try:
446
+ if self._client.supports_tools:
447
+ async for chunk in self._client.chat(messages, tools=TOOL_DEFINITIONS):
448
+ if chunk.startswith("[dim]"):
449
+ stream_widget.update(f"[bold cyan]Exploring...[/bold cyan]\n[dim]{dim_text(chunk)}[/dim]")
450
+ continue
451
+ full += chunk
452
+ stream_widget.update(f"[bold green]{full}[/bold green]")
453
+ stream_widget.scroll_visible()
454
+ else:
455
+ async for chunk in self._client.generate(messages, stream=True):
456
+ full += chunk
457
+ stream_widget.update(f"[bold green]{full}[/bold green]")
458
+ stream_widget.scroll_visible()
459
+
460
+ if full:
461
+ saved = autosave_code_blocks(full)
462
+ stream_widget.classes = ""
463
+ for s in saved:
464
+ self._log(f"\n[dim]GHOST.md saved -> {s['path']} ({s['size']} B)[/dim]")
465
+ if not saved:
466
+ self._log(f"\n[dim]GHOST.md created (check project root)[/dim]")
467
+ except Exception as e:
468
+ stream_widget.update(f"[bold red]Error: {e}[/bold red]")
469
+ finally:
470
+ self._streaming = False
471
+ inp.focus()
472
+
473
+ async def _handle_command(self, cmd: str):
474
+ parts = cmd.split()
475
+ root = parts[0].lower()
476
+
477
+ if root == "/clear":
478
+ await self.action_clear()
479
+
480
+ elif root == "/help" or root == "/?":
481
+ self._show_help()
482
+
483
+ elif root == "/model":
484
+ if len(parts) == 1:
485
+ self._show_model_list()
486
+ else:
487
+ self._model = parts[1]
488
+ self._client = create_client(self._provider, self._model)
489
+ sb = self.query_one(StatusBar)
490
+ sb.model = self._client.model_name
491
+ self._persist_state()
492
+ self._show_info(f"Model → {self._client.model_name}")
493
+
494
+ elif root == "/provider":
495
+ if len(parts) == 1:
496
+ self._show_api_list()
497
+ else:
498
+ pid = parts[1].lower()
499
+ if pid not in PROVIDERS:
500
+ self._show_info(f"Unknown provider: {pid}")
501
+ return
502
+ self._client = create_client(pid, PROVIDERS[pid]["default_model"])
503
+ self._provider = pid
504
+ self._model = self._client.model_name
505
+ sb = self.query_one(StatusBar)
506
+ sb.provider = pid
507
+ sb.model = self._client.model_name
508
+ self._persist_state()
509
+ self._show_model_list()
510
+
511
+ elif root == "/api":
512
+ await self._handle_api(parts[1:])
513
+
514
+ elif root == "/dev":
515
+ await self._show_dev_menu()
516
+
517
+ elif root == "/update":
518
+ await self._check_update()
519
+
520
+ elif root == "/init":
521
+ await self._cmd_init()
522
+
523
+ elif root == "/memory":
524
+ await self._cmd_memory(parts[1:])
525
+
526
+ elif root == "/tasks":
527
+ await self._cmd_tasks(parts[1:])
528
+
529
+ elif root == "/deny":
530
+ await self._cmd_deny(parts[1:])
531
+
532
+ elif root == "/hooks":
533
+ self._cmd_hooks()
534
+
535
+ elif root == "/plan":
536
+ await self._cmd_plan()
537
+
538
+ elif root == "/review":
539
+ await self._cmd_review()
540
+
541
+ elif root == "/summary":
542
+ await self._cmd_summary()
543
+
544
+ elif root == "/ask":
545
+ self._log(
546
+ "\n"
547
+ "[bold]Ask a question:[/bold]\n"
548
+ " [dim]Usage: simply type your question directly. If you want multiple-choice options, list them like:[/dim]\n"
549
+ " [dim] Option A: ...[/dim]\n"
550
+ " [dim] Option B: ...[/dim]"
551
+ )
552
+
553
+ elif root == "/brief":
554
+ self._log("\n[dim]Brief mode toggled. (Placeholder — brief output preference not yet persisted.)[/dim]")
555
+
556
+ else:
557
+ self._show_info(f"Unknown: {root} (/help)")
558
+
559
+ async def _handle_api(self, args: list[str]):
560
+ if not args or args[0] != "connect":
561
+ self._show_api_list()
562
+ return
563
+
564
+ if len(args) == 1:
565
+ self._show_api_list()
566
+ elif len(args) == 2:
567
+ pid = args[1].lower()
568
+ if pid not in PROVIDERS:
569
+ self._show_info(f"Unknown provider: {pid}")
570
+ return
571
+ info = PROVIDERS[pid]
572
+ if info.get("status") == "coming_soon":
573
+ self._show_info(f"[dim]{info['name']} is coming soon.[/dim]")
574
+ return
575
+ if not info["needs_key"]:
576
+ self._show_info(f"[dim]{info['name']} doesn't need an API key (local).[/dim]")
577
+ return
578
+ dialog = ApiKeyDialog(pid, info["name"])
579
+ key = await self.app.push_screen(dialog)
580
+ if key:
581
+ set_api_key(pid, key)
582
+ self._provider = pid
583
+ self._client = create_client(pid, self._model)
584
+ sb = self.query_one(StatusBar)
585
+ sb.provider = pid
586
+ sb.model = self._client.model_name
587
+ self._persist_state()
588
+ self._show_info(f"[green]{info['name']} API key saved.[/green]")
589
+ elif len(args) >= 3:
590
+ pid = args[1].lower()
591
+ key = " ".join(args[2:])
592
+ if pid not in PROVIDERS:
593
+ self._show_info(f"Unknown provider: {pid}")
594
+ return
595
+ set_api_key(pid, key)
596
+ self._provider = pid
597
+ self._client = create_client(pid, self._model)
598
+ sb = self.query_one(StatusBar)
599
+ sb.provider = pid
600
+ sb.model = self._client.model_name
601
+ self._persist_state()
602
+ info = PROVIDERS[pid]
603
+ self._show_info(f"[green]{info['name']} API key saved.[/green]")
604
+
605
+ async def _show_dev_menu(self):
606
+ await self.app.push_screen(DevMenu())
607
+
608
+ def _show_api_list(self):
609
+ table = Table(show_edge=False, show_header=False, padding=(0, 1))
610
+ table.add_column(style="bold", width=14)
611
+ table.add_column(width=30)
612
+ table.add_column(width=14)
613
+
614
+ for pid, info in PROVIDERS.items():
615
+ key = bool(get_api_key(pid))
616
+ st = provider_status(pid, key)
617
+ status_colors = {
618
+ "connected": "green",
619
+ "needs key": "yellow",
620
+ "coming soon": "dim",
621
+ "ready": "green",
622
+ }
623
+ sc = status_colors.get(st, "dim")
624
+ name = info["name"]
625
+ desc = info["description"]
626
+ table.add_row(
627
+ f"[bold]{name}[/bold]",
628
+ f"[dim]{desc}[/dim]",
629
+ f"[{sc}]{st}[/{sc}]",
630
+ )
631
+
632
+ self._log("\n" + "[bold]Available providers:[/bold]")
633
+ self._log(table)
634
+ self._log("[dim]Usage: /api connect <provider> or /api connect <provider> <key>[/dim]")
635
+
636
+ async def _cmd_memory(self, args: list[str]):
637
+ if not args:
638
+ mems = list_memories()
639
+ if not mems:
640
+ self._show_info("No memories saved. Use /memory save <name> <content> to create one.")
641
+ else:
642
+ self._log("\n" + "[bold]Memories:[/bold]")
643
+ for m in mems:
644
+ meta = m.get("metadata", {})
645
+ mtype = meta.get("type", "?")
646
+ pinned = " [dim]📌[/dim]" if meta.get("pinned") else ""
647
+ self._log(f" [bold cyan]{m['name']}[/bold cyan] [dim]({mtype}{pinned})[/dim] — {m.get('description', '')[:80]}")
648
+ return
649
+
650
+ sub = args[0].lower()
651
+ if sub == "save" and len(args) >= 3:
652
+ name = args[1]
653
+ content = " ".join(args[2:])
654
+ from ghostcode.core.memory import save_memory
655
+ result = save_memory(name, content)
656
+ self._show_info(f"Memory saved: [bold cyan]{result['slug']}[/bold cyan] ({result['type']}, {result['bytes']}B)")
657
+ elif sub == "search" and len(args) >= 2:
658
+ query = " ".join(args[1:])
659
+ from ghostcode.core.memory import search_memory
660
+ results = search_memory(query)
661
+ if not results:
662
+ self._show_info(f"No memories matching '{query}'")
663
+ else:
664
+ self._log(f"\n[bold]Memories matching '{query}':[/bold]")
665
+ for r in results:
666
+ self._log(f" [bold cyan]{r['name']}[/bold cyan] — {r.get('description', '')[:100]}")
667
+ elif sub == "delete" and len(args) >= 2:
668
+ name = args[1]
669
+ ok = delete_memory(name)
670
+ self._show_info(f"Memory '{name}' {'deleted' if ok else 'not found'}")
671
+ elif sub == "summary":
672
+ s = memory_summary()
673
+ self._log(f"\n[bold]Memory Summary:[/bold] {s['total']} total")
674
+ for t, c in s.get("by_type", {}).items():
675
+ self._log(f" [cyan]{t}:[/cyan] {c}")
676
+ else:
677
+ self._show_info("Usage: /memory [save|search|delete|summary] ...")
678
+
679
+ async def _cmd_tasks(self, args: list[str]):
680
+ if args and args[0] == "stop" and len(args) >= 2:
681
+ tid = args[1]
682
+ ok = task_manager.stop(tid)
683
+ self._show_info(f"Task {tid} {'stopped' if ok else 'not found or already complete'}")
684
+ return
685
+
686
+ tasks = task_manager.list_tasks()
687
+ if not tasks:
688
+ self._show_info("No tasks. Tasks are created automatically when the AI spawns subagents.")
689
+ else:
690
+ self._log("\n" + "[bold]Tasks:[/bold]")
691
+ for t in tasks:
692
+ color = {"completed": "green", "running": "cyan", "pending": "yellow", "failed": "red", "stopped": "dim"}.get(t["status"], "dim")
693
+ dur = f" ({t['duration_ms']}ms)" if t["duration_ms"] else ""
694
+ self._log(f" [{color}]{t['id']}[/{color}] {t['description']} [dim]{t['status']}{dur}[/dim]")
695
+
696
+ async def _cmd_deny(self, args: list[str]):
697
+ if not args:
698
+ rules = list_deny_rules()
699
+ if not rules:
700
+ self._show_info("No deny rules configured.")
701
+ else:
702
+ self._log("\n" + "[bold]Deny rules:[/bold]")
703
+ for r in rules:
704
+ self._log(f" [bold yellow]{r['pattern']}[/bold yellow] [dim]{r.get('reason', '')}[/dim]")
705
+ return
706
+
707
+ sub = args[0].lower()
708
+ if sub == "add" and len(args) >= 2:
709
+ pattern = args[1]
710
+ reason = " ".join(args[2:]) if len(args) > 2 else ""
711
+ from ghostcode.core.security import add_deny_rule
712
+ add_deny_rule(pattern, reason)
713
+ self._show_info(f"Deny rule added: [bold yellow]{pattern}[/bold yellow]")
714
+ elif sub == "remove" and len(args) >= 2:
715
+ pattern = args[1]
716
+ from ghostcode.core.security import remove_deny_rule
717
+ ok = remove_deny_rule(pattern)
718
+ self._show_info(f"Deny rule {'removed' if ok else 'not found'}: {pattern}")
719
+ else:
720
+ self._show_info("Usage: /deny [add <pattern> [reason]|remove <pattern>]")
721
+
722
+ def _cmd_hooks(self):
723
+ s = hook_registry.summary()
724
+ self._log(f"\n[bold]Hooks:[/bold]")
725
+ self._log(f" Before tool: {s['before_tool']}")
726
+ self._log(f" After tool: {s['after_tool']}")
727
+ self._log(f" Stop hook: {s['stop_hook']}")
728
+ self._log("[dim]Configure via .ghostcode/hooks.json[/dim]")
729
+
730
+ async def _cmd_plan(self):
731
+ self.action_switch_agent()
732
+ self._show_info("[bold cyan]Plan mode[/bold cyan] — explore, analyze, and design before implementing.")
733
+ self._log("[dim]Use the Plan agent to search code, analyze architecture, and create implementation plans. Switch back with Tab.[/dim]")
734
+
735
+ async def _cmd_review(self):
736
+ self._log("\n[bold]Code Review[/bold]")
737
+ self._log("[dim]Run /init to create GHOST.md, then ask the AI to review specific code or files.[/dim]")
738
+
739
+ async def _cmd_summary(self):
740
+ total_msgs = len(self._history)
741
+ mems = count_memories()
742
+ self._log(f"\n[bold]Session Summary[/bold]")
743
+ self._log(f" Messages: {total_msgs}")
744
+ self._log(f" Memories: {mems}")
745
+ self._log(f" Agent: {AGENTS[self._agent]['name']}")
746
+ self._log(f" Provider: {self._provider.upper()} · {self._client.model_name}")
747
+
748
+ def _show_help(self):
749
+ self._log(
750
+ "\n"
751
+ "[bold]Commands:[/bold]\n"
752
+ " [bold cyan]/init[/bold cyan] create GHOST.md for this project\n"
753
+ " [bold cyan]/api connect[/bold cyan] show providers\n"
754
+ " [bold cyan]/api connect[/bold cyan] [green]<provider>[/green] enter API key\n"
755
+ " [bold cyan]/api connect[/bold cyan] [green]<provider> <key>[/green] set key directly\n"
756
+ " [bold cyan]/model[/bold cyan] show models\n"
757
+ " [bold cyan]/model[/bold cyan] [green]<name>[/green] switch model\n"
758
+ " [bold cyan]/provider[/bold cyan] show providers\n"
759
+ " [bold cyan]/provider[/bold cyan] [green]<name>[/green] switch provider\n"
760
+ " [bold cyan]/clear[/bold cyan] clear chat\n"
761
+ " [bold cyan]/memory[/bold cyan] list/manage memories\n"
762
+ " [bold cyan]/memory save[/bold cyan] [green]<name> <content>[/green] save a memory\n"
763
+ " [bold cyan]/memory search[/bold cyan] [green]<query>[/green] search memories\n"
764
+ " [bold cyan]/memory delete[/bold cyan] [green]<name>[/green] delete a memory\n"
765
+ " [bold cyan]/memory summary[/bold cyan] show memory stats\n"
766
+ " [bold cyan]/tasks[/bold cyan] list subagent tasks\n"
767
+ " [bold cyan]/tasks stop[/bold cyan] [green]<id>[/green] stop a task\n"
768
+ " [bold cyan]/deny[/bold cyan] list deny rules\n"
769
+ " [bold cyan]/deny add[/bold cyan] [green]<pattern>[/green] add deny rule\n"
770
+ " [bold cyan]/deny remove[/bold cyan] [green]<pattern>[/green] remove deny rule\n"
771
+ " [bold cyan]/hooks[/bold cyan] show hook status\n"
772
+ " [bold cyan]/plan[/bold cyan] enter plan mode (Tab)\n"
773
+ " [bold cyan]/review[/bold cyan] init code review\n"
774
+ " [bold cyan]/summary[/bold cyan] session summary\n"
775
+ " [bold cyan]/ask[/bold cyan] show ask usage\n"
776
+ " [bold cyan]/brief[/bold cyan] toggle brief output mode\n"
777
+ " [bold cyan]/help[/bold cyan] this help" + "\n"
778
+ "\n"
779
+ "[dim]Tab — switch agent | Ctrl+L — clear | Ctrl+Q — quit | Esc — cancel[/dim]"
780
+ )
781
+
782
+ def _show_model_list(self):
783
+ info = PROVIDERS.get(self._provider, {})
784
+ models = info.get("models", [])
785
+ name = info.get("name", self._provider.upper())
786
+ current = self._client.model_name
787
+
788
+ self._log(f"\n[bold]{name} models[/bold] [dim](current: {current})[/dim]")
789
+
790
+ table = Table(show_edge=False, show_header=False, padding=(0, 1))
791
+ table.add_column(width=2)
792
+ table.add_column(width=50)
793
+
794
+ for m in models:
795
+ marker = "[green]→[/green]" if m == current else " "
796
+ table.add_row(marker, m)
797
+
798
+ self._log(table)
799
+ self._log("[dim]Usage: /model <model-name>[/dim]")