cinna-cli 0.4.3__tar.gz → 0.5.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.
Files changed (112) hide show
  1. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/PKG-INFO +3 -1
  2. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/README.md +2 -0
  3. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/docs/features/local_agent_import/local_agent_import.md +42 -6
  4. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/docs/features/local_agent_import/local_agent_import_acceptance.md +45 -3
  5. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/docs/features/local_agent_import/local_agent_import_tech.md +103 -6
  6. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/pyproject.toml +1 -1
  7. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/src/cinna/client.py +9 -0
  8. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/src/cinna/kit_contract.py +7 -2
  9. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/src/cinna/local_import.py +111 -1
  10. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/tests/test_client.py +15 -0
  11. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/tests/test_local_import.py +327 -5
  12. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/uv.lock +1 -1
  13. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/.claude/commands/cinna-cli.feature.doc.md +0 -0
  14. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/.github/workflows/publish.yml +0 -0
  15. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/.gitignore +0 -0
  16. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/LICENSE.md +0 -0
  17. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/docs/README.md +0 -0
  18. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/docs/features/account_workspace/account_workspace.md +0 -0
  19. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/docs/features/account_workspace/account_workspace_acceptance.md +0 -0
  20. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/docs/features/account_workspace/account_workspace_tech.md +0 -0
  21. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/docs/features/agent_addons/agent_addons.md +0 -0
  22. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/docs/features/agent_addons/agent_addons_acceptance.md +0 -0
  23. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/docs/features/agent_addons/agent_addons_tech.md +0 -0
  24. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/docs/features/agent_api/agent_api.md +0 -0
  25. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/docs/features/agent_api/agent_api_acceptance.md +0 -0
  26. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/docs/features/agent_api/agent_api_tech.md +0 -0
  27. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/docs/features/agent_management/agent_management.md +0 -0
  28. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/docs/features/agent_management/agent_management_acceptance.md +0 -0
  29. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/docs/features/agent_management/agent_management_tech.md +0 -0
  30. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/docs/features/agent_schedules/agent_schedules.md +0 -0
  31. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/docs/features/agent_schedules/agent_schedules_acceptance.md +0 -0
  32. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/docs/features/agent_schedules/agent_schedules_tech.md +0 -0
  33. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/docs/features/bootstrap_onboarding/bootstrap_onboarding.md +0 -0
  34. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/docs/features/bootstrap_onboarding/bootstrap_onboarding_acceptance.md +0 -0
  35. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/docs/features/bootstrap_onboarding/bootstrap_onboarding_tech.md +0 -0
  36. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/docs/features/delegation/delegation.md +0 -0
  37. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/docs/features/delegation/delegation_acceptance.md +0 -0
  38. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/docs/features/delegation/delegation_tech.md +0 -0
  39. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/docs/features/doctor/doctor.md +0 -0
  40. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/docs/features/doctor/doctor_acceptance.md +0 -0
  41. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/docs/features/doctor/doctor_tech.md +0 -0
  42. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/docs/features/git_versioning/git_versioning.md +0 -0
  43. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/docs/features/git_versioning/git_versioning_acceptance.md +0 -0
  44. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/docs/features/git_versioning/git_versioning_tech.md +0 -0
  45. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/docs/features/improvement_requests/improvement_requests.md +0 -0
  46. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/docs/features/improvement_requests/improvement_requests_acceptance.md +0 -0
  47. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/docs/features/improvement_requests/improvement_requests_tech.md +0 -0
  48. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/docs/features/live_sync/live_sync.md +0 -0
  49. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/docs/features/live_sync/live_sync_acceptance.md +0 -0
  50. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/docs/features/live_sync/live_sync_tech.md +0 -0
  51. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/docs/features/mcp_integration/mcp_integration.md +0 -0
  52. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/docs/features/mcp_integration/mcp_integration_acceptance.md +0 -0
  53. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/docs/features/mcp_integration/mcp_integration_tech.md +0 -0
  54. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/docs/features/remote_chat/remote_chat.md +0 -0
  55. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/docs/features/remote_chat/remote_chat_acceptance.md +0 -0
  56. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/docs/features/remote_chat/remote_chat_tech.md +0 -0
  57. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/docs/features/remote_exec/remote_exec.md +0 -0
  58. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/docs/features/remote_exec/remote_exec_acceptance.md +0 -0
  59. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/docs/features/remote_exec/remote_exec_tech.md +0 -0
  60. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/docs/interface.md +0 -0
  61. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/docs/mutagen_capabilities.md +0 -0
  62. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/scripts/check_docs_references.py +0 -0
  63. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/src/cinna/__init__.py +0 -0
  64. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/src/cinna/account.py +0 -0
  65. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/src/cinna/auth.py +0 -0
  66. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/src/cinna/bootstrap.py +0 -0
  67. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/src/cinna/chat.py +0 -0
  68. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/src/cinna/cli_version.py +0 -0
  69. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/src/cinna/config.py +0 -0
  70. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/src/cinna/console.py +0 -0
  71. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/src/cinna/context.py +0 -0
  72. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/src/cinna/delegation.py +0 -0
  73. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/src/cinna/doctor.py +0 -0
  74. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/src/cinna/errors.py +0 -0
  75. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/src/cinna/git_versioning.py +0 -0
  76. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/src/cinna/improve.py +0 -0
  77. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/src/cinna/logging.py +0 -0
  78. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/src/cinna/main.py +0 -0
  79. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/src/cinna/mcp_proxy.py +0 -0
  80. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/src/cinna/mutagen_runtime.py +0 -0
  81. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/src/cinna/scenarios.py +0 -0
  82. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/src/cinna/sync.py +0 -0
  83. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/src/cinna/sync_session.py +0 -0
  84. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/src/cinna/sync_ssh_shim.py +0 -0
  85. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/src/cinna/sync_tui.py +0 -0
  86. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/src/cinna/templates/ACCOUNT_CLAUDE.md.template +0 -0
  87. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/src/cinna/templates/CHAT_TESTING.md +0 -0
  88. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/src/cinna/templates/CLAUDE.md.template +0 -0
  89. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/src/cinna/templates/GIT_VERSIONING.md +0 -0
  90. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/src/cinna/templates/__init__.py +0 -0
  91. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/tests/__init__.py +0 -0
  92. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/tests/conftest.py +0 -0
  93. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/tests/test_account.py +0 -0
  94. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/tests/test_auth.py +0 -0
  95. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/tests/test_bootstrap.py +0 -0
  96. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/tests/test_chat.py +0 -0
  97. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/tests/test_cli_version.py +0 -0
  98. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/tests/test_config.py +0 -0
  99. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/tests/test_console.py +0 -0
  100. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/tests/test_context.py +0 -0
  101. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/tests/test_delegation.py +0 -0
  102. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/tests/test_doctor.py +0 -0
  103. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/tests/test_git_versioning.py +0 -0
  104. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/tests/test_improve.py +0 -0
  105. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/tests/test_kit_contract.py +0 -0
  106. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/tests/test_main.py +0 -0
  107. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/tests/test_mutagen_runtime.py +0 -0
  108. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/tests/test_onboarding.py +0 -0
  109. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/tests/test_scenarios.py +0 -0
  110. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/tests/test_sync.py +0 -0
  111. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/tests/test_sync_session.py +0 -0
  112. {cinna_cli-0.4.3 → cinna_cli-0.5.0}/tests/test_sync_ssh_shim.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: cinna-cli
