elva-cli 0.0.4__tar.gz → 0.1.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.

Potentially problematic release.


This version of elva-cli might be problematic. Click here for more details.

Files changed (42) hide show
  1. {elva_cli-0.0.4 → elva_cli-0.1.0}/PKG-INFO +74 -1
  2. elva_cli-0.1.0/README.md +130 -0
  3. {elva_cli-0.0.4 → elva_cli-0.1.0}/pyproject.toml +1 -0
  4. {elva_cli-0.0.4 → elva_cli-0.1.0}/src/elva_cli/_version.py +2 -2
  5. elva_cli-0.1.0/src/elva_cli/commands/config.py +40 -0
  6. elva_cli-0.1.0/src/elva_cli/context.py +67 -0
  7. {elva_cli-0.0.4 → elva_cli-0.1.0}/src/elva_cli/main.py +28 -5
  8. elva_cli-0.1.0/src/elva_cli/registry.py +46 -0
  9. elva_cli-0.1.0/src/elva_cli/settings/__init__.py +6 -0
  10. elva_cli-0.1.0/src/elva_cli/settings/loader.py +154 -0
  11. elva_cli-0.1.0/src/elva_cli/settings/models.py +38 -0
  12. elva_cli-0.1.0/src/elva_cli/settings/paths.py +40 -0
  13. elva_cli-0.1.0/tests/cli/test_config_command.py +84 -0
  14. elva_cli-0.1.0/tests/cli/test_lazy_imports.py +45 -0
  15. elva_cli-0.1.0/tests/unit/test_context.py +57 -0
  16. elva_cli-0.1.0/tests/unit/test_settings_loader.py +148 -0
  17. elva_cli-0.0.4/README.md +0 -58
  18. elva_cli-0.0.4/src/elva_cli/context.py +0 -5
  19. elva_cli-0.0.4/src/elva_cli/registry.py +0 -5
  20. elva_cli-0.0.4/src/elva_cli/settings/__init__.py +0 -5
  21. elva_cli-0.0.4/src/elva_cli/settings/paths.py +0 -16
  22. {elva_cli-0.0.4 → elva_cli-0.1.0}/.gitignore +0 -0
  23. {elva_cli-0.0.4 → elva_cli-0.1.0}/src/elva_cli/__init__.py +0 -0
  24. {elva_cli-0.0.4 → elva_cli-0.1.0}/src/elva_cli/__main__.py +0 -0
  25. {elva_cli-0.0.4 → elva_cli-0.1.0}/src/elva_cli/auth/__init__.py +0 -0
  26. {elva_cli-0.0.4 → elva_cli-0.1.0}/src/elva_cli/commands/__init__.py +0 -0
  27. {elva_cli-0.0.4 → elva_cli-0.1.0}/src/elva_cli/core/__init__.py +0 -0
  28. {elva_cli-0.0.4 → elva_cli-0.1.0}/src/elva_cli/core/api/__init__.py +0 -0
  29. {elva_cli-0.0.4 → elva_cli-0.1.0}/src/elva_cli/core/services/__init__.py +0 -0
  30. {elva_cli-0.0.4 → elva_cli-0.1.0}/src/elva_cli/core/spec/__init__.py +0 -0
  31. {elva_cli-0.0.4 → elva_cli-0.1.0}/src/elva_cli/errors.py +0 -0
  32. {elva_cli-0.0.4 → elva_cli-0.1.0}/src/elva_cli/logging.py +0 -0
  33. {elva_cli-0.0.4 → elva_cli-0.1.0}/src/elva_cli/telemetry.py +0 -0
  34. {elva_cli-0.0.4 → elva_cli-0.1.0}/src/elva_cli/ui/__init__.py +0 -0
  35. {elva_cli-0.0.4 → elva_cli-0.1.0}/src/elva_cli/ui/prompts.py +0 -0
  36. {elva_cli-0.0.4 → elva_cli-0.1.0}/src/elva_cli/ui/renderables/__init__.py +0 -0
  37. {elva_cli-0.0.4 → elva_cli-0.1.0}/src/elva_cli/ui/views/__init__.py +0 -0
  38. {elva_cli-0.0.4 → elva_cli-0.1.0}/src/elva_cli/update.py +0 -0
  39. {elva_cli-0.0.4 → elva_cli-0.1.0}/tests/cli/test_cli_exit_codes.py +0 -0
  40. {elva_cli-0.0.4 → elva_cli-0.1.0}/tests/unit/test_error_boundary.py +0 -0
  41. {elva_cli-0.0.4 → elva_cli-0.1.0}/tests/unit/test_errors.py +0 -0
  42. {elva_cli-0.0.4 → elva_cli-0.1.0}/tests/unit/test_exit_codes.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: elva-cli
3
- Version: 0.0.4
3
+ Version: 0.1.0
4
4
  Summary: Elva - CLI for Theneo Elva
5
5
  Project-URL: Homepage, https://getelva.ai
6
6
  Project-URL: Source, https://github.com/Theneo-Inc/theneo-elva-cli
@@ -17,6 +17,7 @@ Classifier: Topic :: Software Development :: Documentation
17
17
  Classifier: Typing :: Typed
18
18
  Requires-Python: >=3.11
19
19
  Requires-Dist: platformdirs>=4.2
20
+ Requires-Dist: pydantic>=2.7
20
21
  Requires-Dist: typer<1.0,>=0.15
21
22
  Provides-Extra: dev
22
23
  Requires-Dist: mypy>=1.11; extra == 'dev'
@@ -72,6 +73,77 @@ powershell -c "irm https://astral.sh/uv/install.ps1|iex" # Windows
72
73
  uv tool upgrade elva-cli # or: pipx upgrade elva-cli
