dyno-phi 0.2.0__tar.gz → 0.2.3__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 (77) hide show
  1. {dyno_phi-0.2.0 → dyno_phi-0.2.3}/.claude/skills/phi/SKILL.md +0 -2
  2. {dyno_phi-0.2.0 → dyno_phi-0.2.3}/CHANGELOG.md +23 -0
  3. {dyno_phi-0.2.0 → dyno_phi-0.2.3}/CLI-REFERENCE.md +1 -2
  4. {dyno_phi-0.2.0 → dyno_phi-0.2.3}/PKG-INFO +18 -26
  5. {dyno_phi-0.2.0 → dyno_phi-0.2.3}/README.md +17 -25
  6. {dyno_phi-0.2.0 → dyno_phi-0.2.3}/phi-plugin/skills/phi/SKILL.md +0 -2
  7. dyno_phi-0.2.3/src/phi/_version.py +1 -0
  8. {dyno_phi-0.2.0 → dyno_phi-0.2.3}/src/phi/cli.py +2 -0
  9. {dyno_phi-0.2.0 → dyno_phi-0.2.3}/src/phi/commands/models.py +12 -0
  10. dyno_phi-0.2.3/src/phi/commands/research.py +90 -0
  11. {dyno_phi-0.2.0 → dyno_phi-0.2.3}/src/phi/commands/structure.py +2 -4
  12. dyno_phi-0.2.3/src/phi/commands/tutorial.py +88 -0
  13. {dyno_phi-0.2.0 → dyno_phi-0.2.3}/src/phi/display.py +2 -1
  14. {dyno_phi-0.2.0 → dyno_phi-0.2.3}/src/phi/parser.py +48 -19
  15. {dyno_phi-0.2.0 → dyno_phi-0.2.3}/src/phi/research.py +6 -5
  16. {dyno_phi-0.2.0 → dyno_phi-0.2.3}/tests/test_research.py +26 -0
  17. dyno_phi-0.2.0/src/phi/_version.py +0 -1
  18. dyno_phi-0.2.0/src/phi/commands/research.py +0 -145
  19. {dyno_phi-0.2.0 → dyno_phi-0.2.3}/.claude/settings.json +0 -0
  20. {dyno_phi-0.2.0 → dyno_phi-0.2.3}/.claude-plugin/marketplace.json +0 -0
  21. {dyno_phi-0.2.0 → dyno_phi-0.2.3}/.github/workflows/ci.yml +0 -0
  22. {dyno_phi-0.2.0 → dyno_phi-0.2.3}/.github/workflows/publish.yml +0 -0
  23. {dyno_phi-0.2.0 → dyno_phi-0.2.3}/.gitignore +0 -0
  24. {dyno_phi-0.2.0 → dyno_phi-0.2.3}/.pre-commit-config.yaml +0 -0
  25. {dyno_phi-0.2.0 → dyno_phi-0.2.3}/LICENSE +0 -0
  26. {dyno_phi-0.2.0 → dyno_phi-0.2.3}/biomodals/TEMPLATE_NEW_BIOMODAL.py +0 -0
  27. {dyno_phi-0.2.0 → dyno_phi-0.2.3}/biomodals/__init__.py +0 -0
  28. {dyno_phi-0.2.0 → dyno_phi-0.2.3}/biomodals/base.py +0 -0
  29. {dyno_phi-0.2.0 → dyno_phi-0.2.3}/biomodals/modal_af2rank.py +0 -0
  30. {dyno_phi-0.2.0 → dyno_phi-0.2.3}/biomodals/modal_align_structures.py +0 -0
  31. {dyno_phi-0.2.0 → dyno_phi-0.2.3}/biomodals/modal_alphafold.py +0 -0
  32. {dyno_phi-0.2.0 → dyno_phi-0.2.3}/biomodals/modal_bindcraft.py +0 -0
  33. {dyno_phi-0.2.0 → dyno_phi-0.2.3}/biomodals/modal_boltz.py +0 -0
  34. {dyno_phi-0.2.0 → dyno_phi-0.2.3}/biomodals/modal_boltzgen.py +0 -0
  35. {dyno_phi-0.2.0 → dyno_phi-0.2.3}/biomodals/modal_chai1.py +0 -0
  36. {dyno_phi-0.2.0 → dyno_phi-0.2.3}/biomodals/modal_esm2_predict_masked.py +0 -0
  37. {dyno_phi-0.2.0 → dyno_phi-0.2.3}/biomodals/modal_esmfold.py +0 -0
  38. {dyno_phi-0.2.0 → dyno_phi-0.2.3}/biomodals/modal_ligandmpnn.py +0 -0
  39. {dyno_phi-0.2.0 → dyno_phi-0.2.3}/biomodals/modal_proteinmpnn.py +0 -0
  40. {dyno_phi-0.2.0 → dyno_phi-0.2.3}/biomodals/modal_rf3.py +0 -0
  41. {dyno_phi-0.2.0 → dyno_phi-0.2.3}/biomodals/modal_rfdiffusion3.py +0 -0
  42. {dyno_phi-0.2.0 → dyno_phi-0.2.3}/biomodals/modal_rso.py +0 -0
  43. {dyno_phi-0.2.0 → dyno_phi-0.2.3}/biomodals/modal_tm_score.py +0 -0
  44. {dyno_phi-0.2.0 → dyno_phi-0.2.3}/biomodals/utils/__init__.py +0 -0
  45. {dyno_phi-0.2.0 → dyno_phi-0.2.3}/biomodals/utils/extract_chain_as_mol2.py +0 -0
  46. {dyno_phi-0.2.0 → dyno_phi-0.2.3}/biomodals/utils/extract_ligands.py +0 -0
  47. {dyno_phi-0.2.0 → dyno_phi-0.2.3}/examples/README.md +0 -0
  48. {dyno_phi-0.2.0 → dyno_phi-0.2.3}/examples/binders/pdl1/binder_009.pdb +0 -0
  49. {dyno_phi-0.2.0 → dyno_phi-0.2.3}/examples/binders/pdl1/binder_047.pdb +0 -0
  50. {dyno_phi-0.2.0 → dyno_phi-0.2.3}/examples/binders/pdl1/binder_106.pdb +0 -0
  51. {dyno_phi-0.2.0 → dyno_phi-0.2.3}/examples/binders/pdl1/binder_188.pdb +0 -0
  52. {dyno_phi-0.2.0 → dyno_phi-0.2.3}/examples/binders/pdl1/binder_246.pdb +0 -0
  53. {dyno_phi-0.2.0 → dyno_phi-0.2.3}/examples/fixtures/pdl1/5O45.pdb +0 -0
  54. {dyno_phi-0.2.0 → dyno_phi-0.2.3}/examples/fixtures/pdl1/pdl1_pdb_19_132.pdb +0 -0
  55. {dyno_phi-0.2.0 → dyno_phi-0.2.3}/phi-plugin/.claude-plugin/plugin.json +0 -0
  56. {dyno_phi-0.2.0 → dyno_phi-0.2.3}/phi-plugin/README.md +0 -0
  57. {dyno_phi-0.2.0 → dyno_phi-0.2.3}/pyproject.toml +0 -0
  58. {dyno_phi-0.2.0 → dyno_phi-0.2.3}/src/phi/__init__.py +0 -0
  59. {dyno_phi-0.2.0 → dyno_phi-0.2.3}/src/phi/api.py +0 -0
  60. {dyno_phi-0.2.0 → dyno_phi-0.2.3}/src/phi/commands/__init__.py +0 -0
  61. {dyno_phi-0.2.0 → dyno_phi-0.2.3}/src/phi/commands/auth.py +0 -0
  62. {dyno_phi-0.2.0 → dyno_phi-0.2.3}/src/phi/commands/datasets.py +0 -0
  63. {dyno_phi-0.2.0 → dyno_phi-0.2.3}/src/phi/commands/filter.py +0 -0
  64. {dyno_phi-0.2.0 → dyno_phi-0.2.3}/src/phi/commands/jobs.py +0 -0
  65. {dyno_phi-0.2.0 → dyno_phi-0.2.3}/src/phi/config.py +0 -0
  66. {dyno_phi-0.2.0 → dyno_phi-0.2.3}/src/phi/download.py +0 -0
  67. {dyno_phi-0.2.0 → dyno_phi-0.2.3}/src/phi/polling.py +0 -0
  68. {dyno_phi-0.2.0 → dyno_phi-0.2.3}/src/phi/structure.py +0 -0
  69. {dyno_phi-0.2.0 → dyno_phi-0.2.3}/src/phi/types.py +0 -0
  70. {dyno_phi-0.2.0 → dyno_phi-0.2.3}/src/phi/upload.py +0 -0
  71. {dyno_phi-0.2.0 → dyno_phi-0.2.3}/tests/__init__.py +0 -0
  72. {dyno_phi-0.2.0 → dyno_phi-0.2.3}/tests/test_api.py +0 -0
  73. {dyno_phi-0.2.0 → dyno_phi-0.2.3}/tests/test_config.py +0 -0
  74. {dyno_phi-0.2.0 → dyno_phi-0.2.3}/tests/test_display.py +0 -0
  75. {dyno_phi-0.2.0 → dyno_phi-0.2.3}/tests/test_download.py +0 -0
  76. {dyno_phi-0.2.0 → dyno_phi-0.2.3}/tests/test_structure.py +0 -0
  77. {dyno_phi-0.2.0 → dyno_phi-0.2.3}/tests/test_upload.py +0 -0
