pascal-agent 0.3.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.
pascal/__main__.py ADDED
@@ -0,0 +1,880 @@
1
+ """python -m pascal -- run the Pascal loop."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import asyncio
7
+ import json
8
+ import logging
9
+ import os
10
+ import sys
11
+ from pathlib import Path
12
+ from textwrap import dedent
13
+
14
+ from pascal.config import load_config
15
+ from pascal.receipts import Ledger
16
+ from pascal.loop import run_loop
17
+ from pascal.state import PascalStore
18
+
19
+ _MAIN_EPILOG = dedent(
20
+ """\
21
+ Examples:
22
+ pascal "Review today's inbox and draft replies"
23
+ pascal --status
24
+ pascal --notify "Build finished"
25
+ pascal init
26
+ pascal --init
27
+ """
28
+ )
29
+
30
+ _INIT_EPILOG = dedent(
31
+ """\
32
+ Examples:
33
+ pascal init
34
+ pascal --init
35
+ """
36
+ )
37
+
38
+ _DEFAULT_CONFIG_TOML = dedent(
39
+ """\
40
+ [pascal]
41
+ # model = "gpt-5.4-mini"
42
+ # provider = "openai" # openai | anthropic | codex
43
+ # db_path = "~/.pascal/state.db"
44
+ # max_effect = "E2" # E0(read) E1(analyze) E2(write) E3(push) E4(merge) E5(delete)
45
+ # max_tool_rounds = 10
46
+ """
47
+ )
48
+
49
+ _CONFIG_KEYS = {
50
+ "model": "LLM model name (e.g. gpt-4o, claude-sonnet-4-20250514, o3)",
51
+ "provider": "LLM provider: openai | anthropic | codex",
52
+ "base_url": "API base URL override",
53
+ "db_path": "SQLite database path",
54
+ "max_effect": "Max effect level: E0-E5",
55
+ "max_tool_rounds": "Max tool call rounds per turn",
56
+ }
57
+
58
+ _PROVIDERS = ("openai", "anthropic", "codex")
59
+ _EFFECTS = ("E0", "E1", "E2", "E3", "E4", "E5")
60
+
61
+
62
+ def _print_action(action: dict) -> None:
63
+ a = action.get("action", "?")
64
+ reason = action.get("reason", "")
65
+ result = action.get("result", {})
66
+ status = result.get("status", "") if isinstance(result, dict) else ""
67
+ error = result.get("error", "") if isinstance(result, dict) else ""
68
+ output = result.get("output", "") if isinstance(result, dict) else ""
69
+
70
+ icons = {
71
+ "execute": ">", "think": "~", "pick_task": "+", "complete_task": "v",
72
+ "fail_task": "x", "delegate": "->", "plan": "P", "wait": ".", "escalate": "?",
73
+ "handle_notification": "!", "memorize": "M", "create_subtask": "++",
74
+ "pause_task": "||", "add_rule": "R+", "remove_rule": "R-",
75
+ "set_context": "C", "dismiss_notification": "x",
76
+ }
77
+ symbol = icons.get(a, " ")
78
+
79
+ line = f" [{symbol}] {a}"
80
+ if reason:
81
+ line += f": {reason[:80]}"
82
+ if status and status != "ok":
83
+ line += f" ({status})"
84
+ if error:
85
+ line += f" -- {error[:100]}"
86
+ elif output and a == "execute":
87
+ preview = output.strip().split("\n")[0][:80]
88
+ if preview:
89
+ line += f" -> {preview}"
90
+ print(line)
91
+
92
+
93
+ def _resolve_max_tool_rounds(config, args, *, daemon_mode: bool) -> int:
94
+ """Resolve max_tool_rounds: CLI > config > mode default. max_inner_turns is deprecated alias."""
95
+ # Explicit max_tool_rounds takes priority
96
+ cli_rounds = getattr(args, "max_tool_rounds", None) if args else None
97
+ if cli_rounds is not None:
98
+ return max(1, int(cli_rounds))
99
+ config_rounds = getattr(config, "max_tool_rounds", None)
100
+ if config_rounds is not None:
101
+ return max(1, int(config_rounds))
102
+ # Deprecated alias: max_inner_turns
103
+ cli_turns = getattr(args, "max_inner_turns", None) if args else None
104
+ if cli_turns is not None:
105
+ return max(1, int(cli_turns))
106
+ config_turns = getattr(config, "max_inner_turns", None)
107
+ if config_turns is not None:
108
+ return max(1, int(config_turns))
109
+ return 3 if daemon_mode else 1
110
+
111
+
112
+ async def _run(args, config) -> None:
113
+ store = PascalStore(config.db_path)
114
+
115
+ # Seed initial task if provided
116
+ if args.task:
117
+ promised_to = json.loads(args.promised_to) if getattr(args, "promised_to", None) else None
118
+ proof_required = json.loads(args.proof_required) if getattr(args, "proof_required", None) else None
119
+ task_id = store.add_task(
120
+ args.task, source="cli",
121
+ promised_to=promised_to,
122
+ due_at=getattr(args, "due", None),
123
+ proof_required=proof_required,
124
+ )
125
+ print(f"Task created: {task_id}")
126
+
127
+ # Seed mission if provided
128
+ if args.mission:
129
+ store.set_context("mission", args.mission)
130
+
131
+ # Create LLM provider
132
+ from pascal.config import create_llm
133
+ llm = create_llm(config)
134
+
135
+ # Audit ledger (hash-chained)
136
+ from pathlib import Path
137
+ ledger_path = Path(config.db_path).parent / "audit.jsonl"
138
+ ledger = Ledger(str(ledger_path))
139
+
140
+ # MCP servers (if configured)
141
+ from pascal.mcp import MCPManager
142
+ mcp_manager = MCPManager()
143
+ mcp_configs = _load_mcp_configs(config)
144
+ if mcp_configs:
145
+ await mcp_manager.connect_all(mcp_configs)
146
+
147
+ print(f"Pascal loop starting (model: {config.model})")
148
+ print(f"DB: {config.db_path}")
149
+ print(f"Ledger: {ledger_path}")
150
+ if mcp_manager.connected_servers:
151
+ print(f"MCP: {', '.join(mcp_manager.connected_servers)}")
152
+ print()
153
+
154
+ try:
155
+ actions = await run_loop(
156
+ store, llm,
157
+ verifier_llm=llm,
158
+ ledger=ledger,
159
+ mcp_manager=mcp_manager if mcp_manager.connected_servers else None,
160
+ max_effect=getattr(args, "max_effect", None) or config.max_effect,
161
+ max_iterations=args.max_iterations,
162
+ max_tool_rounds=_resolve_max_tool_rounds(config, args, daemon_mode=False),
163
+ on_action=_print_action,
164
+ )
165
+ finally:
166
+ await mcp_manager.disconnect_all()
167
+
168
+ print(f"\nLoop ended after {len(actions)} action(s).")
169
+ store.close()
170
+
171
+
172
+ async def _resume_run(args, config) -> None:
173
+ store = PascalStore(config.db_path)
174
+ task_id = args.resume
175
+ task = store.connection.execute("SELECT * FROM tasks WHERE id = ?", (task_id,)).fetchone()
176
+ if task is None:
177
+ print(f"Task {task_id} not found", file=sys.stderr)
178
+ sys.exit(1)
179
+ if task["status"] in ("done", "failed"):
180
+ print(f"Task {task_id} is {task['status']}, cannot resume", file=sys.stderr)
181
+ sys.exit(1)
182
+ # Reactivate if paused/blocked
183
+ if task["status"] in ("paused", "blocked"):
184
+ store.activate_task(task_id)
185
+ print(f"Task {task_id} reactivated from {task['status']}")
186
+
187
+ from pascal.config import create_llm
188
+ llm = create_llm(config)
189
+
190
+ cp = store.get_latest_checkpoint(task_id)
191
+ if cp:
192
+ print(f"Resuming from checkpoint (iteration {cp['snapshot'].get('iteration', '?')})")
193
+ print(f"Pascal resuming task: {task['goal']}")
194
+
195
+ # Audit ledger + verifier (same as main path)
196
+ from pathlib import Path as _Path
197
+ ledger_path = _Path(config.db_path).parent / "audit.jsonl"
198
+ ledger = Ledger(str(ledger_path))
199
+
200
+ actions = await run_loop(
201
+ store, llm,
202
+ verifier_llm=llm,
203
+ ledger=ledger,
204
+ max_effect=getattr(args, "max_effect", None),
205
+ max_iterations=args.max_iterations,
206
+ max_tool_rounds=_resolve_max_tool_rounds(config, args, daemon_mode=False),
207
+ on_action=_print_action,
208
+ )
209
+ print(f"\nLoop ended after {len(actions)} action(s).")
210
+ store.close()
211
+
212
+
213
+ def _resume(args, config) -> None:
214
+ asyncio.run(_resume_run(args, config))
215
+
216
+
217
+ def _notify(args, config) -> None:
218
+ """Push a notification into Pascal's desk."""
219
+ store = PascalStore(config.db_path)
220
+ notif_id = store.push_notification(
221
+ source=args.notify_source or "cli",
222
+ message=args.notify,
223
+ priority=args.notify_priority or "normal",
224
+ )
225
+ print(f"Notification pushed: {notif_id}")
226
+ store.close()
227
+
228
+
229
+ def _status(config) -> None:
230
+ """Print Pascal's current desk state."""
231
+ import sys
232
+ if sys.stdout.encoding and sys.stdout.encoding.lower() not in ("utf-8", "utf8"):
233
+ if hasattr(sys.stdout, "reconfigure"):
234
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
235
+ from pascal.desk import Desk
236
+ store = PascalStore(config.db_path)
237
+ desk = Desk(store)
238
+ print(desk.render())
239
+ store.close()
240
+
241
+
242
+ def _add_rule(args, config) -> None:
243
+ store = PascalStore(config.db_path)
244
+ mutable = not args.immutable
245
+ rule_id = store.add_rule(args.add_rule, mutable=mutable, added_by="human")
246
+ lock = " [immutable]" if not mutable else ""
247
+ print(f"Rule added{lock}: {rule_id}")
248
+ store.close()
249
+
250
+
251
+ def _load_mcp_configs(config) -> list:
252
+ """Load MCP server configs from ~/.pascal/mcp.json if it exists."""
253
+ from pascal.mcp import MCPServerConfig
254
+ import json as _json
255
+ mcp_path = Path(config.db_path).expanduser().parent / "mcp.json"
256
+ if not mcp_path.exists():
257
+ return []
258
+ try:
259
+ servers = _json.loads(mcp_path.read_text(encoding="utf-8"))
260
+ return [
261
+ MCPServerConfig(
262
+ name=s["name"], command=s["command"],
263
+ args=s.get("args", []), env=s.get("env"),
264
+ )
265
+ for s in servers
266
+ ]
267
+ except Exception as e:
268
+ print(f"Warning: failed to load MCP config: {e}")
269
+ return []
270
+
271
+
272
+ def _build_parser() -> argparse.ArgumentParser:
273
+ parser = argparse.ArgumentParser(
274
+ prog="pascal",
275
+ description="Pascal CLI for running tasks, checking status, and managing your local workspace.",
276
+ epilog=_MAIN_EPILOG,
277
+ formatter_class=argparse.RawDescriptionHelpFormatter,
278
+ )
279
+ parser.add_argument("--init", action="store_true", help="Create ~/.pascal, pascal.toml, and skills/, then exit")
280
+ parser.add_argument("task", nargs="?", default=None, help="Task to add and execute")
281
+ parser.add_argument("--mission", help="Set the agent's mission")
282
+ parser.add_argument("--status", action="store_true", help="Print current desk state")
283
+ parser.add_argument("--notify", help="Push a notification")
284
+ parser.add_argument("--notify-source", default="cli", help="Notification source")
285
+ parser.add_argument("--notify-priority", choices=["urgent", "normal", "low"], default="normal")
286
+ parser.add_argument("--add-rule", help="Add a behavior rule")
287
+ parser.add_argument("--immutable", action="store_true", help="Make the rule immutable")
288
+ parser.add_argument("--max-iterations", type=int, default=20, help="Max loop iterations")
289
+ parser.add_argument(
290
+ "--max-inner-turns",
291
+ type=int,
292
+ default=None,
293
+ help="Max inner reasoning turns (default: 1 one-shot, 3 daemon; env/TOML supported)",
294
+ )
295
+ parser.add_argument(
296
+ "--max-tool-rounds",
297
+ type=int,
298
+ default=None,
299
+ help="Max tool call rounds per turn (default: None; env/TOML supported)",
300
+ )
301
+ parser.add_argument("--max-effect", choices=["E0", "E1", "E2", "E3", "E4", "E5"],
302
+ help="Max effect level (default: E2, env: PASCAL_MAX_EFFECT)")
303
+ parser.add_argument("--promised-to", help='JSON array of stakeholders')
304
+ parser.add_argument("--due", help="Deadline in ISO 8601")
305
+ parser.add_argument("--proof-required", help='JSON array of required evidence types')
306
+ parser.add_argument("--tick", action="store_true", help="Run scheduler tick")
307
+ parser.add_argument("--resume", metavar="TASK_ID", help="Resume a paused/blocked task from checkpoint")
308
+ parser.add_argument("--daemon", action="store_true", help="Run as always-on daemon with Telegram")
309
+ parser.add_argument("--model", help="LLM model name")
310
+ parser.add_argument("--provider", choices=["anthropic", "openai", "codex"], help="LLM provider")
311
+ parser.add_argument("--base-url", help="API base URL override")
312
+ parser.add_argument("--verbose", "-v", action="store_true")
313
+ return parser
314
+
315
+
316
+ def _build_init_parser() -> argparse.ArgumentParser:
317
+ return argparse.ArgumentParser(
318
+ prog="pascal init",
319
+ description="Create Pascal's home directory and starter configuration.",
320
+ epilog=_INIT_EPILOG,
321
+ formatter_class=argparse.RawDescriptionHelpFormatter,
322
+ )
323
+
324
+
325
+ def _init_pascal_home() -> None:
326
+ pascal_home = Path.home() / ".pascal"
327
+ config_path = pascal_home / "pascal.toml"
328
+ skills_dir = pascal_home / "skills"
329
+ created: list[Path] = []
330
+ existing: list[Path] = []
331
+
332
+ try:
333
+ if pascal_home.exists():
334
+ if not pascal_home.is_dir():
335
+ raise NotADirectoryError(f"{pascal_home} exists and is not a directory")
336
+ existing.append(pascal_home)
337
+ else:
338
+ pascal_home.mkdir(parents=True, exist_ok=True)
339
+ created.append(pascal_home)
340
+
341
+ if config_path.exists():
342
+ if not config_path.is_file():
343
+ raise IsADirectoryError(f"{config_path} exists and is not a file")
344
+ existing.append(config_path)
345
+ else:
346
+ config_path.write_text(_DEFAULT_CONFIG_TOML, encoding="utf-8")
347
+ created.append(config_path)
348
+
349
+ if skills_dir.exists():
350
+ if not skills_dir.is_dir():
351
+ raise NotADirectoryError(f"{skills_dir} exists and is not a directory")
352
+ existing.append(skills_dir)
353
+ else:
354
+ skills_dir.mkdir(parents=True, exist_ok=True)
355
+ created.append(skills_dir)
356
+ except OSError as exc:
357
+ print(f"Pascal initialization failed: {exc}", file=sys.stderr)
358
+ sys.exit(1)
359
+
360
+ print("Pascal initialization complete.")
361
+ if created:
362
+ print("Created:")
363
+ for path in created:
364
+ print(f" - {path}")
365
+ if existing:
366
+ print("Already existed:")
367
+ for path in existing:
368
+ print(f" - {path}")
369
+ print()
370
+ print("Next steps:")
371
+ print(f" - Edit {config_path} to choose your model and provider.")
372
+ print(f" - Add custom skills to {skills_dir}.")
373
+
374
+
375
+ def _read_toml_config() -> tuple[Path, dict]:
376
+ """Read the pascal.toml config. Returns (path, pascal_section)."""
377
+ config_path = Path.home() / ".pascal" / "pascal.toml"
378
+ if not config_path.exists():
379
+ return config_path, {}
380
+ import tomllib
381
+ with config_path.open("rb") as f:
382
+ data = tomllib.load(f)
383
+ return config_path, data.get("pascal", {})
384
+
385
+
386
+ def _write_toml_config(path: Path, section: dict) -> None:
387
+ """Write the pascal section to pascal.toml, preserving [pascal] header."""
388
+ lines = ["[pascal]"]
389
+ for key, value in sorted(section.items()):
390
+ if isinstance(value, str):
391
+ lines.append(f'{key} = "{value}"')
392
+ elif isinstance(value, int):
393
+ lines.append(f"{key} = {value}")
394
+ else:
395
+ lines.append(f'{key} = "{value}"')
396
+ path.parent.mkdir(parents=True, exist_ok=True)
397
+ path.write_text("\n".join(lines) + "\n", encoding="utf-8")
398
+
399
+
400
+ def _config_show() -> None:
401
+ """Show current resolved configuration."""
402
+ config = load_config()
403
+ config_path, toml_data = _read_toml_config()
404
+ print(f"Config file: {config_path}" + (" (exists)" if config_path.exists() else " (not found)"))
405
+ print()
406
+ fields = {
407
+ "model": config.model,
408
+ "provider": config.provider,
409
+ "base_url": config.base_url or "(default)",
410
+ "db_path": config.db_path,
411
+ "max_effect": config.max_effect,
412
+ "max_tool_rounds": config.max_tool_rounds or "(default)",
413
+ }
414
+ import os
415
+ for key, value in fields.items():
416
+ source = ""
417
+ env_key = f"PASCAL_{key.upper()}"
418
+ if os.environ.get(env_key):
419
+ source = f" (from ${env_key})"
420
+ elif key in toml_data:
421
+ source = " (from pascal.toml)"
422
+ else:
423
+ source = " (default)"
424
+ print(f" {key:20s} = {value}{source}")
425
+
426
+ # API key status
427
+ print()
428
+ for name, env in [("OpenAI", "OPENAI_API_KEY"), ("Anthropic", "ANTHROPIC_API_KEY"), ("Codex", "~/.codex/auth.json")]:
429
+ if env.startswith("~"):
430
+ found = (Path.home() / env.replace("~/", "")).exists()
431
+ else:
432
+ found = bool(os.environ.get(env))
433
+ status = "configured" if found else "not set"
434
+ print(f" {name:20s} : {status}")
435
+
436
+
437
+ def _config_set(key: str, value: str) -> None:
438
+ """Set a config value in ~/.pascal/pascal.toml."""
439
+ if key not in _CONFIG_KEYS:
440
+ print(f"Unknown config key: {key}")
441
+ print(f"Available keys: {', '.join(sorted(_CONFIG_KEYS))}")
442
+ sys.exit(1)
443
+ if key == "provider" and value not in _PROVIDERS:
444
+ print(f"Invalid provider: {value}. Choose from: {', '.join(_PROVIDERS)}")
445
+ sys.exit(1)
446
+ if key == "max_effect" and value not in _EFFECTS:
447
+ print(f"Invalid effect level: {value}. Choose from: {', '.join(_EFFECTS)}")
448
+ sys.exit(1)
449
+
450
+ config_path, section = _read_toml_config()
451
+ if key == "max_tool_rounds":
452
+ section[key] = int(value)
453
+ else:
454
+ section[key] = value
455
+ _write_toml_config(config_path, section)
456
+ print(f"Set {key} = {value} in {config_path}")
457
+
458
+
459
+ def _config_get(key: str) -> None:
460
+ """Get a specific config value."""
461
+ config = load_config()
462
+ value = getattr(config, key, None)
463
+ if value is None:
464
+ print(f"Unknown key: {key}")
465
+ sys.exit(1)
466
+ print(value)
467
+
468
+
469
+ def _setup_interactive() -> None:
470
+ """Interactive guided setup."""
471
+ import os
472
+ print("Pascal Setup")
473
+ print("=" * 40)
474
+ print()
475
+
476
+ # Ensure ~/.pascal exists
477
+ _init_pascal_home()
478
+ print()
479
+
480
+ config_path, section = _read_toml_config()
481
+
482
+ # Provider
483
+ current_provider = section.get("provider", "openai")
484
+ print(f"LLM Provider ({', '.join(_PROVIDERS)})")
485
+ print(f" Current: {current_provider}")
486
+ provider = input(f" New value [{current_provider}]: ").strip() or current_provider
487
+ if provider not in _PROVIDERS:
488
+ print(f" Invalid. Using {current_provider}.")
489
+ provider = current_provider
490
+ section["provider"] = provider
491
+
492
+ # Model
493
+ defaults = {"openai": "gpt-5.4-mini", "anthropic": "claude-sonnet-4-20250514", "codex": "o3"}
494
+ current_model = section.get("model", defaults.get(provider, "gpt-5.4-mini"))
495
+ print("\nModel name")
496
+ print(f" Current: {current_model}")
497
+ model = input(f" New value [{current_model}]: ").strip() or current_model
498
+ section["model"] = model
499
+
500
+ # Max effect
501
+ current_effect = section.get("max_effect", "E2")
502
+ print("\nMax effect level (E0=read E1=analyze E2=write E3=push E4=merge E5=delete)")
503
+ print(f" Current: {current_effect}")
504
+ effect = input(f" New value [{current_effect}]: ").strip() or current_effect
505
+ if effect in _EFFECTS:
506
+ section["max_effect"] = effect
507
+ else:
508
+ print(f" Invalid. Keeping {current_effect}.")
509
+
510
+ # API key check
511
+ if provider == "openai":
512
+ if not os.environ.get("OPENAI_API_KEY"):
513
+ print("\nOpenAI API key not found in environment.")
514
+ key = input(" Enter OPENAI_API_KEY (or press Enter to skip): ").strip()
515
+ if key:
516
+ _save_env_var("OPENAI_API_KEY", key)
517
+ elif provider == "anthropic":
518
+ if not os.environ.get("ANTHROPIC_API_KEY"):
519
+ print("\nAnthropic API key not found in environment.")
520
+ key = input(" Enter ANTHROPIC_API_KEY (or press Enter to skip): ").strip()
521
+ if key:
522
+ _save_env_var("ANTHROPIC_API_KEY", key)
523
+ elif provider == "codex":
524
+ auth = Path.home() / ".codex" / "auth.json"
525
+ if not auth.exists():
526
+ print(f"\nCodex auth not found at {auth}")
527
+ print(" Run: codex auth login")
528
+
529
+ _write_toml_config(config_path, section)
530
+ print(f"\nSaved to {config_path}")
531
+ print()
532
+ print("Ready! Try:")
533
+ print(' pascal "Summarize the files in this directory"')
534
+ print(' pascal --status')
535
+ print(' pascal config')
536
+
537
+
538
+ def _save_env_var(key: str, value: str) -> None:
539
+ """Save an env var to ~/.pascal/.env and load into current process."""
540
+ env_path = Path.home() / ".pascal" / ".env"
541
+ env_path.parent.mkdir(parents=True, exist_ok=True)
542
+ # Read existing, replace if key exists, else append
543
+ lines = []
544
+ replaced = False
545
+ if env_path.exists():
546
+ for line in env_path.read_text(encoding="utf-8").splitlines():
547
+ if line.strip().startswith(f"{key}="):
548
+ lines.append(f"{key}={value}")
549
+ replaced = True
550
+ else:
551
+ lines.append(line)
552
+ if not replaced:
553
+ lines.append(f"{key}={value}")
554
+ env_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
555
+ os.environ[key] = value # load into current process immediately
556
+
557
+
558
+ def _quick_auth_setup() -> bool:
559
+ """Inline auth setup when no provider is detected. Returns True if configured."""
560
+ import shutil
561
+ import subprocess
562
+
563
+ print("No LLM provider configured. Quick setup:\n")
564
+ print(" [1] Codex (free with ChatGPT Pro subscription)")
565
+ print(" [2] OpenAI (API key)")
566
+ print(" [3] Anthropic (API key)")
567
+ print(" [0] Cancel\n")
568
+
569
+ try:
570
+ choice = input("Choose [1-3]: ").strip()
571
+ except (KeyboardInterrupt, EOFError):
572
+ return False
573
+
574
+ config_path, section = _read_toml_config()
575
+
576
+ if choice == "1":
577
+ # Codex OAuth
578
+ auth_path = Path.home() / ".codex" / "auth.json"
579
+ if auth_path.exists():
580
+ print("Codex auth already exists.")
581
+ elif shutil.which("codex"):
582
+ print("Opening Codex login...")
583
+ try:
584
+ subprocess.run(["codex", "auth", "login"], timeout=120)
585
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as e:
586
+ print(f" Login failed: {e}")
587
+ print(" Try manually: codex auth login")
588
+ return False
589
+ if not auth_path.exists():
590
+ print(" Auth file not created. Try: codex auth login")
591
+ return False
592
+ else:
593
+ print(" Codex CLI not found. Install it first:")
594
+ print(" npm install -g @anthropic-ai/codex")
595
+ print(" Then: codex auth login")
596
+ return False
597
+ section["provider"] = "codex"
598
+ section["model"] = "gpt-5.4-mini"
599
+ _write_toml_config(config_path, section)
600
+ print("Done! Provider: codex\n")
601
+ return True
602
+
603
+ elif choice == "2":
604
+ # OpenAI API key
605
+ try:
606
+ key = input("OPENAI_API_KEY: ").strip()
607
+ except (KeyboardInterrupt, EOFError):
608
+ return False
609
+ if not key:
610
+ print(" No key provided.")
611
+ return False
612
+ _save_env_var("OPENAI_API_KEY", key)
613
+ section["provider"] = "openai"
614
+ section["model"] = "gpt-5.4-mini"
615
+ _write_toml_config(config_path, section)
616
+ print("Done! Provider: openai\n")
617
+ return True
618
+
619
+ elif choice == "3":
620
+ # Anthropic API key
621
+ try:
622
+ key = input("ANTHROPIC_API_KEY: ").strip()
623
+ except (KeyboardInterrupt, EOFError):
624
+ return False
625
+ if not key:
626
+ print(" No key provided.")
627
+ return False
628
+ _save_env_var("ANTHROPIC_API_KEY", key)
629
+ section["provider"] = "anthropic"
630
+ section["model"] = "claude-sonnet-4-20250514"
631
+ _write_toml_config(config_path, section)
632
+ print("Done! Provider: anthropic\n")
633
+ return True
634
+
635
+ return False
636
+
637
+
638
+ def _auto_detect_provider() -> tuple[str, str]:
639
+ """Auto-detect the best available provider. Returns (provider, model)."""
640
+ import os
641
+ # Priority: Codex (free with Pro) > OpenAI > Anthropic
642
+ if (Path.home() / ".codex" / "auth.json").exists():
643
+ return "codex", "gpt-5.4-mini"
644
+ if os.environ.get("OPENAI_API_KEY"):
645
+ return "openai", "gpt-5.4-mini"
646
+ if os.environ.get("ANTHROPIC_API_KEY"):
647
+ return "anthropic", "claude-sonnet-4-20250514"
648
+ return "", ""
649
+
650
+
651
+ def _ensure_ready() -> bool:
652
+ """Ensure Pascal is initialized. Auto-init on first run. Returns True if ready."""
653
+ pascal_home = Path.home() / ".pascal"
654
+ config_path = pascal_home / "pascal.toml"
655
+
656
+ # First run: auto-init
657
+ if not pascal_home.exists():
658
+ print("First run detected. Setting up Pascal...\n")
659
+ _init_pascal_home()
660
+ print()
661
+
662
+ # Auto-detect provider if not configured
663
+ _, section = _read_toml_config()
664
+ if not section.get("provider"):
665
+ provider, model = _auto_detect_provider()
666
+ if provider:
667
+ section["provider"] = provider
668
+ section["model"] = model
669
+ _write_toml_config(config_path, section)
670
+ print(f"Auto-detected: provider={provider}, model={model}\n")
671
+ else:
672
+ # Interactive provider setup
673
+ result = _quick_auth_setup()
674
+ if not result:
675
+ return False
676
+ return True
677
+
678
+
679
+ def _print_banner() -> None:
680
+ """Print compact banner with current config."""
681
+ from pascal import __version__
682
+ config = load_config()
683
+ print(f"Pascal v{__version__} | {config.provider}/{config.model} | max:{config.max_effect}")
684
+ print("Type a task, or 'help' for commands. Ctrl+C to exit.\n")
685
+
686
+
687
+ async def _run_one_task(task: str, config) -> None:
688
+ """Run a single task through the loop. Shared by REPL and one-shot mode."""
689
+ store = PascalStore(config.db_path)
690
+ store.add_task(task, source="cli")
691
+
692
+ from pascal.config import create_llm
693
+ llm = create_llm(config)
694
+
695
+ ledger_path = Path(config.db_path).expanduser().parent / "audit.jsonl"
696
+ ledger = Ledger(str(ledger_path))
697
+
698
+ from pascal.mcp import MCPManager
699
+ mcp_manager = MCPManager()
700
+ mcp_configs = _load_mcp_configs(config)
701
+ if mcp_configs:
702
+ try:
703
+ await mcp_manager.connect_all(mcp_configs)
704
+ except Exception:
705
+ pass # MCP is optional
706
+
707
+ try:
708
+ await run_loop(
709
+ store, llm,
710
+ verifier_llm=llm,
711
+ ledger=ledger,
712
+ mcp_manager=mcp_manager if mcp_manager.connected_servers else None,
713
+ max_effect=config.max_effect,
714
+ max_iterations=20,
715
+ max_tool_rounds=_resolve_max_tool_rounds(config, None, daemon_mode=False),
716
+ on_action=_print_action,
717
+ )
718
+ finally:
719
+ try:
720
+ await mcp_manager.disconnect_all()
721
+ except Exception:
722
+ pass
723
+ store.close()
724
+
725
+
726
+ def _interactive() -> None:
727
+ """Interactive REPL mode -- type tasks, see Pascal work, repeat."""
728
+ if not _ensure_ready():
729
+ return
730
+
731
+ _print_banner()
732
+ config = load_config()
733
+
734
+ # Show pending work if any
735
+ store = PascalStore(config.db_path)
736
+ active = store.get_active_task()
737
+ pending = store.get_pending_tasks()
738
+ notifs = store.get_pending_notifications()
739
+ if active:
740
+ print(f" Active: {active['goal'][:70]}")
741
+ if pending:
742
+ print(f" Queued: {len(pending)} task(s)")
743
+ if notifs:
744
+ print(f" Notifications: {len(notifs)}")
745
+ if active or pending or notifs:
746
+ print()
747
+ store.close()
748
+
749
+ while True:
750
+ try:
751
+ task = input("> ").strip()
752
+ except (KeyboardInterrupt, EOFError):
753
+ print("\nBye.")
754
+ break
755
+
756
+ if not task:
757
+ continue
758
+ if task.lower() in ("exit", "quit", "q"):
759
+ print("Bye.")
760
+ break
761
+ if task.lower() == "help":
762
+ print(" Type any task to run it. Examples:")
763
+ print(' Summarize the files in this directory')
764
+ print(' Read README.md and explain the architecture')
765
+ print()
766
+ print(" Commands:")
767
+ print(" help Show this help")
768
+ print(" status Show desk state")
769
+ print(" config Show current settings")
770
+ print(" exit Quit")
771
+ print()
772
+ continue
773
+ if task.lower() == "status":
774
+ _status(config)
775
+ print()
776
+ continue
777
+ if task.lower() == "config":
778
+ _config_show()
779
+ print()
780
+ continue
781
+
782
+ print()
783
+ try:
784
+ asyncio.run(_run_one_task(task, config))
785
+ except KeyboardInterrupt:
786
+ print("\n (interrupted)")
787
+ except Exception as exc:
788
+ print(f" Error: {exc}")
789
+ print()
790
+
791
+
792
+ def main() -> None:
793
+ argv = sys.argv[1:]
794
+
795
+ # Subcommands: init, setup, config
796
+ if argv and argv[0] == "init":
797
+ init_parser = _build_init_parser()
798
+ init_parser.parse_args(argv[1:])
799
+ _init_pascal_home()
800
+ return
801
+
802
+ if argv and argv[0] == "setup":
803
+ _setup_interactive()
804
+ return
805
+
806
+ if argv and argv[0] == "config":
807
+ rest = argv[1:]
808
+ if not rest:
809
+ _config_show()
810
+ elif rest[0] == "set" and len(rest) >= 3:
811
+ _config_set(rest[1], rest[2])
812
+ elif rest[0] == "get" and len(rest) >= 2:
813
+ _config_get(rest[1])
814
+ else:
815
+ print("Usage:")
816
+ print(" pascal config Show all settings")
817
+ print(" pascal config set <key> <value> Set a value")
818
+ print(" pascal config get <key> Get a value")
819
+ print(f"\nKeys: {', '.join(sorted(_CONFIG_KEYS))}")
820
+ return
821
+
822
+ parser = _build_parser()
823
+ args = parser.parse_args(argv)
824
+
825
+ if args.init:
826
+ _init_pascal_home()
827
+ return
828
+
829
+ # No task + no flags → interactive REPL
830
+ has_action = (
831
+ args.task or args.status or args.notify or args.add_rule
832
+ or args.daemon or args.tick or getattr(args, "resume", None)
833
+ or args.mission
834
+ )
835
+ if not has_action:
836
+ _interactive()
837
+ return
838
+
839
+ # Ensure initialized before any real work
840
+ if not _ensure_ready():
841
+ return
842
+
843
+ config = load_config(
844
+ **({"model": args.model} if args.model else {}),
845
+ **({"provider": args.provider} if args.provider else {}),
846
+ **({"base_url": args.base_url} if args.base_url else {}),
847
+ **({"max_inner_turns": args.max_inner_turns} if args.max_inner_turns is not None else {}),
848
+ **({"max_tool_rounds": args.max_tool_rounds} if getattr(args, "max_tool_rounds", None) is not None else {}),
849
+ )
850
+
851
+ if args.verbose:
852
+ logging.basicConfig(level=logging.DEBUG)
853
+ else:
854
+ # Suppress noisy MCP/connection warnings in normal mode
855
+ logging.basicConfig(level=logging.ERROR)
856
+
857
+ if args.daemon:
858
+ from pascal.daemon import run_daemon
859
+ asyncio.run(run_daemon(config, args))
860
+ return
861
+ elif getattr(args, "resume", None):
862
+ _resume(args, config)
863
+ elif args.tick:
864
+ from pascal.scheduler import Scheduler
865
+ store = PascalStore(config.db_path)
866
+ result = Scheduler(store, emit=print).tick()
867
+ print(json.dumps(result, indent=2))
868
+ store.close()
869
+ elif args.status:
870
+ _status(config)
871
+ elif args.notify:
872
+ _notify(args, config)
873
+ elif args.add_rule:
874
+ _add_rule(args, config)
875
+ else:
876
+ asyncio.run(_run(args, config))
877
+
878
+
879
+ if __name__ == "__main__":
880
+ main()