deepparallel 0.6.0__tar.gz → 0.7.0__tar.gz

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 (100) hide show
  1. {deepparallel-0.6.0 → deepparallel-0.7.0}/PKG-INFO +2 -1
  2. {deepparallel-0.6.0 → deepparallel-0.7.0}/deepparallel/__init__.py +1 -1
  3. {deepparallel-0.6.0 → deepparallel-0.7.0}/deepparallel/branding.py +80 -0
  4. {deepparallel-0.6.0 → deepparallel-0.7.0}/deepparallel/cli.py +206 -24
  5. {deepparallel-0.6.0 → deepparallel-0.7.0}/deepparallel/licensing.py +89 -24
  6. deepparallel-0.7.0/deepparallel/research/compound_discovery.py +321 -0
  7. {deepparallel-0.6.0 → deepparallel-0.7.0}/deepparallel/serve.py +11 -9
  8. deepparallel-0.7.0/deepparallel/tui/__init__.py +7 -0
  9. deepparallel-0.7.0/deepparallel/tui/app.py +156 -0
  10. deepparallel-0.7.0/deepparallel/tui/renderer.py +136 -0
  11. deepparallel-0.7.0/deepparallel/tui/widgets/animations.py +171 -0
  12. deepparallel-0.7.0/deepparallel/tui/widgets/confirm.py +85 -0
  13. {deepparallel-0.6.0 → deepparallel-0.7.0}/deepparallel.egg-info/PKG-INFO +2 -1
  14. {deepparallel-0.6.0 → deepparallel-0.7.0}/deepparallel.egg-info/SOURCES.txt +8 -0
  15. {deepparallel-0.6.0 → deepparallel-0.7.0}/deepparallel.egg-info/requires.txt +1 -0
  16. {deepparallel-0.6.0 → deepparallel-0.7.0}/pyproject.toml +2 -1
  17. deepparallel-0.7.0/tests/test_cli_licensing.py +297 -0
  18. {deepparallel-0.6.0 → deepparallel-0.7.0}/tests/test_crowe_gateway_backend.py +0 -2
  19. {deepparallel-0.6.0 → deepparallel-0.7.0}/tests/test_crowe_payment_required.py +0 -1
  20. deepparallel-0.7.0/tests/test_licensing_files.py +128 -0
  21. {deepparallel-0.6.0 → deepparallel-0.7.0}/tests/test_tools_mcp.py +1 -1
  22. {deepparallel-0.6.0 → deepparallel-0.7.0}/README.md +0 -0
  23. {deepparallel-0.6.0 → deepparallel-0.7.0}/deepparallel/agent.py +0 -0
  24. {deepparallel-0.6.0 → deepparallel-0.7.0}/deepparallel/backend.py +0 -0
  25. {deepparallel-0.6.0 → deepparallel-0.7.0}/deepparallel/cockpit.py +0 -0
  26. {deepparallel-0.6.0 → deepparallel-0.7.0}/deepparallel/cockpit_observe.py +0 -0
  27. {deepparallel-0.6.0 → deepparallel-0.7.0}/deepparallel/cockpit_panel.py +0 -0
  28. {deepparallel-0.6.0 → deepparallel-0.7.0}/deepparallel/cockpit_sim.py +0 -0
  29. {deepparallel-0.6.0 → deepparallel-0.7.0}/deepparallel/config.py +0 -0
  30. {deepparallel-0.6.0 → deepparallel-0.7.0}/deepparallel/crowe_id.py +0 -0
  31. {deepparallel-0.6.0 → deepparallel-0.7.0}/deepparallel/dsml.py +0 -0
  32. {deepparallel-0.6.0 → deepparallel-0.7.0}/deepparallel/fusion.py +0 -0
  33. {deepparallel-0.6.0 → deepparallel-0.7.0}/deepparallel/memory.py +0 -0
  34. {deepparallel-0.6.0 → deepparallel-0.7.0}/deepparallel/mesh.py +0 -0
  35. {deepparallel-0.6.0 → deepparallel-0.7.0}/deepparallel/registry.json +0 -0
  36. {deepparallel-0.6.0 → deepparallel-0.7.0}/deepparallel/renderer.py +0 -0
  37. {deepparallel-0.6.0 → deepparallel-0.7.0}/deepparallel/research/__init__.py +0 -0
  38. {deepparallel-0.6.0 → deepparallel-0.7.0}/deepparallel/research/conduit.py +0 -0
  39. {deepparallel-0.6.0 → deepparallel-0.7.0}/deepparallel/research/provider.py +0 -0
  40. {deepparallel-0.6.0 → deepparallel-0.7.0}/deepparallel/routing.example.json +0 -0
  41. {deepparallel-0.6.0 → deepparallel-0.7.0}/deepparallel/routing.py +0 -0
  42. {deepparallel-0.6.0 → deepparallel-0.7.0}/deepparallel/session.py +0 -0
  43. {deepparallel-0.6.0 → deepparallel-0.7.0}/deepparallel/supply_chain.py +0 -0
  44. {deepparallel-0.6.0 → deepparallel-0.7.0}/deepparallel/system_prompt.txt +0 -0
  45. {deepparallel-0.6.0 → deepparallel-0.7.0}/deepparallel/tools/__init__.py +0 -0
  46. {deepparallel-0.6.0 → deepparallel-0.7.0}/deepparallel/tools/codeast.py +0 -0
  47. {deepparallel-0.6.0 → deepparallel-0.7.0}/deepparallel/tools/edit.py +0 -0
  48. {deepparallel-0.6.0 → deepparallel-0.7.0}/deepparallel/tools/files.py +0 -0
  49. {deepparallel-0.6.0 → deepparallel-0.7.0}/deepparallel/tools/git_ops.py +0 -0
  50. {deepparallel-0.6.0 → deepparallel-0.7.0}/deepparallel/tools/mcp.py +0 -0
  51. {deepparallel-0.6.0 → deepparallel-0.7.0}/deepparallel/tools/memory.py +0 -0
  52. {deepparallel-0.6.0 → deepparallel-0.7.0}/deepparallel/tools/registry.py +0 -0
  53. {deepparallel-0.6.0 → deepparallel-0.7.0}/deepparallel/tools/sandbox.py +0 -0
  54. {deepparallel-0.6.0 → deepparallel-0.7.0}/deepparallel/tools/search.py +0 -0
  55. {deepparallel-0.6.0 → deepparallel-0.7.0}/deepparallel/tools/shell.py +0 -0
  56. {deepparallel-0.6.0 → deepparallel-0.7.0}/deepparallel/tools/vision.py +0 -0
  57. {deepparallel-0.6.0 → deepparallel-0.7.0}/deepparallel/tools/web.py +0 -0
  58. {deepparallel-0.6.0 → deepparallel-0.7.0}/deepparallel/userinput.py +0 -0
  59. {deepparallel-0.6.0 → deepparallel-0.7.0}/deepparallel.egg-info/dependency_links.txt +0 -0
  60. {deepparallel-0.6.0 → deepparallel-0.7.0}/deepparallel.egg-info/entry_points.txt +0 -0
  61. {deepparallel-0.6.0 → deepparallel-0.7.0}/deepparallel.egg-info/top_level.txt +0 -0
  62. {deepparallel-0.6.0 → deepparallel-0.7.0}/setup.cfg +0 -0
  63. {deepparallel-0.6.0 → deepparallel-0.7.0}/tests/test_agent.py +0 -0
  64. {deepparallel-0.6.0 → deepparallel-0.7.0}/tests/test_backend.py +0 -0
  65. {deepparallel-0.6.0 → deepparallel-0.7.0}/tests/test_backend_chat.py +0 -0
  66. {deepparallel-0.6.0 → deepparallel-0.7.0}/tests/test_backend_stream.py +0 -0
  67. {deepparallel-0.6.0 → deepparallel-0.7.0}/tests/test_branding.py +0 -0
  68. {deepparallel-0.6.0 → deepparallel-0.7.0}/tests/test_cli.py +0 -0
  69. {deepparallel-0.6.0 → deepparallel-0.7.0}/tests/test_cockpit.py +0 -0
  70. {deepparallel-0.6.0 → deepparallel-0.7.0}/tests/test_cockpit_panel.py +0 -0
  71. {deepparallel-0.6.0 → deepparallel-0.7.0}/tests/test_cockpit_sim.py +0 -0
  72. {deepparallel-0.6.0 → deepparallel-0.7.0}/tests/test_config.py +0 -0
  73. {deepparallel-0.6.0 → deepparallel-0.7.0}/tests/test_config_file.py +0 -0
  74. {deepparallel-0.6.0 → deepparallel-0.7.0}/tests/test_crowe_backend.py +0 -0
  75. {deepparallel-0.6.0 → deepparallel-0.7.0}/tests/test_crowe_id_auth.py +0 -0
  76. {deepparallel-0.6.0 → deepparallel-0.7.0}/tests/test_dsml.py +0 -0
  77. {deepparallel-0.6.0 → deepparallel-0.7.0}/tests/test_fusion.py +0 -0
  78. {deepparallel-0.6.0 → deepparallel-0.7.0}/tests/test_git_ops.py +0 -0
  79. {deepparallel-0.6.0 → deepparallel-0.7.0}/tests/test_issuer_signer.py +0 -0
  80. {deepparallel-0.6.0 → deepparallel-0.7.0}/tests/test_licensing.py +0 -0
  81. {deepparallel-0.6.0 → deepparallel-0.7.0}/tests/test_memory.py +0 -0
  82. {deepparallel-0.6.0 → deepparallel-0.7.0}/tests/test_renderer.py +0 -0
  83. {deepparallel-0.6.0 → deepparallel-0.7.0}/tests/test_research.py +0 -0
  84. {deepparallel-0.6.0 → deepparallel-0.7.0}/tests/test_research_provider.py +0 -0
  85. {deepparallel-0.6.0 → deepparallel-0.7.0}/tests/test_routing.py +0 -0
  86. {deepparallel-0.6.0 → deepparallel-0.7.0}/tests/test_serve.py +0 -0
  87. {deepparallel-0.6.0 → deepparallel-0.7.0}/tests/test_serve_session.py +0 -0
  88. {deepparallel-0.6.0 → deepparallel-0.7.0}/tests/test_spinner_color.py +0 -0
  89. {deepparallel-0.6.0 → deepparallel-0.7.0}/tests/test_supply_chain.py +0 -0
  90. {deepparallel-0.6.0 → deepparallel-0.7.0}/tests/test_tool_registry.py +0 -0
  91. {deepparallel-0.6.0 → deepparallel-0.7.0}/tests/test_tools_codeast.py +0 -0
  92. {deepparallel-0.6.0 → deepparallel-0.7.0}/tests/test_tools_edit.py +0 -0
  93. {deepparallel-0.6.0 → deepparallel-0.7.0}/tests/test_tools_files.py +0 -0
  94. {deepparallel-0.6.0 → deepparallel-0.7.0}/tests/test_tools_sandbox.py +0 -0
  95. {deepparallel-0.6.0 → deepparallel-0.7.0}/tests/test_tools_search.py +0 -0
  96. {deepparallel-0.6.0 → deepparallel-0.7.0}/tests/test_tools_shell.py +0 -0
  97. {deepparallel-0.6.0 → deepparallel-0.7.0}/tests/test_tools_vision.py +0 -0
  98. {deepparallel-0.6.0 → deepparallel-0.7.0}/tests/test_tools_web.py +0 -0
  99. {deepparallel-0.6.0 → deepparallel-0.7.0}/tests/test_userinput.py +0 -0
  100. {deepparallel-0.6.0 → deepparallel-0.7.0}/tests/test_userinput_paste.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: deepparallel