@@ -63,8 +63,6 @@ phi research \
63
63
  --dataset-id <ID>
64
64
 
65
65
  # Stream results live
66
- phi research --question "..." --stream
67
-
68
66
  # Append notes to a local file and cloud storage
69
67
  phi research --question "..." --notes-file ./research.md --dataset-id <ID>
70
68
 
@@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.3] - 2026-03-17
11
+
12
+ ### Added
13
+ - `phi research` now accepts `--dataset_id` (underscore) as an alias for `--dataset-id` — consistent with all other commands
14
+ - `phi notes` command implemented: fetches and displays accumulated research notes for a dataset
15
+
16
+ ### Changed
17
+ - `phi design` (`rfdiffusion3`) and `phi boltzgen` no longer appear in `phi --help` or usage line — they remain accessible internally via `DYNO_ENABLE_DESIGN=1`
18
+
19
+ ### Fixed
20
+ - Missing imports (`_request`, `PhiApiError`, `Panel`, `Markdown`, `_C_BLUE`, `_C_SAND`, `json`) in `phi notes` command
21
+ - mypy `Cannot assign to a method` error replaced with a proper `_FilteredSubParsersAction` subclass
22
+
23
+ ## [0.2.2] - 2026-03-17
24
+
25
+ ### Added
26
+ - `phi tutorial` — downloads example PD-L1 binder structures from the API and prints a step-by-step scoring walkthrough
27
+
28
+ ### Fixed
29
+ - GCS internal paths (`gs://...`) no longer shown in status output, research notes, or `phi fetch` results
30
+ - `phi design` / `phi boltzgen` feature-flagged behind `DYNO_ENABLE_DESIGN=1` env var
31
+ - macOS SSL certificate error when downloading tutorial files via signed URLs
32
+
10
33
  ## [0.2.0] - 2026-03-17
11
34
 
12
35
  ### Added