73
74
  ```
74
75
 
76
+ ## Configuration
77
+
78
+ Settings can come from several places. Highest priority wins:
79
+
80
+ 1. Command flags: `--workspace`, `--collection`, `--profile`
81
+ 2. Environment: `ELVA_WORKSPACE`, `ELVA_COLLECTION`, `ELVA_PROFILE`, `ELVA_TIMEOUT`
82
+ 3. `elva.json` in your project
83
+ 4. The selected profile in your user config
84
+ 5. Your user config
85
+ 6. Built in defaults
86
+
87
+ ### Project file
88
+
89
+ Commit an `elva.json` next to your spec and stop repeating flags:
90
+
91
+ ```json
92
+ {
93
+ "workspace": "payments-team",
94
+ "collection": "payments-api"
95
+ }
96
+ ```
97
+
98
+ It is found by walking up from the current directory to the repo root, so it works
99
+ from any subfolder. Keep secrets out of it, it is meant to be committed.
100
+
101
+ ### User config and profiles
102
+
103
+ | Platform | Location |
104
+ |---|---|
105
+ | Linux | `~/.config/elva/config.json` |
106
+ | macOS | `~/Library/Application Support/elva/config.json` |
107
+ | Windows | `%LOCALAPPDATA%\elva\config.json` |
108
+
109
+ A profile is a named set of defaults. Useful when you work across more than one
110
+ workspace and do not want a project file for each:
111
+
112
+ ```json
113
+ {
114
+ "profiles": {
115
+ "work": { "workspace": "work-team", "collection": "work-api" },
116
+ "side": { "workspace": "side-team" }
117
+ }
118
+ }
119
+ ```
120
+
121
+ ```bash
122
+ elva --profile work collection list
123
+ ```
124
+
125
+ A project file beats a profile, so a repo with its own `elva.json` always wins over
126
+ whichever profile you have selected.
127
+
128
+ ### Seeing what was resolved
129
+
130
+ When something targets the wrong place, these two answer it:
131
+
132
+ ```bash
133
+ elva config path # which files were read, and whether they exist
134
+ elva config list # each value, and which layer set it
135
+ ```
136
+
137
+ ```
138
+ $ elva --profile work config list
139
+ collection work-api profile:work
140
+ profile work flag
141
+ timeout 30.0 default
142
+ workspace work-team profile:work
143
+
144
+ profiles side, work
145
+ ```
146
+
75
147
  ## Requirements
76
148
 
77
149
  - Python 3.11 or newer (bundled automatically if you install via `uv tool`)
@@ -81,4 +153,5 @@ uv tool upgrade elva-cli # or: pipx upgrade elva-cli
81
153
 
82
154
  - [Elva](https://getelva.ai)
83
155
  - [Issues](https://github.com/Theneo-Inc/theneo-elva-cli/issues)
156
+ - [Exit codes](docs/exit-codes.md), for scripting and CI
84
157
  - [Contributing](CONTRIBUTING.md)
@@ -0,0 +1,130 @@
1
+ # Elva CLI
2
+
3
+ Manage your [Elva](https://getelva.ai) API projects from the terminal: import specs,
4
+ inspect collections, and generate MCP servers without opening a browser.
5
+
6
+ > **Early alpha.** The command surface is still taking shape. This release ships
7
+ > `--version` and `--help` only; the first working commands land in `0.1.0`.
8
+
9
+ ## Install
10
+
11
+ Requires Python 3.11 or newer.
12
+
13
+ ```bash
14
+ uv tool install elva-cli
15
+ ```
16
+
17
+ Or with [pipx](https://pipx.pypa.io/), if you already use it:
18
+
19
+ ```bash
20
+ pipx install elva-cli
21
+ ```
22
+
23
+ Either way, `elva` is then available from any directory:
24
+
25
+ ```bash
26
+ elva --version
27
+ elva --help
28
+ ```
29
+
30
+ To try it without installing anything:
31
+
32
+ ```bash
33
+ uvx --from elva-cli elva --version
34
+ ```
35
+
36
+ Don't have `uv`? It is a single command and no prerequisites:
37
+
38
+ ```bash
39
+ curl -fsSL https://astral.sh/uv/install.sh | sh # macOS, Linux
40
+ powershell -c "irm https://astral.sh/uv/install.ps1|iex" # Windows
41
+ ```
42
+
43
+ ### Upgrade
44
+
45
+ ```bash
46
+ uv tool upgrade elva-cli # or: pipx upgrade elva-cli
47
+ ```
48
+
49
+ ## Configuration
50
+
51
+ Settings can come from several places. Highest priority wins:
52
+
53
+ 1. Command flags: `--workspace`, `--collection`, `--profile`
54
+ 2. Environment: `ELVA_WORKSPACE`, `ELVA_COLLECTION`, `ELVA_PROFILE`, `ELVA_TIMEOUT`
55
+ 3. `elva.json` in your project
56
+ 4. The selected profile in your user config
57
+ 5. Your user config
58
+ 6. Built in defaults
59
+
60
+ ### Project file
61
+
62
+ Commit an `elva.json` next to your spec and stop repeating flags:
63
+
64
+ ```json
65
+ {
66
+ "workspace": "payments-team",
67
+ "collection": "payments-api"
68
+ }
69
+ ```
70
+
71
+ It is found by walking up from the current directory to the repo root, so it works
72
+ from any subfolder. Keep secrets out of it, it is meant to be committed.
73
+
74
+ ### User config and profiles
75
+
76
+ | Platform | Location |
77
+ |---|---|
78
+ | Linux | `~/.config/elva/config.json` |
79
+ | macOS | `~/Library/Application Support/elva/config.json` |
80
+ | Windows | `%LOCALAPPDATA%\elva\config.json` |
81
+
82
+ A profile is a named set of defaults. Useful when you work across more than one
83
+ workspace and do not want a project file for each:
84
+
85
+ ```json
86
+ {
87
+ "profiles": {
88
+ "work": { "workspace": "work-team", "collection": "work-api" },
89
+ "side": { "workspace": "side-team" }
90
+ }
91
+ }
92
+ ```
93
+
94
+ ```bash
95
+ elva --profile work collection list
96
+ ```
97
+
98
+ A project file beats a profile, so a repo with its own `elva.json` always wins over
99
+ whichever profile you have selected.
100
+
101
+ ### Seeing what was resolved
102
+
103
+ When something targets the wrong place, these two answer it:
104
+
105
+ ```bash
106
+ elva config path # which files were read, and whether they exist
107
+ elva config list # each value, and which layer set it
108
+ ```
109
+
110
+ ```
111
+ $ elva --profile work config list
112
+ collection work-api profile:work
113
+ profile work flag
114
+ timeout 30.0 default
115
+ workspace work-team profile:work
116
+
117
+ profiles side, work
118
+ ```
119
+
120
+ ## Requirements
121
+
122
+ - Python 3.11 or newer (bundled automatically if you install via `uv tool`)
123
+ - An [Elva](https://getelva.ai) account
124
+
125
+ ## Links
126
+
127
+ - [Elva](https://getelva.ai)
128
+ - [Issues](https://github.com/Theneo-Inc/theneo-elva-cli/issues)
129
+ - [Exit codes](docs/exit-codes.md), for scripting and CI
130
+ - [Contributing](CONTRIBUTING.md)
@@ -24,6 +24,7 @@ classifiers = [
24
24
  dependencies = [
25
25
  "typer>=0.15,<1.0",
26
26
  "platformdirs>=4.2",
27
+ "pydantic>=2.7",
27
28
  ]
28
29
 
29
30
  [project.optional-dependencies]
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.0.4'
22
- __version_tuple__ = version_tuple = (0, 0, 4)
21
+ __version__ = version = '0.1.0'
22
+ __version_tuple__ = version_tuple = (0, 1, 0)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -0,0 +1,40 @@
1
+ from __future__ import annotations
2
+
3
+ import typer
4
+
5
+ from elva_cli.context import get_ctx
6
+
7
+ app = typer.Typer(name="config", help="Inspect resolved configuration.", no_args_is_help=True)
8
+
9
+
10
+ def _row(label: str, value: str, note: str = "") -> str:
11
+ return f"{label:<16}{value}{' ' + note if note else ''}"
12
+
13
+
14
+ @app.command("path")
15
+ def path(click_ctx: typer.Context) -> None:
16
+ """Show every file the CLI reads configuration from."""
17
+ from elva_cli.settings import paths
18
+
19
+ ctx = get_ctx(click_ctx)
20
+ typer.echo(_row("config dir", str(paths.config_dir())))
21
+ typer.echo(_row("cache dir", str(paths.cache_dir())))
22
+ for file in ctx.resolution.files:
23
+ typer.echo(
24
+ _row(f"{file.kind} config", str(file.path), "(found)" if file.exists else "(absent)")
25
+ )
26
+
27
+
28
+ @app.command("list")
29
+ def list_(click_ctx: typer.Context) -> None:
30
+ """Show each setting, its value, and which layer set it."""
31
+ ctx = get_ctx(click_ctx)
32
+ resolution = ctx.resolution
33
+ settings = resolution.settings
34
+ for field in sorted(type(settings).model_fields):
35
+ value = getattr(settings, field)
36
+ shown = "-" if value is None else str(value)
37
+ typer.echo(f"{field:<14}{shown:<32}{resolution.origins[field]}")
38
+ if resolution.profiles:
39
+ typer.echo("")
40
+ typer.echo(f"profiles {', '.join(resolution.profiles)}")
@@ -0,0 +1,67 @@
1
+ """The Ctx object, built once in the root callback and passed to every command.
2
+
3
+ Commands read settings from here instead of touching the environment or the
4
+ filesystem themselves, so configuration is resolved in exactly one place.
5
+
6
+ Resolution is a cached_property because it pulls in pydantic. `elva --version`
7
+ must not pay for that.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass
13
+ from functools import cached_property
14
+ from typing import TYPE_CHECKING
15
+
16
+ from elva_cli.errors import ElvaError
17
+
18
+ if TYPE_CHECKING:
19
+ from collections.abc import Mapping
20
+ from pathlib import Path
21
+
22
+ import typer
23
+
24
+ from elva_cli.settings.loader import Resolution
25
+ from elva_cli.settings.models import Settings
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class GlobalOptions:
30
+ profile: str | None = None
31
+ base_url: str | None = None
32
+ workspace: str | None = None
33
+ collection: str | None = None
34
+
35
+
36
+ class Ctx:
37
+ def __init__(self, options: GlobalOptions, *, cwd: Path, env: Mapping[str, str]) -> None:
38
+ self.options = options
39
+ self.cwd = cwd
40
+ self.env = env
41
+
42
+ @cached_property
43
+ def resolution(self) -> Resolution:
44
+ from elva_cli.settings.loader import resolve
45
+
46
+ return resolve(
47
+ overrides={
48
+ "profile": self.options.profile,
49
+ "base_url": self.options.base_url,
50
+ "workspace": self.options.workspace,
51
+ "collection": self.options.collection,
52
+ },
53
+ env=self.env,
54
+ cwd=self.cwd,
55
+ )
56
+
57
+ @property
58
+ def settings(self) -> Settings:
59
+ return self.resolution.settings
60
+
61
+
62
+ def get_ctx(click_ctx: typer.Context) -> Ctx:
63
+ ctx = click_ctx.obj
64
+ if not isinstance(ctx, Ctx):
65
+ msg = "no Ctx on the context; the root callback did not run"
66
+ raise ElvaError(msg)
67
+ return ctx
@@ -5,16 +5,17 @@ from __future__ import annotations
5
5
  import os
6
6
  import platform
7
7
  import sys
8
- from typing import TYPE_CHECKING, Protocol, TypeGuard
8
+ from pathlib import Path
9
+ from typing import Protocol, TypeGuard
9
10
 
10
11
  import typer
11
12
 
13
+ from elva_cli.context import Ctx, GlobalOptions
12
14
  from elva_cli.errors import ElvaError, ExitCode
13
-
14
- if TYPE_CHECKING:
15
- from pathlib import Path
15
+ from elva_cli.registry import LazyGroup
16
16
 
17
17
  app = typer.Typer(
18
+ cls=LazyGroup,
18
19
  name="elva",
19
20
  help="Elva - CLI for Theneo Elva.",
20
21
  no_args_is_help=True,
@@ -35,6 +36,19 @@ def _version_callback(value: bool) -> None:
35
36
 
36
37
  @app.callback()
37
38
  def root(
39
+ click_ctx: typer.Context,
40
+ profile: str | None = typer.Option(
41
+ None, "--profile", envvar="ELVA_PROFILE", help="Named set of defaults from your config."
42
+ ),
43
+ # Internal escape hatch for Theneo development and CI against staging. Users
44
+ # only ever have prod, so it stays out of --help and out of the README.
45
+ base_url: str | None = typer.Option(None, "--base-url", envvar="ELVA_BASE_URL", hidden=True),
46
+ workspace: str | None = typer.Option(
47
+ None, "--workspace", "-w", envvar="ELVA_WORKSPACE", help="Workspace to act on."
48
+ ),
49
+ collection: str | None = typer.Option(
50
+ None, "--collection", "-c", envvar="ELVA_COLLECTION", help="Collection to act on."
51
+ ),
38
52
  version: bool = typer.Option(
39
53
  False,
40
54
  "--version",
@@ -44,7 +58,16 @@ def root(
44
58
  help="Show the current build version.",
45
59
  ),
46
60
  ) -> None:
47
- pass
61
+ click_ctx.obj = Ctx(
62
+ GlobalOptions(
63
+ profile=profile,
64
+ base_url=base_url,
65
+ workspace=workspace,
66
+ collection=collection,
67
+ ),
68
+ cwd=Path.cwd(),
69
+ env=os.environ,
70
+ )
48
71
 
49
72
 
50
73
  def report(error: ElvaError) -> None:
@@ -0,0 +1,46 @@
1
+ """Lazy command dispatch.
2
+
3
+ Only the command a user actually typed gets imported. That keeps `elva --version`
4
+ away from pydantic, httpx and anything else a command pulls in.
5
+
6
+ Adding a command means adding a line here.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import importlib
12
+ from dataclasses import dataclass
13
+ from typing import TYPE_CHECKING, ClassVar
14
+
15
+ from typer.core import TyperGroup
16
+
17
+ if TYPE_CHECKING:
18
+ from typer._click.core import Command, Context
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class Lazy:
23
+ module: str
24
+ help: str
25
+
26
+
27
+ class LazyGroup(TyperGroup):
28
+ commands_: ClassVar[dict[str, Lazy]] = {
29
+ "config": Lazy("elva_cli.commands.config", "Inspect resolved configuration."),
30
+ }
31
+
32
+ def list_commands(self, ctx: Context) -> list[str]:
33
+ return sorted({*super().list_commands(ctx), *self.commands_})
34
+
35
+ def get_command(self, ctx: Context, cmd_name: str) -> Command | None:
36
+ lazy = self.commands_.get(cmd_name)
37
+ if lazy is None:
38
+ return super().get_command(ctx, cmd_name)
39
+
40
+ import typer.main
41
+
42
+ module = importlib.import_module(lazy.module)
43
+ command = typer.main.get_command(module.app)
44
+ command.name = cmd_name
45
+ command.short_help = lazy.help
46
+ return command
@@ -0,0 +1,6 @@
1
+ """Configuration schema and precedence resolution.
2
+
3
+ Order, highest first: command flags, ELVA_* environment, project elva.json,
4
+ the selected profile in the user config, user config, defaults. Resolved once in
5
+ the root callback and frozen onto the Ctx.
6
+ """
@@ -0,0 +1,154 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import dataclass
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ from pydantic import ValidationError as PydanticValidationError
8
+
9
+ from elva_cli.errors import ConfigError
10
+ from elva_cli.settings import paths
11
+ from elva_cli.settings.models import Settings
12
+
13
+ if TYPE_CHECKING:
14
+ from collections.abc import Mapping
15
+ from pathlib import Path
16
+
17
+ ENV_PREFIX = "ELVA_"
18
+
19
+ _ENV_KEYS = {
20
+ "ELVA_BASE_URL": "base_url",
21
+ "ELVA_PROFILE": "profile",
22
+ "ELVA_WORKSPACE": "workspace",
23
+ "ELVA_COLLECTION": "collection",
24
+ "ELVA_TIMEOUT": "timeout",
25
+ }
26
+
27
+ DEFAULT_PROFILE = "default"
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class ConfigFile:
32
+ kind: str
33
+ path: Path
34
+ exists: bool
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class Resolution:
39
+ settings: Settings
40
+ origins: dict[str, str]
41
+ files: tuple[ConfigFile, ...]
42
+ profiles: tuple[str, ...]
43
+
44
+
45
+ def _read_json(path: Path) -> dict[str, Any]:
46
+ try:
47
+ raw = path.read_text(encoding="utf-8")
48
+ except OSError as exc:
49
+ raise ConfigError(f"cannot read {path}: {exc}") from exc
50
+ try:
51
+ data = json.loads(raw)
52
+ except json.JSONDecodeError as exc:
53
+ raise ConfigError(f"{path} is not valid JSON: {exc}") from exc
54
+ if not isinstance(data, dict):
55
+ raise ConfigError(f"{path} must contain a JSON object")
56
+ return data
57
+
58
+
59
+ def _coerce(field: str, raw: str, source: str) -> Any:
60
+ if field == "timeout":
61
+ try:
62
+ return float(raw)
63
+ except ValueError:
64
+ raise ConfigError(f"{source} must be a number, got {raw!r}") from None
65
+ return raw
66
+
67
+
68
+ def _split_profiles(data: dict[str, Any], path: Path) -> tuple[dict[str, Any], dict[str, Any]]:
69
+ profiles = data.pop("profiles", {})
70
+ if not isinstance(profiles, dict):
71
+ raise ConfigError(f'"profiles" in {path} must be an object')
72
+ return data, profiles
73
+
74
+
75
+ def resolve(
76
+ *,
77
+ overrides: Mapping[str, Any] | None = None,
78
+ env: Mapping[str, str],
79
+ cwd: Path,
80
+ ) -> Resolution:
81
+ """Merge every configuration source into one frozen Settings.
82
+
83
+ Highest precedence first, and the first layer carrying a field wins.
84
+ """
85
+ flags = {k: v for k, v in (overrides or {}).items() if v is not None}
86
+
87
+ env_layer: dict[str, Any] = {}
88
+ for key, field in _ENV_KEYS.items():
89
+ raw = env.get(key)
90
+ if raw:
91
+ env_layer[field] = _coerce(field, raw, key)
92
+
93
+ user_path = paths.user_config_file()
94
+ user_layer, profiles = (
95
+ _split_profiles(_read_json(user_path), user_path) if user_path.is_file() else ({}, {})
96
+ )
97
+
98
+ project_path = paths.find_project_file(cwd)
99
+ project_layer = _read_json(project_path) if project_path else {}
100
+ project_layer.pop("profiles", None)
101
+
102
+ selected = (
103
+ flags.get("profile")
104
+ or env_layer.get("profile")
105
+ or project_layer.get("profile")
106
+ or user_layer.get("profile")
107
+ or DEFAULT_PROFILE
108
+ )
109
+ overlay = profiles.get(selected, {})
110
+ if not isinstance(overlay, dict):
111
+ raise ConfigError(f'profile "{selected}" must be an object')
112
+ if selected != DEFAULT_PROFILE and selected not in profiles:
113
+ raise ConfigError(
114
+ f'unknown profile "{selected}"',
115
+ hint=f"Profiles defined in {user_path}: {', '.join(sorted(profiles)) or 'none'}",
116
+ )
117
+
118
+ layers: list[tuple[str, Mapping[str, Any]]] = [
119
+ ("flag", flags),
120
+ ("env", env_layer),
121
+ ("project", project_layer),
122
+ (f"profile:{selected}", overlay),
123
+ ("user", user_layer),
124
+ ]
125
+
126
+ merged: dict[str, Any] = {}
127
+ origins: dict[str, str] = {}
128
+ for name, layer in layers:
129
+ for field, value in layer.items():
130
+ if field not in merged:
131
+ merged[field] = value
132
+ origins[field] = name
133
+
134
+ try:
135
+ settings = Settings(**merged)
136
+ except PydanticValidationError as exc:
137
+ first = exc.errors()[0]
138
+ field = str(first["loc"][0]) if first["loc"] else "config"
139
+ where = origins.get(field, "default")
140
+ raise ConfigError(f"invalid setting {field!r} from {where}: {first['msg']}") from exc
141
+
142
+ for field in Settings.model_fields:
143
+ origins.setdefault(field, "default")
144
+
145
+ files = (
146
+ ConfigFile("project", project_path or cwd / paths.PROJECT_FILE, project_path is not None),
147
+ ConfigFile("user", user_path, user_path.is_file()),
148
+ )
149
+ return Resolution(
150
+ settings=settings,
151
+ origins=origins,
152
+ files=files,
153
+ profiles=tuple(sorted(profiles)),
154
+ )
@@ -0,0 +1,38 @@
1
+ from __future__ import annotations
2
+
3
+ from pydantic import BaseModel, ConfigDict, field_validator
4
+
5
+ DEFAULT_BASE_URL = "https://api.getelva.ai"
6
+
7
+
8
+ class Settings(BaseModel):
9
+ """Every setting the CLI has.
10
+
11
+ Adding a field here is the only way to add a setting: the loader derives its
12
+ precedence handling and `elva config list` derives its output. Unknown keys in
13
+ a config file are an error rather than a silent typo.
14
+ """
15
+
16
+ model_config = ConfigDict(extra="forbid", frozen=True)
17
+
18
+ base_url: str = DEFAULT_BASE_URL
19
+ profile: str = "default"
20
+ workspace: str | None = None
21
+ collection: str | None = None
22
+ timeout: float = 30.0
23
+
24
+ @field_validator("base_url")
25
+ @classmethod
26
+ def _must_be_http(cls, value: str) -> str:
27
+ if not value.startswith(("http://", "https://")):
28
+ msg = "must start with http:// or https://"
29
+ raise ValueError(msg)
30
+ return value.rstrip("/")
31
+
32
+ @field_validator("timeout")
33
+ @classmethod
34
+ def _must_be_positive(cls, value: float) -> float:
35
+ if value <= 0:
36
+ msg = "must be greater than 0"
37
+ raise ValueError(msg)
38
+ return value
@@ -0,0 +1,40 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import platformdirs
6
+
7
+ APP_NAME = "elva"
8
+ PROJECT_FILE = "elva.json"
9
+ USER_CONFIG_FILE = "config.json"
10
+
11
+
12
+ def config_dir() -> Path:
13
+ return Path(platformdirs.user_config_dir(APP_NAME, appauthor=False))
14
+
15
+
16
+ def cache_dir() -> Path:
17
+ return Path(platformdirs.user_cache_dir(APP_NAME, appauthor=False))
18
+
19
+
20
+ def crash_dir() -> Path:
21
+ return cache_dir() / "crashes"
22
+
23
+
24
+ def user_config_file() -> Path:
25
+ return config_dir() / USER_CONFIG_FILE
26
+
27
+
28
+ def find_project_file(start: Path) -> Path | None:
29
+ """Walk up from start looking for elva.json, stopping at the repo root.
30
+
31
+ The directory holding .git is checked and then the search ends, so a stray
32
+ elva.json above someone's checkout is never picked up.
33
+ """
34
+ for directory in [start, *start.parents]:
35
+ candidate = directory / PROJECT_FILE
36
+ if candidate.is_file():
37
+ return candidate
38
+ if (directory / ".git").exists():
39
+ break
40
+ return None
@@ -0,0 +1,84 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import subprocess
5
+ import sys
6
+ from typing import TYPE_CHECKING
7
+
8
+ if TYPE_CHECKING:
9
+ from pathlib import Path
10
+
11
+
12
+ def run(
13
+ *args: str, cwd: Path, env: dict[str, str] | None = None
14
+ ) -> subprocess.CompletedProcess[str]:
15
+ import os
16
+
17
+ full = {**os.environ, "XDG_CONFIG_HOME": str(cwd / "xdgconfig")}
18
+ full.update(env or {})
19
+ return subprocess.run(
20
+ [sys.executable, "-m", "elva_cli", *args],
21
+ capture_output=True,
22
+ text=True,
23
+ check=False,
24
+ cwd=cwd,
25
+ env=full,
26
+ )
27
+
28
+
29
+ def project(tmp_path: Path, data: dict[str, object] | None = None) -> Path:
30
+ root = tmp_path / "repo"
31
+ root.mkdir()
32
+ (root / ".git").mkdir()
33
+ if data is not None:
34
+ (root / "elva.json").write_text(json.dumps(data), encoding="utf-8")
35
+ return root
36
+
37
+
38
+ def test_config_path_lists_every_file_even_when_absent(tmp_path: Path) -> None:
39
+ root = project(tmp_path)
40
+ result = run("config", "path", cwd=root)
41
+ assert result.returncode == 0
42
+ assert "project config" in result.stdout
43
+ assert "user config" in result.stdout
44
+ assert "(absent)" in result.stdout
45
+
46
+
47
+ def test_config_path_marks_an_existing_project_file(tmp_path: Path) -> None:
48
+ root = project(tmp_path, {"workspace": "payments"})
49
+ result = run("config", "path", cwd=root)
50
+ assert "(found)" in result.stdout
51
+ assert "elva.json" in result.stdout
52
+
53
+
54
+ def test_config_list_shows_value_and_origin(tmp_path: Path) -> None:
55
+ root = project(tmp_path, {"workspace": "payments"})
56
+ result = run("config", "list", cwd=root)
57
+ assert result.returncode == 0
58
+ lines = {line.split()[0]: line for line in result.stdout.splitlines() if line.strip()}
59
+ assert "project" in lines["workspace"]
60
+ assert "payments" in lines["workspace"]
61
+ assert "default" in lines["base_url"]
62
+
63
+
64
+ def test_config_list_reflects_a_flag(tmp_path: Path) -> None:
65
+ root = project(tmp_path, {"workspace": "payments"})
66
+ result = run("--workspace", "other", "config", "list", cwd=root)
67
+ assert "other" in result.stdout
68
+ assert "flag" in result.stdout
69
+
70
+
71
+ def test_config_list_reflects_env(tmp_path: Path) -> None:
72
+ root = project(tmp_path)
73
+ result = run("config", "list", cwd=root, env={"ELVA_BASE_URL": "https://env.example.com"})
74
+ assert "https://env.example.com" in result.stdout
75
+ assert "env" in result.stdout
76
+
77
+
78
+ def test_bad_config_reports_a_coded_error(tmp_path: Path) -> None:
79
+ root = project(tmp_path)
80
+ (root / "elva.json").write_text("{oops", encoding="utf-8")
81
+ result = run("config", "list", cwd=root)
82
+ assert result.returncode == 2
83
+ assert "ELVA_CONFIG" in result.stderr
84
+ assert "Traceback" not in result.stderr
@@ -0,0 +1,45 @@
1
+ """Guards the startup cost of `elva --version`.
2
+
3
+ pydantic and httpx are only needed once a command touches settings or the API.
4
+ If they ever get imported at module scope, every invocation pays for them.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import subprocess
10
+ import sys
11
+
12
+ PROBE = """
13
+ import sys
14
+ sys.argv = ["elva", "{args}"]
15
+ from elva_cli import main
16
+ main._run()
17
+ print("pydantic:", "pydantic" in sys.modules)
18
+ print("httpx:", "httpx" in sys.modules)
19
+ """
20
+
21
+
22
+ def probe(args: str) -> str:
23
+ result = subprocess.run(
24
+ [sys.executable, "-c", PROBE.format(args=args)],
25
+ capture_output=True,
26
+ text=True,
27
+ check=True,
28
+ )
29
+ return result.stdout
30
+
31
+
32
+ def test_version_does_not_import_pydantic_or_httpx() -> None:
33
+ out = probe("--version")
34
+ assert "pydantic: False" in out
35
+ assert "httpx: False" in out
36
+
37
+
38
+ def test_importing_main_alone_does_not_import_pydantic() -> None:
39
+ result = subprocess.run(
40
+ [sys.executable, "-c", "import elva_cli.main, sys; print('pydantic' in sys.modules)"],
41
+ capture_output=True,
42
+ text=True,
43
+ check=True,
44
+ )
45
+ assert result.stdout.strip() == "False"
@@ -0,0 +1,57 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import pytest
6
+ import typer
7
+
8
+ from elva_cli.context import Ctx, GlobalOptions, get_ctx
9
+ from elva_cli.errors import ElvaError
10
+
11
+
12
+ def test_flags_reach_the_resolver(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
13
+ monkeypatch.setattr("elva_cli.settings.paths.config_dir", lambda: tmp_path)
14
+ (tmp_path / ".git").mkdir()
15
+ ctx = Ctx(GlobalOptions(workspace="payments"), cwd=tmp_path, env={})
16
+ assert ctx.settings.workspace == "payments"
17
+ assert ctx.resolution.origins["workspace"] == "flag"
18
+
19
+
20
+ def test_resolution_is_computed_once(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
21
+ monkeypatch.setattr("elva_cli.settings.paths.config_dir", lambda: tmp_path)
22
+ (tmp_path / ".git").mkdir()
23
+ calls = 0
24
+ real = __import__("elva_cli.settings.loader", fromlist=["resolve"]).resolve
25
+
26
+ def counting(**kwargs: object) -> object:
27
+ nonlocal calls
28
+ calls += 1
29
+ return real(**kwargs)
30
+
31
+ monkeypatch.setattr("elva_cli.settings.loader.resolve", counting)
32
+ ctx = Ctx(GlobalOptions(), cwd=tmp_path, env={})
33
+ assert ctx.resolution is ctx.resolution
34
+ assert ctx.settings is not None
35
+ assert calls == 1
36
+
37
+
38
+ def _bare_context() -> typer.Context:
39
+ """A click Context with nothing on obj, as if the root callback never ran."""
40
+ dummy = typer.Typer()
41
+
42
+ @dummy.command()
43
+ def noop() -> None: ...
44
+
45
+ return typer.Context(typer.main.get_command(dummy))
46
+
47
+
48
+ def test_get_ctx_returns_what_the_callback_stored() -> None:
49
+ ctx = Ctx(GlobalOptions(), cwd=Path("/"), env={})
50
+ click_ctx = _bare_context()
51
+ click_ctx.obj = ctx
52
+ assert get_ctx(click_ctx) is ctx
53
+
54
+
55
+ def test_get_ctx_fails_loudly_when_the_callback_did_not_run() -> None:
56
+ with pytest.raises(ElvaError, match="root callback"):
57
+ get_ctx(_bare_context())
@@ -0,0 +1,148 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import TYPE_CHECKING, Any
5
+
6
+ import pytest
7
+
8
+ from elva_cli.errors import ConfigError
9
+ from elva_cli.settings.loader import resolve
10
+
11
+ if TYPE_CHECKING:
12
+ from pathlib import Path
13
+
14
+
15
+ @pytest.fixture
16
+ def home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path:
17
+ """Point the user config at a temp dir and give us an isolated project root."""
18
+ config = tmp_path / "config"
19
+ config.mkdir()
20
+ monkeypatch.setattr("elva_cli.settings.paths.config_dir", lambda: config)
21
+ project = tmp_path / "project"
22
+ project.mkdir()
23
+ (project / ".git").mkdir()
24
+ return project
25
+
26
+
27
+ def write(path: Path, data: dict[str, Any]) -> None:
28
+ path.write_text(json.dumps(data), encoding="utf-8")
29
+
30
+
31
+ def test_defaults_when_nothing_is_configured(home: Path) -> None:
32
+ result = resolve(env={}, cwd=home)
33
+ assert result.settings.base_url == "https://api.getelva.ai"
34
+ assert result.settings.workspace is None
35
+ assert result.origins["base_url"] == "default"
36
+
37
+
38
+ def test_flag_beats_env(home: Path) -> None:
39
+ result = resolve(
40
+ overrides={"workspace": "from-flag"}, env={"ELVA_WORKSPACE": "from-env"}, cwd=home
41
+ )
42
+ assert result.settings.workspace == "from-flag"
43
+ assert result.origins["workspace"] == "flag"
44
+
45
+
46
+ def test_env_beats_project_file(home: Path) -> None:
47
+ write(home / "elva.json", {"workspace": "from-project"})
48
+ result = resolve(env={"ELVA_WORKSPACE": "from-env"}, cwd=home)
49
+ assert result.settings.workspace == "from-env"
50
+ assert result.origins["workspace"] == "env"
51
+
52
+
53
+ def test_project_file_beats_user_config(home: Path, tmp_path: Path) -> None:
54
+ write(tmp_path / "config" / "config.json", {"workspace": "from-user"})
55
+ write(home / "elva.json", {"workspace": "from-project"})
56
+ result = resolve(env={}, cwd=home)
57
+ assert result.settings.workspace == "from-project"
58
+ assert result.origins["workspace"] == "project"
59
+
60
+
61
+ def test_user_config_beats_defaults(home: Path, tmp_path: Path) -> None:
62
+ write(tmp_path / "config" / "config.json", {"base_url": "https://staging.example.com"})
63
+ result = resolve(env={}, cwd=home)
64
+ assert result.settings.base_url == "https://staging.example.com"
65
+ assert result.origins["base_url"] == "user"
66
+
67
+
68
+ def test_project_file_is_found_by_walking_up(home: Path) -> None:
69
+ write(home / "elva.json", {"workspace": "root-level"})
70
+ nested = home / "a" / "b"
71
+ nested.mkdir(parents=True)
72
+ result = resolve(env={}, cwd=nested)
73
+ assert result.settings.workspace == "root-level"
74
+
75
+
76
+ def test_search_stops_at_the_repo_root(home: Path, tmp_path: Path) -> None:
77
+ write(tmp_path / "elva.json", {"workspace": "outside-the-repo"})
78
+ result = resolve(env={}, cwd=home)
79
+ assert result.settings.workspace is None
80
+
81
+
82
+ def test_selected_profile_overrides_user_defaults(home: Path, tmp_path: Path) -> None:
83
+ write(
84
+ tmp_path / "config" / "config.json",
85
+ {
86
+ "base_url": "https://api.getelva.ai",
87
+ "profiles": {"staging": {"base_url": "https://api-staging.getelva.ai"}},
88
+ },
89
+ )
90
+ result = resolve(overrides={"profile": "staging"}, env={}, cwd=home)
91
+ assert result.settings.base_url == "https://api-staging.getelva.ai"
92
+ assert result.origins["base_url"] == "profile:staging"
93
+ assert result.profiles == ("staging",)
94
+
95
+
96
+ def test_profile_can_be_selected_by_env(home: Path, tmp_path: Path) -> None:
97
+ write(tmp_path / "config" / "config.json", {"profiles": {"prod": {"timeout": 5.0}}})
98
+ result = resolve(env={"ELVA_PROFILE": "prod"}, cwd=home)
99
+ assert result.settings.timeout == 5.0
100
+
101
+
102
+ def test_unknown_profile_is_an_error(home: Path, tmp_path: Path) -> None:
103
+ write(tmp_path / "config" / "config.json", {"profiles": {"prod": {}}})
104
+ with pytest.raises(ConfigError, match="unknown profile"):
105
+ resolve(overrides={"profile": "nope"}, env={}, cwd=home)
106
+
107
+
108
+ def test_files_are_reported_whether_or_not_they_exist(home: Path) -> None:
109
+ result = resolve(env={}, cwd=home)
110
+ kinds = {f.kind: f.exists for f in result.files}
111
+ assert kinds == {"project": False, "user": False}
112
+
113
+ write(home / "elva.json", {})
114
+ result = resolve(env={}, cwd=home)
115
+ assert {f.kind: f.exists for f in result.files}["project"] is True
116
+
117
+
118
+ def test_every_field_has_an_origin(home: Path) -> None:
119
+ result = resolve(env={}, cwd=home)
120
+ assert set(result.origins) == set(type(result.settings).model_fields)
121
+
122
+
123
+ def test_malformed_json_is_an_error(home: Path) -> None:
124
+ (home / "elva.json").write_text("{not json", encoding="utf-8")
125
+ with pytest.raises(ConfigError, match="not valid JSON"):
126
+ resolve(env={}, cwd=home)
127
+
128
+
129
+ def test_unknown_key_is_an_error(home: Path) -> None:
130
+ write(home / "elva.json", {"nope": 1})
131
+ with pytest.raises(ConfigError, match="invalid setting"):
132
+ resolve(env={}, cwd=home)
133
+
134
+
135
+ def test_bad_base_url_names_the_layer_that_set_it(home: Path) -> None:
136
+ with pytest.raises(ConfigError, match="from env"):
137
+ resolve(env={"ELVA_BASE_URL": "ftp://nope"}, cwd=home)
138
+
139
+
140
+ def test_bad_timeout_from_env_is_an_error(home: Path) -> None:
141
+ with pytest.raises(ConfigError, match="must be a number"):
142
+ resolve(env={"ELVA_TIMEOUT": "soon"}, cwd=home)
143
+
144
+
145
+ def test_settings_are_frozen(home: Path) -> None:
146
+ settings = resolve(env={}, cwd=home).settings
147
+ with pytest.raises(Exception, match=r"frozen|immutable"):
148
+ settings.base_url = "https://elsewhere.example.com"
elva_cli-0.0.4/README.md DELETED
@@ -1,58 +0,0 @@
1
- # Elva CLI
2
-
3
- Manage your [Elva](https://getelva.ai) API projects from the terminal: import specs,
4
- inspect collections, and generate MCP servers without opening a browser.
5
-
6
- > **Early alpha.** The command surface is still taking shape. This release ships
7
- > `--version` and `--help` only; the first working commands land in `0.1.0`.
8
-
9
- ## Install
10
-
11
- Requires Python 3.11 or newer.
12
-
13
- ```bash
14
- uv tool install elva-cli
15
- ```
16
-
17
- Or with [pipx](https://pipx.pypa.io/), if you already use it:
18
-
19
- ```bash
20
- pipx install elva-cli
21
- ```
22
-
23
- Either way, `elva` is then available from any directory:
24
-
25
- ```bash
26
- elva --version
27
- elva --help
28
- ```
29
-
30
- To try it without installing anything:
31
-
32
- ```bash
33
- uvx --from elva-cli elva --version
34
- ```
35
-
36
- Don't have `uv`? It is a single command and no prerequisites:
37
-
38
- ```bash
39
- curl -fsSL https://astral.sh/uv/install.sh | sh # macOS, Linux
40
- powershell -c "irm https://astral.sh/uv/install.ps1|iex" # Windows
41
- ```
42
-
43
- ### Upgrade
44
-
45
- ```bash
46
- uv tool upgrade elva-cli # or: pipx upgrade elva-cli
47
- ```
48
-
49
- ## Requirements
50
-
51
- - Python 3.11 or newer (bundled automatically if you install via `uv tool`)
52
- - An [Elva](https://getelva.ai) account
53
-
54
- ## Links
55
-
56
- - [Elva](https://getelva.ai)
57
- - [Issues](https://github.com/Theneo-Inc/theneo-elva-cli/issues)
58
- - [Contributing](CONTRIBUTING.md)
@@ -1,5 +0,0 @@
1
- """The Ctx object, built once in the root callback and injected into every command.
2
-
3
- Carries resolved settings, the API client, output and logging. Commands never
4
- import global state, which is what makes them testable. Everything expensive is a
5
- cached_property so `elva --version` pays for none of it."""
@@ -1,5 +0,0 @@
1
- """Lazy command dispatch.
2
-
3
- Maps command name to module path so that the command a user typed is the only
4
- command module imported. Declaring a command here is the only way to add one, so
5
- the startup cost of the tree stays visible in one file."""
@@ -1,5 +0,0 @@
1
- """Configuration schema and precedence resolution.
2
-
3
- Order, highest first: command flags, ELVA_* environment, project elva.toml, user
4
- config.toml, defaults. Resolved once in the root callback and frozen onto the Ctx;
5
- nothing downstream re-reads the environment or the filesystem."""
@@ -1,16 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from pathlib import Path
4
-
5
- import platformdirs
6
-
7
- APP_NAME = "elva"
8
-
9
-
10
- def cache_dir() -> Path:
11
- """Per-user cache location: XDG on Linux, ~/Library on macOS, %LOCALAPPDATA% on Windows."""
12
- return Path(platformdirs.user_cache_dir(APP_NAME, appauthor=False))
13
-
14
-
15
- def crash_dir() -> Path:
16
- return cache_dir() / "crashes"
File without changes