bharatcode 0.1.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.
bharatcode/commands.py ADDED
@@ -0,0 +1,1072 @@
1
+ """
2
+ Slash commands — like Claude Code's /clear, /compact, /review, /cost, /doctor, /git, /memory, /skill, /plan.
3
+ """
4
+ import os
5
+ from .ui import console, show_info, show_success, show_warning, show_error
6
+ from .config import load_config, save_config, model_label
7
+
8
+
9
+ # ── Interactive dropdown helpers ───────────────────────────────────────────────
10
+
11
+ def _try_questionary_select(prompt: str, choices: list) -> str | None:
12
+ """
13
+ Show an interactive arrow-key dropdown using questionary.
14
+ Each item in choices is (display_label, value).
15
+ Returns the selected value, or None if cancelled / questionary not installed.
16
+ """
17
+ try:
18
+ import questionary
19
+ from questionary import Style
20
+
21
+ q_style = Style([
22
+ ("highlighted", "fg:cyan bold"),
23
+ ("pointer", "fg:cyan bold"),
24
+ ("selected", "fg:green"),
25
+ ("question", "fg:yellow bold"),
26
+ ("instruction", "fg:gray italic"),
27
+ ])
28
+
29
+ q_choices = [
30
+ questionary.Choice(title=label, value=val)
31
+ for label, val in choices
32
+ ]
33
+ q_choices.append(questionary.Separator())
34
+ q_choices.append(questionary.Choice(title="↩ Cancel", value=None))
35
+
36
+ result = questionary.select(
37
+ prompt,
38
+ choices=q_choices,
39
+ style=q_style,
40
+ instruction=" (↑↓ move Enter select Ctrl-C cancel)",
41
+ ).ask()
42
+ return result
43
+ except ImportError:
44
+ return "__FALLBACK__"
45
+ except (KeyboardInterrupt, EOFError):
46
+ return None
47
+
48
+
49
+ def _numbered_select(title: str, choices: list) -> str | None:
50
+ """
51
+ Fallback numbered list when questionary is not installed.
52
+ choices = [(label, value), ...]
53
+ """
54
+ console.print(f"\n[bold]{title}[/bold]")
55
+ for i, (label, val) in enumerate(choices, 1):
56
+ console.print(f" [green]{i:>2}[/green] {label}")
57
+ console.print()
58
+ try:
59
+ raw = input(" Enter number (or name, or Enter to cancel): ").strip()
60
+ except (EOFError, KeyboardInterrupt):
61
+ return None
62
+ if not raw:
63
+ return None
64
+ if raw.isdigit():
65
+ idx = int(raw) - 1
66
+ if 0 <= idx < len(choices):
67
+ return choices[idx][1]
68
+ show_error(f"Invalid number: {raw}")
69
+ return None
70
+ # Try matching by value
71
+ for label, val in choices:
72
+ if raw.lower() == str(val).lower():
73
+ return val
74
+ show_error(f"Not found: {raw}")
75
+ return None
76
+
77
+
78
+ def _select(prompt: str, choices: list) -> str | None:
79
+ """Show questionary dropdown, fallback to numbered list."""
80
+ result = _try_questionary_select(prompt, choices)
81
+ if result == "__FALLBACK__":
82
+ return _numbered_select(prompt, choices)
83
+ return result
84
+
85
+ COMMANDS: dict[str, dict] = {}
86
+
87
+ def command(name: str, description: str):
88
+ def decorator(fn):
89
+ COMMANDS[name] = {"fn": fn, "description": description}
90
+ return fn
91
+ return decorator
92
+
93
+ def handle_slash_command(cmd: str, session: dict) -> bool:
94
+ parts = cmd.strip().lstrip("/").split(maxsplit=1)
95
+ name = parts[0].lower()
96
+ args = parts[1] if len(parts) > 1 else ""
97
+
98
+ if name in COMMANDS:
99
+ COMMANDS[name]["fn"](args, session)
100
+ return True
101
+
102
+ show_warning(f"Unknown command: /{name} — type /help")
103
+ return False
104
+
105
+ # ── Conversation ──────────────────────────────────────────────────────────────
106
+
107
+ @command("clear", "Clear conversation history")
108
+ def cmd_clear(args: str, session: dict):
109
+ session["messages"] = []
110
+ show_success("Conversation cleared.")
111
+
112
+
113
+ @command("changes", "Show all files modified this session")
114
+ def cmd_changes(args: str, session: dict):
115
+ change_log = session.get("change_log", {})
116
+ if not change_log:
117
+ show_info("No files modified this session.")
118
+ return
119
+ console.print(
120
+ f"\n[bold]Session Changes[/bold] "
121
+ f"[dim]({len(change_log)} file{'s' if len(change_log) > 1 else ''} touched)[/dim]\n"
122
+ )
123
+ for path in sorted(change_log.keys()):
124
+ stats = change_log[path]
125
+ writes = stats.get("writes", 0)
126
+ edits = stats.get("edits", 0)
127
+ parts = []
128
+ if writes:
129
+ parts.append(f"[green]{writes} write{'s' if writes > 1 else ''}[/green]")
130
+ if edits:
131
+ parts.append(f"[cyan]{edits} edit{'s' if edits > 1 else ''}[/cyan]")
132
+ stat_str = " ".join(parts) if parts else "[dim]touched[/dim]"
133
+ console.print(f" [cyan]{path}[/cyan] {stat_str}")
134
+ console.print()
135
+
136
+ @command("compact", "Summarise old conversation history to free up context (use when session is very long)")
137
+ def cmd_compact(args: str, session: dict):
138
+ from .agent import _auto_compact, _estimate_tokens
139
+ from openai import OpenAI
140
+ from .config import get_api_key, load_config
141
+
142
+ msgs = session.get("messages", [])
143
+ if len(msgs) < 4:
144
+ show_info("Not enough history to compact.")
145
+ return
146
+
147
+ before = _estimate_tokens(msgs)
148
+ cfg = load_config()
149
+ client = OpenAI(api_key=get_api_key(), base_url="https://api.deepseek.com")
150
+
151
+ # Force compact regardless of threshold
152
+ from .agent import _COMPACT_TARGET_RATIO
153
+ import math
154
+ cutoff = max(2, math.floor(len(msgs) * _COMPACT_TARGET_RATIO))
155
+ # Temporarily lower threshold so _auto_compact fires
156
+ import bharatcode.agent as _ag
157
+ old_thresh = _ag._COMPACT_THRESHOLD
158
+ _ag._COMPACT_THRESHOLD = 0
159
+ compacted = _auto_compact(
160
+ msgs, client, cfg.get("model", "deepseek-v4-flash"),
161
+ file_cache=session.get("file_cache", {}), # Feature 6: pass cache for context-aware compact
162
+ )
163
+ _ag._COMPACT_THRESHOLD = old_thresh
164
+
165
+ after = _estimate_tokens(msgs)
166
+ if compacted:
167
+ show_success(f"Compacted: ~{before:,} → ~{after:,} tokens ({len(msgs)} messages remaining)")
168
+ else:
169
+ show_info("Nothing to compact.")
170
+
171
+ # ── Code Actions ──────────────────────────────────────────────────────────────
172
+
173
+ @command("review", "Review current directory code")
174
+ def cmd_review(args: str, session: dict):
175
+ from .agent import run_agent
176
+ target = args or os.getcwd()
177
+ show_info(f"Reviewing: {target}")
178
+ run_agent(
179
+ f"Do a thorough code review of: {target}. Check bugs, security, Indian compliance.",
180
+ project_path=os.getcwd(),
181
+ )
182
+
183
+ @command("audit", "Run Indian compliance audit (DPDP, RBI, GST)")
184
+ def cmd_audit(args: str, session: dict):
185
+ from .agent import run_agent
186
+ show_info("Running Indian compliance audit...")
187
+ run_agent(
188
+ "Audit this project for DPDP Act 2023, RBI, GST, Aadhaar/PAN compliance.",
189
+ project_path=os.getcwd(),
190
+ )
191
+
192
+ @command("plan", "Toggle plan mode — agent reads only and proposes plan before any changes")
193
+ def cmd_plan(args: str, session: dict):
194
+ arg = args.strip().lower()
195
+
196
+ # Explicit on/off/approve
197
+ if arg in ("on",):
198
+ session["plan_mode"] = True
199
+ elif arg in ("off", "approve", "go", "execute", "yes", "y"):
200
+ session["plan_mode"] = False
201
+ else:
202
+ session["plan_mode"] = not session.get("plan_mode", False)
203
+
204
+ if session.get("plan_mode"):
205
+ console.print(
206
+ "\n[bold cyan]PLAN MODE ON[/bold cyan] "
207
+ "[dim]Agent will only read files and propose a plan — no writes, no bash.[/dim]\n"
208
+ "[dim]When you're happy with the plan, type [/dim][cyan]/plan off[/cyan][dim] then re-send your task to execute.[/dim]\n"
209
+ )
210
+ else:
211
+ console.print(
212
+ "\n[bold green]PLAN MODE OFF[/bold green] "
213
+ "[dim]Agent can now write files and run commands.[/dim]\n"
214
+ )
215
+
216
+ # ── New Website / App ────────────────────────────────────────────────────────
217
+
218
+ @command("newsite", "Build a website: /newsite Chhelu Portfolio - developer portfolio")
219
+ def cmd_newsite(args: str, session: dict):
220
+ from .skills import ask_skill_questions, build_skill_prompt
221
+ from .agent import run_agent
222
+ import os
223
+
224
+ prefilled: dict = {}
225
+ if args.strip():
226
+ parts = args.strip().split(" - ", 1) if " - " in args else args.strip().split(",", 1)
227
+ if len(parts) == 2:
228
+ prefilled["name"] = parts[0].strip()
229
+ prefilled["desc"] = parts[1].strip()
230
+ else:
231
+ prefilled["name"] = args.strip()
232
+
233
+ answers = ask_skill_questions("newsite", prefilled=prefilled)
234
+ if answers is None:
235
+ return
236
+
237
+ name = answers.get("name", "website")
238
+ if not name:
239
+ show_error("Site name is required.")
240
+ return
241
+
242
+ dest = name.lower().replace(" ", "-")
243
+ dest_path = os.path.join(os.getcwd(), dest)
244
+ os.makedirs(dest_path, exist_ok=True)
245
+ console.print(f"[dim]Output: {dest_path}[/dim]\n")
246
+
247
+ task = f"""{build_skill_prompt("newsite", answers)}
248
+
249
+ Output folder (absolute): {dest_path}
250
+
251
+ CRITICAL PATH RULE: Every file MUST use the full absolute path.
252
+ Correct: <<<FILE:{dest_path}/frontend/src/App.jsx>>>
253
+ Correct: <<<FILE:{dest_path}/backend/app/__init__.py>>>
254
+ WRONG: <<<FILE:frontend/src/App.jsx>>>"""
255
+
256
+ run_agent(task, project_path=dest_path,
257
+ auto_approve=session.get("auto_approve", False),
258
+ history=session.get("messages"),
259
+ system_content=session.get("system"),
260
+ file_cache=session.get("file_cache"))
261
+
262
+
263
+ @command("newapp", "Build an app: /newapp TaskFlow - kanban project manager")
264
+ def cmd_newapp(args: str, session: dict):
265
+ from .skills import ask_skill_questions, build_skill_prompt
266
+ from .agent import run_agent
267
+ import os
268
+
269
+ prefilled: dict = {}
270
+ raw = args.strip()
271
+ if raw:
272
+ parts = raw.split(" - ", 1) if " - " in raw else raw.split(",", 1)
273
+ if len(parts) == 2:
274
+ prefilled["name"] = parts[0].strip()
275
+ prefilled["desc"] = parts[1].strip()
276
+ else:
277
+ prefilled["name"] = raw.strip()
278
+
279
+ answers = ask_skill_questions("newapp", prefilled=prefilled)
280
+ if answers is None:
281
+ return
282
+
283
+ name = answers.get("name", "app")
284
+ if not name:
285
+ show_error("App name is required.")
286
+ return
287
+
288
+ dest = name.lower().replace(" ", "-")
289
+ dest_path = os.path.join(os.getcwd(), dest)
290
+ os.makedirs(dest_path, exist_ok=True)
291
+ console.print(f"[dim]Output: {dest_path}[/dim]\n")
292
+
293
+ task = f"""{build_skill_prompt("newapp", answers)}
294
+
295
+ Output folder (absolute): {dest_path}
296
+
297
+ CRITICAL PATH RULE: Every file MUST use the full absolute path.
298
+ Correct: <<<FILE:{dest_path}/frontend/src/App.jsx>>>
299
+ Correct: <<<FILE:{dest_path}/backend/app/__init__.py>>>
300
+ WRONG: <<<FILE:frontend/src/App.jsx>>>"""
301
+
302
+ run_agent(task, project_path=dest_path,
303
+ auto_approve=session.get("auto_approve", False),
304
+ history=session.get("messages"),
305
+ system_content=session.get("system"),
306
+ file_cache=session.get("file_cache"))
307
+
308
+
309
+ # ── Skills ────────────────────────────────────────────────────────────────────
310
+
311
+ _SKILL_DESCRIPTIONS = {
312
+ "newsite": "Full-stack site — pick frontend + backend tech, frontend/ + backend/ folders",
313
+ "newapp": "Full-stack app — pick frontend + backend tech, detailed per-framework rules",
314
+ "docker": "Dockerize everything — multi-stage build, compose, healthcheck, .dockerignore",
315
+ "ci-github": "GitHub Actions CI/CD — lint → test → build → deploy, caching, secrets",
316
+ }
317
+
318
+
319
+ @command("skills", "Browse and run skills interactively (arrow-key dropdown)")
320
+ def cmd_skills(args: str, session: dict):
321
+ from .skills import load_skills, BUILTIN_SKILLS, ask_skill_questions, build_skill_prompt, get_skill_raw
322
+ from .agent import run_agent
323
+
324
+ skills = load_skills()
325
+ builtin = list(BUILTIN_SKILLS.keys())
326
+ custom = [k for k in skills if k not in builtin]
327
+
328
+ choices = []
329
+ for name in builtin:
330
+ desc = _SKILL_DESCRIPTIONS.get(name, "")
331
+ choices.append((f"{name:<18} [dim]{desc}[/dim]", name))
332
+ for name in custom:
333
+ preview = skills[name].split("\n")[0][:55]
334
+ choices.append((f"{name:<18} [cyan](custom)[/cyan] {preview}", name))
335
+
336
+ selected = _select("Select a skill to run:", choices)
337
+ if not selected:
338
+ return
339
+
340
+ _run_skill(selected, session)
341
+
342
+
343
+ @command("skill", "Run a skill directly: /skill razorpay")
344
+ def cmd_skill(args: str, session: dict):
345
+ if not args:
346
+ cmd_skills("", session)
347
+ return
348
+ _run_skill(args.strip().lower(), session)
349
+
350
+
351
+ def _run_skill(name: str, session: dict) -> None:
352
+ """Ask Q&A for a skill, build the prompt, and run the agent."""
353
+ from .skills import BUILTIN_SKILLS, ask_skill_questions, build_skill_prompt, get_skill_raw
354
+ from .agent import run_agent
355
+ import os
356
+
357
+ if name in BUILTIN_SKILLS:
358
+ # Interactive Q&A for built-in skills
359
+ answers = ask_skill_questions(name)
360
+ if answers is None:
361
+ return # user cancelled
362
+
363
+ # Scaffold skills need a real output folder
364
+ task_prompt = build_skill_prompt(name, answers)
365
+
366
+ if name in ("newsite", "newapp"):
367
+ proj_name = answers.get("name", name)
368
+ dest = proj_name.lower().replace(" ", "-")
369
+ dest_path = os.path.join(os.getcwd(), dest)
370
+ os.makedirs(dest_path, exist_ok=True)
371
+ console.print(f"[dim]Output: {dest_path}[/dim]\n")
372
+ task_prompt = (
373
+ f"{task_prompt}\n\nOutput folder (absolute): {dest_path}\n"
374
+ f"CRITICAL PATH RULE: Every file MUST be written inside {dest_path}."
375
+ )
376
+ run_agent(task_prompt, project_path=dest_path,
377
+ auto_approve=session.get("auto_approve", False),
378
+ history=session.get("messages"),
379
+ system_content=session.get("system"),
380
+ file_cache=session.get("file_cache"))
381
+ else:
382
+ show_info(f"Running skill: {name}")
383
+ run_agent(task_prompt, project_path=os.getcwd(),
384
+ auto_approve=session.get("auto_approve", False),
385
+ history=session.get("messages"),
386
+ system_content=session.get("system"),
387
+ file_cache=session.get("file_cache"))
388
+ else:
389
+ # Custom file-based skill — raw prompt, no Q&A
390
+ raw = get_skill_raw(name)
391
+ if not raw:
392
+ show_error(f"Skill '{name}' not found. Type /skills to browse.")
393
+ return
394
+ show_info(f"Running custom skill: {name}")
395
+ run_agent(raw, project_path=os.getcwd(),
396
+ auto_approve=session.get("auto_approve", False),
397
+ history=session.get("messages"),
398
+ system_content=session.get("system"),
399
+ file_cache=session.get("file_cache"))
400
+
401
+ # ── Git ───────────────────────────────────────────────────────────────────────
402
+
403
+ @command("git", "Show git status and recent commits")
404
+ def cmd_git(args: str, session: dict):
405
+ import subprocess
406
+ try:
407
+ status = subprocess.run(
408
+ ["git", "status", "--short"], capture_output=True, text=True, timeout=5
409
+ ).stdout
410
+ log = subprocess.run(
411
+ ["git", "log", "--oneline", "-8"], capture_output=True, text=True, timeout=5
412
+ ).stdout
413
+ branch = subprocess.run(
414
+ ["git", "branch", "--show-current"], capture_output=True, text=True, timeout=5
415
+ ).stdout.strip()
416
+
417
+ console.print(f"\n[bold]Git Status[/bold] branch: [cyan]{branch}[/cyan]")
418
+ if status:
419
+ for line in status.splitlines():
420
+ color = "green" if line.startswith("?") else "yellow" if line.startswith("M") else "red"
421
+ console.print(f" [{color}]{line}[/{color}]")
422
+ else:
423
+ console.print(" [dim]Clean working tree[/dim]")
424
+
425
+ if log:
426
+ console.print("\n[bold]Recent Commits[/bold]")
427
+ for line in log.splitlines():
428
+ sha, *rest = line.split(" ", 1)
429
+ console.print(f" [dim]{sha}[/dim] {' '.join(rest)}")
430
+ console.print()
431
+ except FileNotFoundError:
432
+ show_error("git not found in PATH")
433
+ except Exception as e:
434
+ show_error(str(e))
435
+
436
+ @command("diff", "Show uncommitted git changes")
437
+ def cmd_diff(args: str, session: dict):
438
+ import subprocess
439
+ try:
440
+ diff = subprocess.run(
441
+ ["git", "diff", "--stat"], capture_output=True, text=True, timeout=5
442
+ ).stdout
443
+ full = subprocess.run(
444
+ ["git", "diff"], capture_output=True, text=True, timeout=5
445
+ ).stdout
446
+ if not diff and not full:
447
+ console.print("[dim]No uncommitted changes.[/dim]")
448
+ return
449
+ console.print(f"\n[bold]Uncommitted Changes[/bold]\n{diff}")
450
+ if full and len(full) < 6000:
451
+ from rich.syntax import Syntax
452
+ console.print(Syntax(full, "diff", theme="monokai"))
453
+ elif full:
454
+ console.print(f"[dim](diff too large to display — {len(full):,} chars)[/dim]")
455
+ except FileNotFoundError:
456
+ show_error("git not found")
457
+ except Exception as e:
458
+ show_error(str(e))
459
+
460
+ # ── Cost & Status ─────────────────────────────────────────────────────────────
461
+
462
+ @command("cost", "Show session token usage and estimated cost")
463
+ def cmd_cost(args: str, session: dict):
464
+ from .cost import session_cost
465
+ session_cost.display(console)
466
+
467
+ @command("status", "Show Sylithe Code version, model, API status")
468
+ def cmd_status(args: str, session: dict):
469
+ import platform
470
+ from . import __version__
471
+ cfg = load_config()
472
+ key = cfg.get("api_key", "")
473
+ key_display = (key[:8] + "..." + key[-4:]) if key else "[red]NOT SET[/red]"
474
+
475
+ # Test API connectivity
476
+ api_ok = False
477
+ try:
478
+ from openai import OpenAI
479
+ client = OpenAI(api_key=key, base_url="https://api.deepseek.com")
480
+ client.models.list()
481
+ api_ok = True
482
+ except Exception:
483
+ pass
484
+
485
+ console.print(f"\n[bold]Sylithe Code Status[/bold]")
486
+ console.print(f" [dim]Version[/dim] [cyan]{__version__}[/cyan]")
487
+ console.print(f" [dim]Model[/dim] [cyan]{model_label(cfg.get('model', 'deepseek-v4-flash'))}[/cyan]")
488
+ console.print(f" [dim]API key[/dim] {key_display}")
489
+ console.print(f" [dim]API status[/dim] {'[green]connected[/green]' if api_ok else '[red]unreachable[/red]'}")
490
+ console.print(f" [dim]Python[/dim] [cyan]{platform.python_version()}[/cyan]")
491
+ console.print(f" [dim]Platform[/dim] [cyan]{platform.system()} {platform.release()}[/cyan]")
492
+ console.print(f" [dim]Workdir[/dim] [cyan]{os.getcwd()}[/cyan]")
493
+ console.print()
494
+
495
+ @command("doctor", "Diagnose Sylithe Code setup")
496
+ def cmd_doctor(args: str, session: dict):
497
+ import subprocess
498
+ checks = []
499
+
500
+ # Python version
501
+ import sys
502
+ py_ok = sys.version_info >= (3, 10)
503
+ checks.append(("Python >= 3.10", py_ok, f"Python {sys.version.split()[0]}"))
504
+
505
+ # API key
506
+ cfg = load_config()
507
+ key = cfg.get("api_key", "")
508
+ checks.append(("DeepSeek API key set", bool(key), key[:8] + "..." if key else "NOT SET"))
509
+
510
+ # API connectivity
511
+ api_ok = False
512
+ try:
513
+ from openai import OpenAI
514
+ client = OpenAI(api_key=key, base_url="https://api.deepseek.com")
515
+ client.models.list()
516
+ api_ok = True
517
+ except Exception as e:
518
+ pass
519
+ checks.append(("DeepSeek API reachable", api_ok, "OK" if api_ok else "Connection failed"))
520
+
521
+ # Git
522
+ try:
523
+ r = subprocess.run(["git", "--version"], capture_output=True, text=True, timeout=3)
524
+ git_ok = r.returncode == 0
525
+ git_ver = r.stdout.strip()
526
+ except Exception:
527
+ git_ok, git_ver = False, "not found"
528
+ checks.append(("git installed", git_ok, git_ver))
529
+
530
+ # Required packages
531
+ for pkg in ["rich", "click", "openai", "dotenv"]:
532
+ try:
533
+ __import__(pkg.replace("-", "_"))
534
+ checks.append((f"package: {pkg}", True, "installed"))
535
+ except ImportError:
536
+ checks.append((f"package: {pkg}", False, "MISSING — run: pip install " + pkg))
537
+
538
+ # BHARATCODE.md
539
+ has_md = (os.path.exists("BHARATCODE.md"))
540
+ checks.append(("BHARATCODE.md in project", has_md, "found" if has_md else "run: bharatcode init"))
541
+
542
+ console.print("\n[bold]Sylithe Code Doctor[/bold]\n")
543
+ for name, ok, detail in checks:
544
+ icon = "[green]✓[/green]" if ok else "[red]✗[/red]"
545
+ color = "dim" if ok else "red"
546
+ console.print(f" {icon} {name:<30} [{color}]{detail}[/{color}]")
547
+ console.print()
548
+ all_ok = all(ok for _, ok, _ in checks)
549
+ if all_ok:
550
+ show_success("All checks passed! Sylithe Code is ready.")
551
+ else:
552
+ show_warning("Some checks failed. Fix the issues above.")
553
+
554
+ # ── Memory ────────────────────────────────────────────────────────────────────
555
+
556
+ @command("memory", "Manage persistent memory: /memory [list] | /memory add <text> | /memory del <id>")
557
+ def cmd_memory(args: str, session: dict):
558
+ from .memory import add_memory, delete_memory, show_memories, MEMORY_DIR
559
+ parts = args.strip().split(maxsplit=1)
560
+ sub = parts[0].lower() if parts else "list"
561
+ rest = parts[1] if len(parts) > 1 else ""
562
+
563
+ if sub in ("list", "ls", ""):
564
+ show_memories(console)
565
+ console.print(f"[dim] Memory dir: {MEMORY_DIR}[/dim]")
566
+ elif sub == "add":
567
+ if not rest:
568
+ show_error("Usage: /memory add <text>")
569
+ return
570
+ entry = add_memory(rest)
571
+ show_success(f"Memory saved (id={entry['id']}): {rest[:60]}")
572
+ elif sub in ("del", "delete", "rm"):
573
+ try:
574
+ mid = int(rest)
575
+ if delete_memory(mid):
576
+ show_success(f"Memory {mid} deleted.")
577
+ else:
578
+ show_error(f"Memory id={mid} not found.")
579
+ except ValueError:
580
+ show_error("Usage: /memory del <id>")
581
+ else:
582
+ # Bare text — treat whole args as "add"
583
+ entry = add_memory(args)
584
+ show_success(f"Memory saved (id={entry['id']}): {args[:60]}")
585
+
586
+
587
+ @command("resume", "Resume a previous session: /resume [session_id]")
588
+ def cmd_resume(args: str, session: dict):
589
+ from . import session_storage
590
+ import os
591
+
592
+ cwd = os.getcwd()
593
+ args = args.strip()
594
+ recent = session_storage.list_recent(cwd, max_n=5)
595
+
596
+ if not recent:
597
+ show_info("No previous sessions found for this directory.")
598
+ return
599
+
600
+ # Pick which session to load
601
+ if args:
602
+ # User specified a session ID prefix
603
+ match = next((s for s in recent if s["session_id"].startswith(args)), None)
604
+ if not match:
605
+ show_error(f"Session '{args}' not found.")
606
+ for s in recent:
607
+ console.print(f" [dim]{s['session_id']}[/dim] {s['mtime_str']} {s['last_message']}")
608
+ return
609
+ chosen = match
610
+ else:
611
+ # Show list and let user pick
612
+ choices = [
613
+ (
614
+ f"{s['session_id']} {s['mtime_str']} ({s['turns']} turns) \"{s['last_message']}\"",
615
+ s["session_id"],
616
+ )
617
+ for s in recent
618
+ ]
619
+ picked = _select("Resume which session?", choices)
620
+ if not picked:
621
+ return
622
+ chosen = next(s for s in recent if s["session_id"] == picked)
623
+
624
+ # Load messages
625
+ messages = session_storage.load_messages(chosen["path"])
626
+ if not messages:
627
+ show_error("Session file is empty.")
628
+ return
629
+
630
+ # Restore into session
631
+ session["messages"][:] = messages
632
+ session_storage.save_latest_pointer(cwd, chosen["session_id"])
633
+
634
+ show_success(
635
+ f"Resumed session {chosen['session_id']} — "
636
+ f"{len(messages)} messages, {chosen['turns']} user turns."
637
+ )
638
+ console.print(
639
+ f"[dim] Last message: \"{chosen['last_message']}\"[/dim]\n"
640
+ f"[dim] Continue where you left off — or just start typing a new task.[/dim]"
641
+ )
642
+
643
+ # ── Settings ──────────────────────────────────────────────────────────────────
644
+
645
+ @command("yolo", "Toggle auto-approve mode (skip all permission prompts)")
646
+ def cmd_yolo(args: str, session: dict):
647
+ session["auto_approve"] = not session.get("auto_approve", False)
648
+ if session["auto_approve"]:
649
+ console.print("[yellow]Auto-approve ON[/yellow] — all bash commands run without prompts.")
650
+ else:
651
+ console.print("[green]Auto-approve OFF[/green] — permission prompts restored.")
652
+
653
+ @command("model", "Switch model: /model sylithe-flash | sylithe-pro")
654
+ def cmd_model(args: str, session: dict):
655
+ if not args:
656
+ cfg = load_config()
657
+ show_info(f"Current model: {model_label(cfg.get('model', 'deepseek-v4-flash'))}")
658
+ console.print(" Options: [cyan]Sylithe Code Flash[/cyan] [cyan]Sylithe Code Pro[/cyan]")
659
+ console.print(" [dim]Usage: /model sylithe-flash or /model sylithe-pro[/dim]")
660
+ return
661
+ from .config import MODEL_API_MAP
662
+ name = args.strip()
663
+ api_name = MODEL_API_MAP.get(name, name)
664
+ cfg = load_config()
665
+ cfg["model"] = api_name
666
+ save_config(cfg)
667
+ show_success(f"Model switched to: {model_label(api_name)}")
668
+
669
+ @command("pwd", "Show current working directory")
670
+ def cmd_pwd(args: str, session: dict):
671
+ show_info(os.getcwd())
672
+
673
+ @command("config", "Show current config")
674
+ def cmd_config(args: str, session: dict):
675
+ cfg = load_config()
676
+ console.print("\n[bold]Config[/bold]")
677
+ for k, v in cfg.items():
678
+ if k == "api_key" and v:
679
+ v = v[:8] + "..." + v[-4:]
680
+ console.print(f" [dim]{k:<20}[/dim] [cyan]{v}[/cyan]")
681
+ console.print()
682
+
683
+ # ── Agents ────────────────────────────────────────────────────────────────────
684
+
685
+ @command("agent", "Spawn a specialist AI agent — explore, coder, verifier, researcher: /agent <type> <task>")
686
+ def cmd_agent(args: str, session: dict):
687
+ from .subagent import run_subagent, AGENT_TYPES
688
+
689
+ parts = args.strip().split(maxsplit=1)
690
+ agent_type = parts[0].lower() if parts else ""
691
+ task = parts[1] if len(parts) > 1 else ""
692
+
693
+ if not agent_type or agent_type not in AGENT_TYPES:
694
+ # Show interactive selector with taglines
695
+ choices = [
696
+ (
697
+ f"{info['icon']} [{atype:<10}] {info['tagline']}",
698
+ atype,
699
+ )
700
+ for atype, info in AGENT_TYPES.items()
701
+ ]
702
+ console.print(
703
+ "\n[bold]Sylithe Code Agents[/bold] [dim]— each specialist runs with its own isolated context[/dim]"
704
+ )
705
+ agent_type = _select("Which agent do you want to spawn?", choices)
706
+ if not agent_type:
707
+ return
708
+
709
+ if not task:
710
+ try:
711
+ info = AGENT_TYPES[agent_type]
712
+ console.print(
713
+ f"\n {info['icon']} [bold {info['color']}]{info['label']}[/bold {info['color']}] "
714
+ f"[dim]{info['tagline']}[/dim]\n"
715
+ )
716
+ console.print("[dim]What should this agent do? Be specific — it has no memory of your session.[/dim]")
717
+ task = input(" > ").strip()
718
+ except (EOFError, KeyboardInterrupt):
719
+ pass
720
+ if not task:
721
+ show_error("No task provided.")
722
+ return
723
+
724
+ result = run_subagent(
725
+ task=task,
726
+ agent_type=agent_type,
727
+ project_path=os.getcwd(),
728
+ parent_system=session.get("system"),
729
+ parent_file_cache=session.get("file_cache"),
730
+ )
731
+ if result.success:
732
+ if result.output:
733
+ from rich.panel import Panel
734
+ from rich.markdown import Markdown
735
+ console.print(Panel(
736
+ Markdown(result.output),
737
+ border_style="cyan",
738
+ title=f"[bold cyan]{AGENT_TYPES[agent_type]['label']} Agent[/bold cyan]",
739
+ padding=(0, 1),
740
+ ))
741
+ show_success(f"{AGENT_TYPES[agent_type]['label']} agent completed in {result.duration:.1f}s")
742
+ else:
743
+ show_error(f"Agent failed: {result.error}")
744
+
745
+
746
+ # ── Coordinator Mode ──────────────────────────────────────────────────────────
747
+
748
+ @command("coordinator", "Enter coordinator mode — main agent orchestrates parallel specialist workers")
749
+ def cmd_coordinator(args: str, session: dict):
750
+ from .coordinator import WorkerPool, COORDINATOR_SYSTEM_PROMPT
751
+
752
+ if session.get("coordinator_mode"):
753
+ # Already in coordinator mode — show live worker status
754
+ pool = session.get("worker_pool")
755
+ if pool:
756
+ console.print("\n[bold cyan]Coordinator — Active Workers[/bold cyan]")
757
+ console.print(pool.status_table())
758
+ return
759
+
760
+ from rich.panel import Panel
761
+ console.print(Panel(
762
+ "[bold cyan]Coordinator Mode[/bold cyan]\n\n"
763
+ "Sylithe Code is now your orchestrator. It will:\n\n"
764
+ " 🚀 [cyan]spawn_worker[/cyan] — launch parallel specialist agents (non-blocking)\n"
765
+ " 📨 [cyan]send_message[/cyan] — continue a worker with new instructions\n"
766
+ " 🛑 [cyan]task_stop[/cyan] — kill a worker that went off track\n\n"
767
+ "Workers run in background threads and report back via [dim]<task-notification>[/dim] messages.\n"
768
+ "The coordinator synthesizes their findings and directs the next phase.\n\n"
769
+ "[dim]Worker types: explore / coder / verifier / researcher / general[/dim]\n"
770
+ "[dim]Type /workers to see active workers. /exit-coordinator to return to normal.[/dim]",
771
+ border_style="cyan",
772
+ title="[bold cyan]⚡ Coordinator Mode[/bold cyan]",
773
+ padding=(1, 2),
774
+ ))
775
+
776
+ pool = WorkerPool()
777
+ base_system = session.get("system", "")
778
+
779
+ # Save base system so we can restore it on exit
780
+ session["base_system"] = base_system
781
+ session["system"] = base_system + COORDINATOR_SYSTEM_PROMPT
782
+ session["coordinator_mode"] = True
783
+ session["worker_pool"] = pool
784
+
785
+ show_success("Coordinator mode active. Send your task and I'll orchestrate workers to solve it.")
786
+
787
+
788
+ @command("workers", "Show status of all coordinator workers in this session")
789
+ def cmd_workers(args: str, session: dict):
790
+ if not session.get("coordinator_mode"):
791
+ show_warning("Not in coordinator mode. Type /coordinator to enter.")
792
+ return
793
+ pool = session.get("worker_pool")
794
+ if not pool:
795
+ show_warning("No worker pool found.")
796
+ return
797
+ console.print("\n[bold]Active Workers[/bold]")
798
+ console.print(pool.status_table())
799
+
800
+
801
+ @command("exit-coordinator", "Exit coordinator mode and return to normal agent mode")
802
+ def cmd_exit_coordinator(args: str, session: dict):
803
+ if not session.get("coordinator_mode"):
804
+ show_info("Already in normal mode.")
805
+ return
806
+
807
+ pool = session.get("worker_pool")
808
+ if pool:
809
+ # Show final summary before exit
810
+ from rich.table import Table
811
+ console.print("\n[bold]Final Worker Summary[/bold]")
812
+ console.print(pool.status_table())
813
+
814
+ session["coordinator_mode"] = False
815
+ session["worker_pool"] = None
816
+ session["system"] = session.get("base_system", session.get("system", ""))
817
+ show_success("Returned to normal agent mode.")
818
+
819
+
820
+ # ── Help ──────────────────────────────────────────────────────────────────────
821
+
822
+ @command("export", "Export session as Markdown: /export or /export session.md")
823
+ def cmd_export(args: str, session: dict):
824
+ """Save the current conversation to a Markdown file."""
825
+ import datetime
826
+ from pathlib import Path
827
+
828
+ history = session.get("messages", [])
829
+ if not history:
830
+ show_warning("Nothing to export — conversation is empty.")
831
+ return
832
+
833
+ # Build output path
834
+ ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
835
+ default_name = f"bharatcode_session_{ts}.md"
836
+ out_path = Path(args.strip() or default_name)
837
+ if out_path.is_dir():
838
+ out_path = out_path / default_name
839
+
840
+ lines: list[str] = [f"# BharatCode Session — {datetime.datetime.now():%Y-%m-%d %H:%M}\n\n"]
841
+ for msg in history:
842
+ role = msg.get("role", "")
843
+ if role == "user":
844
+ content = msg.get("content", "")
845
+ if isinstance(content, list):
846
+ # multipart: extract text blocks only
847
+ content = " ".join(
848
+ b.get("text", "") for b in content if isinstance(b, dict) and b.get("type") == "text"
849
+ )
850
+ lines.append(f"**You:** {content}\n\n")
851
+ elif role == "assistant":
852
+ content = msg.get("content") or ""
853
+ if isinstance(content, list):
854
+ content = " ".join(
855
+ b.get("text", "") for b in content if isinstance(b, dict) and b.get("type") == "text"
856
+ )
857
+ if content:
858
+ lines.append(f"**BharatCode:** {content}\n\n")
859
+ # tool calls summary
860
+ tool_calls = msg.get("tool_calls", [])
861
+ if tool_calls:
862
+ names = ", ".join(
863
+ tc.get("function", {}).get("name", "?") for tc in tool_calls
864
+ )
865
+ lines.append(f"*Tools used: {names}*\n\n")
866
+ elif role == "tool":
867
+ pass # skip raw tool results — they clutter the export
868
+
869
+ try:
870
+ out_path.write_text("".join(lines), encoding="utf-8")
871
+ show_success(f"Session exported to {out_path} ({len(history)} messages)")
872
+ except Exception as exc:
873
+ show_error(f"Export failed: {exc}")
874
+
875
+
876
+ _HELP_GROUPS = {
877
+ "Scaffold": ["newsite", "newapp"],
878
+ "Coordinator": ["coordinator", "workers", "exit-coordinator"],
879
+ "Sub-agents": ["agent"],
880
+ "Conversation": ["clear", "compact", "changes", "resume", "export"],
881
+ "Code Actions": ["review", "audit", "plan"],
882
+ "Skills": ["skills", "skill"],
883
+ "Git": ["git", "diff"],
884
+ "Status": ["cost", "status", "doctor"],
885
+ "Memory": ["memory"],
886
+ "Settings": ["yolo", "model", "config", "pwd"],
887
+ }
888
+
889
+ _CLI_CMDS = [
890
+ ("bharatcode new website \"Name\"", "Build a website from scratch"),
891
+ ("bharatcode new website \"Name\" \"desc\"", "Website with description"),
892
+ ("bharatcode new app \"Name\" --type flask", "Flask app from scratch"),
893
+ ("bharatcode new app \"Name\" --type react", "React app from scratch"),
894
+ ("bharatcode new app \"Name\" --type fullstack", "Full-stack app from scratch"),
895
+ ("bharatcode new app \"Name\" --type node", "Node.js app from scratch"),
896
+ ("bharatcode new app \"Name\" --type nextjs", "Next.js app from scratch"),
897
+ ("bharatcode fix \"bug\"", "Fix a bug"),
898
+ ("bharatcode build \"feature\"", "Build a feature"),
899
+ ("bharatcode review [path]", "Code review"),
900
+ ("bharatcode test src/file.py", "Write & run tests"),
901
+ ("bharatcode audit", "Indian compliance"),
902
+ ("bharatcode ask \"question\"", "Ask about code"),
903
+ ("bharatcode explain src/file.py", "Explain a file"),
904
+ ("bharatcode refactor src/file.py", "Refactor code"),
905
+ ("bharatcode init", "Create BHARATCODE.md"),
906
+ ("bharatcode -y fix \"bug\"", "Fix, skip prompts"),
907
+ ]
908
+
909
+
910
+ @command("help", "Show all commands (interactive dropdown)")
911
+ def cmd_help(args: str, session: dict):
912
+ # Build flat choice list from all groups
913
+ choices = []
914
+ for group, names in _HELP_GROUPS.items():
915
+ for name in names:
916
+ if name in COMMANDS:
917
+ desc = COMMANDS[name]["description"]
918
+ choices.append((
919
+ f"/{name:<14} [dim]{desc}[/dim] [bright_black]({group})[/bright_black]",
920
+ name,
921
+ ))
922
+
923
+ selected = _select("Select a command to learn more or run:", choices)
924
+
925
+ if selected is None:
926
+ # User cancelled — print full help instead
927
+ _print_full_help()
928
+ return
929
+
930
+ # Show details for selected command
931
+ desc = COMMANDS[selected]["description"]
932
+ console.print(f"\n[bold green]/{selected}[/bold green] {desc}\n")
933
+
934
+ # Command-specific help text
935
+ detailed = {
936
+ "newsite": (
937
+ "Builds a fully custom website — not a generic template, a real site designed for your project.\n"
938
+ "Splits CSS into variables/reset/typography/layout/components/responsive files.\n"
939
+ "Usage: /newsite <name>\n"
940
+ " /newsite <name> - <description>\n"
941
+ "Example: /newsite Chhelu Portfolio - dark theme developer portfolio with projects and blog"
942
+ ),
943
+ "newapp": (
944
+ "Builds a complete application with proper file separation, real models, real routes, real logic.\n"
945
+ "Full-stack apps get CORS configured, Vite proxy, .env files, and exact startup commands.\n"
946
+ "Usage: /newapp <name> [--flask|--react|--fullstack|--node|--nextjs]\n"
947
+ "Example: /newapp ShopIndia e-commerce with Razorpay payments --fullstack"
948
+ ),
949
+ "agent": (
950
+ "Spawn a specialist AI agent that runs with its own isolated context and dedicated tools.\n"
951
+ "Types:\n"
952
+ " explore — reads everything, writes nothing, maps your codebase\n"
953
+ " coder — full access, ships complete production code\n"
954
+ " verifier — ruthless auditor, finds every bug and security hole\n"
955
+ " researcher — live web fetcher, gets real docs and real examples\n"
956
+ " general — all tools, no restrictions\n"
957
+ "Usage: /agent <type> <task>\n"
958
+ "Example: /agent verifier Read backend/app.py and report every security issue with line numbers"
959
+ ),
960
+ "skills": (
961
+ "Opens an interactive dropdown with all available skills.\n"
962
+ "Arrow keys to navigate, Enter to select, Ctrl-C to cancel.\n"
963
+ "After selecting, some skills (newsite/newapp) ask for a project name."
964
+ ),
965
+ "skill": (
966
+ "Run a skill directly without the dropdown.\n"
967
+ "Usage: /skill <name>\n"
968
+ "Example: /skill razorpay | /skill docker | /skill jwt-auth"
969
+ ),
970
+ "plan": (
971
+ "Plan mode: agent reads files and proposes a plan — no writes, no bash.\n"
972
+ "Review the plan, then type /plan off to let the agent execute it.\n"
973
+ "/plan on — enable (read-only)\n"
974
+ "/plan off — disable (agent can now write and run commands)"
975
+ ),
976
+ "compact": (
977
+ "Compresses old conversation history into a dense summary to free up context tokens.\n"
978
+ "Use when: the agent seems to lose track, or the session has been running for a long time.\n"
979
+ "Sylithe Code also auto-compacts when history exceeds ~50,000 tokens."
980
+ ),
981
+ "yolo": (
982
+ "Toggles auto-approve mode — skips all permission prompts for bash, write, and edit.\n"
983
+ "Use when you trust the agent and want it to run without interruption.\n"
984
+ "Green = ON (no prompts). Default = OFF (prompts on bash commands)."
985
+ ),
986
+ "model": (
987
+ "Switch the model. Sylithe Code also auto-selects based on task complexity.\n"
988
+ "/model sylithe-flash — Sylithe Code Flash, fast, cheap, great for most tasks (~$0.27/1M in)\n"
989
+ "/model sylithe-pro — Sylithe Code Pro, deeper reasoning, debugging / architecture (~$0.55/1M in)\n"
990
+ "Auto-select upgrades Flash → Pro for complex tasks automatically."
991
+ ),
992
+ "coordinator": (
993
+ "Enters coordinator mode — Sylithe Code becomes an orchestrator that spawns\n"
994
+ "parallel specialist workers instead of doing the work itself.\n\n"
995
+ "Workflow:\n"
996
+ " 1. You send a task (e.g. 'fix the auth bug and add tests')\n"
997
+ " 2. Coordinator spawns parallel explore workers to map the code\n"
998
+ " 3. When workers finish, coordinator receives <task-notification> messages\n"
999
+ " 4. Coordinator synthesizes findings → spawns coder workers with precise specs\n"
1000
+ " 5. After implementation → spawns a fresh verifier to prove it works\n\n"
1001
+ "Worker types:\n"
1002
+ " explore — maps codebase (read-only, fast, can run 5 in parallel)\n"
1003
+ " researcher — fetches live docs, API specs, real examples from the web\n"
1004
+ " coder — implements code, runs tests, commits\n"
1005
+ " verifier — audits code, runs tests, finds security holes\n"
1006
+ " general — all tools, use when task spans multiple roles\n\n"
1007
+ "Type /workers to see all active workers and their status.\n"
1008
+ "Type /exit-coordinator to return to normal mode."
1009
+ ),
1010
+ "memory": (
1011
+ "Persistent memory survives across sessions — the agent reads it at the start of every task.\n"
1012
+ "/memory list — see everything saved\n"
1013
+ "/memory add <text> — save a fact (paths, decisions, preferences, metrics)\n"
1014
+ "/memory del <id> — remove a specific memory by its ID"
1015
+ ),
1016
+ }
1017
+ if selected in detailed:
1018
+ console.print(f"[dim]{detailed[selected]}[/dim]\n")
1019
+
1020
+ # Ask if they want to run it
1021
+ try:
1022
+ run_it = input(f" Run /{selected} now? [y/N] ").strip().lower()
1023
+ except (EOFError, KeyboardInterrupt):
1024
+ run_it = ""
1025
+
1026
+ if run_it != "y":
1027
+ return
1028
+
1029
+ # Commands that still need a CLI argument before running.
1030
+ # newsite / newapp are NOT here — their built-in Q&A collects everything.
1031
+ _arg_hints = {
1032
+ "skill": ("Skill name", "Example: razorpay"),
1033
+ "review": ("Path to review (Enter for current dir)", ""),
1034
+ "explain": ("File to explain", "Example: src/auth.py"),
1035
+ "refactor":("File to refactor", "Example: src/utils.py"),
1036
+ "test": ("File to test", "Example: src/payment.py"),
1037
+ }
1038
+
1039
+ if selected in _arg_hints:
1040
+ hint, example = _arg_hints[selected]
1041
+ if example:
1042
+ console.print(f" [dim]{example}[/dim]")
1043
+ try:
1044
+ extra = input(f" /{selected} {hint}: ").strip()
1045
+ except (EOFError, KeyboardInterrupt):
1046
+ extra = ""
1047
+
1048
+ # Commands where blank is valid (review defaults to cwd)
1049
+ if not extra and selected not in ("review",):
1050
+ show_error(f"No name provided — run /{selected} <name> manually.")
1051
+ return
1052
+
1053
+ handle_slash_command(f"/{selected} {extra}".strip(), session)
1054
+ else:
1055
+ handle_slash_command(f"/{selected}", session)
1056
+
1057
+
1058
+ def _print_full_help():
1059
+ """Print the traditional full help listing."""
1060
+ console.print()
1061
+ for group, names in _HELP_GROUPS.items():
1062
+ console.print(f"[bold]{group}[/bold]")
1063
+ for name in names:
1064
+ if name in COMMANDS:
1065
+ console.print(f" [green]/{name:<14}[/green] {COMMANDS[name]['description']}")
1066
+ console.print()
1067
+
1068
+ console.print("[bold]CLI Commands[/bold]")
1069
+ for cmd, desc in _CLI_CMDS:
1070
+ console.print(f" [cyan]{cmd:<44}[/cyan] [dim]{desc}[/dim]")
1071
+ console.print()
1072
+ console.print("[dim]Tip: type /help for interactive mode, or /skills to browse skills with dropdown.[/dim]\n")