Skip to content

feat(cli): visionset token create/list/revoke — and workspace resolution, decided once (#26) - #98

Merged
JArmandoAnaya merged 4 commits into
mainfrom
26-cli-token-commands
Jul 28, 2026
Merged

feat(cli): visionset token create/list/revoke — and workspace resolution, decided once (#26)#98
JArmandoAnaya merged 4 commits into
mainfrom
26-cli-token-commands

Conversation

@JArmandoAnaya

Copy link
Copy Markdown
Contributor

Closes #26.

Three real visionset token commands over TokenService, and the half every later surface task depends on: workspace resolution, decided once.

Workspace resolution, promoted

WORKSPACE_ENV_VAR and resolve_workspace_root(explicit) move out of server/dependencies.py and into kernel/services/workspace_service.py, beside DB_FILENAME — the same fact read from the other end. The rule can live in neither caller: import-linter forbids visionset.server importing visionset.cli. Both names stay importable from server.dependencies, which is where the server's "which workspace do I serve?" question is documented.

Precedence, first match wins:

Source Walks up?
1 explicit--workspace / -w no
2 VISIONSET_WORKSPACE, non-empty no
3 nearest visionset.db at or above cwd yes
4 the working directory

Only case 3 walks, and that asymmetry is the whole rule. A flag and an env var are somebody stating which workspace; if the stated directory holds none, walking to its parent and quietly minting a credential into whatever lives up there is the worst thing this function could do. Git draws the line in the same place — discovery walks up, --git-dir and GIT_DIR do not. Finding nothing is not an erroropen owns that refusal and already names the path.

⚠️ Behaviour change for the server, deliberate. With no VISIONSET_WORKSPACE set, a server started below a workspace now serves it instead of answering 500 NOT_A_WORKSPACE. That is the cost of one resolver rather than two; a new test in test_workspace_dependency.py pins it and docs/auth.md states it.

Three decisions worth not re-litigating

--workspace is per command, not on the root callback. A Click group parses with allow_interspersed_args=False, so an option on @app.callback() must precede the subcommand — visionset token create --name ci --workspace X would fail with "No such option". Nobody types the flag first. test_the_flag_may_follow_the_subcommand is the test that would have caught the other choice.

Stdout is data; stderr is everything a person reads. create puts the secret alone on stdout, so TOKEN=$(visionset token create --name ci) is exactly the secret, and the shown-once warning on stderr so it survives that redirection. list names its three columns one at a time rather than dumping the model, so a field added to Token cannot leak into a listing — a digest is not a secret, but it verifies a guess offline.

One exit code for the whole error family, not a table. 0 success, 1 any VisionSetError as one sentence on stderr, 2 Click's own usage errors. A REST client branches on a machine-readable code because it is a program; a shell branches on zero versus non-zero and a person reads the sentence. cli/_errors.py keeps a sparse MRO-walked hint map for remedies a terminal can act on — one entry today, because NotAWorkspace's own sentence ends in "use WorkspaceService.init", which is a Python API a CLI user cannot call.

Smaller calls: revoke resolves by name in two service calls rather than adding a revoke_by_name — the intermediate read is what prints the name actually matched (names are unique case-insensitively) and short-circuits an already-revoked token, which exits 0 without prompting because a retried command must be safe. --yes skips the prompt; typer.confirm(abort=True) also refuses on EOF, so a destructive command that cannot ask never acts. The create --name / revoke <name> asymmetry is the issue's own spelling, kept rather than silently "fixed".

No docs/cli.md. auth.md owns tokens, workspaces.md owns the workspace and now the rule that names one. A cli.md written today would duplicate both; it earns its keep when #33/#34 land a second command family.

Ledger

  • No migrationFORMAT_VERSION stays 11, M3's ledger is spent.
  • openapi.json unchanged — verified by re-export, zero diff. server: project and schema endpoints (CRUD + versioning) #27 is still the first task that moves it.
  • VERSION stays 0.0.1.dev0. No new dependency, no import-linter change, no new kernel error, service, model or domain event.
  • 1056 tests, up from 1016. New: tests/cli/ (30) and a resolution section in tests/kernel/test_workspace_service.py (9).

Checks

ruff check .                         All checks passed!
ruff format --check .                138 files already formatted
mypy src/visionset/kernel            Success: no issues found in 54 source files
mypy src/visionset                   Success: no issues found in 69 source files
lint-imports                         Contracts: 2 kept, 0 broken.
pytest                               1056 passed in 67.89s
export_openapi.py + git diff         no drift
examples/sdk_end_to_end.py           ok
examples/ingest_end_to_end.py        ok
pnpm version:check                   All frontend packages are at 0.0.1-dev.0.

Plus a manual walk from a directory two levels below a workspace: discovery found it, $(...) captured exactly the secret, the listing showed neither secret nor digest, re-revoking exited 0, running outside any workspace exited 1 with the hint — and no visionset.db-wal/-shm was left behind, which is what the finally: close() is for.

…alks

`resolve_workspace_root(explicit)` and `WORKSPACE_ENV_VAR` move beside
`DB_FILENAME`, where the fact they read from the other end already lives. The
server and the CLI both need the answer and import-linter forbids either
importing the other, so the rule belongs above both.

Precedence: an explicit path, then a non-empty `VISIONSET_WORKSPACE`, then the
nearest `visionset.db` at or above the working directory, then the working
directory. Only the third case walks — a stated root traded for its parent is
how a credential gets minted into the wrong workspace.
The provisional resolver is gone; both names stay importable from
`server.dependencies`, which is where the server's "which workspace do I
serve?" question is documented.

One deliberate behaviour change: with no `VISIONSET_WORKSPACE` set, a server
started below a workspace now serves it instead of answering 500
NOT_A_WORKSPACE.
Three commands over `TokenService`, each resolving a workspace through the
kernel's rule and calling exactly one service method.

`--workspace`/`-w` is declared per command, not on the root callback: a Click
group stops parsing at the first non-option token, so a callback option would
reject `visionset token create --name ci --workspace X` — the invocation
everybody types.

Stdout is data, stderr is prose. `create` puts the secret alone on stdout so
`TOKEN=$(visionset token create --name ci)` is exactly the secret, and the
shown-once warning on stderr so it survives that redirection. `list` names its
three columns one at a time, so neither a secret nor a digest can reach it.

Exit codes: 0 success, 1 for any VisionSetError as one sentence on stderr, 2 for
Click's own usage errors.
workspaces.md gains the precedence table and the argument for why only cwd
detection walks upward; auth.md gains the three commands, the stdout/stderr
split, the terminal's exit-code contract, and the behaviour change the server
inherited from sharing one resolver.

No docs/cli.md: auth.md owns tokens and workspaces.md owns the workspace. One
earns its keep when the second command family lands and it has more than a
stub to hold.
@JArmandoAnaya
JArmandoAnaya merged commit 7764c66 into main Jul 28, 2026
3 checks passed
@JArmandoAnaya
JArmandoAnaya deleted the 26-cli-token-commands branch July 28, 2026 01:52
JArmandoAnaya added a commit that referenced this pull request Aug 21, 2026
…ion, decided once (#26) (#98)

* feat(kernel): promote workspace resolution — one rule, and only cwd walks

`resolve_workspace_root(explicit)` and `WORKSPACE_ENV_VAR` move beside
`DB_FILENAME`, where the fact they read from the other end already lives. The
server and the CLI both need the answer and import-linter forbids either
importing the other, so the rule belongs above both.

Precedence: an explicit path, then a non-empty `VISIONSET_WORKSPACE`, then the
nearest `visionset.db` at or above the working directory, then the working
directory. Only the third case walks — a stated root traded for its parent is
how a credential gets minted into the wrong workspace.

* refactor(server): read workspace resolution from the kernel

The provisional resolver is gone; both names stay importable from
`server.dependencies`, which is where the server's "which workspace do I
serve?" question is documented.

One deliberate behaviour change: with no `VISIONSET_WORKSPACE` set, a server
started below a workspace now serves it instead of answering 500
NOT_A_WORKSPACE.

* feat(cli): visionset token create/list/revoke, against a real workspace

Three commands over `TokenService`, each resolving a workspace through the
kernel's rule and calling exactly one service method.

`--workspace`/`-w` is declared per command, not on the root callback: a Click
group stops parsing at the first non-option token, so a callback option would
reject `visionset token create --name ci --workspace X` — the invocation
everybody types.

Stdout is data, stderr is prose. `create` puts the secret alone on stdout so
`TOKEN=$(visionset token create --name ci)` is exactly the secret, and the
shown-once warning on stderr so it survives that redirection. `list` names its
three columns one at a time, so neither a secret nor a digest can reach it.

Exit codes: 0 success, 1 for any VisionSetError as one sentence on stderr, 2 for
Click's own usage errors.

* docs: the token commands, and where 'which workspace' is decided

workspaces.md gains the precedence table and the argument for why only cwd
detection walks upward; auth.md gains the three commands, the stdout/stderr
split, the terminal's exit-code contract, and the behaviour change the server
inherited from sharing one resolver.

No docs/cli.md: auth.md owns tokens and workspaces.md owns the workspace. One
earns its keep when the second command family lands and it has more than a
stub to hold.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

cli: visionset token create/list/revoke with persistence

1 participant