@@ -5,7 +5,7 @@ Submit and monitor computational biology jobs, manage datasets, run structure
5
5
  prediction and inverse-folding pipelines, and download results — all from your
6
6
  terminal.
7
7
 
8
- **Version:** 0.2.0 · **Package:** `dyno-phi` · **Requires:** Python ≥ 3.9
8
+ **Version:** 0.2.2 · **Package:** `dyno-phi` · **Requires:** Python ≥ 3.9
9
9
 
10
10
  ---
11
11
 
@@ -679,7 +679,6 @@ phi research --question QUESTION [options]
679
679
  | `--dataset-id ID` | — | Associate notes with a dataset and sync to cloud storage |
680
680
  | `--notes-file FILE` | `./research.md` | Local append-only notes file |
681
681
  | `--no-save` | — | Skip saving the report to the local notes file |
682
- | `--stream` | — | Stream results live via SSE (skips job tracking) |
683
682
 
684
683
  **Examples:**
685
684
  ```bash
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dyno-phi
3
- Version: 0.2.0
3
+ Version: 0.2.3
4
4
  Summary: Phi CLI and biomodals for the dyno protein design platform
5
5
  Project-URL: Homepage, https://dynotx.com
6
6
  Project-URL: Repository, https://github.com/dynotx/phi-cli
@@ -97,43 +97,34 @@ phi login
97
97
 
98
98
  ## Quick start
99
99
 
100
- ### Single-sequence / single-structure jobs
100
+ ### Try the tutorial
101
101
 
102
- ```bash
103
- # Structure prediction (ESMFold)
104
- phi folding --fasta sequences.fasta
105
-
106
- # Complex structure prediction (AlphaFold2 multimer)
107
- phi complex_folding --fasta binder_target.fasta
102
+ The fastest way to get started — downloads five example PD-L1 binder structures
103
+ and walks you through the full pipeline:
108
104
 
109
- # Sequence design via inverse folding (ProteinMPNN)
110
- phi inverse_folding --pdb design.pdb --num-sequences 20
105
+ ```bash
106
+ phi tutorial
111
107
  ```
112
108
 
113
- ### Batch scoring workflow
109
+ This fetches the example files, prints step-by-step instructions, and leaves
110
+ you ready to run `phi filter`.
111
+
112
+ ### Scoring your own structures
114
113
 
115
114
  ```bash
116
- # 1. Upload a directory of PDB/CIF files
115
+ # 1. Upload PDB/CIF files
117
116
  phi upload ./designs/
118
117
 
119
- # Output:
120
- # dataset_id d7c3a1b2-...
121
- # Dashboard: https://design.dynotx.com/dashboard/datasets/d7c3a1b2-...
122
- # Run a job against this dataset:
123
- # phi folding --dataset-id d7c3a1b2-...
124
- # phi complex_folding --dataset-id d7c3a1b2-...
125
- # phi inverse_folding --dataset-id d7c3a1b2-...
126
- # phi filter --dataset-id d7c3a1b2-... --preset default --wait
127
-
128
- # 2. Run the full filter pipeline (inverse folding → folding → complex folding → score)
129
- phi filter --dataset-id d7c3a1b2-... --preset default --wait
118
+ # 2. Run the full filter pipeline
119
+ phi filter --preset default --wait
130
120
 
131
- # 3. Download results (structures, scores CSV, raw score JSONs)
121
+ # 3. View scores and download results
122
+ phi scores
132
123
  phi download --out ./results/
133
124
  ```
134
125
 
135
- After each command, `phi` prints the active dataset and job IDs and a link to
136
- the dashboard:
126
+ After each command, `phi` prints the active dataset and a link to the
127
+ dashboard:
137
128
 
138
129
  ```
139
130
  Active: dataset [d7c3a1b2-...] · job [cb4553f5-...]
@@ -146,6 +137,7 @@ Dashboard: https://design.dynotx.com/dashboard/datasets/d7c3a1b2-...
146
137
 
147
138
  | Command | Alias | Description |
148
139
  |---|---|---|
140
+ | `phi tutorial` | — | Download example structures and print a step-by-step walkthrough |
149
141
  | `phi login` | — | Verify API key and print identity |
150
142
  | `phi upload` | — | Upload PDB/CIF files or a directory |
151
143
  | `phi fetch` | — | Download a structure from RCSB PDB or AlphaFold DB, crop, and optionally upload |
@@ -56,43 +56,34 @@ phi login
56
56
 
57
57
  ## Quick start
58
58
 
59
- ### Single-sequence / single-structure jobs
59
+ ### Try the tutorial
60
60
 
61
- ```bash
62
- # Structure prediction (ESMFold)
63
- phi folding --fasta sequences.fasta
64
-
65
- # Complex structure prediction (AlphaFold2 multimer)
66
- phi complex_folding --fasta binder_target.fasta
61
+ The fastest way to get started — downloads five example PD-L1 binder structures
62
+ and walks you through the full pipeline:
67
63
 
68
- # Sequence design via inverse folding (ProteinMPNN)
69
- phi inverse_folding --pdb design.pdb --num-sequences 20
64
+ ```bash
65
+ phi tutorial
70
66
  ```
71
67
 
72
- ### Batch scoring workflow
68
+ This fetches the example files, prints step-by-step instructions, and leaves
69
+ you ready to run `phi filter`.
70
+
71
+ ### Scoring your own structures
73
72
 