3
- Version: 0.6.0
3
+ Version: 0.7.0
4
4
  Summary: DeepParallel - a multi-model agentic coding CLI with cross-model Guardian review, served via Crowe Logic.
5
5
  Author-email: Michael Crowe <michael@crowelogic.com>
6
6
  License: Apache-2.0
@@ -16,6 +16,7 @@ Requires-Dist: python-dotenv>=1.0.0
16
16
  Requires-Dist: tree-sitter>=0.25.0
17
17
  Requires-Dist: tree-sitter-language-pack>=1.8.0
18
18
  Requires-Dist: cryptography>=42.0.0
19
+ Requires-Dist: textual>=0.52.0
19
20
  Provides-Extra: dev
20
21
  Requires-Dist: pytest>=8.0.0; extra == "dev"
21
22
  Requires-Dist: ruff>=0.6.0; extra == "dev"
@@ -1,3 +1,3 @@
1
1
  """DeepParallel CLI package."""
2
2
 
3
- __version__ = "0.6.0"
3
+ __version__ = "0.7.0"
@@ -173,6 +173,86 @@ def info(msg: str) -> None:
173
173
  console.print(f"[{DIM}]{msg}[/]")
174
174
 
175
175
 
176
+ def upgrade_card(feature: str, current_tier: str, needed_tier: str, unlocks: str, url: str) -> None:
177
+ body = Text()
178
+ body.append(f"{feature} ", style=f"bold {DP_ACCENT}")
179
+ body.append("is a ", style="white")
180
+ body.append(needed_tier, style=f"bold {CROWE_ACCENT}")
181
+ body.append(" feature. You are on ", style="white")
182
+ body.append(f"{current_tier}", style=AMBER_HEX)
183
+ body.append(".\n\n", style="white")
184
+ body.append("Unlocks ", style=DIM)
185
+ body.append(f"{unlocks}\n\n", style="white")
186
+ body.append(f"{ARROW} ", style=CROWE_ACCENT)
187
+ body.append("dp upgrade", style=f"bold {CROWE_ACCENT}")
188
+ body.append(f" {url}", style=DIM)
189
+ console.print(
190
+ _gutter(
191
+ Panel(
192
+ body,
193
+ title=Text(f"{MARK} Upgrade to {needed_tier}", style=DP_ACCENT),
194
+ title_align="left",
195
+ border_style=AMBER_HEX,
196
+ box=box.ROUNDED,
197
+ )
198
+ )
199
+ )
200
+
201
+
202
+ def license_panel(
203
+ title: str,
204
+ header_lines: list[tuple[str, str]],
205
+ rows: list[tuple[bool, str]],
206
+ footer_lines: list[str] | None = None,
207
+ ) -> None:
208
+ body = Text()
209
+ for label, value in header_lines:
210
+ body.append(f"{label:<9}", style=DIM)
211
+ body.append(f"{value}\n", style="white")
212
+ if rows:
213
+ body.append("\n")
214
+ for unlocked, desc in rows:
215
+ if unlocked:
216
+ body.append(f"{CHECK} ", style=GREEN_HEX)
217
+ body.append(f"{desc}\n", style="white")
218
+ else:
219
+ body.append(f"{CROSS} ", style=RED_HEX)
220
+ body.append(f"{desc}\n", style=DIM)
221
+ if footer_lines:
222
+ body.append("\n")
223
+ for line in footer_lines:
224
+ body.append(f"{line}\n", style=DIM)
225
+ console.print(
226
+ _gutter(
227
+ Panel(
228
+ body,
229
+ title=Text(f"{MARK} {title}", style=DP_ACCENT),
230
+ title_align="left",
231
+ border_style=DP_ACCENT,
232
+ box=box.ROUNDED,
233
+ )
234
+ )
235
+ )
236
+
237
+
238
+ def first_run_banner() -> None:
239
+ body = Text()
240
+ body.append("You're on ", style=DIM)
241
+ body.append("Free", style=f"bold {AMBER_HEX}")
242
+ body.append(". Unlock Guardian, fusion, and the ", style=DIM)
243
+ body.append("--deep", style="white")
244
+ body.append(" council: ", style=DIM)
245
+ body.append("dp upgrade", style=f"bold {CROWE_ACCENT}")
246
+ console.print(_gutter(Panel(body, border_style=DP_ACCENT, box=box.ROUNDED)))
247
+
248
+
249
+ def upgrade_link(url: str, opened: bool) -> None:
250
+ if opened:
251
+ console.print(f"[{DIM}]Opening your browser to DeepParallel pricing:[/] {url}")
252
+ else:
253
+ console.print(f"[{DIM}]Upgrade at[/] {url}")
254
+
255
+
176
256
  def error(msg: str) -> None:
