cinna-cli 0.2.0__tar.gz → 0.2.2__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.
Files changed (45) hide show
  1. {cinna_cli-0.2.0 → cinna_cli-0.2.2}/PKG-INFO +47 -1
  2. {cinna_cli-0.2.0 → cinna_cli-0.2.2}/README.md +46 -0
  3. {cinna_cli-0.2.0 → cinna_cli-0.2.2}/docs/README.md +18 -0
  4. {cinna_cli-0.2.0 → cinna_cli-0.2.2}/pyproject.toml +1 -1
  5. {cinna_cli-0.2.0 → cinna_cli-0.2.2}/src/cinna/account.py +382 -12
  6. {cinna_cli-0.2.0 → cinna_cli-0.2.2}/src/cinna/context.py +10 -2
  7. cinna_cli-0.2.2/src/cinna/doctor.py +454 -0
  8. {cinna_cli-0.2.0 → cinna_cli-0.2.2}/src/cinna/main.py +81 -6
  9. {cinna_cli-0.2.0 → cinna_cli-0.2.2}/src/cinna/mcp_proxy.py +87 -11
  10. {cinna_cli-0.2.0 → cinna_cli-0.2.2}/src/cinna/sync_session.py +30 -0
  11. {cinna_cli-0.2.0 → cinna_cli-0.2.2}/tests/test_account.py +298 -8
  12. {cinna_cli-0.2.0 → cinna_cli-0.2.2}/tests/test_context.py +3 -0
  13. cinna_cli-0.2.2/tests/test_doctor.py +245 -0
  14. {cinna_cli-0.2.0 → cinna_cli-0.2.2}/uv.lock +1 -1
  15. {cinna_cli-0.2.0 → cinna_cli-0.2.2}/.github/workflows/publish.yml +0 -0
  16. {cinna_cli-0.2.0 → cinna_cli-0.2.2}/.gitignore +0 -0
  17. {cinna_cli-0.2.0 → cinna_cli-0.2.2}/LICENSE.md +0 -0
  18. {cinna_cli-0.2.0 → cinna_cli-0.2.2}/docs/interface.md +0 -0
  19. {cinna_cli-0.2.0 → cinna_cli-0.2.2}/docs/mutagen_capabilities.md +0 -0
  20. {cinna_cli-0.2.0 → cinna_cli-0.2.2}/src/cinna/__init__.py +0 -0
  21. {cinna_cli-0.2.0 → cinna_cli-0.2.2}/src/cinna/auth.py +0 -0
  22. {cinna_cli-0.2.0 → cinna_cli-0.2.2}/src/cinna/bootstrap.py +0 -0
  23. {cinna_cli-0.2.0 → cinna_cli-0.2.2}/src/cinna/client.py +0 -0
  24. {cinna_cli-0.2.0 → cinna_cli-0.2.2}/src/cinna/config.py +0 -0
  25. {cinna_cli-0.2.0 → cinna_cli-0.2.2}/src/cinna/console.py +0 -0
  26. {cinna_cli-0.2.0 → cinna_cli-0.2.2}/src/cinna/errors.py +0 -0
  27. {cinna_cli-0.2.0 → cinna_cli-0.2.2}/src/cinna/logging.py +0 -0
  28. {cinna_cli-0.2.0 → cinna_cli-0.2.2}/src/cinna/mutagen_runtime.py +0 -0
  29. {cinna_cli-0.2.0 → cinna_cli-0.2.2}/src/cinna/sync.py +0 -0
  30. {cinna_cli-0.2.0 → cinna_cli-0.2.2}/src/cinna/sync_ssh_shim.py +0 -0
  31. {cinna_cli-0.2.0 → cinna_cli-0.2.2}/src/cinna/sync_tui.py +0 -0
  32. {cinna_cli-0.2.0 → cinna_cli-0.2.2}/src/cinna/templates/ACCOUNT_CLAUDE.md.template +0 -0
  33. {cinna_cli-0.2.0 → cinna_cli-0.2.2}/src/cinna/templates/CLAUDE.md.template +0 -0
  34. {cinna_cli-0.2.0 → cinna_cli-0.2.2}/src/cinna/templates/__init__.py +0 -0
  35. {cinna_cli-0.2.0 → cinna_cli-0.2.2}/tests/__init__.py +0 -0
  36. {cinna_cli-0.2.0 → cinna_cli-0.2.2}/tests/conftest.py +0 -0
  37. {cinna_cli-0.2.0 → cinna_cli-0.2.2}/tests/test_auth.py +0 -0
  38. {cinna_cli-0.2.0 → cinna_cli-0.2.2}/tests/test_bootstrap.py +0 -0
  39. {cinna_cli-0.2.0 → cinna_cli-0.2.2}/tests/test_client.py +0 -0
  40. {cinna_cli-0.2.0 → cinna_cli-0.2.2}/tests/test_config.py +0 -0
  41. {cinna_cli-0.2.0 → cinna_cli-0.2.2}/tests/test_main.py +0 -0
  42. {cinna_cli-0.2.0 → cinna_cli-0.2.2}/tests/test_mutagen_runtime.py +0 -0
  43. {cinna_cli-0.2.0 → cinna_cli-0.2.2}/tests/test_sync.py +0 -0
  44. {cinna_cli-0.2.0 → cinna_cli-0.2.2}/tests/test_sync_session.py +0 -0
  45. {cinna_cli-0.2.0 → cinna_cli-0.2.2}/tests/test_sync_ssh_shim.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cinna-cli
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: Local development CLI for Cinna Core agents
5
5
  Project-URL: Homepage, https://github.com/opencinna/cinna-cli