3
- Version: 0.4.3
3
+ Version: 0.5.0
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
@@ -338,6 +338,8 @@ The folder rules come from the kit's versioned contract, `.cinna-kit/layout.json
338
338
 
339
339
  Where the folder has been published is recorded in `publications.json`, a **sibling** of `cinna-agent.json` holding one entry per Cinna instance — it cannot live inside the manifest, because each entry records a hash of the exported tree and the manifest is part of that tree. Every step is idempotent (agent by the entry whose `platform_url` matches the instance you are logged into, credentials by name, schedules by name), so a partial import is resumed with `--update` instead of duplicating anything. The publication is recorded **only after the push settled** — `--no-push`, a failed flush, or remaining conflicts leave it unrecorded, and the next run is a plain `--update`. A legacy `cloud` block is migrated into the ledger on the first write and is still read until then, so an older folder's `--update` never creates a second agent. `--dry-run` makes no platform call and writes nothing.
340
340
 
341
+ The manifest's `runtime.engine` (`opencode`, `claude`, `codex`, …) is sent when the agent is **created**; the platform runs an engine it does not support on OpenCode, and `--update` never changes an existing agent's engine. The plan prints an `Engine:` line, and a `Not carried to the cloud:` line naming the manifest fields present that the cloud has no place for (`runtime.model`, `runtime.complexity`, `runtime.credential`, `runtime.permissions`, unknown `runtime.*` keys, `handovers` — the import sends no handover at all — and `capabilities`). A malformed `runtime` costs a warning, never the import.
342
+
341
343
  ### `cinna skills list <agent> [--json]`
342
344
 
343
345
  List everything an agent carries beyond its prompt, as one deduplicated list. An **addon** is either an installed plugin or a `skills/<name>/` folder — a `SKILL.md` plus its files that the engine loads on demand. The two overlap (a skill installed from the catalog is *also* a plugin link), and the platform owns the dedupe rule, so this prints the server's projection rather than folding the halves itself: one row per addon, with its kind, source (`marketplace` / `bundle` / `catalog` / `local`), status, name and version.
@@ -301,6 +301,8 @@ The folder rules come from the kit's versioned contract, `.cinna-kit/layout.json
301
301
 
302
302
  Where the folder has been published is recorded in `publications.json`, a **sibling** of `cinna-agent.json` holding one entry per Cinna instance — it cannot live inside the manifest, because each entry records a hash of the exported tree and the manifest is part of that tree. Every step is idempotent (agent by the entry whose `platform_url` matches the instance you are logged into, credentials by name, schedules by name), so a partial import is resumed with `--update` instead of duplicating anything. The publication is recorded **only after the push settled** — `--no-push`, a failed flush, or remaining conflicts leave it unrecorded, and the next run is a plain `--update`. A legacy `cloud` block is migrated into the ledger on the first write and is still read until then, so an older folder's `--update` never creates a second agent. `--dry-run` makes no platform call and writes nothing.
303
303
 
304
+ The manifest's `runtime.engine` (`opencode`, `claude`, `codex`, …) is sent when the agent is **created**; the platform runs an engine it does not support on OpenCode, and `--update` never changes an existing agent's engine. The plan prints an `Engine:` line, and a `Not carried to the cloud:` line naming the manifest fields present that the cloud has no place for (`runtime.model`, `runtime.complexity`, `runtime.credential`, `runtime.permissions`, unknown `runtime.*` keys, `handovers` — the import sends no handover at all — and `capabilities`). A malformed `runtime` costs a warning, never the import.
305
+
304
306
  ### `cinna skills list <agent> [--json]`
305
307
 
306
308
  List everything an agent carries beyond its prompt, as one deduplicated list. An **addon** is either an installed plugin or a `skills/<name>/` folder — a `SKILL.md` plus its files that the engine loads on demand. The two overlap (a skill installed from the catalog is *also* a plugin link), and the platform owns the dedupe rule, so this prints the server's projection rather than folding the halves itself: one row per addon, with its kind, source (`marketplace` / `bundle` / `catalog` / `local`), status, name and version.