177
257
  console.print(f"[bold red]error[/] {msg}")
178
258
 
@@ -58,6 +58,27 @@ from deepparallel.cockpit import Cockpit
58
58
  from deepparallel.cockpit_observe import make_observer
59
59
 
60
60
 
61
+ _soft_cards_shown: set[str] = set()
62
+
63
+
64
+ def require_feature(feature: str, *, hard: bool, exit_code: int = 3) -> bool:
65
+ have = licensing.resolve_tier()
66
+ if licensing.check_feature(feature, have)[0]:
67
+ return True
68
+ if hard or feature not in _soft_cards_shown:
69
+ branding.upgrade_card(
70
+ feature,
71
+ have.label,
72
+ licensing.feature_tier(feature).label,
73
+ licensing.feature_unlocks(feature),
74
+ licensing.upgrade_url(),
75
+ )
76
+ if hard:
77
+ sys.exit(exit_code)
78
+ _soft_cards_shown.add(feature)
79
+ return False
80
+
81
+
61
82
  def _build_messages(history: list[tuple[str, str]], system: str, user_msg: str) -> list[dict]:
62
83
  msgs: list[dict] = [{"role": "system", "content": system}]
63
84
  for role, content in history:
@@ -82,9 +103,7 @@ def _effective_backend(base: Backend, settings: Settings, mode: str) -> Backend:
82
103
  """
83
104
  if mode not in ("reason", "escalate"):
84
105
  return base
85
- ok, msg = licensing.check_feature("fusion")
86
- if not ok:
87
- branding.info(msg)
106
+ if not require_feature("fusion", hard=False):
88
107
  return base
89
108
  reasoner = backend_for_deployment(settings, settings.reasoner_deployment)
90
109
  if mode == "reason":
@@ -100,10 +119,7 @@ def _wrap_fusion(backend: Backend, settings: Settings) -> Backend:
100
119
  def _run_deep(settings: Settings, messages: list[dict]) -> None:
101
120
  """Heavy multi-model fan-out + judge, one-shot. Chains are shown with
