Skip to content

Commit 5e2e3fa

Browse files
jdicorpocursoragent
andcommitted
fix(init): prefer linking existing project before create (2.4.9)
Update cf init to present existing platform projects and default to linking, while still allowing explicit new project creation with confirmation. Also harden shuttle sorting against null tapeout dates and add regression tests for the new init prompts. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 7577b6f commit 5e2e3fa

3 files changed

Lines changed: 144 additions & 3 deletions

File tree

chipfoundry_cli/main.py

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -389,6 +389,59 @@ def _prompt_with_default(label: str, current: Optional[str], detected: Optional[
389389
return raw
390390

391391

392+
def _shuttle_sort_key(shuttle: dict) -> str:
393+
"""Sort shuttles by date while handling null/missing dates safely."""
394+
tapeout_date = shuttle.get("tapeout_date")
395+
if isinstance(tapeout_date, str) and tapeout_date.strip():
396+
return tapeout_date
397+
return "9999-12-31"
398+
399+
400+
def _confirm_new_project_creation() -> bool:
401+
"""Ask for explicit confirmation before creating a new platform project."""
402+
return click.confirm(
403+
"Create a NEW platform project now? "
404+
"(Select 'No' if you intended to link an existing project with `cf link`.)",
405+
default=False,
406+
)
407+
408+
409+
def _prompt_init_platform_action() -> str:
410+
"""Ask whether init should link to an existing project or create a new one."""
411+
console.print("\n[bold]Platform action[/bold]")
412+
console.print(" [cyan]1[/cyan]. Link to an existing platform project")
413+
console.print(" [cyan]2[/cyan]. Create a new platform project")
414+
choice = console.input("Select option [1/2, default 1]: ").strip()
415+
if choice in ("", "1"):
416+
return "link"
417+
if choice == "2":
418+
return "create"
419+
console.print("[yellow]Invalid selection — defaulting to linking an existing project.[/yellow]")
420+
return "link"
421+
422+
423+
def _choose_platform_project(projects: List[dict]) -> Optional[dict]:
424+
"""Show a numbered project list and return the selected project, if any."""
425+
console.print("\n[bold]Your platform projects:[/bold]")
426+
for i, p in enumerate(projects, 1):
427+
status_str = p.get('status', 'unknown')
428+
shuttle_str = f" — {p.get('shuttle_name', '')}" if p.get('shuttle_name') else ""
429+
console.print(f" [cyan]{i}[/cyan]. {p['name']}{shuttle_str} [{status_str}]")
430+
console.print(f" [cyan]{len(projects) + 1}[/cyan]. Create a new platform project")
431+
432+
choice = console.input("\nSelect project number: ").strip()
433+
try:
434+
idx = int(choice) - 1
435+
if 0 <= idx < len(projects):
436+
return projects[idx]
437+
if idx == len(projects):
438+
return None
439+
except ValueError:
440+
pass
441+
console.print("[red]Invalid selection.[/red]")
442+
return None
443+
444+
392445
@main.command('init')
393446
@click.option('--project-root', required=False, type=click.Path(file_okay=False), help='Project directory (defaults to current directory).')
394447
@click.option('--shuttle', default=None, help='Shuttle name or ID to associate with the project.')
@@ -539,12 +592,43 @@ def _merged(key_local: str, key_platform: Optional[str] = None) -> Optional[str]
539592
console.print(f" Portal: {portal_url}/projects/{platform_id}")
540593
return
541594

595+
if api_key:
596+
try:
597+
projects = _api_get("/projects/me")
598+
except SystemExit:
599+
projects = []
600+
if projects:
601+
action = _prompt_init_platform_action()
602+
if action == "link":
603+
selected = _choose_platform_project(projects)
604+
if selected:
605+
proj['platform_project_id'] = selected['id']
606+
if selected.get('name'):
607+
old_name = proj.get('name')
608+
proj['name'] = selected['name']
609+
if old_name and old_name != selected['name']:
610+
console.print(
611+
f"[yellow]Updated project name: '{old_name}' → '{selected['name']}' "
612+
"(synced from platform)[/yellow]"
613+
)
614+
with open(project_json_path, 'w') as f:
615+
json.dump(data, f, indent=2)
616+
portal_url = _get_portal_url()
617+
console.print(f"\n[green]✓ Linked to existing platform project[/green]")
618+
console.print(f" Name: {selected['name']}")
619+
console.print(f" ID: {selected['id']}")
620+
if github_repo_url:
621+
console.print(f" GitHub: {github_repo_url}")
622+
console.print(f" Portal: {portal_url}/projects/{selected['id']}")
623+
return
624+
console.print("[dim]Continuing with new project creation.[/dim]")
625+
542626
shuttle_id = shuttle
543627
if not shuttle_id:
544628
try:
545629
shuttles = _api_get("/shuttles/available")
546630
if shuttles:
547-
shuttles.sort(key=lambda s: s.get('tapeout_date', '9999-12-31'))
631+
shuttles.sort(key=_shuttle_sort_key)
548632
console.print("\n[bold]Available shuttles:[/bold]")
549633
for i, s in enumerate(shuttles, 1):
550634
deadline = s.get('tapeout_date', '')
@@ -571,6 +655,13 @@ def _merged(key_local: str, key_platform: Optional[str] = None) -> Optional[str]
571655
if github_repo_url:
572656
create_data["github_repo_url"] = github_repo_url
573657

658+
if not _confirm_new_project_creation():
659+
with open(project_json_path, 'w') as f:
660+
json.dump(data, f, indent=2)
661+
console.print("[yellow]Skipped platform project creation.[/yellow]")
662+
console.print("[dim]Tip: Run [bold]cf link[/bold] to select an existing platform project.[/dim]")
663+
return
664+
574665
try:
575666
project_resp = _api_post("/projects", create_data)
576667
new_id = project_resp.get('id')

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "chipfoundry-cli"
3-
version = "2.4.6"
3+
version = "2.4.9"
44
description = "CLI tool to automate ChipFoundry project submission to SFTP server"
55
authors = ["ChipFoundry <marwan.abbas@chipfoundry.io>"]
66
readme = "README.md"

tests/test_init_command.py

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,13 @@
33
"""
44
import pytest
55
from click.testing import CliRunner
6-
from chipfoundry_cli.main import main
6+
from chipfoundry_cli.main import (
7+
main,
8+
_shuttle_sort_key,
9+
_confirm_new_project_creation,
10+
_prompt_init_platform_action,
11+
_choose_platform_project,
12+
)
713
from pathlib import Path
814
import json
915
import tempfile
@@ -66,6 +72,50 @@ def test_init_defaults_to_current_directory(self, temp_project_dir):
6672

6773
assert result.exit_code == 0
6874

75+
def test_shuttle_sort_key_handles_none_tapeout_date(self):
76+
"""Shuttle sort key should not crash on null or missing dates."""
77+
shuttles = [
78+
{"id": "late", "tapeout_date": None},
79+
{"id": "soon", "tapeout_date": "2026-06-01"},
80+
{"id": "missing"},
81+
{"id": "middle", "tapeout_date": "2026-07-15"},
82+
]
83+
84+
sorted_ids = [s["id"] for s in sorted(shuttles, key=_shuttle_sort_key)]
85+
assert sorted_ids == ["soon", "middle", "late", "missing"]
86+
87+
def test_confirm_new_project_creation_uses_safe_default(self, monkeypatch):
88+
"""Creation confirmation should default to 'No' to prevent accidental project creation."""
89+
captured = {}
90+
91+
def fake_confirm(text, default):
92+
captured["text"] = text
93+
captured["default"] = default
94+
return True
95+
96+
monkeypatch.setattr("chipfoundry_cli.main.click.confirm", fake_confirm)
97+
approved = _confirm_new_project_creation()
98+
99+
assert approved is True
100+
assert captured["default"] is False
101+
assert "Create a NEW platform project now?" in captured["text"]
102+
103+
def test_prompt_init_platform_action_defaults_to_link(self, monkeypatch):
104+
"""init should default to linking an existing project."""
105+
monkeypatch.setattr("chipfoundry_cli.main.console.input", lambda _msg: "")
106+
action = _prompt_init_platform_action()
107+
assert action == "link"
108+
109+
def test_choose_platform_project_returns_selected_project(self, monkeypatch):
110+
"""Chooser should return selected project entry."""
111+
projects = [
112+
{"id": "p1", "name": "Project 1", "status": "draft"},
113+
{"id": "p2", "name": "Project 2", "status": "submitted"},
114+
]
115+
monkeypatch.setattr("chipfoundry_cli.main.console.input", lambda _msg: "2")
116+
selected = _choose_platform_project(projects)
117+
assert selected == projects[1]
118+
69119

70120
if __name__ == '__main__':
71121
pytest.main([__file__, '-v'])

0 commit comments

Comments
 (0)