fix: projects use/get cross-org lookup (#638)

Tab completion lists projects from all orgs the user can access, but
`projects use <id>` validated against only the default org. A project
from a non-default org would tab-complete fine then fail with "Project
not found".

The issue is `_discover_organization` running before the direct-ID path,
scoping the validation query to whichever org has `is_default=True`.
Moved org discovery to the interactive listing branch only. When the
user passes a project ID directly, validation now queries all accessible
orgs (unless they explicitly passed `--org`). Same fix applied to
`projects get <id>`.
This commit is contained in:
Adrian Lyjak
2026-05-14 16:17:08 -04:00
committed by GitHub
parent 3263e9b0f1
commit 06ff626efe
3 changed files with 58 additions and 10 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"llamactl": patch
---
Fix `projects use <id>` and `projects get <id>` failing with "not found" for projects outside the default org
@@ -44,18 +44,23 @@ def get_projects(project_id: str | None, org_id: str | None, output: str) -> Non
try:
auth_svc = _get_service().current_auth_service()
profile = validate_authenticated_profile()
if org_id is None:
org = _discover_organization(auth_svc)
if org is not None:
org_id = org.org_id
projects = _list_projects(auth_svc, org_id=org_id)
if project_id:
# Look up a specific project across all accessible orgs,
# unless the user explicitly scoped with --org.
projects = _list_projects(auth_svc, org_id=org_id)
projects = [
project for project in projects if project.project_id == project_id
]
if not projects:
raise click.ClickException(f"Project {project_id} not found")
else:
# Scope the listing to the default org when --org is not given.
if org_id is None:
org = _discover_organization(auth_svc)
if org is not None:
org_id = org.org_id
projects = _list_projects(auth_svc, org_id=org_id)
if not projects and output == "text":
status("no projects found")
@@ -93,16 +98,13 @@ def use_project(project_id: str | None, org_id: str | None) -> None:
profile = validate_authenticated_profile()
try:
if org_id is None:
org = _discover_organization(auth_svc)
if org is not None:
org_id = org.org_id
if project_id and profile.project_id == project_id:
return
if project_id:
if auth_svc.env.requires_auth:
# Validate across all accessible orgs (matching tab completion),
# unless the user explicitly scoped with --org.
projects = _list_projects(auth_svc, org_id=org_id)
if not next(
(
@@ -117,6 +119,11 @@ def use_project(project_id: str | None, org_id: str | None) -> None:
status(f"switched project {project_id}")
return
# Scope the interactive listing to the default org when --org is not given.
if org_id is None:
org = _discover_organization(auth_svc)
if org is not None:
org_id = org.org_id
projects = _list_projects(auth_svc, org_id=org_id)
if not projects:
+36
View File
@@ -181,6 +181,42 @@ def test_auth_logout_missing() -> None:
assert "Profile 'missing' not found" in result.output
def test_projects_use_validates_across_all_orgs() -> None:
"""projects use <id> should not scope validation to the default org.
Tab completion lists projects from all orgs, so the validation path
must also search all orgs — otherwise a project from a non-default org
completes but then fails with 'Project not found'.
"""
runner = CliRunner()
cross_org_project = MagicMock(project_id="cross-org-proj-id")
with (
patch(
"llama_agents.cli.commands.projects.validate_authenticated_profile",
return_value=MagicMock(name="p", project_id="current-proj"),
),
patch("llama_agents.cli.config.env_service.service") as mock_service,
patch(
"llama_agents.cli.commands.projects._list_projects",
return_value=[cross_org_project],
) as mock_list,
patch(
"llama_agents.cli.commands.projects._discover_organization",
return_value=MagicMock(org_id="default-org-id"),
),
):
mock_auth_svc = MagicMock()
mock_auth_svc.env.requires_auth = True
mock_service.current_auth_service.return_value = mock_auth_svc
result = runner.invoke(app, ["projects", "use", "cross-org-proj-id"])
assert result.exit_code == 0
# Must call _list_projects without org scoping (org_id=None),
# not with the auto-discovered default org.
mock_list.assert_called_once_with(mock_auth_svc, org_id=None)
mock_auth_svc.set_project.assert_called_once()
def test_projects_use_non_interactive_lists_options_and_hints() -> None:
runner = CliRunner()
with (