102
121
  generic labels (no raw model names) per the brand rule. Paid (Pro+)."""
103
- ok, msg = licensing.check_feature("deep")
104
- if not ok:
105
- branding.error(msg)
106
- sys.exit(2)
122
+ require_feature("deep", hard=True, exit_code=2)
107
123
  chains = [
108
124
  (f"candidate-{i + 1}", backend_for_deployment(settings, dep))
109
125
  for i, dep in enumerate(settings.parallel_deployments)
@@ -135,10 +151,7 @@ def _run_deep(settings: Settings, messages: list[dict]) -> None:
135
151
 
136
152
 
137
153
  def _run_dual(settings: Settings, messages: list[dict], dual: str, synth: bool) -> None:
138
- ok, msg = licensing.check_feature("dual")
139
- if not ok:
140
- branding.error(msg)
141
- sys.exit(2)
154
+ require_feature("dual", hard=True, exit_code=2)
142
155
  names = [s.strip() for s in dual.split(",") if s.strip()] if dual else []
143
156
  if len(names) >= 2:
144
157
  la, ra, lname, rname = names[0], names[1], names[0], names[1]
@@ -170,9 +183,7 @@ def _build_guardian(settings: Settings) -> Backend | None:
170
183
  """
171
184
  if not settings.guardian_enabled:
