cite-agent 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.

Potentially problematic release.


This version of cite-agent might be problematic. Click here for more details.

cite_agent/cli.py ADDED
@@ -0,0 +1,512 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Nocturnal Archive CLI - Command Line Interface
4
+ Provides a terminal interface similar to cursor-agent
5
+ """
6
+
7
+ import argparse
8
+ import asyncio
9
+ import os
10
+ import random
11
+ import sys
12
+ import time
13
+ from datetime import datetime
14
+ from pathlib import Path
15
+ from typing import Optional
16
+
17
+ from rich import box
18
+ from rich.console import Console
19
+ from rich.panel import Panel
20
+ from rich.table import Table
21
+ from rich.theme import Theme
22
+
23
+ from .enhanced_ai_agent import EnhancedNocturnalAgent, ChatRequest
24
+ from .setup_config import NocturnalConfig, DEFAULT_QUERY_LIMIT, MANAGED_SECRETS
25
+ from .telemetry import TelemetryManager
26
+ from .updater import NocturnalUpdater
27
+
28
+ class NocturnalCLI:
29
+ """Command Line Interface for Nocturnal Archive"""
30
+
31
+ def __init__(self):
32
+ self.agent: Optional[EnhancedNocturnalAgent] = None
33
+ self.session_id = f"cli_{os.getpid()}"
34
+ self.telemetry = None
35
+ self.console = Console(theme=Theme({
36
+ "banner": "bold magenta",
37
+ "success": "bold green",
38
+ "warning": "bold yellow",
39
+ "error": "bold red",
40
+ }))
41
+ self._tips = [
42
+ "Use [bold]nocturnal --setup[/] to rerun the onboarding wizard anytime.",
43
+ "Run [bold]nocturnal tips[/] when you need a refresher on power moves.",
44
+ "Pass a one-off question directly: [bold]nocturnal \"summarize the latest 10-Q\"[/].",
45
+ "Enable verbose logging by exporting [bold]NOCTURNAL_DEBUG=1[/] before launching.",
46
+ "Use [bold]/plan[/] inside the chat to nudge the agent toward structured research steps.",
47
+ "Hit [bold]Ctrl+C[/] to stop a long-running call; the agent will clean up gracefully.",
48
+ "Remember the sandbox: prefix shell commands with [bold]![/] to execute safe utilities only.",
49
+ "If you see an auto-update notice, the CLI will restart itself to load the latest build.",
50
+ ]
51
+
52
+ async def initialize(self):
53
+ """Initialize the agent with automatic updates"""
54
+ # Check for update notifications from previous runs
55
+ self._check_update_notification()
56
+ self._show_intro_panel()
57
+
58
+ self._enforce_latest_build()
59
+
60
+ config = NocturnalConfig()
61
+ had_config = config.setup_environment()
62
+ TelemetryManager.refresh()
63
+ self.telemetry = TelemetryManager.get()
64
+
65
+ if not config.check_setup():
66
+ self.console.print("\n[warning]šŸ‘‹ Hey there, looks like this machine hasn't met Nocturnal yet.[/warning]")
67
+ self.console.print("[banner]Let's get you signed in — this only takes a minute.[/banner]")
68
+ try:
69
+ if not config.interactive_setup():
70
+ self.console.print("[error]āŒ Setup was cancelled. Exiting without starting the agent.[/error]")
71
+ return False
72
+ except (KeyboardInterrupt, EOFError):
73
+ self.console.print("\n[error]āŒ Setup interrupted. Exiting without starting the agent.[/error]")
74
+ return False
75
+ config.setup_environment()
76
+ TelemetryManager.refresh()
77
+ self.telemetry = TelemetryManager.get()
78
+ elif not had_config:
79
+ # config.setup_environment() may have populated env vars from file silently
80
+ self.console.print("[success]āš™ļø Loaded saved credentials for this device.[/success]")
81
+
82
+ self.agent = EnhancedNocturnalAgent()
83
+ success = await self.agent.initialize()
84
+
85
+ if not success:
86
+ self.console.print("[error]āŒ Failed to initialize agent. Please check your API keys.[/error]")
87
+ self.console.print("\nšŸ’” Setup help:")
88
+ self.console.print(" • Ensure your Groq API key is correct (re-run `nocturnal` to update it).")
89
+ self.console.print(" • You can edit ~/.nocturnal_archive/config.env or set GROQ_API_KEY manually.")
90
+ return False
91
+
92
+ self._show_ready_panel()
93
+ self._show_beta_banner()
94
+ return True
95
+
96
+ def _show_beta_banner(self):
97
+ account_email = os.getenv("NOCTURNAL_ACCOUNT_EMAIL", "")
98
+ configured_limit = DEFAULT_QUERY_LIMIT
99
+ if configured_limit <= 0:
100
+ limit_text = "Unlimited"
101
+ else:
102
+ limit_text = f"{configured_limit}"
103
+ details = [
104
+ f"Daily limit: [bold]{limit_text}[/] queries",
105
+ "Telemetry streaming: [bold]enabled[/] (control plane)",
106
+ "Auto-update: [bold]enforced[/] on launch",
107
+ "Sandbox: safe shell commands only • SQL workflows supported",
108
+ ]
109
+ if account_email:
110
+ details.insert(0, f"Signed in as: [bold]{account_email}[/]")
111
+
112
+ panel = Panel(
113
+ "\n".join(details),
114
+ title="šŸŽŸļø Beta Access Active",
115
+ border_style="magenta",
116
+ padding=(1, 2),
117
+ box=box.ROUNDED,
118
+ )
119
+ self.console.print(panel)
120
+
121
+ def _show_intro_panel(self):
122
+ message = (
123
+ "Warming up your research cockpit…\n"
124
+ "[dim]Loading config, telemetry, and background update checks.[/dim]"
125
+ )
126
+ panel = Panel(
127
+ message,
128
+ title="šŸŒ™ Initializing Nocturnal Archive",
129
+ border_style="magenta",
130
+ padding=(1, 2),
131
+ box=box.ROUNDED,
132
+ )
133
+ self.console.print(panel)
134
+
135
+ def _show_ready_panel(self):
136
+ panel = Panel(
137
+ "Systems check complete.\n"
138
+ "Type [bold]help[/] for commands or [bold]tips[/] for power moves.",
139
+ title="āœ… Nocturnal Archive ready!",
140
+ border_style="green",
141
+ padding=(1, 2),
142
+ box=box.ROUNDED,
143
+ )
144
+ self.console.print(panel)
145
+
146
+ def _enforce_latest_build(self):
147
+ """Ensure the CLI is running the most recent published build."""
148
+ try:
149
+ updater = NocturnalUpdater()
150
+ update_info = updater.check_for_updates()
151
+ except Exception:
152
+ return
153
+
154
+ if not update_info or not update_info.get("available"):
155
+ return
156
+
157
+ latest_version = update_info.get("latest", "latest")
158
+ self.console.print(f"[banner]ā¬†ļø Updating Nocturnal Archive to {latest_version} before launch...[/banner]")
159
+
160
+ if updater.update_package():
161
+ self._save_update_notification(latest_version)
162
+ self.console.print("[warning]ā™»ļø Restarting to finish applying the update...[/warning]")
163
+ self._restart_cli()
164
+
165
+ def _restart_cli(self):
166
+ """Re-exec the CLI using the current interpreter and arguments."""
167
+ try:
168
+ argv = [sys.executable, "-m", "nocturnal_archive.cli", *sys.argv[1:]]
169
+ os.execv(sys.executable, argv)
170
+ except Exception:
171
+ # If restart fails just continue in the current process.
172
+ pass
173
+
174
+ def _save_update_notification(self, new_version):
175
+ """Save update notification for next run"""
176
+ try:
177
+ import json
178
+ from pathlib import Path
179
+
180
+ notify_file = Path.home() / ".nocturnal_archive" / "update_notification.json"
181
+ notify_file.parent.mkdir(exist_ok=True)
182
+
183
+ with open(notify_file, 'w') as f:
184
+ json.dump({
185
+ "updated_to": new_version,
186
+ "timestamp": time.time()
187
+ }, f)
188
+ except Exception:
189
+ pass
190
+
191
+ def _check_update_notification(self):
192
+ """Check if we should show update notification"""
193
+ try:
194
+ import json
195
+ import time
196
+ from pathlib import Path
197
+
198
+ notify_file = Path.home() / ".nocturnal_archive" / "update_notification.json"
199
+ if notify_file.exists():
200
+ with open(notify_file, 'r') as f:
201
+ data = json.load(f)
202
+
203
+ # Show notification if update happened in last 24 hours
204
+ if time.time() - data.get("timestamp", 0) < 86400:
205
+ self.console.print(f"[success]šŸŽ‰ Updated to version {data['updated_to']}![/success]")
206
+
207
+ # Clean up notification
208
+ notify_file.unlink()
209
+
210
+ except Exception:
211
+ pass
212
+
213
+ async def interactive_mode(self):
214
+ """Interactive chat mode"""
215
+ if not await self.initialize():
216
+ return
217
+
218
+ self.console.print("\n[bold]šŸ¤– Interactive Mode[/] — Type your questions or 'quit' to exit")
219
+ self.console.rule(style="magenta")
220
+
221
+ try:
222
+ while True:
223
+ try:
224
+ user_input = self.console.input("\n[bold cyan]šŸ‘¤ You[/]: ").strip()
225
+
226
+ if user_input.lower() in ['quit', 'exit', 'q']:
227
+ break
228
+ if user_input.lower() == 'tips':
229
+ self.show_tips()
230
+ continue
231
+ if user_input.lower() == 'feedback':
232
+ self.collect_feedback()
233
+ continue
234
+
235
+ if not user_input:
236
+ continue
237
+ except (EOFError, KeyboardInterrupt):
238
+ self.console.print("\n[warning]šŸ‘‹ Goodbye![/warning]")
239
+ break
240
+
241
+ self.console.print("[bold violet]šŸ¤– Agent[/]: ", end="", highlight=False)
242
+
243
+ try:
244
+ request = ChatRequest(
245
+ question=user_input,
246
+ user_id="cli_user",
247
+ conversation_id=self.session_id
248
+ )
249
+
250
+ response = await self.agent.process_request(request)
251
+
252
+ # Print response with proper formatting
253
+ self.console.print(response.response)
254
+
255
+ # Show usage stats occasionally
256
+ if hasattr(self.agent, 'daily_token_usage') and self.agent.daily_token_usage > 0:
257
+ stats = self.agent.get_usage_stats()
258
+ if stats['usage_percentage'] > 10: # Show if >10% used
259
+ self.console.print(f"\nšŸ“Š Usage: {stats['usage_percentage']:.1f}% of daily limit")
260
+
261
+ except Exception as e:
262
+ self.console.print(f"\n[error]āŒ Error: {e}[/error]")
263
+
264
+ finally:
265
+ if self.agent:
266
+ await self.agent.close()
267
+
268
+ async def single_query(self, question: str):
269
+ """Process a single query"""
270
+ if not await self.initialize():
271
+ return
272
+
273
+ try:
274
+ self.console.print(f"šŸ¤– [bold]Processing[/]: {question}")
275
+ self.console.rule(style="magenta")
276
+
277
+ request = ChatRequest(
278
+ question=question,
279
+ user_id="cli_user",
280
+ conversation_id=self.session_id
281
+ )
282
+
283
+ response = await self.agent.process_request(request)
284
+
285
+ self.console.print(f"\nšŸ“ [bold]Response[/]:\n{response.response}")
286
+
287
+ if response.tools_used:
288
+ self.console.print(f"\nšŸ”§ Tools used: {', '.join(response.tools_used)}")
289
+
290
+ if response.tokens_used > 0:
291
+ stats = self.agent.get_usage_stats()
292
+ self.console.print(
293
+ f"\nšŸ“Š Tokens used: {response.tokens_used} "
294
+ f"(Daily usage: {stats['usage_percentage']:.1f}%)"
295
+ )
296
+
297
+ finally:
298
+ if self.agent:
299
+ await self.agent.close()
300
+
301
+ def setup_wizard(self):
302
+ """Interactive setup wizard"""
303
+ config = NocturnalConfig()
304
+ return config.interactive_setup()
305
+
306
+ def show_tips(self):
307
+ """Display a rotating set of CLI power tips"""
308
+ sample_count = 4 if len(self._tips) >= 4 else len(self._tips)
309
+ tips = random.sample(self._tips, sample_count)
310
+ table = Table(show_header=False, box=box.MINIMAL_DOUBLE_HEAD, padding=(0, 1))
311
+ for tip in tips:
312
+ table.add_row(f"• {tip}")
313
+
314
+ self.console.print(Panel(table, title="✨ Quick Tips", border_style="cyan", padding=(1, 2)))
315
+ self.console.print("[dim]Run `nocturnal tips` again for a fresh batch.[/dim]")
316
+
317
+ def collect_feedback(self) -> int:
318
+ """Collect feedback from the user and store it locally"""
319
+ self.console.print(
320
+ Panel(
321
+ "Share what’s working, what feels rough, or any paper/finance workflows you wish existed.\n"
322
+ "Press Enter on an empty line to finish.",
323
+ title="šŸ“ Beta Feedback",
324
+ border_style="cyan",
325
+ padding=(1, 2),
326
+ )
327
+ )
328
+
329
+ lines = []
330
+ while True:
331
+ try:
332
+ line = self.console.input("[dim]> [/]")
333
+ except (KeyboardInterrupt, EOFError):
334
+ self.console.print("[warning]Feedback capture cancelled.[/warning]")
335
+ return 1
336
+
337
+ if not line.strip():
338
+ break
339
+ lines.append(line)
340
+
341
+ if not lines:
342
+ self.console.print("[warning]No feedback captured — nothing was saved.[/warning]")
343
+ return 1
344
+
345
+ feedback_dir = Path.home() / ".nocturnal_archive" / "feedback"
346
+ feedback_dir.mkdir(parents=True, exist_ok=True)
347
+ timestamp = datetime.utcnow().strftime("%Y%m%d-%H%M%S")
348
+ feedback_path = feedback_dir / f"feedback-{timestamp}.md"
349
+
350
+ content = "\n".join(lines)
351
+ with open(feedback_path, "w", encoding="utf-8") as handle:
352
+ handle.write("# Nocturnal Archive Beta Feedback\n")
353
+ handle.write(f"timestamp = {timestamp}Z\n")
354
+ handle.write("\n")
355
+ handle.write(content)
356
+ handle.write("\n")
357
+
358
+ self.console.print(
359
+ f"[success]Thanks for the intel! Saved to[/success] [bold]{feedback_path}[/bold]"
360
+ )
361
+ self.console.print("[dim]Attach that file when you send feedback to the team.[/dim]")
362
+ return 0
363
+
364
+ def main():
365
+ """Main CLI entry point"""
366
+ parser = argparse.ArgumentParser(
367
+ description="Nocturnal Archive - AI Research Assistant",
368
+ formatter_class=argparse.RawDescriptionHelpFormatter,
369
+ epilog="""
370
+ Examples:
371
+ nocturnal # Interactive mode
372
+ nocturnal "find papers on ML" # Single query
373
+ nocturnal --setup # Setup wizard
374
+ nocturnal --version # Show version
375
+ """
376
+ )
377
+
378
+ parser.add_argument(
379
+ 'query',
380
+ nargs='?',
381
+ help='Single query to process (if not provided, starts interactive mode)'
382
+ )
383
+
384
+ parser.add_argument(
385
+ '--setup',
386
+ action='store_true',
387
+ help='Run setup wizard for API keys'
388
+ )
389
+
390
+ parser.add_argument(
391
+ '--version',
392
+ action='store_true',
393
+ help='Show version information'
394
+ )
395
+
396
+ parser.add_argument(
397
+ '--interactive', '-i',
398
+ action='store_true',
399
+ help='Force interactive mode even with query'
400
+ )
401
+
402
+ parser.add_argument(
403
+ '--update',
404
+ action='store_true',
405
+ help='Check for and install updates'
406
+ )
407
+
408
+ parser.add_argument(
409
+ '--check-updates',
410
+ action='store_true',
411
+ help='Check for available updates'
412
+ )
413
+
414
+ # Auto-update is now enforced; no CLI flag provided to disable it.
415
+
416
+ parser.add_argument(
417
+ '--tips',
418
+ action='store_true',
419
+ help='Show quick CLI tips and exit'
420
+ )
421
+
422
+ parser.add_argument(
423
+ '--feedback',
424
+ action='store_true',
425
+ help='Capture beta feedback and save it locally'
426
+ )
427
+
428
+ parser.add_argument(
429
+ '--import-secrets',
430
+ metavar='PATH',
431
+ help='Import API keys from a .env style file'
432
+ )
433
+
434
+ parser.add_argument(
435
+ '--no-plaintext',
436
+ action='store_true',
437
+ help='Fail secret import if keyring is unavailable'
438
+ )
439
+
440
+ args = parser.parse_args()
441
+
442
+ # Handle version
443
+ if args.version:
444
+ print("Nocturnal Archive v1.0.0")
445
+ print("AI Research Assistant with real data integration")
446
+ return
447
+
448
+ if args.tips or (args.query and args.query.lower() == "tips" and not args.interactive):
449
+ cli = NocturnalCLI()
450
+ cli.show_tips()
451
+ return
452
+
453
+ if args.feedback or (args.query and args.query.lower() == "feedback" and not args.interactive):
454
+ cli = NocturnalCLI()
455
+ exit_code = cli.collect_feedback()
456
+ sys.exit(exit_code)
457
+
458
+ # Handle secret import before setup as it can be used non-interactively
459
+ if args.import_secrets:
460
+ config = NocturnalConfig()
461
+ try:
462
+ results = config.import_from_env_file(args.import_secrets, allow_plaintext=not args.no_plaintext)
463
+ except FileNotFoundError as exc:
464
+ print(f"āŒ {exc}")
465
+ sys.exit(1)
466
+ if not results:
467
+ print("āš ļø No supported secrets found in the provided file.")
468
+ sys.exit(1)
469
+ overall_success = True
470
+ for key, (status, message) in results.items():
471
+ label = MANAGED_SECRETS.get(key, {}).get('label', key)
472
+ icon = "āœ…" if status else "āš ļø"
473
+ print(f"{icon} {label}: {message}")
474
+ if not status:
475
+ overall_success = False
476
+ sys.exit(0 if overall_success else 1)
477
+
478
+ # Handle setup
479
+ if args.setup:
480
+ cli = NocturnalCLI()
481
+ success = cli.setup_wizard()
482
+ sys.exit(0 if success else 1)
483
+
484
+ # Handle updates
485
+ if args.update or args.check_updates:
486
+ updater = NocturnalUpdater()
487
+ if args.update:
488
+ success = updater.update_package()
489
+ sys.exit(0 if success else 1)
490
+ else:
491
+ updater.show_update_status()
492
+ sys.exit(0)
493
+
494
+ # Handle query or interactive mode
495
+ async def run_cli():
496
+ cli = NocturnalCLI()
497
+
498
+ if args.query and not args.interactive:
499
+ await cli.single_query(args.query)
500
+ else:
501
+ await cli.interactive_mode()
502
+
503
+ try:
504
+ asyncio.run(run_cli())
505
+ except KeyboardInterrupt:
506
+ print("\nšŸ‘‹ Goodbye!")
507
+ except Exception as e:
508
+ print(f"āŒ Error: {e}")
509
+ sys.exit(1)
510
+
511
+ if __name__ == "__main__":
512
+ main()