6
6
  Project-URL: Repository, https://github.com/opencinna/cinna-cli
@@ -112,6 +112,31 @@ cd hr-manager-agent/
112
112
  cinna set-token yWo36tbkdAOzrALxOEKq31_OA2iMelEg
113
113
  ```
114
114
 
115
+ ### `cinna login [domain]`
116
+
117
+ Sign in to an account workspace in the browser — **no setup token to paste**. One command serves two cases:
118
+
119
+ - **Resume** — run it from inside an existing account workspace and it refreshes the stored account CLI token **in place** (reusing the platform URL + machine name; other settings like the active user workspace are preserved).
120
+ - **Connect new** — run it anywhere else and it bootstraps a fresh account workspace. It asks for the platform **domain** (or pass it as an argument — protocol optional, `localhost` recognized), then creates the workspace **in the current folder if it's empty**, or **in a subfolder you name** if it isn't. So you can `mkdir my-cinna && cd my-cinna && cinna login`, or just `cinna login app.example.com` straight into the current directory.
121
+
122
+ Either way it opens a browser authorization URL (OAuth 2.0 device flow); once you click **Authorize** (already signed in to the platform) the CLI receives a fresh token and writes `.cinna/account.json`.
123
+
124
+ ```bash
125
+ # Resume the account workspace you're in:
126
+ cd my-cinna/ && cinna login
127
+
128
+ # Connect a new account from a fresh folder:
129
+ mkdir my-cinna && cd my-cinna && cinna login # prompts for the domain
130
+ cinna login app.example.com # or pass it directly
131
+ cinna login app.example.com --dir my-cinna # always into a named subfolder
132
+ # Your verification code: WX7K-9Q2P
133
+ # Open this URL and click Authorize:
134
+ # https://app.example.com/device?code=WX7K-9Q2P
135
+ # ✓ Account workspace ready.
136
+ ```
137
+
138
+ Use it when `cinna account status` or `cinna doctor` reports the account token has expired. Because the per-agent tokens minted from an account (`cinna agent sync`) can only be re-minted while the account token is valid, the flow is: `cinna login` (refresh the account), then `cinna doctor` (re-mint the dependent sub-agent tokens). If the platform doesn't expose the device-login endpoints yet, `cinna login` says so and points you at the `cinna account setup` paste fallback.
139
+
115
140
  ### `cinna account setup <token_or_url>`
116
141
 
117
142
  Initialize an **account workspace** — a multi-agent root from which you can discover your agents and attach per-agent workspaces without going back to the UI. Setup is initiated from **Settings → Channels → Local Development** on the platform, which emits a `curl | python3` one-liner (same pattern as the per-agent flow):
@@ -285,6 +310,27 @@ List every agent registered on this machine (from `~/.cinna/agents.json`). Three
285
310
  2. **Location** — workspace path on top, platform UI link below. Missing directories are flagged in red.
286
311
  3. **Sync** — Mutagen session state on top (`active` / `paused` / `connecting` / `error`), plus a per-agent backend probe (`valid token` / `expired token` / `no connection`) on the bottom. The probes run in parallel with a short timeout so the view stays snappy even with many registered agents.
287
312
 
313
+ ### `cinna doctor`
314
+
315
+ Diagnose and repair stale sync state across the whole machine. Over time the per-user registry (`~/.cinna/agents.json`) and the Mutagen daemon drift out of sync as agents are deleted, environments are spun down, and tokens expire — `cinna doctor` reconciles the two and heals the leftovers in one pass. Run it from anywhere; it is not workspace-scoped.
316
+
317
+ It detects and fixes:
318
+
319
+ - **Deleted workspaces** — registry entries whose workspace folder (or its `.cinna/config.json`) is gone. The entry is removed, along with any leftover Mutagen session.
320
+ - **Halted sessions** — sessions stopped on `halted-on-root-deletion` (the local `workspace/` root was deleted) while the agent dir is otherwise intact. Terminated; `cinna dev` recreates a clean one.
321
+ - **Dead-remote sessions** — sessions stuck retrying a remote env that no longer exists (`connecting-beta` / beta polling error). Mutagen has **no** "give up after N failures" option — a session retries forever until paused or terminated — so `doctor` is the cleanup path for these. Terminated.
322
+ - **Orphaned sessions** — `cinna-*` sessions with no registry entry at all. Terminated.
323
+ - **Expired tokens** — for **account-managed** workspaces (those under an account root), the CLI token is re-minted automatically through the parent account token, no pasting required. **Standalone** workspaces (set up via `cinna setup`) can only be refreshed with a pasted setup token, so they are reported with a `cinna set-token` hint rather than changed.
324
+ - **Expired account token** — a sub-agent token can only be re-minted while the **account** token that mints it is still valid. When the account token has itself expired, doctor probes it once and surfaces a single _"renew the account token — run `cinna login`"_ finding (listing the blocked sub-agents) instead of a pile of re-mints that would all fail with 401. Run `cinna login`, then re-run `cinna doctor` to re-mint the dependents.
325
+
326
+ ```bash
327
+ cinna doctor # diagnose, then apply all fixes behind one confirmation
328
+ cinna doctor --dry-run # report problems only; change nothing
329
+ cinna doctor --yes # apply every fix non-interactively
330
+ ```
331
+
332
+ The diagnosis is split into two tables: **Will fix** (everything doctor can repair — deleted-workspace cleanup, halted/dead/orphaned session termination, account token re-mints) and **No automatic fix — manual action needed** (standalone expired tokens, which need a pasted setup token and are never touched). Everything actionable is applied together behind a single `Apply N fix(es)?` confirmation, so the count always matches the "Will fix" table.
333
+
288
334
  ### `cinna disconnect`
289
335
 
290
336
  Stop sync, remove `.cinna/` config and generated files (`CLAUDE.md`, `BUILDING_AGENT.md`, `.mcp.json`, `opencode.json`, `mutagen.yml`). Workspace files are preserved.
@@ -75,6 +75,31 @@ cd hr-manager-agent/
75
75
  cinna set-token yWo36tbkdAOzrALxOEKq31_OA2iMelEg
76
76
  ```