172
185
  return None
173
- ok, msg = licensing.check_feature("guardian")
174
- if not ok:
175
- branding.info(msg)
186
+ if not require_feature("guardian", hard=False):
176
187
  return None
177
188
  return backend_for_deployment(settings, settings.guardian_deployment)
178
189
 
@@ -185,8 +196,7 @@ def _build_mesh(settings: Settings):
185
196
  """
186
197
  if not settings.guardian_enabled:
187
198
  return None
188
- ok, _ = licensing.check_feature("mesh")
189
- if not ok:
199
+ if not licensing.check_feature("mesh")[0]:
190
200
  return None
191
201
  from deepparallel import mesh
192
202
 
@@ -439,6 +449,33 @@ def _chat_loop(settings: Settings) -> None:
439
449
  _stream_repl(_wrap_fusion(backend, settings), settings)
440
450
 
441
451
 
452
+ _LICENSE_COMMANDS = {"activate", "deactivate", "upgrade", "account", "whoami"}
453
+
454
+
455
+ def _is_interactive() -> bool:
456
+ return sys.stdout.isatty() and not _bool_env("DEEPPARALLEL_PLAIN", False)
457
+
458
+
459
+ def _first_run_marker() -> Path:
460
+ return licensing.license_file_path().parent / ".first_run_shown"
461
+
462
+
463
+ def _maybe_first_run_nudge() -> None:
464
+ if not _is_interactive():
465
+ return
466
+ if licensing.resolve_tier() is not licensing.Tier.FREE:
467
+ return
468
+ marker = _first_run_marker()
469
+ if marker.exists():
470
+ return
471
+ try:
472
+ marker.parent.mkdir(parents=True, exist_ok=True)
473
+ marker.write_text("shown\n")
474
+ except OSError:
475
+ pass
476
+ branding.first_run_banner()
477
+
478
+
442
479
  @click.group(
443
480
  invoke_without_command=True,
444
481
  context_settings={"help_option_names": ["-h", "--help"]},
@@ -459,6 +496,8 @@ def main(ctx: click.Context, temperature: float | None, assume_yes: bool) -> Non
459
496
  if assume_yes:
460
497
  settings = replace(settings, auto_approve=True)
461
498
  ctx.obj["settings"] = settings
499
+ if ctx.invoked_subcommand not in _LICENSE_COMMANDS:
500
+ _maybe_first_run_nudge()
462
501
  if ctx.invoked_subcommand is None:
463
502
  _chat_loop(settings)
464
503
 
@@ -481,6 +520,14 @@ def chat(ctx: click.Context, no_tools: bool, assume_yes: bool, fuse: str | None)
481
520
  _chat_loop(settings)
482
521
 
483
522
 
523
+ @main.command()
524
+ def tui() -> None:
525
+ """Launch the DeepParallel TUI (persistent multi-panel terminal interface)."""
526
+ from deepparallel.tui.app import run_tui
527
+
528
+ run_tui()
529
+
530
+
484
531
  @main.command()
485
532
  @click.option("--no-tools", is_flag=True, help="Disable tools (plain chat).")
486
533
  @click.option("--yes", "-y", "assume_yes", is_flag=True, help="Auto-approve tool actions.")
@@ -617,10 +664,7 @@ def review(ctx: click.Context, as_diff: bool, path: str | None) -> None:
617
664
  """
618
665
  settings: Settings = ctx.obj["settings"]
619
666
  if not settings.byok:
620
- ok, msg = licensing.check_feature("review")
621
- if not ok:
622
- branding.error(msg)
623
- sys.exit(3)
667
+ require_feature("review", hard=True, exit_code=3)
624
668
  if as_diff:
625
669
  content = sys.stdin.read()
626
670
  elif path:
@@ -682,10 +726,7 @@ def audit(ctx: click.Context, path: str) -> None:
682
726
  seen) in source files and manifests. Exit code: 0 clean, 2 if a likely
683
727
  hallucinated dependency is found - so it can gate a commit or PR. Paid (Pro+).
