ellf-cli 5.0.18__tar.gz → 6.0.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.
- {ellf_cli-5.0.18/ellf_cli.egg-info → ellf_cli-6.0.0}/PKG-INFO +1 -1
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/README.md +11 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/about.json +1 -1
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/auth.py +45 -1
- ellf_cli-6.0.0/ellf_cli/commands/_org_select.py +86 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/commands/general.py +72 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/config.py +5 -2
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/ellf.json +62 -1
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/messages.py +7 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0/ellf_cli.egg-info}/PKG-INFO +1 -1
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli.egg-info/SOURCES.txt +3 -0
- ellf_cli-6.0.0/tests/test_logout.py +126 -0
- ellf_cli-6.0.0/tests/test_org_select.py +120 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/LICENSE +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/MANIFEST.in +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/__init__.py +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/__main__.py +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/about.py +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/appdirs.py +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/cli.py +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/cloud/__init__.py +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/cloud/gcp.py +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/cluster_config.py +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/commands/__init__.py +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/commands/_cluster_select.py +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/commands/_recipe_file.py +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/commands/_recipe_subcommand.py +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/commands/_state.py +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/commands/actions.py +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/commands/agents.py +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/commands/assets.py +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/commands/auth.py +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/commands/clusters.py +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/commands/config.py +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/commands/datasets.py +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/commands/files/__init__.py +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/commands/files/cp.py +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/commands/files/ls.py +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/commands/files/rm.py +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/commands/files/rsync.py +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/commands/files/stats.py +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/commands/import_export.py +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/commands/infra/__init__.py +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/commands/infra/_helpers.py +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/commands/infra/deploy.py +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/commands/infra/init_values.py +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/commands/infra/provision.py +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/commands/infra/register.py +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/commands/infra/setup.py +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/commands/infra/start.py +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/commands/infra/terraform.py +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/commands/infra/tls.py +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/commands/jobs.py +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/commands/packages.py +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/commands/paths.py +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/commands/plans.py +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/commands/projects.py +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/commands/publish_code.py +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/commands/publish_data.py +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/commands/recipes.py +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/commands/secrets.py +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/commands/support.py +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/commands/tasks.py +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/commands/todos.py +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/ellf_skills/.claude-plugin/plugin.json +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/ellf_skills/.gitignore +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/ellf_skills/skill_variants.json +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/ellf_skills/skills/ellf-annotate.assistant/SKILL.md +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/ellf_skills/skills/ellf-annotate.assistant/references/annotation_audit.md +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/ellf_skills/skills/ellf-annotate.assistant/references/builtin_ellf_annotation_recipes.md +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/ellf_skills/skills/ellf-annotate.coding/SKILL.md +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/ellf_skills/skills/ellf-annotate.coding/references/annotation_audit.md +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/ellf_skills/skills/ellf-annotate.coding/references/builtin_ellf_annotation_recipes.md +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/ellf_skills/skills/ellf-annotate.coding/references/builtin_prodigy_recipes.md +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/ellf_skills/skills/ellf-ask/SKILL.md +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/ellf_skills/skills/ellf-handoff/SKILL.md +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/ellf_skills/skills/ellf-monitor.assistant/SKILL.md +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/ellf_skills/skills/ellf-monitor.assistant/references/annotation_metrics.md +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/ellf_skills/skills/ellf-monitor.assistant/references/training_monitoring.md +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/ellf_skills/skills/ellf-monitor.coding/SKILL.md +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/ellf_skills/skills/ellf-monitor.coding/references/annotation_metrics.md +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/ellf_skills/skills/ellf-monitor.coding/references/training_monitoring.md +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/ellf_skills/skills/ellf-monitor.coding/scripts/check_training.py +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/ellf_skills/skills/ellf-ops.assistant/SKILL.md +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/ellf_skills/skills/ellf-ops.coding/SKILL.md +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/ellf_skills/skills/ellf-ops.coding/references/data_infra_cli.md +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/ellf_skills/skills/ellf-ops.coding/scripts/run_job.py +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/ellf_skills/skills/ellf-patterns/SKILL.md +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/ellf_skills/skills/ellf-patterns/references/pattern_strategies.md +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/ellf_skills/skills/ellf-prodigy/SKILL.md +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/ellf_skills/skills/ellf-prodigy/assets/templates/template_action_recipe.py +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/ellf_skills/skills/ellf-prodigy/assets/templates/template_agent_recipe.py +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/ellf_skills/skills/ellf-prodigy/assets/templates/template_blocks_ui.py +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/ellf_skills/skills/ellf-prodigy/assets/templates/template_correct.py +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/ellf_skills/skills/ellf-prodigy/assets/templates/template_custom_ui.py +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/ellf_skills/skills/ellf-prodigy/assets/templates/template_manual.py +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/ellf_skills/skills/ellf-prodigy/assets/templates/template_pages_ui.py +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/ellf_skills/skills/ellf-prodigy/assets/templates/template_routing.py +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/ellf_skills/skills/ellf-prodigy/assets/templates/template_task_recipe.py +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/ellf_skills/skills/ellf-prodigy/assets/templates/template_teach.py +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/ellf_skills/skills/ellf-prodigy/references/builtin_recipes.md +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/ellf_skills/skills/ellf-prodigy/references/ellf_recipe_sdk.md +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/ellf_skills/skills/ellf-prodigy/references/lint_recipe.py +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/ellf_skills/skills/ellf-prodigy/references/prodigy_recipe_api.md +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/ellf_skills/skills/ellf-prodigy/references/template_index.md +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/ellf_skills/skills/ellf-project.assistant/SKILL.md +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/ellf_skills/skills/ellf-project.assistant/references/consulting_patterns.md +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/ellf_skills/skills/ellf-project.assistant/references/explosion_strategy.md +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/ellf_skills/skills/ellf-project.assistant/references/prodigy_llm_bot.md +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/ellf_skills/skills/ellf-project.coding/SKILL.md +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/ellf_skills/skills/ellf-project.coding/references/consulting_patterns.md +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/ellf_skills/skills/ellf-project.coding/references/explosion_strategy.md +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/ellf_skills/skills/ellf-project.coding/references/prodigy_llm_bot.md +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/ellf_skills/skills/ellf-support.assistant/SKILL.md +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/ellf_skills/skills/ellf-support.coding/SKILL.md +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/ellf_skills/skills/ellf-todo/SKILL.md +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/ellf_skills/skills/ellf-train.assistant/SKILL.md +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/ellf_skills/skills/ellf-train.assistant/references/diagnostics.md +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/ellf_skills/skills/ellf-train.assistant/references/evaluation_guide.md +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/ellf_skills/skills/ellf-train.assistant/references/model_selection.md +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/ellf_skills/skills/ellf-train.assistant/references/training_paradigms.md +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/ellf_skills/skills/ellf-train.assistant/references/workflow.md +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/ellf_skills/skills/ellf-train.coding/SKILL.md +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/ellf_skills/skills/ellf-train.coding/references/config_advanced.md +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/ellf_skills/skills/ellf-train.coding/references/config_architectures.md +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/ellf_skills/skills/ellf-train.coding/references/config_training.md +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/ellf_skills/skills/ellf-train.coding/references/diagnostics.md +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/ellf_skills/skills/ellf-train.coding/references/evaluation_guide.md +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/ellf_skills/skills/ellf-train.coding/references/experiment_patterns.md +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/ellf_skills/skills/ellf-train.coding/references/model_selection.md +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/ellf_skills/skills/ellf-train.coding/references/training_paradigms.md +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/ellf_skills/skills/ellf-train.coding/references/training_troubleshooting.md +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/ellf_skills/skills/ellf-train.coding/references/workflow.md +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/ellf_skills/skills/ellf-train.coding/scripts/ellf_logger.py +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/errors.py +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/helm.py +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/key_pair.py +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/main.py +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/query.py +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/recipes_cookiecutter/cookiecutter.json +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/recipes_cookiecutter/{{cookiecutter.package_dir}}/.gitignore +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/recipes_cookiecutter/{{cookiecutter.package_dir}}/README.md.tmpl +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/recipes_cookiecutter/{{cookiecutter.package_dir}}/requirements-dev.in +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/recipes_cookiecutter/{{cookiecutter.package_dir}}/requirements.in +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/recipes_cookiecutter/{{cookiecutter.package_dir}}/setup.py.tmpl +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/recipes_cookiecutter/{{cookiecutter.package_dir}}/{{cookiecutter.package_name}}/__init__.py +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/recipes_cookiecutter/{{cookiecutter.package_dir}}/{{cookiecutter.package_name}}/about.py +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/recipes_cookiecutter/{{cookiecutter.package_dir}}/{{cookiecutter.package_name}}/recipes/__init__.py +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/recipes_cookiecutter/{{cookiecutter.package_dir}}/{{cookiecutter.package_name}}/recipes/example_task.py +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/testing/__init__.py +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/ty.py +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/ui.py +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/url.py +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli/util.py +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli.egg-info/dependency_links.txt +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli.egg-info/entry_points.txt +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli.egg-info/not-zip-safe +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli.egg-info/requires.txt +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/ellf_cli.egg-info/top_level.txt +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/pyproject.toml +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/setup.cfg +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/setup.py +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/tests/test_appdirs.py +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/tests/test_auth.py +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/tests/test_config.py +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/tests/test_errors.py +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/tests/test_files_cp.py +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/tests/test_files_cp_helpers.py +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/tests/test_info.py +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/tests/test_invalid_secrets.py +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/tests/test_key_pair.py +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/tests/test_login.py +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/tests/test_main.py +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/tests/test_plans.py +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/tests/test_projects.py +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/tests/test_query.py +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/tests/test_recipe_file.py +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/tests/test_recipes.py +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/tests/test_state.py +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/tests/test_support.py +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/tests/test_ty.py +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/tests/test_ui.py +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/tests/test_ui_extras.py +0 -0
- {ellf_cli-5.0.18 → ellf_cli-6.0.0}/tests/test_util.py +0 -0
|
@@ -1069,11 +1069,22 @@ Log in to your Ellf account. You normally don't need to call this manually. It w
|
|
|
1069
1069
|
| Argument | Type | Description | Default |
|
|
1070
1070
|
| --- | --- | --- | --- |
|
|
1071
1071
|
| `--no-cluster` | `bool` | Don't use a cluster | `False` |
|
|
1072
|
+
| `--no-org` | `bool` | Skip the organization selection step (stay in the org that the device-flow login lands in) | `False` |
|
|
1072
1073
|
| `--no-browser` | `bool` | Don't open a browser, just print the login URL | `False` |
|
|
1074
|
+
| `--org` | `str` | Name or ID of the organization to log into. If omitted: auto-select when only one is available, or prompt interactively | `None` |
|
|
1073
1075
|
| `--cluster` | `str` | Name or ID of the cluster to log into. If omitted: auto-select when only one is available, or prompt interactively | `None` |
|
|
1074
1076
|
| `--json` | `bool` | Output the result as JSON | `False` |
|
|
1075
1077
|
| `--claude` | `bool` | Install Ellf Claude Code skills and transcript hook into ~/.claude/ | `False` |
|
|
1076
1078
|
|
|
1079
|
+
### `ellf logout`
|
|
1080
|
+
|
|
1081
|
+
Log out by deleting locally-stored auth tokens. PAM bearer tokens can't be revoked server-side, so this is a local operation: subsequent commands will trigger a fresh device-flow login. The cached tokens would have expired on their own within roughly an hour. Use ``--all`` to also reset the saved defaults (active org, cluster, project, etc.), e.g. when handing the machine to a different user.
|
|
1082
|
+
|
|
1083
|
+
| Argument | Type | Description | Default |
|
|
1084
|
+
| --- | --- | --- | --- |
|
|
1085
|
+
| `--all` | `bool` | Also clear saved defaults (broker host, active org/cluster/project/task/agent), not just the auth tokens | `False` |
|
|
1086
|
+
| `--json` | `bool` | Output the result as JSON | `False` |
|
|
1087
|
+
|
|
1077
1088
|
### `ellf info`
|
|
1078
1089
|
|
|
1079
1090
|
Print information about the CLI
|
|
@@ -169,6 +169,8 @@ class AuthState(Protocol):
|
|
|
169
169
|
|
|
170
170
|
def set_active_cluster(self, cluster_id: UUID, broker_url: URL) -> None: ...
|
|
171
171
|
|
|
172
|
+
def set_active_org(self, org_id: UUID) -> AccessTokenCredential: ...
|
|
173
|
+
|
|
172
174
|
def get_id_token(self, force_refresh: bool = False) -> str: ...
|
|
173
175
|
|
|
174
176
|
def get_api_token(self, force_refresh: bool = False) -> AccessTokenCredential: ...
|
|
@@ -208,7 +210,11 @@ class AuthStateImpl:
|
|
|
208
210
|
# PAM lookup-by-address every time the user runs an ellf command.
|
|
209
211
|
# `auth.cluster_id` falls back to the address lookup only if this is None.
|
|
210
212
|
self._cluster_id = settings.cluster_id
|
|
211
|
-
|
|
213
|
+
# Seed org_id from saved settings too: the user's api_token already
|
|
214
|
+
# carries the org_id in its `oid` claim, but we don't decode the JWT
|
|
215
|
+
# here. The saved setting reflects whatever was chosen at the last
|
|
216
|
+
# `ellf login` and stays in sync via set_active_org.
|
|
217
|
+
self._org_id = settings.org_id
|
|
212
218
|
self._secrets_path = ctx.secrets_path
|
|
213
219
|
self._saved_settings_path = ctx.saved_settings_path
|
|
214
220
|
self._client = None
|
|
@@ -379,6 +385,44 @@ class AuthStateImpl:
|
|
|
379
385
|
self._cluster_id = cluster_id
|
|
380
386
|
self._cluster_client = None
|
|
381
387
|
|
|
388
|
+
def set_active_org(self, org_id: UUID) -> AccessTokenCredential:
|
|
389
|
+
"""Exchange the current api_token for one scoped to ``org_id``.
|
|
390
|
+
|
|
391
|
+
PAM tokens carry an org_id claim (each user-org pair is a separate
|
|
392
|
+
``models.User`` record), so "switching org" is a token-exchange,
|
|
393
|
+
not a local-only change. We POST to ``/v1/token/switch-org`` with
|
|
394
|
+
the current api_token and persist the response in secrets. Any
|
|
395
|
+
cached cluster-scoped broker tokens are dropped because they were
|
|
396
|
+
derived from the old api_token's org.
|
|
397
|
+
|
|
398
|
+
Persisting ``org_id`` to ``SavedSettings`` is the caller's
|
|
399
|
+
responsibility.
|
|
400
|
+
"""
|
|
401
|
+
api_token = self.get_api_token().access_token
|
|
402
|
+
res = httpx.post(
|
|
403
|
+
str(self.pam_url / "api/v1/token/switch-org"),
|
|
404
|
+
headers={"authorization": f"Bearer {api_token}"},
|
|
405
|
+
json={"org_id": str(org_id)},
|
|
406
|
+
timeout=AUTH_REQUEST_TIMEOUT_SECONDS,
|
|
407
|
+
)
|
|
408
|
+
if res.status_code != 200:
|
|
409
|
+
err = _format_http_error(res)
|
|
410
|
+
if res.status_code == 401:
|
|
411
|
+
raise EllfIDTokenError(err)
|
|
412
|
+
raise EllfError(Messages.E137.format(org_id=org_id) + f": {err}")
|
|
413
|
+
data = res.json()
|
|
414
|
+
new_token = AccessTokenCredential(access_token=data["access_token"])
|
|
415
|
+
self.secrets.api_token = new_token
|
|
416
|
+
# Broker tokens were minted for the previous org; clear them so
|
|
417
|
+
# the next get_cluster_token re-issues against the new org.
|
|
418
|
+
self.secrets.broker_tokens = {}
|
|
419
|
+
if self._secrets_path is not None:
|
|
420
|
+
self.secrets.save(self._secrets_path)
|
|
421
|
+
self._org_id = org_id
|
|
422
|
+
self._client = None
|
|
423
|
+
self._cluster_client = None
|
|
424
|
+
return new_token
|
|
425
|
+
|
|
382
426
|
@staticmethod
|
|
383
427
|
def _check_token_expired(
|
|
384
428
|
token: AccessTokenCredential | BrokerAccessTokenCredential | str,
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import builtins
|
|
4
|
+
import sys
|
|
5
|
+
from typing import Union
|
|
6
|
+
from uuid import UUID
|
|
7
|
+
|
|
8
|
+
from wasabi import msg
|
|
9
|
+
|
|
10
|
+
from ellf_pam_sdk import Client
|
|
11
|
+
from ellf_pam_sdk.models import OrgMembership
|
|
12
|
+
|
|
13
|
+
from ..errors import CLIError
|
|
14
|
+
from ..messages import Messages
|
|
15
|
+
from ..ui import isatty
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def list_person_orgs(client: Client) -> builtins.list[OrgMembership]:
|
|
19
|
+
"""Return all org memberships for the authenticated person.
|
|
20
|
+
|
|
21
|
+
The PAM ``org.all()`` listing is org-scoped by the api_token, so it
|
|
22
|
+
only returns the *current* org. To enumerate everywhere this person
|
|
23
|
+
can sign in we hit ``/api/v1/person/orgs`` directly — the same
|
|
24
|
+
endpoint the web app's org switcher uses.
|
|
25
|
+
"""
|
|
26
|
+
response = client._sync_client.post("/api/v1/person/orgs")
|
|
27
|
+
response.raise_for_status()
|
|
28
|
+
return [OrgMembership.model_validate(row) for row in response.json()]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _match_org(
|
|
32
|
+
orgs: builtins.list[OrgMembership],
|
|
33
|
+
name_or_id: Union[str, UUID],
|
|
34
|
+
) -> OrgMembership | None:
|
|
35
|
+
if isinstance(name_or_id, UUID):
|
|
36
|
+
for o in orgs:
|
|
37
|
+
if o.org_id == name_or_id:
|
|
38
|
+
return o
|
|
39
|
+
return None
|
|
40
|
+
needle = str(name_or_id)
|
|
41
|
+
for o in orgs:
|
|
42
|
+
if str(o.org_id) == needle or o.org_name == needle:
|
|
43
|
+
return o
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _prompt_for_org(orgs: builtins.list[OrgMembership]) -> OrgMembership:
|
|
48
|
+
if not isatty(sys.stdin) or not isatty(sys.stdout):
|
|
49
|
+
raise CLIError(Messages.E136)
|
|
50
|
+
msg.info("Multiple organizations are available:")
|
|
51
|
+
for idx, org in enumerate(orgs, start=1):
|
|
52
|
+
print(f" [{idx}] {org.org_name} ({org.org_id})")
|
|
53
|
+
while True:
|
|
54
|
+
raw = input(f"Pick an organization [1-{len(orgs)}]: ").strip()
|
|
55
|
+
if raw.isdigit():
|
|
56
|
+
choice = int(raw)
|
|
57
|
+
if 1 <= choice <= len(orgs):
|
|
58
|
+
return orgs[choice - 1]
|
|
59
|
+
matched = _match_org(orgs, raw)
|
|
60
|
+
if matched is not None:
|
|
61
|
+
return matched
|
|
62
|
+
msg.warn(f"Invalid selection: {raw!r}")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def select_org(
|
|
66
|
+
client: Client,
|
|
67
|
+
name_or_id: Union[str, UUID, None] = None,
|
|
68
|
+
) -> OrgMembership:
|
|
69
|
+
"""Pick an org the current person belongs to.
|
|
70
|
+
|
|
71
|
+
When ``name_or_id`` is provided, looks it up exactly. Otherwise:
|
|
72
|
+
auto-selects the only org, or prompts in a TTY when there are
|
|
73
|
+
multiple. Raises ``CLIError`` if the person has no orgs or the
|
|
74
|
+
caller is non-interactive with multiple orgs.
|
|
75
|
+
"""
|
|
76
|
+
orgs = list_person_orgs(client)
|
|
77
|
+
if not orgs:
|
|
78
|
+
raise CLIError(Messages.E135)
|
|
79
|
+
if name_or_id is not None:
|
|
80
|
+
matched = _match_org(orgs, name_or_id)
|
|
81
|
+
if matched is None:
|
|
82
|
+
raise CLIError(Messages.E038.format(noun="org", name_or_id=name_or_id))
|
|
83
|
+
return matched
|
|
84
|
+
if len(orgs) == 1:
|
|
85
|
+
return orgs[0]
|
|
86
|
+
return _prompt_for_org(orgs)
|
|
@@ -17,6 +17,7 @@ from ..messages import Messages
|
|
|
17
17
|
from ..ui import print_as_json, print_mutation_result
|
|
18
18
|
from ..util import URL
|
|
19
19
|
from ._cluster_select import select_cluster
|
|
20
|
+
from ._org_select import select_org
|
|
20
21
|
from ._state import get_auth_state, get_root_cfg, get_saved_settings
|
|
21
22
|
|
|
22
23
|
|
|
@@ -62,14 +63,18 @@ def _install_claude_skills() -> None:
|
|
|
62
63
|
@cli.command(
|
|
63
64
|
"login",
|
|
64
65
|
no_cluster=Arg("--no-cluster", help=Messages.no_cluster),
|
|
66
|
+
no_org=Arg("--no-org", help=Messages.no_org),
|
|
65
67
|
no_browser=Arg("--no-browser", help=Messages.no_browser),
|
|
68
|
+
org=Arg("--org", help=Messages.login_org),
|
|
66
69
|
cluster=Arg("--cluster", help=Messages.login_cluster),
|
|
67
70
|
as_json=Arg("--json", help=Messages.as_json),
|
|
68
71
|
claude=Arg("--claude", help=Messages.claude),
|
|
69
72
|
)
|
|
70
73
|
def login(
|
|
71
74
|
no_cluster: bool = False,
|
|
75
|
+
no_org: bool = False,
|
|
72
76
|
no_browser: bool = False,
|
|
77
|
+
org: Optional[Union[str, UUID]] = None,
|
|
73
78
|
cluster: Optional[Union[str, UUID]] = None,
|
|
74
79
|
as_json: bool = False,
|
|
75
80
|
claude: bool = False,
|
|
@@ -82,6 +87,11 @@ def login(
|
|
|
82
87
|
auth.no_browser = no_browser
|
|
83
88
|
auth._ensure_readable_secrets()
|
|
84
89
|
auth.get_api_token(force_refresh=True)
|
|
90
|
+
if not no_org:
|
|
91
|
+
# Org selection happens before cluster selection so cluster lookups
|
|
92
|
+
# below run against the chosen org's api_token, not whatever org
|
|
93
|
+
# the device-flow login happened to land in.
|
|
94
|
+
_select_and_persist_org(auth, org)
|
|
85
95
|
if not no_cluster:
|
|
86
96
|
try:
|
|
87
97
|
_select_and_persist_cluster(auth, cluster)
|
|
@@ -98,6 +108,68 @@ def login(
|
|
|
98
108
|
_install_claude_skills()
|
|
99
109
|
|
|
100
110
|
|
|
111
|
+
@cli.command(
|
|
112
|
+
"logout",
|
|
113
|
+
all_=Arg("--all", help=Messages.logout_all),
|
|
114
|
+
as_json=Arg("--json", help=Messages.as_json),
|
|
115
|
+
)
|
|
116
|
+
def logout(all_: bool = False, as_json: bool = False) -> None:
|
|
117
|
+
"""
|
|
118
|
+
Log out by deleting locally-stored auth tokens.
|
|
119
|
+
|
|
120
|
+
PAM bearer tokens can't be revoked server-side, so this is a local
|
|
121
|
+
operation: subsequent commands will trigger a fresh device-flow
|
|
122
|
+
login. The cached tokens would have expired on their own within
|
|
123
|
+
roughly an hour. Use ``--all`` to also reset the saved defaults
|
|
124
|
+
(active org, cluster, project, etc.), e.g. when handing the
|
|
125
|
+
machine to a different user.
|
|
126
|
+
"""
|
|
127
|
+
from ..auth import FileSecrets
|
|
128
|
+
|
|
129
|
+
ctx = get_root_cfg()
|
|
130
|
+
secrets_existed = ctx.secrets_path.exists()
|
|
131
|
+
FileSecrets.clean(ctx.secrets_path)
|
|
132
|
+
settings_reset = False
|
|
133
|
+
if all_:
|
|
134
|
+
settings_path = ctx.saved_settings_path
|
|
135
|
+
if settings_path.exists():
|
|
136
|
+
SavedSettings.blank().save(settings_path)
|
|
137
|
+
settings_reset = True
|
|
138
|
+
print_mutation_result(
|
|
139
|
+
{
|
|
140
|
+
"status": "ok",
|
|
141
|
+
"secrets_removed": secrets_existed,
|
|
142
|
+
"settings_reset": settings_reset,
|
|
143
|
+
},
|
|
144
|
+
Messages.T013,
|
|
145
|
+
as_json=as_json,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _select_and_persist_org(
|
|
150
|
+
auth: AuthState,
|
|
151
|
+
name_or_id: Optional[Union[str, UUID]] = None,
|
|
152
|
+
) -> UUID:
|
|
153
|
+
"""Pick an org via pam and write it into SavedSettings.
|
|
154
|
+
|
|
155
|
+
If the chosen org differs from the current api_token's org, calls
|
|
156
|
+
``/v1/token/switch-org`` to re-issue the token for the target org.
|
|
157
|
+
The first time a person logs in here we always have *some* api_token
|
|
158
|
+
(from the device-flow login), but we don't know which org it landed
|
|
159
|
+
in without a round-trip, so the switch is unconditional when an org
|
|
160
|
+
is explicitly selected — that's cheap and avoids a JWT decode here.
|
|
161
|
+
"""
|
|
162
|
+
chosen = select_org(auth.client, name_or_id)
|
|
163
|
+
settings = get_saved_settings()
|
|
164
|
+
previous_org_id = settings.org_id
|
|
165
|
+
if previous_org_id != chosen.org_id:
|
|
166
|
+
auth.set_active_org(chosen.org_id)
|
|
167
|
+
settings.update("org_id", chosen.org_id)
|
|
168
|
+
settings.save(get_root_cfg().saved_settings_path)
|
|
169
|
+
msg.good(f"Logged in to organization '{chosen.org_name}'")
|
|
170
|
+
return chosen.org_id
|
|
171
|
+
|
|
172
|
+
|
|
101
173
|
def _select_and_persist_cluster(
|
|
102
174
|
auth: AuthState,
|
|
103
175
|
name_or_id: Optional[Union[str, UUID]] = None,
|
|
@@ -40,6 +40,7 @@ class SavedSettings(BaseModel):
|
|
|
40
40
|
agent: UUID | None = None
|
|
41
41
|
pam_host: str | None = DEFAULT_PAM_HOST
|
|
42
42
|
cluster_id: UUID | None = None
|
|
43
|
+
org_id: UUID | None = None
|
|
43
44
|
recipes_file: str | None = None
|
|
44
45
|
|
|
45
46
|
@classmethod
|
|
@@ -52,6 +53,7 @@ class SavedSettings(BaseModel):
|
|
|
52
53
|
agent=None,
|
|
53
54
|
pam_host=DEFAULT_PAM_HOST,
|
|
54
55
|
cluster_id=None,
|
|
56
|
+
org_id=None,
|
|
55
57
|
recipes_file=None,
|
|
56
58
|
)
|
|
57
59
|
|
|
@@ -96,7 +98,7 @@ class SavedSettings(BaseModel):
|
|
|
96
98
|
@overload
|
|
97
99
|
def update(
|
|
98
100
|
self,
|
|
99
|
-
field: Literal["project", "task", "action", "agent", "cluster_id"],
|
|
101
|
+
field: Literal["project", "task", "action", "agent", "cluster_id", "org_id"],
|
|
100
102
|
value: UUID | None = None,
|
|
101
103
|
) -> UUID | None: ...
|
|
102
104
|
|
|
@@ -110,12 +112,13 @@ class SavedSettings(BaseModel):
|
|
|
110
112
|
"action",
|
|
111
113
|
"agent",
|
|
112
114
|
"cluster_id",
|
|
115
|
+
"org_id",
|
|
113
116
|
"recipes_file",
|
|
114
117
|
],
|
|
115
118
|
value: str | UUID | None = None,
|
|
116
119
|
) -> str | UUID | None:
|
|
117
120
|
old_value = getattr(self, field)
|
|
118
121
|
setattr(self, field, value)
|
|
119
|
-
if old_value != value and field in ["broker_host", "pam_host"]:
|
|
122
|
+
if old_value != value and field in ["broker_host", "pam_host", "org_id"]:
|
|
120
123
|
self.reset_defaults()
|
|
121
124
|
return getattr(self, field)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"prog": "ellf",
|
|
3
3
|
"help": "Ellf Command Line Interface.",
|
|
4
|
-
"version": "5.0.
|
|
4
|
+
"version": "5.0.18",
|
|
5
5
|
"extra_key": "_extra",
|
|
6
6
|
"commands": {
|
|
7
7
|
"actions": {
|
|
@@ -164,6 +164,19 @@
|
|
|
164
164
|
"type": null,
|
|
165
165
|
"orig_type": "bool"
|
|
166
166
|
},
|
|
167
|
+
{
|
|
168
|
+
"id": "no_org",
|
|
169
|
+
"option": "--no-org",
|
|
170
|
+
"short": null,
|
|
171
|
+
"orig_help": "Skip the organization selection step (stay in the org that the device-flow login lands in)",
|
|
172
|
+
"default": false,
|
|
173
|
+
"help": "Skip the organization selection step (stay in the org that the device-flow login lands in) (bool)",
|
|
174
|
+
"action": "store_true",
|
|
175
|
+
"choices": null,
|
|
176
|
+
"has_converter": false,
|
|
177
|
+
"type": null,
|
|
178
|
+
"orig_type": "bool"
|
|
179
|
+
},
|
|
167
180
|
{
|
|
168
181
|
"id": "no_browser",
|
|
169
182
|
"option": "--no-browser",
|
|
@@ -177,6 +190,19 @@
|
|
|
177
190
|
"type": null,
|
|
178
191
|
"orig_type": "bool"
|
|
179
192
|
},
|
|
193
|
+
{
|
|
194
|
+
"id": "org",
|
|
195
|
+
"option": "--org",
|
|
196
|
+
"short": null,
|
|
197
|
+
"orig_help": "Name or ID of the organization to log into. If omitted: auto-select when only one is available, or prompt interactively",
|
|
198
|
+
"default": null,
|
|
199
|
+
"help": "Name or ID of the organization to log into. If omitted: auto-select when only one is available, or prompt interactively (str)",
|
|
200
|
+
"action": null,
|
|
201
|
+
"choices": null,
|
|
202
|
+
"has_converter": false,
|
|
203
|
+
"type": "str",
|
|
204
|
+
"orig_type": "str"
|
|
205
|
+
},
|
|
180
206
|
{
|
|
181
207
|
"id": "cluster",
|
|
182
208
|
"option": "--cluster",
|
|
@@ -222,6 +248,41 @@
|
|
|
222
248
|
"parent": null,
|
|
223
249
|
"is_placeholder": false
|
|
224
250
|
},
|
|
251
|
+
"logout": {
|
|
252
|
+
"name": "logout",
|
|
253
|
+
"args": [
|
|
254
|
+
{
|
|
255
|
+
"id": "all_",
|
|
256
|
+
"option": "--all",
|
|
257
|
+
"short": null,
|
|
258
|
+
"orig_help": "Also clear saved defaults (broker host, active org/cluster/project/task/agent), not just the auth tokens",
|
|
259
|
+
"default": false,
|
|
260
|
+
"help": "Also clear saved defaults (broker host, active org/cluster/project/task/agent), not just the auth tokens (bool)",
|
|
261
|
+
"action": "store_true",
|
|
262
|
+
"choices": null,
|
|
263
|
+
"has_converter": false,
|
|
264
|
+
"type": null,
|
|
265
|
+
"orig_type": "bool"
|
|
266
|
+
},
|
|
267
|
+
{
|
|
268
|
+
"id": "as_json",
|
|
269
|
+
"option": "--json",
|
|
270
|
+
"short": null,
|
|
271
|
+
"orig_help": "Output the result as JSON",
|
|
272
|
+
"default": false,
|
|
273
|
+
"help": "Output the result as JSON (bool)",
|
|
274
|
+
"action": "store_true",
|
|
275
|
+
"choices": null,
|
|
276
|
+
"has_converter": false,
|
|
277
|
+
"type": null,
|
|
278
|
+
"orig_type": "bool"
|
|
279
|
+
}
|
|
280
|
+
],
|
|
281
|
+
"description": "\nLog out by deleting locally-stored auth tokens.\n\nPAM bearer tokens can't be revoked server-side, so this is a local\noperation: subsequent commands will trigger a fresh device-flow\nlogin. The cached tokens would have expired on their own within\nroughly an hour. Use ``--all`` to also reset the saved defaults\n(active org, cluster, project, etc.), e.g. when handing the\nmachine to a different user.\n",
|
|
282
|
+
"allow_extra": false,
|
|
283
|
+
"parent": null,
|
|
284
|
+
"is_placeholder": false
|
|
285
|
+
},
|
|
225
286
|
"info": {
|
|
226
287
|
"name": "info",
|
|
227
288
|
"args": [
|
|
@@ -75,6 +75,7 @@ ellf secret create my-credentials OPENAI_API_KEY="sk-..." CUSTOM_SECRET=-
|
|
|
75
75
|
T009 = "Successfully deregistered {noun} {name}"
|
|
76
76
|
T011 = "Successfully copied files"
|
|
77
77
|
T012 = "Login successful"
|
|
78
|
+
T013 = "Logout successful"
|
|
78
79
|
T014 = "Opening browser to authenticate..."
|
|
79
80
|
T015 = "If a browser doesn't open automatically after some seconds, you can copy and open this link:"
|
|
80
81
|
T016 = "Alternatively, you can manually open:"
|
|
@@ -170,8 +171,11 @@ ellf secret create my-credentials OPENAI_API_KEY="sk-..." CUSTOM_SECRET=-
|
|
|
170
171
|
output_dir = "Output directory for the {noun}"
|
|
171
172
|
pam_host_config = "Host or URL of the Prodigy Annotation Manager (PAM) app"
|
|
172
173
|
no_cluster = "Don't use a cluster"
|
|
174
|
+
no_org = "Skip the organization selection step (stay in the org that the device-flow login lands in)"
|
|
173
175
|
no_browser = "Don't open a browser, just print the login URL"
|
|
176
|
+
logout_all = "Also clear saved defaults (broker host, active org/cluster/project/task/agent), not just the auth tokens"
|
|
174
177
|
login_cluster = "Name or ID of the cluster to log into. If omitted: auto-select when only one is available, or prompt interactively"
|
|
178
|
+
login_org = "Name or ID of the organization to log into. If omitted: auto-select when only one is available, or prompt interactively"
|
|
175
179
|
claude = "Install Ellf Claude Code skills and transcript hook into ~/.claude/"
|
|
176
180
|
use_active_venv = "Use the currently active virtualenv, instead of making a temporary one"
|
|
177
181
|
filter_by = "Filter by {filter}"
|
|
@@ -217,6 +221,9 @@ ellf secret create my-credentials OPENAI_API_KEY="sk-..." CUSTOM_SECRET=-
|
|
|
217
221
|
E118 = "Error requesting recipe schemas"
|
|
218
222
|
E121 = "Invalid JSON for meta"
|
|
219
223
|
E128 = "Cannot parse path: {path}"
|
|
224
|
+
E135 = "No organizations are accessible for this account"
|
|
225
|
+
E136 = "Multiple organizations available. Run `ellf login --org <name>` to pick one"
|
|
226
|
+
E137 = "Could not switch to organization {org_id}"
|
|
220
227
|
E144 = "Not a valid Python package: name should end in .tar.gz or .whl"
|
|
221
228
|
E145 = "No valid meta.json found in package"
|
|
222
229
|
E146 = "Invalid meta.json key ellf_recipes"
|
|
@@ -35,6 +35,7 @@ ellf_cli/cloud/__init__.py
|
|
|
35
35
|
ellf_cli/cloud/gcp.py
|
|
36
36
|
ellf_cli/commands/__init__.py
|
|
37
37
|
ellf_cli/commands/_cluster_select.py
|
|
38
|
+
ellf_cli/commands/_org_select.py
|
|
38
39
|
ellf_cli/commands/_recipe_file.py
|
|
39
40
|
ellf_cli/commands/_recipe_subcommand.py
|
|
40
41
|
ellf_cli/commands/_state.py
|
|
@@ -166,7 +167,9 @@ tests/test_info.py
|
|
|
166
167
|
tests/test_invalid_secrets.py
|
|
167
168
|
tests/test_key_pair.py
|
|
168
169
|
tests/test_login.py
|
|
170
|
+
tests/test_logout.py
|
|
169
171
|
tests/test_main.py
|
|
172
|
+
tests/test_org_select.py
|
|
170
173
|
tests/test_plans.py
|
|
171
174
|
tests/test_projects.py
|
|
172
175
|
tests/test_query.py
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
from uuid import uuid4
|
|
2
|
+
|
|
3
|
+
from ellf_cli.auth import FileSecrets
|
|
4
|
+
from ellf_cli.commands._state import get_root_cfg, get_saved_settings
|
|
5
|
+
from ellf_cli.commands.general import logout
|
|
6
|
+
from ellf_cli.config import DEFAULT_PAM_HOST, SavedSettings
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _seed_secrets() -> None:
|
|
10
|
+
"""Write a secrets.json with a sentinel id_token at the active config path."""
|
|
11
|
+
ctx = get_root_cfg()
|
|
12
|
+
ctx.secrets_path.parent.mkdir(parents=True, exist_ok=True)
|
|
13
|
+
FileSecrets(id_token="sentinel-id-token").save(ctx.secrets_path)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _seed_settings() -> None:
|
|
17
|
+
"""Write saved-defaults.json populated with workflow defaults."""
|
|
18
|
+
ctx = get_root_cfg()
|
|
19
|
+
settings = SavedSettings(
|
|
20
|
+
broker_host="https://cluster.example.com",
|
|
21
|
+
project=uuid4(),
|
|
22
|
+
task=uuid4(),
|
|
23
|
+
action=uuid4(),
|
|
24
|
+
agent=uuid4(),
|
|
25
|
+
pam_host="https://pam.example.com",
|
|
26
|
+
cluster_id=uuid4(),
|
|
27
|
+
org_id=uuid4(),
|
|
28
|
+
recipes_file="/tmp/recipes.json",
|
|
29
|
+
)
|
|
30
|
+
settings.save(ctx.saved_settings_path)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_logout_removes_secrets_file():
|
|
34
|
+
_seed_secrets()
|
|
35
|
+
ctx = get_root_cfg()
|
|
36
|
+
assert ctx.secrets_path.exists()
|
|
37
|
+
logout()
|
|
38
|
+
assert not ctx.secrets_path.exists()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_logout_is_idempotent_when_no_secrets():
|
|
42
|
+
ctx = get_root_cfg()
|
|
43
|
+
assert not ctx.secrets_path.exists()
|
|
44
|
+
# Should not raise even when nothing to clean up.
|
|
45
|
+
logout()
|
|
46
|
+
assert not ctx.secrets_path.exists()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_logout_default_keeps_saved_settings():
|
|
50
|
+
_seed_secrets()
|
|
51
|
+
_seed_settings()
|
|
52
|
+
ctx = get_root_cfg()
|
|
53
|
+
logout()
|
|
54
|
+
# secrets gone, saved-defaults untouched
|
|
55
|
+
assert not ctx.secrets_path.exists()
|
|
56
|
+
persisted = SavedSettings.from_file(ctx.saved_settings_path)
|
|
57
|
+
assert persisted.org_id is not None
|
|
58
|
+
assert persisted.cluster_id is not None
|
|
59
|
+
assert persisted.broker_host == "https://cluster.example.com"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_logout_all_resets_saved_settings():
|
|
63
|
+
_seed_secrets()
|
|
64
|
+
_seed_settings()
|
|
65
|
+
ctx = get_root_cfg()
|
|
66
|
+
logout(all_=True)
|
|
67
|
+
assert not ctx.secrets_path.exists()
|
|
68
|
+
persisted = SavedSettings.from_file(ctx.saved_settings_path)
|
|
69
|
+
# `blank()` zeroes everything except pam_host, which defaults back
|
|
70
|
+
# to DEFAULT_PAM_HOST so the next `ellf login` still knows where
|
|
71
|
+
# to talk.
|
|
72
|
+
assert persisted.org_id is None
|
|
73
|
+
assert persisted.cluster_id is None
|
|
74
|
+
assert persisted.broker_host is None
|
|
75
|
+
assert persisted.project is None
|
|
76
|
+
assert persisted.task is None
|
|
77
|
+
assert persisted.action is None
|
|
78
|
+
assert persisted.agent is None
|
|
79
|
+
assert persisted.recipes_file is None
|
|
80
|
+
assert persisted.pam_host == DEFAULT_PAM_HOST
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_logout_all_without_existing_settings_file():
|
|
84
|
+
"""--all on a machine that never wrote saved-defaults.json shouldn't
|
|
85
|
+
create one. Otherwise we'd leave a sticky artifact on what should be
|
|
86
|
+
a "clean slate" command.
|
|
87
|
+
"""
|
|
88
|
+
_seed_secrets()
|
|
89
|
+
ctx = get_root_cfg()
|
|
90
|
+
# Defensive: confirm settings file isn't there
|
|
91
|
+
if ctx.saved_settings_path.exists():
|
|
92
|
+
ctx.saved_settings_path.unlink()
|
|
93
|
+
logout(all_=True)
|
|
94
|
+
assert not ctx.secrets_path.exists()
|
|
95
|
+
assert not ctx.saved_settings_path.exists()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_logout_does_not_invoke_network(monkeypatch):
|
|
99
|
+
"""logout is a local operation — no PAM round-trip required."""
|
|
100
|
+
import httpx
|
|
101
|
+
|
|
102
|
+
def _explode(*_args, **_kwargs):
|
|
103
|
+
raise AssertionError("logout must not touch the network")
|
|
104
|
+
|
|
105
|
+
monkeypatch.setattr(httpx, "post", _explode)
|
|
106
|
+
monkeypatch.setattr(httpx, "get", _explode)
|
|
107
|
+
_seed_secrets()
|
|
108
|
+
logout()
|
|
109
|
+
# If we got here without the assertion firing, no network call happened.
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_logout_clears_get_saved_settings_cache_independence():
|
|
113
|
+
"""The in-memory cached SavedSettings from a prior get_saved_settings()
|
|
114
|
+
call is not mutated by `logout` itself — it just rewrites the file.
|
|
115
|
+
The next process invocation will pick up the new on-disk state.
|
|
116
|
+
"""
|
|
117
|
+
_seed_secrets()
|
|
118
|
+
_seed_settings()
|
|
119
|
+
cached = get_saved_settings()
|
|
120
|
+
assert cached.org_id is not None
|
|
121
|
+
logout(all_=True)
|
|
122
|
+
# Re-read from disk to verify the persisted state, since the cached
|
|
123
|
+
# object lives in a ContextVar and isn't refreshed in-process.
|
|
124
|
+
ctx = get_root_cfg()
|
|
125
|
+
on_disk = SavedSettings.from_file(ctx.saved_settings_path)
|
|
126
|
+
assert on_disk.org_id is None
|