77
77
 
78
+ ### `cinna login [domain]`
79
+
80
+ Sign in to an account workspace in the browser — **no setup token to paste**. One command serves two cases:
81
+
82
+ - **Resume** — run it from inside an existing account workspace and it refreshes the stored account CLI token **in place** (reusing the platform URL + machine name; other settings like the active user workspace are preserved).
83
+ - **Connect new** — run it anywhere else and it bootstraps a fresh account workspace. It asks for the platform **domain** (or pass it as an argument — protocol optional, `localhost` recognized), then creates the workspace **in the current folder if it's empty**, or **in a subfolder you name** if it isn't. So you can `mkdir my-cinna && cd my-cinna && cinna login`, or just `cinna login app.example.com` straight into the current directory.
84
+
85
+ Either way it opens a browser authorization URL (OAuth 2.0 device flow); once you click **Authorize** (already signed in to the platform) the CLI receives a fresh token and writes `.cinna/account.json`.
86
+
87
+ ```bash
88
+ # Resume the account workspace you're in:
89
+ cd my-cinna/ && cinna login
90
+
91
+ # Connect a new account from a fresh folder:
92
+ mkdir my-cinna && cd my-cinna && cinna login # prompts for the domain
93
+ cinna login app.example.com # or pass it directly
94
+ cinna login app.example.com --dir my-cinna # always into a named subfolder
95
+ # Your verification code: WX7K-9Q2P
96
+ # Open this URL and click Authorize:
97
+ # https://app.example.com/device?code=WX7K-9Q2P
98
+ # ✓ Account workspace ready.
99
+ ```
100
+
101
+ Use it when `cinna account status` or `cinna doctor` reports the account token has expired. Because the per-agent tokens minted from an account (`cinna agent sync`) can only be re-minted while the account token is valid, the flow is: `cinna login` (refresh the account), then `cinna doctor` (re-mint the dependent sub-agent tokens). If the platform doesn't expose the device-login endpoints yet, `cinna login` says so and points you at the `cinna account setup` paste fallback.
102
+
78
103
  ### `cinna account setup <token_or_url>`
79
104
 
80
105
  Initialize an **account workspace** — a multi-agent root from which you can discover your agents and attach per-agent workspaces without going back to the UI. Setup is initiated from **Settings → Channels → Local Development** on the platform, which emits a `curl | python3` one-liner (same pattern as the per-agent flow):
@@ -248,6 +273,27 @@ List every agent registered on this machine (from `~/.cinna/agents.json`). Three
248
273
  2. **Location** — workspace path on top, platform UI link below. Missing directories are flagged in red.
249
274
  3. **Sync** — Mutagen session state on top (`active` / `paused` / `connecting` / `error`), plus a per-agent backend probe (`valid token` / `expired token` / `no connection`) on the bottom. The probes run in parallel with a short timeout so the view stays snappy even with many registered agents.
250
275
 
276
+ ### `cinna doctor`
277
+
278
+ Diagnose and repair stale sync state across the whole machine. Over time the per-user registry (`~/.cinna/agents.json`) and the Mutagen daemon drift out of sync as agents are deleted, environments are spun down, and tokens expire — `cinna doctor` reconciles the two and heals the leftovers in one pass. Run it from anywhere; it is not workspace-scoped.
279
+
280
+ It detects and fixes:
281
+
282
+ - **Deleted workspaces** — registry entries whose workspace folder (or its `.cinna/config.json`) is gone. The entry is removed, along with any leftover Mutagen session.
283
+ - **Halted sessions** — sessions stopped on `halted-on-root-deletion` (the local `workspace/` root was deleted) while the agent dir is otherwise intact. Terminated; `cinna dev` recreates a clean one.
284
+ - **Dead-remote sessions** — sessions stuck retrying a remote env that no longer exists (`connecting-beta` / beta polling error). Mutagen has **no** "give up after N failures" option — a session retries forever until paused or terminated — so `doctor` is the cleanup path for these. Terminated.
285
+ - **Orphaned sessions** — `cinna-*` sessions with no registry entry at all. Terminated.
286
+ - **Expired tokens** — for **account-managed** workspaces (those under an account root), the CLI token is re-minted automatically through the parent account token, no pasting required. **Standalone** workspaces (set up via `cinna setup`) can only be refreshed with a pasted setup token, so they are reported with a `cinna set-token` hint rather than changed.
287
+ - **Expired account token** — a sub-agent token can only be re-minted while the **account** token that mints it is still valid. When the account token has itself expired, doctor probes it once and surfaces a single _"renew the account token — run `cinna login`"_ finding (listing the blocked sub-agents) instead of a pile of re-mints that would all fail with 401. Run `cinna login`, then re-run `cinna doctor` to re-mint the dependents.
288
+
289
+ ```bash
290
+ cinna doctor # diagnose, then apply all fixes behind one confirmation
291
+ cinna doctor --dry-run # report problems only; change nothing
292
+ cinna doctor --yes # apply every fix non-interactively
293
+ ```
294
+
295
+ The diagnosis is split into two tables: **Will fix** (everything doctor can repair — deleted-workspace cleanup, halted/dead/orphaned session termination, account token re-mints) and **No automatic fix — manual action needed** (standalone expired tokens, which need a pasted setup token and are never touched). Everything actionable is applied together behind a single `Apply N fix(es)?` confirmation, so the count always matches the "Will fix" table.
296
+
251
297
  ### `cinna disconnect`