684
728
  """
685
- ok, msg = licensing.check_feature("audit")
686
- if not ok:
687
- branding.error(msg)
688
- sys.exit(3)
729
+ require_feature("audit", hard=True, exit_code=3)
689
730
  try:
690
731
  content = Path(path).expanduser().read_text(encoding="utf-8")
691
732
  except OSError as e:
@@ -760,6 +801,47 @@ def research_conduit() -> None:
760
801
  )
761
802
 
762
803
 
804
+ @research.command("compound-discovery")
805
+ @click.option("--target", default="EGFR", help="Biological target (gene symbol).")
806
+ @click.option(
807
+ "--compounds",
808
+ default="erlotinib,gefitinib,osimertinib",
809
+ help="Comma-separated compound names.",
810
+ )
811
+ @click.option("--dry-run", is_flag=True, help="Print the MCP tool call manifest.")
812
+ @click.option("--output", default=None, help="Output report path (Markdown + JSON).")
813
+ def research_compound_discovery(
814
+ target: str, compounds: str, dry_run: bool, output: str | None
815
+ ) -> None:
816
+ """PubChem compound discovery workflow across 10 stages.
817
+
818
+ Generates a full intelligence report: target assays, compound profiling,
819
+ bioactivity, safety, interactions, 3D structure, cross-references, and
820
+ analog discovery. Uses PubChem MCP tools when available; --dry-run prints
821
+ the tool call manifest without executing.
822
+ """
823
+ from deepparallel.research.compound_discovery import (
824
+ generate_markdown_report,
825
+ run_dry_run,
826
+ run_workflow,
827
+ )
828
+
829
+ compound_list = [c.strip() for c in compounds.split(",")]
830
+ if dry_run:
831
+ console.print(run_dry_run(target, compound_list))
832
+ return
833
+ result = run_workflow(target, compound_list)
834
+ report = generate_markdown_report(result)
835
+ print(report)
836
+ if output:
837
+ path = Path(output)
838
+ path.write_text(report)
839
+ json_path = path.with_suffix(".json")
840
+ json_path.write_text(result.to_json())
841
+ branding.info(f"Report written to {path}")
842
+ branding.info(f"JSON data written to {json_path}")
843
+
844
+
763
845
  @main.command(name="tools")
764
846
  def tools_cmd() -> None:
765
847
  """List the agent tools available to DeepParallel."""
@@ -821,5 +903,105 @@ def doctor(ctx: click.Context) -> None:
821
903
  sys.exit(1)
822
904
 
823
905
 
906
+ def _format_exp(payload: dict) -> str:
907
+ exp = int(payload.get("exp") or 0)
908
+ if not exp:
909
+ return "no expiry"
910
+ import datetime
911
+
912
+ return datetime.datetime.fromtimestamp(exp).strftime("%Y-%m-%d")
913
+
914
+
915
+ def _show_account(settings: Settings) -> None:
916
+ source, payload = licensing.active_license()
917
+ tier = licensing.resolve_tier()
918
+ if source == "env":
919
+ src_label = "DEEPPARALLEL_LICENSE env var"
920
+ elif source == "file":
921
+ src_label = str(licensing.license_file_path())
922
+ else:
923
+ src_label = "none"
924
+ if source != "none" and payload is None:
925
+ src_label += " (invalid or expired)"
926
+ header = [("Tier", tier.label), ("Source", src_label)]
927
+ if payload and payload.get("email"):
928
+ header.append(("Account", str(payload["email"])))
929
+ if payload:
930
+ header.append(("Expires", _format_exp(payload)))
931
+ rows = [(unlocked, desc) for _f, desc, _need, unlocked in licensing.features_matrix(tier)]
932
+ footer = None
933
+ if tier is licensing.Tier.FREE:
934
+ footer = ["Unlock Guardian, fusion, and the --deep council: dp upgrade"]
935
+ branding.license_panel("DeepParallel account", header, rows, footer)
936
+
937
+
938
+ @main.command()
939
+ @click.argument("key", required=True)
940
+ @click.pass_context
941
+ def activate(ctx: click.Context, key: str) -> None:
942
+ payload = licensing.verify_token(key.strip())
943
+ if not payload:
944
+ branding.error(
945
+ "that license key is invalid or expired. Copy it exactly from your "
946
+ "purchase email, or run dp upgrade to buy one."
947
+ )
948
+ sys.exit(2)
949
+ tier = licensing.tier_from_payload(payload)
950
+ try:
951
+ path = licensing.write_license(key)
952
+ except OSError as e:
953
+ branding.error(f"could not write the license file: {e}")
954
+ sys.exit(1)
955
+ header = [("Tier", tier.label)]
956
+ if payload.get("email"):
957
+ header.append(("Account", str(payload["email"])))
958
+ header.append(("Expires", _format_exp(payload)))
959
+ header.append(("License", str(path)))
960
+ rows = [(unlocked, desc) for _f, desc, _need, unlocked in licensing.features_matrix(tier)]
961
+ branding.license_panel("License activated", header, rows)
962
+
963
+
964
+ @main.command()
965
+ @click.pass_context
966
+ def account(ctx: click.Context) -> None:
967
+ _show_account(ctx.obj["settings"])
968
+
969
+
970
+ @main.command()
971
+ @click.pass_context
972
+ def whoami(ctx: click.Context) -> None:
973
+ _show_account(ctx.obj["settings"])
974
+
975
+
976
+ @main.command()
977
+ @click.option("--yes", "-y", "assume_yes", is_flag=True, help="Skip the confirmation prompt.")
978
+ @click.pass_context
979
+ def deactivate(ctx: click.Context, assume_yes: bool) -> None:
980
+ path = licensing.license_file_path()
981
+ if not path.exists():
982
+ branding.info("no stored license; already on Free.")
983
+ return
984
+ if not assume_yes and not click.confirm(f"Remove the license at {path} and revert to Free?"):
985
+ branding.info("kept the current license.")
986
+ return
987
+ if licensing.remove_license():
988
+ branding.info("license removed; you are now on Free.")
989
+ else:
990
+ branding.error("could not remove the license file.")
991
+ sys.exit(1)
992
+
993
+
994
+ @main.command()
995
+ def upgrade() -> None:
996
+ import webbrowser
997
+
998
+ url = licensing.upgrade_url()
999
+ try:
1000
+ opened = bool(webbrowser.open(url))
1001
+ except Exception:
1002
+ opened = False
1003
+ branding.upgrade_link(url, opened)
1004
+
1005
+
824
1006
  if __name__ == "__main__":
825
1007
  main()
@@ -24,12 +24,9 @@ import time
24
24
  from enum import IntEnum
25
25
  from pathlib import Path
26
26
 
27
- # Issuer public key (Ed25519, raw, base64). The matching private key is the
28
- # issuance secret and is never shipped.
29
27
  _EMBEDDED_PUBKEY = "BdxSRIB5F2K2bXf7c0UqsR6jC/cSRMSxojpzRpTCUQg="
30
28
 
31
- # Paid features -> minimum tier required.
32
- _FEATURE_TIER: dict[str, "Tier"] = {}
29
+ DEFAULT_PRICING_URL = "https://deepparallel.dev/pricing"
33
30
 
34
31
 
35
32
  class Tier(IntEnum):
@@ -42,12 +39,24 @@ class Tier(IntEnum):
42
39
  return {0: "Free", 1: "Pro", 2: "Team"}[int(self)]
43
40
 
44
41
 
42
+ FEATURE_INFO: dict[str, tuple["Tier", str]] = {
43
+ "fusion": (Tier.PRO, "Reasoner-plus-answerer fusion (//fuse, //escalate)"),
44
+ "deep": (Tier.PRO, "The --deep multi-model council with a judge"),
45
+ "dual": (Tier.PRO, "Side-by-side dual-model compare (--dual)"),
46
+ "guardian": (Tier.PRO, "Guardian edit review before changes apply"),
47
+ "mesh": (Tier.PRO, "Verification mesh: a parallel reviewer panel per edit"),
48
+ "review": (Tier.PRO, "Hosted cross-model code review gate (dp review)"),
49
+ "audit": (Tier.PRO, "Supply-chain audit gate for hallucinated deps (dp audit)"),
50
+ }
51
+
52
+ _FEATURE_TIER: dict[str, "Tier"] = {name: tier for name, (tier, _desc) in FEATURE_INFO.items()}
53
+
54
+
45
55
  def _b64url_decode(s: str) -> bytes:
46
56
  return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
47
57
 
48
58
 
49
59
  def verify_token(token: str, pubkey_b64: str | None = None) -> dict | None:
50
- """Return the payload dict if the token is validly signed and unexpired."""
51
60
  if not token or "." not in token:
52
61
  return None
53
62
  try:
@@ -58,7 +67,7 @@ def verify_token(token: str, pubkey_b64: str | None = None) -> dict | None:
58
67
  try:
59
68
  pub = Ed25519PublicKey.from_public_bytes(_b64url_decode(pubkey_b64 or _EMBEDDED_PUBKEY))
60
69
  pub.verify(_b64url_decode(sig), body.encode())
61
- except Exception: # noqa: BLE001 - any failure (bad sig/key/encoding) = invalid
70
+ except Exception:
62
71
  return None
63
72
  try:
64
73
  payload = json.loads(_b64url_decode(body))
@@ -71,11 +80,6 @@ def verify_token(token: str, pubkey_b64: str | None = None) -> dict | None:
71
80
 
72
81
 
73
82
  def sign_token(payload: dict, private_key_b64: str) -> str:
74
- """Sign a license payload into a `body.signature` token (issuer-side).
75
-
76
- Requires the private issuance key; verify_token() checks it with the public
77
- key. Used by the license-issuer (Stripe webhook) and the admin CLI.
78
- """
79
83
  from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
80
84
 
81
85
  priv = Ed25519PrivateKey.from_private_bytes(_b64url_decode_std(private_key_b64))
@@ -85,35 +89,39 @@ def sign_token(payload: dict, private_key_b64: str) -> str:
85
89
 
86
90
 
87
91
  def _b64url_decode_std(s: str) -> bytes:
88
- """Decode standard OR url-safe base64 (keys are emitted as standard b64)."""
89
92
  s = s.strip()
90
93
  pad = "=" * (-len(s) % 4)
91
94
  try:
92
95
  return base64.b64decode(s + pad)
93
- except Exception: # noqa: BLE001
96
+ except Exception:
94
97
  return base64.urlsafe_b64decode(s + pad)
95
98
 
96
99
 
97
- def _license_file_token() -> str | None:
98
- path = Path(
100
+ def license_file_path() -> Path:
101
+ return Path(
99
102
  os.environ.get("DEEPPARALLEL_LICENSE_FILE", "~/.config/deepparallel/license")
100
103
  ).expanduser()
104
+
105
+
106
+ def _license_file_token() -> str | None:
101
107
  try:
102
- return path.read_text().strip() or None
108
+ return license_file_path().read_text().strip() or None
103
109
  except OSError:
104
110
  return None
105
111
 
106
112
 
113
+ def tier_from_payload(payload: dict | None) -> Tier:
114
+ if not payload:
115
+ return Tier.FREE
116
+ name = str(payload.get("tier", "")).upper()
117
+ return Tier[name] if name in Tier.__members__ else Tier.FREE
118
+
119
+
107
120
  def resolve_tier() -> Tier:
108
- """Resolve the active tier from the license env var or file. FREE on absence/failure."""
109
121
  token = os.environ.get("DEEPPARALLEL_LICENSE") or _license_file_token()
110
122
  if not token:
111
123
  return Tier.FREE
112
- payload = verify_token(token)
113
- if not payload:
114
- return Tier.FREE
115
- name = str(payload.get("tier", "")).upper()
116
- return getattr(Tier, name, Tier.FREE) if name in Tier.__members__ else Tier.FREE
124
+ return tier_from_payload(verify_token(token))
117
125
 
118
126
 
119
127
  def tier_allows(have: Tier, need: Tier) -> bool:
@@ -121,12 +129,69 @@ def tier_allows(have: Tier, need: Tier) -> bool:
121
129
 
122
130
 
123
131
  def check_feature(feature: str, have: Tier | None = None) -> tuple[bool, str]:
124
- """Return (allowed, message). Paid features need PRO+; message nudges upgrade."""
125
132
  have = resolve_tier() if have is None else have
126
133
  need = _FEATURE_TIER.get(feature, Tier.PRO)
127
134
  if tier_allows(have, need):
128
135
  return True, ""
129
136
  return False, (
130
137
  f"{feature} requires DeepParallel {need.label} (you are on {have.label}). "
131
- f"Upgrade at https://deepparallel.dev/pricing"
138
+ f"Upgrade at {upgrade_url()}"
132
139
  )
140
+
141
+
142
+ def feature_tier(feature: str) -> Tier:
143
+ return _FEATURE_TIER.get(feature, Tier.PRO)
144
+
145
+
146
+ def feature_unlocks(feature: str) -> str:
147
+ info = FEATURE_INFO.get(feature)
148
+ return info[1] if info else feature
149
+
150
+
151
+ def features_matrix(have: Tier | None = None) -> list[tuple[str, str, Tier, bool]]:
152
+ have = resolve_tier() if have is None else have
153
+ return [
154
+ (feature, desc, need, tier_allows(have, need))
155
+ for feature, (need, desc) in FEATURE_INFO.items()
156
+ ]
157
+
158
+
159
+ def upgrade_url() -> str:
160
+ return (
161
+ os.environ.get("DEEPPARALLEL_CHECKOUT_URL")
162
+ or os.environ.get("DEEPPARALLEL_PRICING_URL")
163
+ or DEFAULT_PRICING_URL
164
+ )
165
+
166
+
167
+ def write_license(token: str) -> Path:
168
+ path = license_file_path()
169
+ path.parent.mkdir(parents=True, exist_ok=True)
170
+ try:
171
+ os.chmod(path.parent, 0o700)
172
+ except OSError:
173
+ pass
174
+ path.write_text(token.strip() + "\n")
175
+ try:
176
+ os.chmod(path, 0o600)
177
+ except OSError:
178
+ pass
179
+ return path
180
+
181
+
182
+ def remove_license() -> bool:
183
+ try:
184
+ license_file_path().unlink()
185
+ return True
186
+ except (FileNotFoundError, OSError):
187
+ return False
188
+
189
+
190
+ def active_license() -> tuple[str, dict | None]:
191
+ env_token = os.environ.get("DEEPPARALLEL_LICENSE")
192
+ if env_token:
193
+ return "env", verify_token(env_token)
194
+ file_token = _license_file_token()
195
+ if file_token:
196
+ return "file", verify_token(file_token)
197
+ return "none", None