@@ -21,7 +21,9 @@ The kit's machine-readable half is a **versioned contract** (`layout.json`,
21
21
  `GET /api/agent-start/contract.tar.gz` with a cheap version poll at
22
22
  `GET /api/agent-start/contract/version`. cinna-cli reads the copy installed at
23
23
  `.cinna-kit/`; a shipped build should obtain it from those routes rather than
24
- vendoring one.
24
+ vendoring one. This build implements contract **1.5.0** (`runtime.engine`,
25
+ `runtime.complexity` and `handovers[].target_kind` in core's one schema); only
26
+ the major decides compatibility, so any `1.x` folder imports.
25
27
 
26
28
  Nothing platform-side is new: the import replays the manifest through the
27
29
  existing account-scoped verbs (`agent create`, the bulk prompt write, `agent
@@ -57,6 +59,30 @@ sync`, `sync push`, `account credentials create`, `agent schedule create`,
57
59
  `.env.sample` and `.env.template` still travel. Credentials are created as
58
60
  **empty drafts** and the command prints the URLs the user opens to fill each
59
61
  one in the browser. No secret value is read, sent, or printed at any point.
62
+ - **A credential spec can name its slot.** Since contract 1.4.0 a manifest
63
+ credential may carry `service_uri` — the slot the agent's credential reader
64
+ matches an attached credential on. The import passes it to the draft
65
+ unchanged; one that is not a non-empty string is refused with the other
66
+ manifest guards, before the agent is created.
67
+ - **The engine is a preference, carried once.** A manifest's `runtime.engine`
68
+ (`opencode`, `claude`, `codex`, or a name this build has never heard of) is
69
+ sent to the platform when the agent is **created**, which maps it to the
70
+ environment's SDK and runs an engine it does not support on OpenCode — the
71
+ folder is never refused over it. `--update` never changes an existing agent's
72
+ engine. A `runtime` that is not an object, or an engine that is not a string,
73
+ costs a warning and nothing is sent.
74
+ - **What the cloud does not carry is named, not dropped silently.** The other
75
+ `runtime` fields (`model`, `complexity`, `credential`, `permissions`, and any
76
+ key this build does not know) have no cloud counterpart, and the import
77
+ sends no `handovers` at all (not only the desktop's `target_kind`) and maps
78
+ `capabilities` onto nothing; the plan lists the ones present in one
79
+ `Not carried to the cloud:` line.
80
+ - **Using cloud credentials on the desktop is not an import concern.** A
81
+ locally running agent in Cinna Desktop gets credential values from the
82
+ platform's desktop delivery route, gated per credential by *allow local use*
83
+ for credentials shared with you (your own always qualify). That exchange is
84
+ between the platform and the desktop; `cinna agent import` never reads
85
+ credential values, and the CLI has no switch for *allow local use* yet.
60
86
  - **The ledger is a sibling file, never a manifest key.** Where an agent folder
61
87
  has been published is recorded in `publications.json` beside
62
88
  `cinna-agent.json` — one entry per Cinna instance. It has to be a separate
@@ -83,10 +109,11 @@ Each prints as `[n/9]`:
83
109
  contract: refuse a folder built against a newer **major** contract version,
84
110
  warn on an older one or on a folder that records none, resolve the exclude
85
111
  list and the secret rules, plan the copy and compute the tree's
86
- `content_hash`.
112
+ `content_hash`. Read `runtime` tolerantly and print the `Engine:` line and,
113
+ when any are present, the `Not carried to the cloud:` line.
87
114
  2. **Agent** — resolve by the `publications.json` entry for this instance
88
115
  (requires `--update`) or create a new agent in the active user workspace
89
- (`--workspace` overrides).
116
+ (`--workspace` overrides), passing `runtime.engine` on creation only.
90
117
  3. **Prompts + metadata** — one bulk write carrying `description`,
91
118
  `router_trigger_prompt`, `example_prompts`, and the three document prompts
92
119
  read from the files named in `prompts`; then the status refresh command.
@@ -99,7 +126,8 @@ Each prints as `[n/9]`:
99
126
  6. **Push** — `cinna sync push` equivalent (skipped by `--no-push`).
100
127
  7. **Credentials** — one empty draft per spec, attached to the agent; an
101
128
  existing credential of the same name is attached rather than recreated.
102
- Setup URLs are collected for the summary.
129
+ A spec's `service_uri` is set on a new draft; an existing credential keeps
130
+ its own. Setup URLs are collected for the summary.
103
131
  8. **Schedules** — one per spec, name-idempotent; `--update` rewrites an
104
132
  existing schedule in place.
105
133
  9. **Record** — add or update this instance's entry in `publications.json`
@@ -131,7 +159,11 @@ file that would be copied, every credential draft, every schedule — and makes
131
159
  `publications.json` entry whose `platform_url` matches the instance you are
132
160
  logged into (not by workspace, and not by position in the list), rewrites
133
161
  prompts and metadata, re-copies the tree, pushes, attaches existing credentials,
134
- and updates the schedules in place.
162
+ and updates the schedules in place. The agent's engine is left as it is; when
163
+ the manifest names one, the `Engine:` line says it was not applied. An
164
+ `--update` with no known agent id cannot tell from the plan whether it will
165
+ find the agent by name or create it, so the plan says the engine is sent only
166
+ on creation, and the lookup then says which happened.
135
167
 
136
168
  ### Resume a partial import
137
169
 
@@ -160,11 +192,15 @@ created are reused, not duplicated.
160
192
  | `schema_version` newer than supported | Refused with an upgrade hint — never half-read |
161
193
  | Folder built against a newer **major** contract version | Refused with an upgrade hint — a minor or patch difference is not a compatibility question and passes silently |
162
194
  | Folder records no `contract_version` | Warned and imported — every folder created before contract 1.0.0 is in that state |
195
+ | A credential's `service_uri` is empty or not a string | Refused before any platform call, naming the credential |
196
+ | `runtime` is not an object, or `runtime.engine` is not a string | Warned and imported; no engine is sent, so the agent gets the platform default |
197
+ | `runtime.engine` names an engine the cloud does not support | Imported; the platform runs the agent on OpenCode |
198
+ | Older platform without the `engine` create field | Imported; the platform ignores the unknown field and the agent gets its default engine |
163
199
  | Already imported to this instance, no `--update` | Refused, naming the agent id and where the link was recorded |
164
200
  | The recorded agent id is not among your agents | Refused — you are logged into a different platform, or the agent was deleted |
165
201
  | `--update` with several name matches | Refused, listing them; add the `publications.json` entry to disambiguate |
166
202
  | Push failed or left conflicts | Warned, **nothing recorded**, with the resolve + `--update` hint |
167
- | `.cinna-kit/layout.json` missing or unreadable | Imported with the built-in contract copy, the `Exclusions:` line marked `DEGRADED`, and **no `content_hash` recorded** — a hash over a file set another host would not select is worse than none |
203
+ | `.cinna-kit/layout.json` missing or unreadable | Imported with the built-in copy of contract 1.5.0, the `Exclusions:` line marked `DEGRADED`, and **no `content_hash` recorded** — a hash over a file set another host would not select is worse than none |
168
204
  | A travelling file or directory cannot be read | Refused — a complete-looking export with a subtree missing from it is the one outcome worth stopping for |
169
205
  | `publications.json` unreadable | Refused, and the file is left exactly as it was |
170
206
  | Credential/schedule 403 | The platform error surfaces verbatim; earlier steps stay done and `--update` resumes |
@@ -121,7 +121,8 @@ any change to the orchestrator.
121
121
 
122
122
  - **Steps:** in a scratch copy of the agent folder, one at a time: bump
123
123
  `schema_version` to `2`; rename the folder so it disagrees with `slug`; make
124
- `cron_string` `"every morning"`; drop a credential's `type`.
124
+ `cron_string` `"every morning"`; drop a credential's `type`; set a
125
+ credential's `service_uri` to `""`.
125
126
  - **Expected:** each is refused *before* any platform call, with a message that
126
127
  names the offending field.
127
128
  - **Watch for:** a half-applied import (agent created, then the manifest
@@ -242,14 +243,55 @@ any change to the orchestrator.
242
243
  ### 10f. The `contract_version` gate
243
244
 
244
245
  - **Steps:** set the manifest's `contract_version` to `2.0.0` and import; then
245
- to `1.4.2` and import; then remove the key entirely and import.
246
+ to `1.5.2` and import; then remove the key entirely and import.
246
247
  - **Expected:** `2.0.0` is refused before any platform call, naming `2.x`;
247
- `1.4.2` passes **silently** (same major — a minor contract change is additive
248
+ `1.5.2` passes **silently** (same major — a minor contract change is additive
248
249
  by definition and reaches a non-adopting reader as silence); the missing key
249
250
  warns about a re-stamp and imports anyway.
250
251
  - **Watch for:** a warning on the same-major pair. That is the gate's shape, not
251
252
  a bug: anything an old reader must notice needs a major bump.
252
253
 
254
+ ### 10g. A credential slot reaches the draft (contract 1.4.0)
255
+
256
+ - **Goal:** a manifest credential that names its slot gets a draft the agent's
257
+ credential reader can match, in the cloud and on the desktop.
258
+ - **Setup:** a fresh agent whose manifest credential carries
259
+ `"service_uri": "imap://billing"` and whose name does **not** exist yet in the
260
+ account.
261
+ - **Steps:**
262
+ ```bash
263
+ cinna agent import MyAgents/Local/<agent> --yes
264
+ cinna account credentials list
265
+ ```
266
+ - **Expected:** the draft is listed with `imap://billing` in the **Slot** column; after it is filled
267
+ in the browser, `cinna skills list <agent>` (if a skill declares the slot)
268
+ shows it ready. Re-running with `--update` does not change the URI of the
269
+ existing credential.
270
+ - **Watch for:** a draft created without the slot, so the reader falls back to
271
+ name matching; a second draft created because the slot differs.
272
+
273
+ ### 10h. The engine travels at creation, and nothing else from `runtime` (contract 1.5.0)
274
+
275
+ - **Setup:** a fresh agent whose manifest carries
276
+ `"runtime": {"engine": "claude", "model": "claude-sonnet", "complexity": "medium"}`
277
+ and a handover with `"target_kind": "coordinator"`. Requires a cinna-core that
278
+ accepts `engine` on `POST /api/v1/cli/account/agents`.
279
+ - **Steps:**
280
+ ```bash
281
+ cinna agent import MyAgents/Local/<agent> --dry-run
282
+ cinna agent import MyAgents/Local/<agent> --yes
283
+ cinna agent import MyAgents/Local/<agent> --update --yes
284
+ ```
285
+ - **Expected:** both the dry run and the import print
286
+ `Engine: claude — sent as a preference (the cloud runs engines it does not support on OpenCode)`
287
+ and `Not carried to the cloud: runtime.model, runtime.complexity, handovers`;
288
+ the new agent's environment runs on Claude Code. Set `"engine": "codex"` on a
289
+ fresh agent: it imports and runs on OpenCode. The `--update` run prints
290
+ `Engine: claude — not applied: --update never changes an existing agent's engine`
291
+ and the environment is unchanged.
292
+ - **Watch for:** the import refusing an unknown engine; the model override
293
+ changing; an older cinna-core rejecting the create (it should ignore `engine`).
294
+
253
295
  ### 11. Verification loop the guide promises
254
296
 
255
297
  - **Steps:**
@@ -39,7 +39,7 @@ endpoint exists for this feature.
39
39
 
40
40
  | Step | Call |
41
41
  |------|------|
42
- | 2 | `list_account_agents()` (resolve) / `create_agent(name, description, user_workspace_id=…)` |
42
+ | 2 | `list_account_agents()` (resolve) / `create_agent(name, description, user_workspace_id=…, engine=…)` — `engine` is in the body only when set |
43
43
  | 3 | `update_agent_config(agent_id, fields)` → `PUT agents/{id}` **through the api-proxy escape hatch**, then `set_status_refresh_command(agent_id, cmd)` |
44
44
  | 4 | `account.py:run_agent_sync(agent_id, None)` (mints the child token, provisions the workspace) — skipped when `resolve_child_workspace()` already finds one |
45
45
  | 6 | `sync_session.ensure_session()` + `sync_session.flush()` — the same pair `cinna sync push` uses |
@@ -70,10 +70,59 @@ pragmatic subset `kit.py validate` checks:
70
70
  - `slug` matches `^[a-z0-9][a-z0-9-]{1,62}$` **and** equals the folder name.
71
71
  - `name` present, ≤ 255 chars; `description` a string when present.
72
72
  - `prompts` an object; `example_prompts` a list of strings.
73
- - Each credential has a non-empty `name` and `type`.
73
+ - Each credential has a non-empty `name` and `type`, and a `service_uri`
74
+ (contract 1.4.0, optional) is a non-empty string when present. Checked here
75
+ rather than left to the platform because the agent already exists by the time
76
+ `_sync_credentials()` sends it — a 422 there is a half-applied import.
74
77
  - Each schedule has a name, a 5-field `cron_string`, a known `schedule_type`, and
75
78
  a `command` when it is a script schedule.
76
79
 
80
+ `runtime` is **not** validated here — `read_runtime(manifest)` reads it
81
+ tolerantly after the manifest loads, because its fields are additive minors
82
+ that must never cost the import:
83
+
84
+ - `runtime` not an object, or `runtime.engine` not a string → a
85
+ `console.warn` naming the type, and no engine is sent.
86
+ - `runtime.engine` stripped; empty means absent. Otherwise sent raw — it is not
87
+ an enum, and the backend owns the mapping (`claude` → Claude Code, everything
88
+ it does not support → OpenCode, decision 4 of the 1.5.0 unification plan).
89
+ - The fields the cloud import does not carry are returned as dotted paths, in
90
+ a fixed order: `runtime.model`, `runtime.complexity`, `runtime.credential`,
91
+ `runtime.permissions` (`_UNCARRIED_RUNTIME_KEYS`), then any unknown
92
+ `runtime.*` key sorted, then `handovers` — the whole list, when it is
93
+ non-empty, because this import sends no handover at all; naming only
94
+ `handovers[].target_kind` would imply the rest of a handover travels — then
95
+ `capabilities`, when any of its keys has a value (`x-import: ignored`). A
96
+ key whose value is `null` counts as absent.
97
+
98
+ The engine reaches `_resolve_or_create_agent()` and is passed to
99
+ `create_agent()` **only on the create branch**. An agent resolved by id or
100
+ reattached by name keeps its engine: the id case says so in the step-1 `Engine:`
101
+ line (`known_agent_id` is known before the plan prints). `--update` without a
102
+ known id only learns at step 2 whether a name match exists, so its step-1 line
103
+ is the undecided one, and step 2 prints either the kept line (name-matched
104
+ resume) or the sent line (no match, agent created). The `Engine:` values,
105
+ verbatim:
106
+
107
+ ```
108
+ Engine: claude — sent as a preference (the cloud runs engines it does not support on OpenCode)
109
+ Engine: claude — not applied: --update never changes an existing agent's engine
110
+ Engine: claude — sent only if this import creates the agent; --update keeps the engine of an existing agent it finds by name
111
+ Engine: not set — the cloud agent gets the platform's default
112
+ Not carried to the cloud: runtime.model, runtime.complexity, handovers
113
+ ```
114
+
115
+ The last line is printed only when the list is non-empty. The warnings:
116
+
117
+ ```
118
+ ! cinna-agent.json: 'runtime' is not an object (str) — ignored; the cloud agent gets the platform's default engine.
119
+ ! cinna-agent.json: 'runtime.engine' is not a string (int) — ignored; the cloud agent gets the platform's default engine.
120
+ ```
121
+
122
+ Sending `engine` to an older cinna-core is safe: `AccountAgentCreateBody` is a
123
+ plain `SQLModel` with no `extra` setting, so pydantic's default (`ignore`)
124
+ drops the unknown field and the agent is created with the default engine.
125
+
77
126
  Unknown keys are preserved: the dict is mutated and re-dumped, so a manifest
78
127
  written by a newer kit survives a round-trip through an older CLI.
79
128
 
@@ -91,7 +140,39 @@ hosts read it — Cinna Desktop, cinna-core's `kit.py`, and this CLI — so the
91
140
  folder rules are data rather than prose each host re-derives.
92
141
  `src/cinna/kit_contract.py` is cinna-cli's reader and the port of the algorithms
93
142
  that must stay byte-identical across hosts. `SUPPORTED_CONTRACT_VERSION` names
94
- the contract version this build implements.
143
+ the contract version this build implements — **1.5.0**. It is the tool half of
144
+ the compatibility pair when no kit sits beside the agent, and it is the version
145
+ named in the `DEGRADED —` `Exclusions:` line.
146
+
147
+ ### Contract 1.5.0 — what changed for this reader
148
+
149
+ 1.5.0 brings the desktop's `runtime.engine`, `runtime.complexity` and
150
+ `handovers[].target_kind` into cinna-core's one manifest schema. Nothing this
151
+ module evaluates changed — `cloud_import_excludes`, `secret_files` and the
152
+ ledger schema are as in 1.4.0. The manifest side is `local_import.py`:
153
+ `runtime.engine` now reaches the platform at creation, and the fields the cloud
154
+ does not carry are printed (see *Manifest handling*).
155
+
156
+ ### Contract 1.4.0 — what changed for this reader
157
+
158
+ 1.4.0 (cloud and local credential delivery) is additive for cinna-cli; the
159
+ cross-repo checks below pass unchanged against cinna-core's 1.4.0 files.
160
+
161
+ - `cloud_import_excludes` is unchanged in content: `credentials/` was already
162
+ excluded whole, and the notes now say why (the platform regenerates
163
+ `credentials/README.md` and feeds it to the cloud prompt). `DEFAULT_EXCLUDE`
164
+ and `DEFAULT_SECRET_FILE_RULES` equal the 1.4.0 lists.
165
+ - `desktop_owned` shrank to a bare path list (the `contract_keys` block is
166
+ gone). This module does not read `desktop_owned`, so nothing depends on it.
167
+ - The manifest schema gained an optional `credentials[].service_uri`;
168
+ `local_import.py:load_manifest()` validates it and
169
+ `local_import.py:_sync_credentials()` passes it to
170
+ `client.py:AccountClient.create_credential()`.
171
+ - Credential values for a locally running agent come from cinna-core's
172
+ `/api/v1/external/credentials` routes (list, and a desktop-only
173
+ `materialize`), gated per credential by `allow_local_use`. Those are
174
+ desktop-JWT routes; `POST|PUT /api/v1/cli/account/credentials` do not accept
175
+ `allow_local_use` yet, so the CLI neither sends nor shows it.
95
176
 
96
177
  ### Finding it
97
178
 
@@ -293,7 +374,9 @@ failure warns and yields no file rather than aborting the import.
293
374
  absent + no `--update` → create.
294
375
  - **Credentials** — `list_credentials()` keyed by name; an existing name is only
295
376
  *attached* (`share_credential_with_agent`), never recreated, so re-runs cannot
296
- produce a second empty draft that shadows the filled one.
377
+ produce a second empty draft that shadows the filled one. Its `service_uri`
378
+ is not compared or rewritten either — the manifest's slot applies to a new
379
+ draft only.
297
380
  - **Schedules** — `list_schedules()` keyed by name; existing + `--update` →
298
381
  `update_schedule`, existing without `--update` → left alone with a notice.
299
382
  - **Manifest settle** — `write_manifest()` runs after the confirmation and
@@ -316,7 +399,8 @@ failure warns and yields no file rather than aborting the import.
316
399
 
317
400
  `--dry-run` returns before the first `AccountClient` is constructed and before
318
401
  any local write: it prints the resolved plan (file list, credential drafts,
319
- schedules, create-vs-update) and stops. The test asserts the patched
402
+ schedules, the `Engine:` and `Not carried to the cloud:` lines,
403
+ create-vs-update) and stops. The test asserts the patched
320
404
  `AccountClient` class was never called at all.
321
405
 
322
406
  ## Tests
@@ -386,4 +470,17 @@ against `publications.schema.json`.
386
470
  - `run_agent_sync` is called exactly once when no workspace exists yet;
387
471
  - `--no-push` and push-with-conflicts both leave the ledger unwritten;
388
472
  - `layout.json` supplying the exclude list while `credentials/` stays excluded;
389
- - `--workspace` / `--name` threading and verbatim `PlatformError` surfacing.
473
+ - a bad credential `service_uri` refused by `load_manifest()`, and a good one
474
+ reaching `create_credential()`;
475
+ - `--workspace` / `--name` threading and verbatim `PlatformError` surfacing;
476
+ - contract 1.5.0 runtime: `read_runtime()` over absent, null, blank, unknown and
477
+ full `runtime` blocks and empty/non-empty `handovers`; the stripped engine
478
+ reaching `create_agent()`; the default-engine line; a non-object `runtime`
479
+ and a non-string engine warning without refusing; the exact
480
+ `Not carried to the cloud:` line on a real run and on `--dry-run`, with no
481
+ runtime field in the metadata write; `--update` by id and by name match
482
+ leaving the engine alone and saying so once, and saying nothing when the
483
+ manifest names no engine.
484
+
485
+ `tests/test_client.py` asserts `create_agent()` puts `engine` in the body only
486
+ when it is set.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "cinna-cli"
3
- version = "0.4.3"
3
+ version = "0.5.0"
4
4
  description = "Local development CLI for Cinna Core agents"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -318,6 +318,7 @@ class AccountClient:
318
318
  name: str,
319
319
  description: str | None = None,
320
320
  user_workspace_id: str | None = None,
321
+ engine: str | None = None,
321
322
  ) -> dict:
322
323
  """POST /api/v1/cli/account/agents — create an agent (thin client).
323
324
 
@@ -325,12 +326,20 @@ class AccountClient:
325
326
  (AI credentials, env template, environment creation) and returns the
326
327
  full agent record. ``user_workspace_id`` targets the account's active
327
328
  user workspace (``None`` = Default).
329
+
330
+ ``engine`` is a Local Agent Kit ``runtime.engine`` preference
331
+ (``opencode``, ``claude``, ``codex``, …), sent raw: the backend maps it
332
+ to the environment's SDK and runs an engine it does not support on
333
+ OpenCode. It is sent only when set — an older backend ignores the
334
+ unknown field, so sending it is backward compatible.
328
335
  """
329
336
  body: dict = {"name": name}
330
337
  if description is not None:
331
338
  body["description"] = description
332
339
  if user_workspace_id is not None:
333
340
  body["user_workspace_id"] = user_workspace_id
341
+ if engine is not None:
342
+ body["engine"] = engine
334
343
  response = self._client.post("/api/v1/cli/account/agents", json=body)
335
344
  return self._handle_response(response).json()
336
345
 
@@ -24,7 +24,12 @@ rather than compute it a different way. The safe direction is a property of the
24
24
  consequence, not a house style.
25
25
 
26
26
  Ported from cinna-core's ``docs/local_agent_kit/tools/kit.py``, which is the
27
- reference implementation, at contract version 1.0.0.
27
+ reference implementation, at contract version 1.0.0; the offline fallbacks below
28
+ are checked against contract 1.5.0. Neither 1.4.0 (cloud and local credential
29
+ delivery — ``credentials/`` was already excluded whole) nor 1.5.0 (the
30
+ ``runtime.engine``/``runtime.complexity``/``handovers[].target_kind`` manifest
31
+ fields brought into core's schema, read by :mod:`cinna.local_import`) changed
32
+ anything this module evaluates.
28
33
  """
29
34
 
30
35
  from __future__ import annotations
@@ -54,7 +59,7 @@ CONTRACT_VERSION_FILENAME = "CONTRACT_VERSION"
54
59
 
55
60
  #: The contract version this CLI implements. Only the MAJOR component is a
56
61
  #: compatibility question — see ``check_contract_compatibility``.
57
- SUPPORTED_CONTRACT_VERSION = "1.0.0"
62
+ SUPPORTED_CONTRACT_VERSION = "1.5.0"
58
63
 
59
64
 
60
65
  # ── Offline fallbacks ───────────────────────────────────────────────────────
@@ -181,6 +181,16 @@ def load_manifest(source: Path) -> dict:
181
181
  raise click.ClickException(
182
182
  f"{MANIFEST_FILENAME}: credential '{cred.get('name')}' needs a 'type'."
183
183
  )
184
+ # Contract 1.4.0: an optional explicit slot. Checked here because the
185
+ # agent already exists by the time the credential draft is created.
186
+ service_uri = cred.get("service_uri")
187
+ if service_uri is not None and (
188
+ not isinstance(service_uri, str) or not service_uri.strip()
189
+ ):
190
+ raise click.ClickException(
191
+ f"{MANIFEST_FILENAME}: credential '{cred['name']}' has a "
192
+ f"'service_uri' that is not a non-empty string."
193
+ )
184
194
 
185
195
  for sched in _manifest_list(data, "schedules"):
186
196
  if not isinstance(sched.get("name"), str) or not sched["name"].strip():
@@ -217,6 +227,67 @@ def _manifest_list(data: dict, key: str) -> list[dict]:
217
227
  return value
218
228
 
219
229
 
230
+ #: ``runtime.*`` keys the contract names that the cloud import does not carry
231
+ #: (contract 1.5.0, ``x-import: ignored``), in the order they are reported.
232
+ #: ``runtime.engine`` is the one runtime field that travels — at creation only.
233
+ _UNCARRIED_RUNTIME_KEYS = ("model", "complexity", "credential", "permissions")
234
+
235
+
236
+ def read_runtime(manifest: dict) -> tuple[str | None, list[str]]:
237
+ """``(engine to send, manifest fields the cloud does not carry)``.
238
+
239
+ Read tolerantly, never refused: ``runtime`` is optional and its fields are
240
+ additive minors, so a malformed value costs a warning and nothing is sent —
241
+ the cloud agent then gets the platform's default engine.
242
+
243
+ The engine is a *preference*, sent stripped and otherwise raw; the backend
244
+ owns the mapping (``claude`` → Claude Code, anything it does not support →
245
+ OpenCode). The not-carried list names every field present with a value that
246
+ this import drops on the floor, so the loss is printed rather than silent:
247
+ the known ``runtime`` keys, any ``runtime`` key this build does not know,
248
+ ``handovers`` — whole, because this import never sends a handover at all
249
+ (naming only ``target_kind`` would imply the rest of one travels) — and
250
+ ``capabilities``, which the import maps onto nothing.
251
+ """
252
+ not_carried: list[str] = []
253
+ engine: str | None = None
254
+
255
+ runtime = manifest.get("runtime")
256
+ if runtime is not None and not isinstance(runtime, dict):
257
+ console.warn(
258
+ f"{MANIFEST_FILENAME}: 'runtime' is not an object "
259
+ f"({type(runtime).__name__}) — ignored; the cloud agent gets the "
260
+ f"platform's default engine."
261
+ )
262
+ elif isinstance(runtime, dict):
263
+ raw = runtime.get("engine")
264
+ if isinstance(raw, str):
265
+ engine = raw.strip() or None
266
+ elif raw is not None:
267
+ console.warn(
268
+ f"{MANIFEST_FILENAME}: 'runtime.engine' is not a string "
269
+ f"({type(raw).__name__}) — ignored; the cloud agent gets the "
270
+ f"platform's default engine."
271
+ )
272
+ present = [k for k, v in runtime.items() if k != "engine" and v is not None]
273
+ not_carried += [f"runtime.{k}" for k in _UNCARRIED_RUNTIME_KEYS if k in present]
274
+ not_carried += [
275
+ f"runtime.{k}" for k in sorted(present) if k not in _UNCARRIED_RUNTIME_KEYS
276
+ ]
277
+
278
+ handovers = manifest.get("handovers")
279
+ if isinstance(handovers, list) and handovers:
280
+ not_carried.append("handovers")
281
+
282
+ capabilities = manifest.get("capabilities")
283
+ if isinstance(capabilities, dict) and any(
284
+ v is not None for v in capabilities.values()
285
+ ):
286
+ not_carried.append("capabilities")
287
+
288
+ return engine, not_carried
289
+
290
+
220
291
  def _schedule_type(sched: dict) -> str:
221
292
  raw = (sched.get("schedule_type") or "static_prompt").strip()
222
293
  resolved = _SCHEDULE_TYPE_ALIASES.get(raw)
@@ -676,6 +747,7 @@ def run_agent_import(
676
747
  description = manifest.get("description")
677
748
  credentials = _manifest_list(manifest, "credentials")
678
749
  schedules = _manifest_list(manifest, "schedules")
750
+ engine, not_carried = read_runtime(manifest)
679
751
  contract = load_export_contract(source)
680
752
  known_agent_id, known_from = resolve_known_agent_id(
681
753
  source, manifest, account_cfg.platform_url
@@ -707,6 +779,12 @@ def run_agent_import(
707
779
  )
708
780
  _line(f" Credentials: {len(credentials)} draft(s)")
709
781
  _line(f" Schedules: {len(schedules)}")
782
+ # Without a known id, --update decides create-or-reattach only when it
783
+ # looks the agent up by name, so the plan cannot say which yet.
784
+ existing = True if known_agent_id else (None if update else False)
785
+ _line(f" Engine: {_engine_line(engine, existing)}")
786
+ if not_carried:
787
+ _line(f" Not carried to the cloud: {', '.join(not_carried)}")
710
788
 
711
789
  if dry_run:
712
790
  console.console.print()
@@ -779,6 +857,7 @@ def run_agent_import(
779
857
  known_agent_id=known_agent_id,
780
858
  update=update,
781
859
  user_workspace_id=user_workspace_id,
860
+ engine=engine,
782
861
  )
783
862
  agent_id = agent["id"]
784
863
 
@@ -890,11 +969,17 @@ def _resolve_or_create_agent(
890
969
  known_agent_id: str | None,
891
970
  update: bool,
892
971
  user_workspace_id: str | None,
972
+ engine: str | None = None,
893
973
  ) -> dict:
894
974
  """Return the cloud agent record, creating it when this is a first import.
895
975
 
896
976
  Resolution is by id whenever the manifest carries one — a duplicate display
897
977
  name on the platform must never make the import write into the wrong agent.
978
+
979
+ ``engine`` reaches the platform only on creation. An existing agent keeps
980
+ its engine: the id-resolved case already said so in the plan. For --update
981
+ without an id the plan could not know, so the outcome is said here —
982
+ kept on a name-matched resume, sent when no agent matched.
898
983
  """
899
984
  if known_agent_id:
900
985
  items = client.list_account_agents().get("data", [])
@@ -925,6 +1010,8 @@ def _resolve_or_create_agent(
925
1010
  f" Reusing existing agent {matches[0].get('name')} "
926
1011
  f"({matches[0].get('id')})."
927
1012
  )
1013
+ if engine:
1014
+ _line(f" Engine: {_engine_line(engine, existing=True)}")
928
1015
  return matches[0]
929
1016
  if len(matches) > 1:
930
1017
  rows = ", ".join(f"{a['name']} ({a['id']})" for a in matches)
@@ -934,12 +1021,35 @@ def _resolve_or_create_agent(
934
1021
  )
935
1022
 
936
1023
  agent = client.create_agent(
937
- agent_name, description, user_workspace_id=user_workspace_id
1024
+ agent_name, description, user_workspace_id=user_workspace_id, engine=engine
938
1025
  )
939
1026
  _line(f" Created agent {agent.get('name')} ({agent.get('id')}).")
1027
+ if update and engine:
1028
+ _line(f" Engine: {_engine_line(engine, existing=False)}")
940
1029
  return agent
941
1030
 
942
1031
 
1032
+ def _engine_line(engine: str | None, existing: bool | None) -> str:
1033
+ """The ``Engine:`` line's value — what the cloud will do with the preference.
1034
+
1035
+ ``existing`` is ``None`` when that is not known yet: --update without a
1036
+ known id reattaches to an agent matched by name, or creates one.
1037
+ """
1038
+ if engine is None:
1039
+ return "not set — the cloud agent gets the platform's default"
1040
+ if existing is None:
1041
+ return (
1042
+ f"{engine} — sent only if this import creates the agent; "
1043
+ f"--update keeps the engine of an existing agent it finds by name"
1044
+ )
1045
+ if existing:
1046
+ return (
1047
+ f"{engine} — not applied: --update never changes an existing "
1048
+ f"agent's engine"
1049
+ )
1050
+ return f"{engine} — sent as a preference (the cloud runs engines it does not support on OpenCode)"
1051
+
1052
+
943
1053
  def _write_prompts_and_metadata(
944
1054
  client: AccountClient, agent_id: str, source: Path, manifest: dict
945
1055
  ) -> None:
@@ -469,3 +469,18 @@ def test_a_bare_coded_body_is_still_a_coded_refusal(account_client):
469
469
  account_client.install_skill_on_agent("agent-123", "pkg-uuid")
470
470
  assert exc_info.value.code == "already_installed"
471
471
  assert "already added" in exc_info.value.detail
472
+
473
+
474
+ @respx.mock
475
+ @pytest.mark.parametrize("engine, sent", [(None, False), ("claude", True)])
476
+ def test_create_agent_sends_the_engine_only_when_set(account_client, engine, sent):
477
+ """An older core ignores an unknown body field; a newer one maps it."""
478
+ route = respx.post("https://platform.example.com/api/v1/cli/account/agents").respond(
479
+ 200, json={"id": "agent-1", "name": "Invoice Watcher"}
480
+ )
481
+
482
+ account_client.create_agent("Invoice Watcher", engine=engine)
483
+ body = json.loads(route.calls.last.request.content)
484
+ assert ("engine" in body) is sent
485
+ if sent:
486
+ assert body["engine"] == "claude"