74
73
  ```bash
75
- # 1. Upload a directory of PDB/CIF files
74
+ # 1. Upload PDB/CIF files
76
75
  phi upload ./designs/
77
76
 
78
- # Output:
79
- # dataset_id d7c3a1b2-...
80
- # Dashboard: https://design.dynotx.com/dashboard/datasets/d7c3a1b2-...
81
- # Run a job against this dataset:
82
- # phi folding --dataset-id d7c3a1b2-...
83
- # phi complex_folding --dataset-id d7c3a1b2-...
84
- # phi inverse_folding --dataset-id d7c3a1b2-...
85
- # phi filter --dataset-id d7c3a1b2-... --preset default --wait
86
-
87
- # 2. Run the full filter pipeline (inverse folding → folding → complex folding → score)
88
- phi filter --dataset-id d7c3a1b2-... --preset default --wait
77
+ # 2. Run the full filter pipeline
78
+ phi filter --preset default --wait
89
79
 
90
- # 3. Download results (structures, scores CSV, raw score JSONs)
80
+ # 3. View scores and download results
81
+ phi scores
91
82
  phi download --out ./results/
92
83
  ```
93
84
 
94
- After each command, `phi` prints the active dataset and job IDs and a link to
95
- the dashboard:
85
+ After each command, `phi` prints the active dataset and a link to the
86
+ dashboard:
96
87
 