252
298
 
253
299
  Stop sync, remove `.cinna/` config and generated files (`CLAUDE.md`, `BUILDING_AGENT.md`, `.mcp.json`, `opencode.json`, `mutagen.yml`). Workspace files are preserved.
@@ -132,6 +132,13 @@ Key properties:
132
132
  - **Probeable** — `cinna list` and `cinna status` call `GET /sync-runtime` as a cheap authenticated probe and label the token `valid` / `expired` / `no connection`.
133
133
  - **Refreshable in place** — `cinna set-token <token_or_url>` re-exchanges a fresh setup token through `POST /api/cli-setup/{token}` and rewrites both stores without re-cloning the workspace. The refresh is bound to the agent already in the workspace: if the exchanged token belongs to a different agent, the command aborts.
134
134
 
135
+ ### Account CLI Token
136
+
137
+ A second token type (`token_type="cli-account"`) issued to an **account workspace** (`.cinna/account.json`). Scoped only to the `/account/*` routes — it discovers agents and mints per-agent CLI tokens (`cinna agent sync`), but cannot itself sync or exec. Same 7-day rolling expiry as a CLI token.
138
+
139
+ - **Refreshable without a paste** — `cinna login` runs an RFC 8628 device-authorization flow: the CLI prints a short code + URL, the user clicks **Authorize** in the browser (already signed in), and the CLI swaps the fresh token into `.cinna/account.json` in place. Run from an empty/new folder, the same command instead bootstraps a brand-new account workspace.
140
+ - **Mints child tokens** — per-agent tokens minted from it carry its id as provenance and are re-mintable via `POST /account/agents/{id}/mint` (used by `cinna agent sync` and `cinna doctor`).
141
+
135
142
  ### Knowledge Source
136
143
 
137
144
  A documentation/data source attached to an agent. Queried via the MCP proxy's `knowledge_query` tool, backed by the platform's vector search.
@@ -190,6 +197,8 @@ An open protocol for connecting AI tools to external data/capabilities. The CLI
190
197
  main.py (CLI commands — Click)
191
198
  │
192
199
  ├── bootstrap.py — setup orchestration
200
+ ├── account.py — account workspace; `cinna login` (device auth), `cinna account`, `cinna agent`
201
+ ├── doctor.py — `cinna doctor`: reconcile registry ↔ Mutagen, repair stale state, refresh tokens
193
202
  ├── config.py — .cinna/config.json: load/save/find
194
203
  ├── auth.py — JWT storage, Authorization headers
195
204
  ├── client.py — PlatformClient: HTTP + SSE stream_exec
@@ -393,6 +402,10 @@ Backend validates on every request:
393
402
 
394
403
  When a CLI token expires (or is revoked) the normal remedy is `cinna set-token <token_or_url>` from inside the agent directory. Internally it reuses `_exchange_setup_token()` from `bootstrap.py` — the same helper that backs `cinna setup` — so the server side is a plain re-run of `POST /api/cli-setup/{token}`. The workspace's existing `platform_url` is used as the fallback when a bare token is pasted, which lets agents registered against different platforms each refresh from their own directory without extra flags. The workspace tarball is **not** re-downloaded, and `CLAUDE.md` / `BUILDING_AGENT.md` / `.mcp.json` / `opencode.json` are left in place — only the stored CLI token changes.
395
404
 
405
+ **Account tokens** refresh without a paste. `cinna login` (run inside an account workspace) drives the platform's RFC 8628 device-authorization flow: `POST /account/login/start` returns a short code + verification URL, the user clicks **Authorize** in the browser (already signed in), and the CLI polls `POST /account/login/poll` until it receives the fresh token, which it writes back into `.cinna/account.json` in place. The poll endpoint always returns HTTP 200 with a `status` field (`authorization_pending` / `slow_down` / `authorized` / `access_denied` / `expired_token`) — a deliberate divergence from RFC 8628's 400+`error` shape. Run from a fresh folder, the same command bootstraps a new account workspace instead of resuming one.
406
+
407
+ **Bulk repair** is `cinna doctor`. It reconciles the `~/.cinna/agents.json` registry against the Mutagen daemon and heals the state that drifts as agents come and go — registry entries whose workspace was deleted, sessions halted on a deleted local root or stuck retrying a dead remote env, and orphaned sessions (Mutagen has no "stop after N failures" knob, so these retry forever until terminated). Expired **per-agent** tokens under an account workspace are re-minted automatically through the parent account token; when the **account** token has itself expired, doctor groups the blocked agents into a single "run `cinna login`" finding instead of attempting re-mints that would 401. Standalone agents are reported for a manual `cinna set-token`.
408
+
396
409
  ### Authorization
397
410
 
398
411
  | Resource | Rule |
@@ -423,6 +436,11 @@ When a CLI token expires (or is revoked) the normal remedy is `cinna set-token <
423
436
  | GET | `/api/v1/cli/agents/{id}/sync-runtime` | CLI JWT | Required Mutagen version + hash (also used by `cinna list` / `cinna status` as a cheap token-validity probe) |
424
437
  | POST | `/api/v1/cli/agents/{id}/exec` | CLI JWT | Streaming SSE command execution |
425
438
  | WSS | `/api/v1/cli/agents/{id}/sync-stream` | CLI JWT | Mutagen transport tunnel |
439
+ | POST | `/api/v1/cli/account/login/start` | None | Begin a `cinna login` device-authorization request |
440
+ | POST | `/api/v1/cli/account/login/poll` | None | Poll a `cinna login` request — always HTTP 200 + `status` |
441
+ | POST | `/api/v1/cli/account/agents/{id}/mint` | Account token | Mint a per-agent CLI token (`cinna agent sync`, `cinna doctor` re-mint) |
442
+
443
+ The account-workspace surface adds the broader `/api/v1/cli/account/*` route group (login, agents, credentials, connect, schedules, status, api-proxy); only the routes the sync / login / doctor paths use are listed here.
426
444
 
427
445
  Endpoints that were part of the old Docker-replica model (`build-context`, `workspace` POST, `workspace/manifest`, `credentials`) have been removed from the backend and from this CLI.
428
446
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "cinna-cli"
3
- version = "0.2.0"
3
+ version = "0.2.2"
4
4
  description = "Local development CLI for Cinna Core agents"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -23,6 +23,8 @@ import os
23
23
  import platform
24
24
  import re
25
25
  import sys
26
+ import time
27
+ import webbrowser
26
28
  from dataclasses import asdict, dataclass
27
29
  from pathlib import Path
28
30
  from urllib.parse import urlparse
@@ -161,6 +163,44 @@ def parse_account_setup_input(raw_input: str) -> tuple[str, str]:
161
163
  return platform_url, text
162
164
 
163
165
 
166
+ def default_account_dir_name(platform_url: str) -> str:
167
+ """Derive a default workspace folder name from the platform domain.
168
+
169
+ e.g. ``https://demo-core.opencinna.io`` → ``demo-core_opencinna_io``.
170
+ Hostname only (creds/port stripped); every run of non
171
+ ``[A-Za-z0-9-]`` characters collapses to a single underscore. Falls back to
172
+ ``DEFAULT_ACCOUNT_DIR`` when the URL has no usable host.
173
+ """
174
+ host = urlparse(platform_url).netloc or platform_url.strip()
175
+ host = host.split("@")[-1].split(":")[0] # strip user:pass@ and :port
176
+ slug = re.sub(r"[^A-Za-z0-9-]+", "_", host).strip("_")
177
+ return slug or DEFAULT_ACCOUNT_DIR
178
+
179
+
180
+ def _prompt_account_dir(default: str) -> str:
181
+ """Ask for the workspace folder name, offering ``default``.
182
+
183
+ Works in the ``curl ... | python3 -`` bootstrap too: there stdin carries the
184
+ installer script (not keystrokes), so when stdin is not a TTY we talk to the
185
+ controlling terminal via ``/dev/tty`` as long as stdout is a TTY. With no
186
+ terminal attached (CI, captured output) we return ``default`` unchanged so
187
+ non-interactive runs stay non-interactive.
188
+ """
189
+ if sys.stdin.isatty():
190
+ return click.prompt("Workspace folder name", default=default)
191
+
192
+ if not sys.stdout.isatty():
193
+ return default
194
+ try:
195
+ with open("/dev/tty", "r+") as tty:
196
+ tty.write(f"Workspace folder name [{default}]: ")
197
+ tty.flush()
198
+ line = tty.readline()
199
+ except OSError:
200
+ return default
201
+ return line.strip() or default
202
+
203
+
164
204
  def _exchange_account_setup_token(
165
205
  platform_url: str, token: str, machine_name: str
166
206
  ) -> dict:
@@ -286,6 +326,311 @@ def probe_account_token(config: AccountConfig) -> str:
286
326
  return "unreachable"
287
327
 
288
328
 
329
+ # ── Browser re-auth (device authorization flow) ─────────────────────────────
330
+ #
331
+ # `cinna login` refreshes the account token in place without a pasted setup
332
+ # token. It is an OAuth 2.0 Device Authorization Grant (RFC 8628): the CLL
333
+ # starts a request, the user authorizes it in a browser already signed in to the
334
+ # platform, and the CLI polls until the backend hands back a fresh account token.
335
+ #
336
+ # Backend contract (both unauthenticated — the point is the old token is dead):
337
+ # POST {platform}/api/v1/cli/account/login/start
338
+ # body: {machine_name, machine_info}
339
+ # 200 : {device_code, user_code, verification_uri,
340
+ # verification_uri_complete?, interval?, expires_in?}
341
+ # POST {platform}/api/v1/cli/account/login/poll
342
+ # body: {device_code}
343
+ # 200 : {status: "authorization_pending"|"slow_down"|"authorized"
344
+ # |"access_denied"|"expired_token",
345
+ # account_token?, platform_url?, frontend_url?, machine_name?}
346
+
347
+ _LOGIN_DEFAULT_INTERVAL = 5 # seconds between polls when the server omits one
348
+ _LOGIN_DEFAULT_EXPIRY = 900 # safety cap when the server omits expires_in
349
+ _LOGIN_START_PATH = "/api/v1/cli/account/login/start"
350
+ _LOGIN_POLL_PATH = "/api/v1/cli/account/login/poll"
351
+
352
+
353
+ def _login_start(platform_url: str, machine_name: str) -> dict:
354
+ """Begin a device-login request; returns the authorize URL + device code."""
355
+ url = f"{platform_url.rstrip('/')}{_LOGIN_START_PATH}"
356
+ machine_info = f"{platform.system()}/{platform.machine()}"
357
+ logger.info("Starting device login at %s", url)
358
+ try:
359
+ response = httpx.post(
360
+ url,
361
+ json={"machine_name": machine_name, "machine_info": machine_info},
362
+ timeout=30.0,
363
+ )
364
+ except httpx.HTTPError as exc:
365
+ raise click.ClickException(f"Could not reach {platform_url}: {exc}")
366
+ if response.status_code == 404:
367
+ raise click.ClickException(
368
+ "This platform does not support 'cinna login' yet.\n"
369
+ "Refresh from the UI instead: open Settings → Local Development to "
370
+ "mint a new account setup token, then run\n"
371
+ " cinna account setup <token> (in the parent directory)."
372
+ )
373
+ if response.status_code != 200:
374
+ try:
375
+ detail = response.json().get("detail", response.text)
376
+ except Exception:
377
+ detail = response.text
378
+ raise click.ClickException(f"Login could not be started: {detail}")
379
+ return response.json()
380
+
381
+
382
+ def _login_poll(platform_url: str, device_code: str) -> dict:
383
+ """Poll a pending device-login request once."""
384
+ url = f"{platform_url.rstrip('/')}{_LOGIN_POLL_PATH}"
385
+ response = httpx.post(url, json={"device_code": device_code}, timeout=30.0)
386
+ if response.status_code != 200:
387
+ try:
388
+ detail = response.json().get("detail", response.text)
389
+ except Exception:
390
+ detail = response.text
391
+ raise click.ClickException(f"Login polling failed: {detail}")
392
+ return response.json()
393
+
394
+
395
+ def _poll_until_authorized(
396
+ platform_url: str, device_code: str, interval: int, expires_in: int
397
+ ) -> dict:
398
+ """Block until the user authorizes (or the request is denied / expires).
399
+
400
+ Honors the RFC 8628 ``slow_down`` backoff and the ``expires_in`` deadline.
401
+ Returns the authorized payload (carrying ``account_token``).
402
+ """
403
+ deadline = time.monotonic() + expires_in
404
+ while time.monotonic() < deadline:
405
+ time.sleep(max(1, interval))
406
+ data = _login_poll(platform_url, device_code)
407
+ status = (data.get("status") or "").lower()
408
+ if status in ("authorized", "complete", "success"):
409
+ if not data.get("account_token"):
410
+ raise click.ClickException(
411
+ "Authorization succeeded but the server returned no token."
412
+ )
413
+ return data
414
+ if status in ("authorization_pending", "pending", ""):
415
+ continue
416
+ if status == "slow_down":
417
+ interval += 5
418
+ continue
419
+ if status in ("access_denied", "denied"):
420
+ raise click.ClickException("Authorization was denied in the browser.")
421
+ if status in ("expired_token", "expired"):
422
+ raise click.ClickException(
423
+ "The login request expired before you authorized it. "
424
+ "Run 'cinna login' again."
425
+ )
426
+ raise click.ClickException(f"Unexpected login status: {status!r}")
427
+ raise click.ClickException(
428
+ "Timed out waiting for authorization. Run 'cinna login' again."
429
+ )
430
+
431
+
432
+ def _device_login(platform_url: str, machine_name: str, frontend_url: str | None = None) -> dict:
433
+ """Drive the full device-authorization handshake; return the authorized
434
+ payload.
435
+
436
+ Starts the request, surfaces the verification URL + user code (and opens a
437
+ browser), then polls until the user authorizes. The returned dict carries
438
+ ``account_token`` plus any server-refreshed ``platform_url`` /
439
+ ``frontend_url`` / ``machine_name``.
440
+ """
441
+ console.status(f"Signing in to {frontend_url or platform_url} as {machine_name}…")
442
+ start = _login_start(platform_url, machine_name)
443
+
444
+ device_code = start.get("device_code")
445
+ if not device_code:
446
+ raise click.ClickException("Server did not return a device code.")
447
+ user_code = start.get("user_code") or ""
448
+ verify_url = (
449
+ start.get("verification_uri_complete")
450
+ or start.get("verification_url_complete")
451
+ or start.get("verification_uri")
452
+ or start.get("verification_url")
453
+ or start.get("verify_url")
454
+ )
455
+ if not verify_url:
456
+ raise click.ClickException("Server did not return an authorization URL.")
457
+ interval = int(start.get("interval") or _LOGIN_DEFAULT_INTERVAL)
458
+ expires_in = int(start.get("expires_in") or _LOGIN_DEFAULT_EXPIRY)
459
+
460
+ console.console.print()
461
+ if user_code:
462
+ console.console.print(f" Your verification code: [bold]{user_code}[/bold]")
463
+ console.console.print(" Open this URL and click Authorize:")
464
+ console.console.print(f" [bold]{verify_url}[/bold]")
465
+ console.console.print()
466
+ try:
467
+ webbrowser.open(verify_url)
468
+ except Exception:
469
+ pass # headless / no browser — the printed URL is the fallback.
470
+
471
+ with console.spinner("Waiting for authorization…"):
472
+ return _poll_until_authorized(platform_url, device_code, interval, expires_in)
473
+
474
+
475
+ def _is_local_host(host: str) -> bool:
476
+ h = host.split(":")[0].lower()
477
+ return h in ("localhost", "127.0.0.1", "0.0.0.0", "::1") or h.endswith(".local")
478
+
479
+
480
+ def _normalize_platform_url(raw: str) -> str:
481
+ """Turn a user-typed domain into a ``scheme://netloc`` platform URL.
482
+
483
+ Accepts ``app.example.com``, ``https://app.example.com/``,
484
+ ``http://localhost:8000``, etc. A missing scheme defaults to ``https`` —
485
+ except for local hosts (``localhost`` / loopback / ``.local``), which get
486
+ ``http``. Any path/query the user pasted is dropped.
487
+ """
488
+ text = (raw or "").strip().strip("'\"").strip()
489
+ if not text:
490
+ raise click.ClickException("No domain provided.")
491
+ if "://" not in text:
492
+ host_part = text.split("/")[0]
493
+ scheme = "http" if _is_local_host(host_part) else "https"
494
+ text = f"{scheme}://{text}"
495
+ parsed = urlparse(text)
496
+ if not parsed.netloc:
497
+ raise click.ClickException(f"Could not parse a domain from {raw!r}.")
498
+ return f"{parsed.scheme}://{parsed.netloc}"
499
+
500
+
501
+ # ``cinna.log`` is written into cwd by the CLI's own logging setup before this
502
+ # check runs, so a genuinely fresh folder still "contains" it — treat it (and
503
+ # OS cruft) as not counting toward emptiness.
504
+ _IGNORABLE_DIR_ENTRIES = {".DS_Store", "cinna.log"}
505
+
506
+
507
+ def _dir_is_empty(path: Path) -> bool:
508
+ """True if ``path`` doesn't exist or holds nothing but ignorable cruft."""
509
+ if not path.exists():
510
+ return True
511
+ return all(child.name in _IGNORABLE_DIR_ENTRIES for child in path.iterdir())
512
+
513
+
514
+ def _refresh_account_token_in_place(account_root: Path) -> None:
515
+ """Resume path: swap a fresh token into an existing account workspace."""
516
+ account_cfg = load_account_config(account_root)
517
+ result = _device_login(
518
+ account_cfg.platform_url, account_cfg.machine_name, account_cfg.frontend_url
519
+ )
520
+
521
+ account_cfg.account_token = result["account_token"]
522
+ if result.get("platform_url"):
523
+ account_cfg.platform_url = result["platform_url"]
524
+ if result.get("frontend_url"):
525
+ account_cfg.frontend_url = result["frontend_url"]
526
+ if result.get("machine_name"):
527
+ account_cfg.machine_name = result["machine_name"]
528
+ save_account_config(account_cfg, account_root)
529
+
530
+ console.status(
531
+ f"Signed in — account token refreshed for {account_cfg.machine_name}."
532
+ )
533
+ console.console.print(
534
+ " Re-mint expired sub-agent tokens with [bold]cinna doctor[/bold]."
535
+ )
536
+
537
+
538
+ def _login_new_account(
539
+ domain: str | None, machine_name: str, dir_name: str | None
540
+ ) -> None:
541
+ """Bootstrap path: connect a brand-new account workspace via the browser.
542
+
543
+ Prompts for the platform domain when not given, picks where to create the
544
+ workspace (the current folder when it's empty, otherwise a subfolder the
545
+ user names), runs the device-login flow against that domain, and
546
+ materializes a standard account workspace with the returned token.
547
+ """
548
+ console.status("No cinna account workspace here — let's connect a new one.")
549
+ if not domain:
550
+ domain = click.prompt("Platform domain to log in to (e.g. app.example.com)")
551
+ platform_url = _normalize_platform_url(domain)
552
+
553
+ cwd = Path.cwd()
554
+ if dir_name:
555
+ account_root = cwd / dir_name
556
+ elif _dir_is_empty(cwd):
557
+ account_root = cwd
558
+ else:
559
+ default_sub = default_account_dir_name(platform_url)
560
+ sub = click.prompt(
561
+ "This folder isn't empty — name a subfolder to create the account "
562
+ "workspace in",
563
+ default=default_sub,
564
+ )
565
+ account_root = cwd / sub
566
+
567
+ if account_config_path(account_root).exists():
568
+ raise click.ClickException(
569
+ f"'{account_root}' already contains an account workspace.\n"
570
+ f"Run 'cinna login' from inside it to refresh its token."
571
+ )
572
+
573
+ result = _device_login(platform_url, machine_name)
574
+
575
+ config = AccountConfig(
576
+ platform_url=result.get("platform_url") or platform_url,
577
+ frontend_url=result.get("frontend_url") or platform_url,
578
+ account_token=result["account_token"],
579
+ machine_name=result.get("machine_name") or machine_name,
580
+ )
581
+ _write_account_files(config, account_root)
582
+ with console.spinner("Downloading context package…"):
583
+ _install_context_package(config, account_root)
584
+
585
+ rel = account_root if account_root == cwd else account_root.relative_to(cwd)
586
+ console.status(f"Account workspace ready at {account_root}")
587
+ console.console.print()
588
+ if account_root != cwd:
589
+ console.console.print(f" cd {rel}/")
590
+ console.console.print(
591
+ " cinna account agents # list agents you can build"
592
+ )
593
+ console.console.print(
594
+ " cinna agent sync <agent> # attach an agent workspace under agents/"
595
+ )
596
+ console.console.print()
597
+
598
+
599
+ def run_login(
600
+ domain: str | None = None,
601
+ machine_name: str | None = None,
602
+ dir_name: str | None = None,
603
+ ) -> None:
604
+ """`cinna login` — resume an account workspace, or connect a new one.
605
+
606
+ Inside an existing account workspace it refreshes the stored token in place
607
+ (the ``domain`` / ``dir_name`` hints are ignored). Otherwise it bootstraps a
608
+ new account workspace: it asks for the platform domain (unless given),
609
+ creates the workspace in the current folder when empty — or in a named
610
+ subfolder when not — and signs in via the browser device flow. Either way no
611
+ setup token is pasted.
612
+ """
613
+ try:
614
+ account_root = find_account_root()
615
+ except AccountConfigNotFoundError:
616
+ account_root = None
617
+
618
+ if account_root is not None:
619
+ if domain or dir_name:
620
+ console.warn(
621
+ "Already inside an account workspace — refreshing it in place "
622
+ "(domain / --dir ignored)."
623
+ )
624
+ _refresh_account_token_in_place(account_root)
625
+ return
626
+
627
+ _login_new_account(domain, machine_name or _fallback_machine_name(), dir_name)
628
+
629
+
630
+ def _fallback_machine_name() -> str:
631
+ return f"{os.environ.get('USER', 'dev')}'s {platform.node()}"
632
+
633
+
289
634
  # ── Command bodies ──────────────────────────────────────────────────────────
290
635
 
291
636
 
@@ -335,8 +680,13 @@ def _write_account_mcp_config(account_root: Path) -> None:
335
680
  ``POST /account/knowledge/search`` — the account analogue of the per-agent
336
681
  workspace's knowledge tool. Auto-generated infra: overwritten on every
337
682
  ``cinna account setup`` / ``cinna account refresh-context``.
683
+
684
+ The config path is written **relative** to the account root (anchored at the
685
+ launch cwd, which MCP clients set to the workspace folder) so the folder can
686
+ be moved without breaking the proxy. ``run_mcp_proxy`` additionally walks up
687
+ from cwd, which self-heals older configs that stored an absolute path.
338
688
  """
339
- account_config = str(account_config_path(account_root))
689
+ account_config = f"{CONFIG_DIR}/{ACCOUNT_CONFIG_FILE}"
340
690
 
341
691
  mcp_json = {
342
692
  "mcpServers": {
@@ -433,12 +783,40 @@ def _install_context_package(
433
783
  return True
434
784
 
435
785
 
786
+ def _write_account_files(config: AccountConfig, account_root: Path) -> None:
787
+ """Create the account workspace dir + config + generated files (no context).
788
+
789
+ The filesystem half of materializing an account workspace, shared by
790
+ ``cinna account setup`` (paste a setup token) and ``cinna login`` (browser
791
+ device flow). The caller downloads the context package separately so each
792
+ can frame that slow, best-effort step in its own UI.
793
+ """
794
+ account_root.mkdir(parents=True, exist_ok=True)
795
+ save_account_config(config, account_root)
796
+ agents_dir(account_root).mkdir(exist_ok=True)
797
+ _write_account_claude_md(account_root, config)
798
+ _write_account_claude_settings(account_root)
799
+ _write_account_mcp_config(account_root)
800
+
801
+
436
802
  def run_account_setup(
437
- setup_input: str, machine_name: str, dir_name: str = DEFAULT_ACCOUNT_DIR
803
+ setup_input: str, machine_name: str, dir_name: str | None = None
438
804
  ) -> None:
439
- """Full account setup flow — called by `cinna account setup <token_or_url>`."""
805
+ """Full account setup flow — called by `cinna account setup <token_or_url>`.
806
+
807
+ When ``dir_name`` is not given (no ``--dir``), the folder name defaults to
808
+ the platform domain normalized (e.g. ``demo-core_opencinna_io``); the user
809
+ can accept it or type their own at the prompt.
810
+ """
440
811
  total = 3
441
812
 
813
+ # Parse before touching the filesystem / network so we can derive the
814
+ # default folder name from the platform domain (and fail fast on bad input).
815
+ platform_url, token = parse_account_setup_input(setup_input)
816
+
817
+ if not dir_name:
818
+ dir_name = _prompt_account_dir(default_account_dir_name(platform_url))
819
+
442
820
  # Guard the target directory before burning the single-use setup token.
443
821
  account_root = Path.cwd() / dir_name
444
822
  if account_config_path(account_root).exists():
@@ -449,25 +827,17 @@ def run_account_setup(
449
827
 
450
828
  # Step 1: Exchange the account setup token
451
829
  console.step(1, total, "Authenticating...")
452
- platform_url, token = parse_account_setup_input(setup_input)
453
830
  payload = _exchange_account_setup_token(platform_url, token, machine_name)
454
831
 
455
832
  # Step 2: Materialize the account workspace
456
833
  console.step(2, total, "Creating account workspace...")
457
- account_root.mkdir(exist_ok=True)
458
-
459
834
  config = AccountConfig(
460
835
  platform_url=payload["platform_url"],
461
836
  frontend_url=payload.get("frontend_url") or payload["platform_url"],
462
837
  account_token=payload["account_token"],
463
838
  machine_name=payload.get("machine_name") or machine_name,
464
839
  )
465
- save_account_config(config, account_root)
466
- agents_dir(account_root).mkdir(exist_ok=True)
467
-
468
- _write_account_claude_md(account_root, config)
469
- _write_account_claude_settings(account_root)
470
- _write_account_mcp_config(account_root)
840
+ _write_account_files(config, account_root)
471
841
 
472
842
  # Step 3: Context package (best-effort — setup succeeds without it)
473
843
  console.step(3, total, "Downloading context package...")