97
88
  ```
98
89
  Active: dataset [d7c3a1b2-...] · job [cb4553f5-...]
@@ -105,6 +96,7 @@ Dashboard: https://design.dynotx.com/dashboard/datasets/d7c3a1b2-...
105
96
 
106
97
  | Command | Alias | Description |
107
98
  |---|---|---|
99
+ | `phi tutorial` | — | Download example structures and print a step-by-step walkthrough |
108
100
  | `phi login` | — | Verify API key and print identity |
109
101
  | `phi upload` | — | Upload PDB/CIF files or a directory |
110
102
  | `phi fetch` | — | Download a structure from RCSB PDB or AlphaFold DB, crop, and optionally upload |
@@ -63,8 +63,6 @@ phi research \
63
63
  --dataset-id <ID>
64
64
 
65
65
  # Stream results live
66
- phi research --question "..." --stream
67
-
68
66
  # Append notes to a local file and cloud storage
69
67
  phi research --question "..." --notes-file ./research.md --dataset-id <ID>
70
68
 
@@ -0,0 +1 @@
1
+ __version__ = "0.2.3"
@@ -16,6 +16,7 @@ from phi.commands.models import (
16
16
  )
17
17
  from phi.commands.research import cmd_notes, cmd_research
18
18
  from phi.commands.structure import cmd_fetch
19
+ from phi.commands.tutorial import cmd_tutorial
19
20
  from phi.config import _load_state
20
21
  from phi.display import _C_BLUE, _die, console
21
22
  from phi.parser import build_parser
@@ -23,6 +24,7 @@ from phi.types import PhiApiError
23
24
 
24
25
  COMMANDS = {
25
26
  "login": cmd_login,
27
+ "tutorial": cmd_tutorial,
26
28
  "upload": cmd_upload,
27
29
  "ingest-session": cmd_ingest_session,
28
30
  "datasets": cmd_datasets,
@@ -1,4 +1,7 @@
1
+ from __future__ import annotations
2
+
1
3
  import argparse
4
+ import os
2
5
  from pathlib import Path
3
6
 
4
7
  from phi.api import _require_key, _submit
@@ -6,6 +9,13 @@ from phi.display import _die, _print_status, _print_submission, console
6
9
  from phi.download import _download_job, _read_fasta
7
10
  from phi.polling import _poll
8
11
 
12
+ _DESIGN_ENABLED = os.environ.get("DYNO_ENABLE_DESIGN", "").lower() in ("1", "true", "yes")
13
+
14
+
15
+ def _require_design_flag() -> None:
16
+ if not _DESIGN_ENABLED:
17
+ _die("This command is not yet available.")
18
+
9
19
 
10
20
  def _run_model_job(job_type: str, params: dict, args: argparse.Namespace) -> None:
11
21
  from phi.config import POLL_INTERVAL as _INTERVAL
@@ -92,6 +102,7 @@ def cmd_boltz(args: argparse.Namespace) -> None:
92
102
 
93
103
 
94
104
  def cmd_rfdiffusion3(args: argparse.Namespace) -> None:
105
+ _require_design_flag()
95
106
  params: dict = {
96
107
  "num_designs": args.num_designs,
97
108
  "inference_steps": args.steps,
@@ -129,6 +140,7 @@ def cmd_rfdiffusion3(args: argparse.Namespace) -> None:
129
140
 
130
141
 
131
142
  def cmd_boltzgen(args: argparse.Namespace) -> None:
143
+ _require_design_flag()
132
144
  params: dict = {
133
145
  "protocol": args.protocol,
134
146
  "num_designs": args.num_designs,
@@ -0,0 +1,90 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import os
6
+ from pathlib import Path
7
+
8
+ from rich.markdown import Markdown
9
+ from rich.panel import Panel
10
+
11
+ from phi.api import _request
12
+ from phi.display import _C_BLUE, _C_ROSE, _C_SAND, _die, console
13
+ from phi.research import _stream_research
14
+ from phi.types import PhiApiError
15
+
16
+ _ERROR_REPORT_PREFIXES = ("# Research Failed", "# Error", "Error: Command failed")
17
+
18
+
19
+ def _is_error_report(report: str) -> bool:
20
+ stripped = report.strip()
21
+ return any(stripped.startswith(prefix) for prefix in _ERROR_REPORT_PREFIXES)
22
+
23
+
24
+ def cmd_research(args: argparse.Namespace) -> None:
25
+ notes_file: Path | None = None if args.no_save else Path(args.notes_file)
26
+ dataset_id: str | None = args.dataset_id
27
+
28
+ stream_url = os.environ.get("DYNO_RESEARCH_STREAM_URL") or args.url
29
+
30
+ context: str | None = None
31
+ if args.context_file:
32
+ try:
33
+ prior = Path(args.context_file).read_text(encoding="utf-8")
34
+ existing = args.context or ""
35
+ context = (prior + "\n\n" + existing).strip() if existing else prior
36
+ except Exception as exc:
37
+ console.print(f"[{_C_ROSE}]Warning: could not read context file:[/] {exc}")
38
+ elif args.context:
39
+ context = args.context
40
+
41
+ _stream_research(
42
+ question=args.question,
43
+ base_url=stream_url,
44
+ notes_file=notes_file,
45
+ dataset_id=dataset_id,
46
+ context=context,
47
+ )
48
+
49
+
50
+ def cmd_notes(args: argparse.Namespace) -> None:
51
+ dataset_id: str = args.dataset_id
52
+
53
+ try:
54
+ data = _request("GET", f"/datasets/{dataset_id}/research-notes")
55
+ except PhiApiError as exc:
56
+ _die(str(exc))
57
+
58
+ if not data.get("exists"):
59
+ console.print(f"[{_C_ROSE}]No research notes found[/] for dataset {dataset_id}")
60
+ return
61
+
62
+ content: str = data.get("content") or ""
63
+ data.get("gcs_url")
64
+ data.get("gcs_uri")
65
+
66
+ if args.out:
67
+ out = Path(args.out)
68
+ if out.suffix.lower() == ".md":
69
+ dest = out
70
+ else:
71
+ out.mkdir(parents=True, exist_ok=True)
72
+ dest = out / "research.md"
73
+ dest.parent.mkdir(parents=True, exist_ok=True)
74
+ dest.write_text(content, encoding="utf-8")
75
+ console.print(f"[{_C_SAND}]Notes saved[/] → {dest}")
76
+ return
77
+
78
+ if args.json:
79
+ print(json.dumps(data, indent=2))
80
+ return
81
+
82
+ console.print()
83
+ console.print(
84
+ Panel(
85
+ Markdown(content),
86
+ title=f"[{_C_BLUE}]Research Notes[/] — {dataset_id[:8]}…",
87
+ border_style=_C_BLUE,
88
+ padding=(1, 2),
89
+ )
90
+ )
@@ -153,14 +153,12 @@ def _fetch_and_upload(
153
153
  ("Source ", source_url),
154
154
  ("File ", str(out_path)),
155
155
  ("Dataset ", dataset_id),
156
- ("GCS URI ", gcs_uri),
157
156
  ]:
158
157
  console.print(f" [bold]{label}[/bold] {value}")
159
158
  console.print()
160
159
  console.print(" [dim]Next steps:[/dim]")
161
- console.print(
162
- f" [dim] phi design --target-pdb-gcs {gcs_uri} --hotspots <A45,A67> --num-designs 50[/dim]"
163
- )
160
+ console.print(f" [dim] phi upload {out_path}[/dim]")
161
+ console.print(" [dim] phi filter --preset default --wait[/dim]")
164
162
  console.rule()
165
163
 
166
164
 
@@ -0,0 +1,88 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import urllib.request
5
+ from pathlib import Path
6
+
7
+ from phi.api import _request
8
+ from phi.config import _save_state, _ssl_context
9
+ from phi.display import _C_BLUE, _C_SAND, _die, console
10
+
11
+
12
+ def cmd_tutorial(args: argparse.Namespace) -> None:
13
+ out = Path(args.out)
14
+
15
+ # ── 1. Fetch manifest (standard Clerk JWT auth, same as all endpoints) ───
16
+ console.print("[dim]Fetching tutorial dataset …[/]")
17
+ try:
18
+ manifest = _request("GET", "/tutorial")
19
+ except Exception as exc:
20
+ _die(
21
+ f"Could not reach the tutorial endpoint: {exc}\n"
22
+ " Check your connection and API key, then try again."
23
+ )
24
+
25
+ files: list[dict] = manifest.get("files", [])
26
+ dataset_id: str | None = manifest.get("dataset_id")
27
+ message: str | None = manifest.get("message")
28
+
29
+ if not files:
30
+ _die("No tutorial files returned by the API.")
31
+
32
+ # ── 2. Download each file (plain HTTP — signed URLs are self-authenticating)
33
+ out.mkdir(parents=True, exist_ok=True)
34
+ console.print(f" Downloading {len(files)} file(s) to [{_C_BLUE}]{out}/[/] …\n")
35
+
36
+ for entry in files:
37
+ filename: str = entry["filename"]
38
+ url: str = entry["url"]
39
+ dest = out / filename
40
+ dest.parent.mkdir(parents=True, exist_ok=True)
41
+ try:
42
+ req = urllib.request.Request(url)
43
+ with urllib.request.urlopen(req, context=_ssl_context()) as resp:
44
+ dest.write_bytes(resp.read())
45
+ console.print(f" [bold {_C_SAND}]✓[/] {filename}")
46
+ except Exception as exc:
47
+ _die(f"Failed to download {filename}: {exc}")
48
+
49
+ # ── 3. Cache dataset_id so phi filter needs zero extra flags ─────────────
50
+ if dataset_id:
51
+ _save_state({"dataset_id": dataset_id})
52
+ console.print(
53
+ f"\n[dim]dataset_id [{_C_BLUE}]{dataset_id}[/] cached — "
54
+ f"run [bold]phi filter[/] to start scoring.[/]"
55
+ )
56
+
57
+ # ── 4. Print step-by-step guide ──────────────────────────────────────────
58
+ if message:
59
+ console.print(f"\n[dim]{message}[/]")
60
+
61
+ if dataset_id:
62
+ upload_step = "[dim] (skipped — dataset already ready)[/]"
63
+ else:
64
+ upload_step = f" [{_C_SAND}]phi upload {out}/[/]"
65
+
66
+ console.print(f"""
67
+ [bold]── Tutorial: PD-L1 binder scoring pipeline ──────────────────[/]
68
+
69
+ You have {len(files)} example binder structures in [{_C_BLUE}]{out}/[/].
70
+
71
+ [bold]Step 1 — Upload[/]
72
+ {upload_step}
73
+
74
+ [bold]Step 2 — Run the filter pipeline[/]
75
+ [{_C_SAND}]phi filter --preset default --wait[/]
76
+
77
+ Runs: ProteinMPNN → ESMFold → AlphaFold2 → score
78
+ Typical runtime: 10–30 min for {len(files)} structures.
79
+
80
+ [bold]Step 3 — View scores[/]
81
+ [{_C_SAND}]phi scores[/]
82
+
83
+ [bold]Step 4 — Download results[/]
84
+ [{_C_SAND}]phi download --out ./results[/]
85
+
86
+ [bold]Dashboard[/]
87
+ [{_C_BLUE}]https://design.dynotx.com/dashboard[/]
88
+ """)
@@ -272,7 +272,8 @@ def _print_status(s: dict) -> None:
272
272
  table.add_column("filename", style=_C_BLUE, no_wrap=True)
273
273
  table.add_column("type", style="dim")
274
274
  for f in files[:10]:
275
- fname = f.get("filename") or f.get("gcs_url", "?")
275
+ raw = f.get("filename") or f.get("gcs_url", "?")
276
+ fname = raw.split("/")[-1] if raw.startswith("gs://") else raw
276
277
  ftype = f.get("artifact_type", "")
277
278
  table.add_row(fname, ftype)
278
279
  if len(files) > 10:
@@ -1,18 +1,21 @@
1
+ from __future__ import annotations
2
+
1
3
  import argparse
2
4
 
3
5
  from phi._version import __version__
4
6
  from phi.config import _FILTER_PRESETS, POLL_INTERVAL
5
7
 
6
8
  _CLI_EPILOG = """\
9
+ Quick start:
10
+ phi tutorial # download example structures + print step-by-step guide
11
+ phi filter --preset default --wait
12
+ phi scores
13
+ phi download --out ./results
14
+
7
15
  Fetch and prepare target structures:
8
16
  phi fetch --pdb 4ZQK --chain A --residues 56-290 --out target.pdb
9
17
  phi fetch --uniprot Q9NZQ7 --trim-low-confidence 70 --upload
10
18
 
11
- Design (backbone generation):
12
- phi design --target-pdb target.pdb --hotspots A45,A67 --num-designs 50
13
- phi design --length 80 --num-designs 20
14
- phi boltzgen --yaml design.yaml --protocol protein-anything --num-designs 10
15
-
16
19
  Validation (fold + score):
17
20
  phi esmfold --fasta sequences.fasta
18
21
  phi alphafold --fasta complex.fasta
@@ -20,10 +23,10 @@ Validation (fold + score):
20
23
  phi esm2 --fasta sequences.fasta
21
24
  phi boltz --fasta complex.fasta
22
25
 
23
- Batch filter pipeline (100-50,000 designs):
24
- phi upload --dir ./designs/ --file-type pdb
25
- phi filter --dataset-id <id> --preset default --wait
26
- phi download --out ./results
26
+ Batch filter pipeline:
27
+ phi upload ./designs/
28
+ phi filter --preset default --wait
29
+ phi download --out ./results
27
30
 
28
31
  Dataset management:
29
32
  phi datasets # list your datasets
@@ -43,6 +46,8 @@ def _add_fasta_args(p: argparse.ArgumentParser) -> None:
43
46
  g.add_argument("--fasta-str", metavar="FASTA", help="FASTA content as a string (for scripting)")
44
47
  g.add_argument(
45
48
  "--dataset-id",
49
+ "--dataset_id",
50
+ dest="dataset_id",
46
51
  metavar="DATASET_ID",
47
52
  help="Pre-ingested dataset ID (for batch runs of 100–50,000 files)",
48
53
  )
@@ -54,6 +59,8 @@ def _add_pdb_args(p: argparse.ArgumentParser) -> None:
54
59
  g.add_argument("--pdb-gcs", metavar="URI", help="Cloud storage URI to PDB (gs://…)")
55
60
  g.add_argument(
56
61
  "--dataset-id",
62
+ "--dataset_id",
63
+ dest="dataset_id",
57
64
  metavar="DATASET_ID",
58
65
  help="Pre-ingested dataset ID (for batch runs of 100–50,000 files)",
59
66
  )
@@ -232,6 +239,13 @@ def _add_boltzgen_args(p: argparse.ArgumentParser) -> None:
232
239
  )
233
240
 
234
241
 
242
+ class _FilteredSubParsersAction(argparse._SubParsersAction):
243
+ """Subparser action that omits choices with help=SUPPRESS from help output."""
244
+
245
+ def _get_subactions(self) -> list[argparse.Action]:
246
+ return [a for a in self._choices_actions if a.help is not argparse.SUPPRESS]
247
+
248
+
235
249
  def build_parser() -> argparse.ArgumentParser:
236
250
  root = argparse.ArgumentParser(
237
251
  prog="phi",
@@ -251,8 +265,20 @@ def build_parser() -> argparse.ArgumentParser:
251
265
  metavar="S",
252
266
  help=f"Seconds between status-poll requests (default: {POLL_INTERVAL})",
253
267
  )
268
+ root.register("action", "parsers", _FilteredSubParsersAction)
254
269
  sub = root.add_subparsers(dest="command", required=True)
255
270
 
271
+ p = sub.add_parser(
272
+ "tutorial",
273
+ help="Download example structures and print a step-by-step scoring walkthrough",
274
+ )
275
+ p.add_argument(
276
+ "--out",
277
+ metavar="DIR",
278
+ default="examples",
279
+ help="Directory to download example files into (default: ./examples)",
280
+ )
281
+
256
282
  p = sub.add_parser("login", help="Verify API key and print connection + identity details")
257
283
  p.add_argument("--json", action="store_true")
258
284
 
@@ -415,14 +441,14 @@ def build_parser() -> argparse.ArgumentParser:
415
441
  p = sub.add_parser(
416
442
  "rfdiffusion3",
417
443
  aliases=["design"],
418
- help="All-atom backbone generation: binder design, de novo, motif scaffolding (2–5 min/design)",
444
+ help=argparse.SUPPRESS,
419
445
  )
420
446
  _add_rfdiffusion3_args(p)
421
447
  _add_job_args(p)
422
448
 
423
449
  p = sub.add_parser(
424
450
  "boltzgen",
425
- help="All-atom generative design from a YAML spec — proteins, peptides, small molecules (10–20 min)",
451
+ help=argparse.SUPPRESS,
426
452
  )
427
453
  _add_boltzgen_args(p)
428
454
  _add_job_args(p)
@@ -469,7 +495,9 @@ def build_parser() -> argparse.ArgumentParser:
469
495
  )
470
496
  p.add_argument(
471
497
  "--dataset-id",
498
+ "--dataset_id",
472
499
  metavar="ID",
500
+ dest="dataset_id",
473
501
  help="Associate notes with a dataset and sync to cloud storage",
474
502
  )
475
503
  p.add_argument(
@@ -482,17 +510,12 @@ def build_parser() -> argparse.ArgumentParser:
482
510
  "--no-save", action="store_true", help="Skip saving the report to the local notes file"
483
511
  )
484
512
  p.add_argument(
485
- "--stream",
486
- action="store_true",
487
- help="Stream results live from Modal SSE endpoint (skips job tracking)",
488
- )
489
- p.add_argument(
490
- "--stream-url",
513
+ "--url",
491
514
  metavar="URL",
492
515
  default="https://dynotx--research-agent-streaming-fastapi-app.modal.run",
493
- help="Override the SSE base URL",
516
+ help="Override the SSE streaming base URL",
494
517
  )
495
- _add_job_args(p)
518
+ p.add_argument("--out", metavar="DIR", help="Save report and citations to DIR")
496
519
 
497
520
  p = sub.add_parser("notes", help="View accumulated research notes for a dataset")
498
521
  p.add_argument(
@@ -547,6 +570,8 @@ def build_parser() -> argparse.ArgumentParser:
547
570
  )
548
571
  p.add_argument(
549
572
  "--dataset-id",
573
+ "--dataset_id",
574
+ dest="dataset_id",
550
575
  default=None,
551
576
  metavar="ID",
552
577
  help="Dataset of PDB/CIF designs (default: cached from last upload or `phi use`)",
@@ -640,4 +665,8 @@ def build_parser() -> argparse.ArgumentParser:
640
665
  p.add_argument("--out", metavar="FILE", help="Save scores CSV to file")
641
666
  p.add_argument("--json", action="store_true", help="Output raw JSON")
642
667
 
668
+ # Hide internal/unreleased commands from the usage line.
669
+ _hidden_cmds = {"rfdiffusion3", "design", "boltzgen"}
670
+ sub.metavar = "{" + ",".join(c for c in sub.choices if c not in _hidden_cmds) + "}"
671
+
643
672
  return root
@@ -150,13 +150,14 @@ def _stream_research(
150
150
  max_turns: int = 15,
151
151
  notes_file: Path | None = None,
152
152
  dataset_id: str | None = None,
153
+ context: str | None = None,
153
154
  ) -> None:
154
- url = f"{base_url.rstrip('/')}/research/stream?" + urllib.parse.urlencode(
155
- {"question": question, "max_turns": max_turns}
156
- )
155
+ params: dict = {"question": question, "max_turns": max_turns}
156
+ if context:
157
+ params["context"] = context
158
+ url = f"{base_url.rstrip('/')}/research/stream?" + urllib.parse.urlencode(params)
157
159
 
158
- console.print(f"\n[{_C_BLUE}]Connecting to research stream…[/]")
159
- console.print(f"[dim]{url}[/]\n")
160
+ console.print(f"\n[{_C_BLUE}]Connecting to research stream…[/]\n")
160
161
 
161
162
  state = _ResearchStreamState()
162
163
  req = urllib.request.Request(url, headers={"Accept": "text/event-stream"})
@@ -3,6 +3,7 @@ from pathlib import Path
3
3
 
4
4
  sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
5
5
 
6
+ from phi.commands.research import _is_error_report
6
7
  from phi.research import (
7
8
  _build_research_section,
8
9
  _dispatch_sse_event,
@@ -85,6 +86,31 @@ def test_build_research_section_includes_citations() -> None:
85
86
  assert "Sources (2)" in section
86
87
 
87
88
 
89
+ def test_is_error_report_detects_research_failed() -> None:
90
+ report = "# Research Failed\n\nError: Command failed with exit code 1"
91
+ assert _is_error_report(report) is True
92
+
93
+
94
+ def test_is_error_report_detects_error_header() -> None:
95
+ assert _is_error_report("# Error\n\nSomething went wrong") is True
96
+
97
+
98
+ def test_is_error_report_detects_bare_error_prefix() -> None:
99
+ assert _is_error_report("Error: Command failed with exit code 1") is True
100
+
101
+
102
+ def test_is_error_report_passes_valid_report() -> None:
103
+ assert _is_error_report("# AAV Biology\n\nAAVs are...") is False
104
+
105
+
106
+ def test_is_error_report_passes_empty() -> None:
107
+ assert _is_error_report("") is False
108
+
109
+
110
+ def test_is_error_report_handles_leading_whitespace() -> None:
111
+ assert _is_error_report(" # Research Failed\n\nError") is True # strip() normalises whitespace
112
+
113
+
88
114
  def test_build_research_section_handles_invalid_citations() -> None:
89
115
  section = _build_research_section("query", "report", "not-valid-json")
90
116
  assert "query" in section
@@ -1 +0,0 @@
1
- __version__ = "0.2.0"
@@ -1,145 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import argparse
4
- import json
5
- from pathlib import Path
6
-
7
- from rich.markdown import Markdown
8
- from rich.panel import Panel
9
-
10
- import phi.config as config
11
- from phi.api import _request, _submit
12
- from phi.display import _C_BLUE, _C_ROSE, _C_SAND, _die, _print_status, _print_submission, console
13
- from phi.download import _download_job
14
- from phi.polling import _poll
15
- from phi.research import _append_research_notes, _stream_research
16
- from phi.types import PhiApiError
17
-
18
-
19
- def cmd_research(args: argparse.Namespace) -> None:
20
- notes_file: Path | None = None if args.no_save else Path(args.notes_file)
21
- dataset_id: str | None = args.dataset_id
22
-
23
- if args.stream:
24
- _stream_research(
25
- question=args.question,
26
- base_url=args.stream_url,
27
- notes_file=notes_file,
28
- dataset_id=dataset_id,
29
- )
30
- return
31
-
32
- params: dict = {
33
- "question": args.question,
34
- "databases": [d.strip() for d in args.databases.split(",") if d.strip()],
35
- "max_papers": args.max_papers,
36
- "include_structures": args.structures,
37
- }
38
- if args.target:
39
- params["target"] = args.target
40
-
41
- if args.context_file:
42
- try:
43
- prior = Path(args.context_file).read_text(encoding="utf-8")
44
- existing = args.context or ""
45
- params["context"] = (prior + "\n\n" + existing).strip() if existing else prior
46
- except Exception as exc:
47
- console.print(f"[{_C_ROSE}]Warning: could not read context file:[/] {exc}")
48
- elif args.context:
49
- params["context"] = args.context
50
-
51
- result = _submit("research", params, run_id=args.run_id)
52
- _print_submission(result)
53
- if args.wait:
54
- console.print(f"\n[dim]Polling every {config.POLL_INTERVAL}s …[/]")
55
- final = _poll(result["job_id"])
56
- _print_status(final)
57
-
58
- report = (final.get("outputs") or {}).get("report_md")
59
- citations_raw = (final.get("outputs") or {}).get("citations")
60
-
61
- if report:
62
- if notes_file is not None:
63
- _append_research_notes(
64
- notes_file=notes_file,
65
- question=args.question,
66
- report=report,
67
- citations_raw=citations_raw,
68
- dataset_id=dataset_id,
69
- )
70
-
71
- if args.out:
72
- out = Path(args.out)
73
- out.mkdir(parents=True, exist_ok=True)
74
- (out / "research_report.md").write_text(report)
75
- console.print(f"\n[{_C_SAND}]Report written[/] → {out}/research_report.md")
76
- if citations_raw:
77
- try:
78
- citations = json.loads(citations_raw)
79
- (out / "citations.json").write_text(json.dumps(citations, indent=2))
80
- console.print(f"[{_C_SAND}]Citations written[/] → {out}/citations.json")
81
- except Exception:
82
- pass
83
- else:
84
- console.print("\n" + "─" * 60)
85
- console.print(report)
86
- if citations_raw:
87
- try:
88
- citations = json.loads(citations_raw)
89
- console.print(f"\n[{_C_BLUE}]Sources ({len(citations)})[/]")
90
- for i, src in enumerate(citations[:10], 1):
91
- title = src.get("title") or src.get("pmid") or str(src)
92
- console.print(f" [{_C_BLUE}]{i}.[/] {title}")
93
- if len(citations) > 10:
94
- console.print(f" [dim]… and {len(citations) - 10} more[/]")
95
- except Exception:
96
- pass
97
- elif args.out and final.get("status") == "completed":
98
- _download_job(final, args.out)
99
-
100
-
101
- def cmd_notes(args: argparse.Namespace) -> None:
102
- dataset_id: str = args.dataset_id
103
-
104
- try:
105
- data = _request("GET", f"/datasets/{dataset_id}/research-notes")
106
- except PhiApiError as exc:
107
- _die(str(exc))
108
-
109
- if not data.get("exists"):
110
- console.print(f"[{_C_ROSE}]No research notes found[/] for dataset {dataset_id}")
111
- return
112
-
113
- content: str = data.get("content") or ""
114
- gcs_url: str | None = data.get("gcs_url")
115
- gcs_uri: str | None = data.get("gcs_uri")
116
-
117
- if args.out:
118
- out = Path(args.out)
119
- if out.suffix.lower() == ".md":
120
- dest = out
121
- else:
122
- out.mkdir(parents=True, exist_ok=True)
123
- dest = out / "research.md"
124
- dest.parent.mkdir(parents=True, exist_ok=True)
125
- dest.write_text(content, encoding="utf-8")
126
- console.print(f"[{_C_SAND}]Notes saved[/] → {dest}")
127
- if gcs_url:
128
- console.print(f"[dim]Download URL:[/] {gcs_url}")
129
- return
130
-
131
- if args.json:
132
- print(json.dumps(data, indent=2))
133
- return
134
-
135
- console.print()
136
- console.print(
137
- Panel(
138
- Markdown(content),
139
- title=f"[{_C_BLUE}]Research Notes[/] — {dataset_id[:8]}…",
140
- border_style=_C_BLUE,
141
- padding=(1, 2),
142
- )
143
- )
144
- if gcs_uri:
145
- console.print(f"[dim]Storage URI:[/] {gcs_uri}")
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes