sanka-cli 0.2.3__tar.gz → 0.2.4__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 (138) hide show
  1. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/PKG-INFO +20 -1
  2. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/README.md +19 -0
  3. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/pyproject.toml +1 -1
  4. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka/cli/__init__.py +44 -23
  5. sanka_cli-0.2.4/src/sanka/cli/_compact.py +50 -0
  6. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka/runtime/extensions/lifecycle.py +1 -2
  7. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka_cli/__init__.py +1 -1
  8. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka_cli/client.py +28 -1
  9. sanka_cli-0.2.4/src/sanka_cli/commands/cloud.py +240 -0
  10. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka_cli/main.py +2 -0
  11. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka_cli/runtime.py +8 -1
  12. sanka_cli-0.2.4/src/sanka_cli/skills/sanka-cli/SKILL.md +48 -0
  13. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/tests/test_cli.py +1 -1
  14. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/tests/test_cli_developer_experience.py +66 -1
  15. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/tests/test_extension_lifecycle.py +1 -0
  16. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/tests/test_mcp.py +2 -2
  17. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/tests/test_sanka_facade.py +1 -1
  18. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/tests/test_skill_commands.py +7 -1
  19. sanka_cli-0.2.3/src/sanka_cli/skills/sanka-cli/SKILL.md +0 -62
  20. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/.gitignore +0 -0
  21. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/LICENSES/AGPL-3.0-only.txt +0 -0
  22. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/LICENSES/Apache-2.0.txt +0 -0
  23. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/NOTICE +0 -0
  24. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka/__init__.py +0 -0
  25. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka/_client.py +0 -0
  26. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka/cli/_output.py +0 -0
  27. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka/cli/_research.py +0 -0
  28. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka/cli/py.typed +0 -0
  29. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka/connector/__init__.py +0 -0
  30. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka/connector/py.typed +0 -0
  31. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka/runtime/__about__.py +0 -0
  32. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka/runtime/__init__.py +0 -0
  33. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka/runtime/concurrency.py +0 -0
  34. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka/runtime/connector_client.py +0 -0
  35. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka/runtime/connector_host.py +0 -0
  36. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka/runtime/engine.py +0 -0
  37. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka/runtime/execution/__init__.py +0 -0
  38. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka/runtime/execution/continuous.py +0 -0
  39. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka/runtime/execution/errors.py +0 -0
  40. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka/runtime/execution/local.py +0 -0
  41. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka/runtime/execution/model.py +0 -0
  42. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka/runtime/execution/report_codec.py +0 -0
  43. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka/runtime/execution/routes.py +0 -0
  44. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka/runtime/execution/scope.py +0 -0
  45. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka/runtime/execution/state.py +0 -0
  46. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka/runtime/execution/validate.py +0 -0
  47. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka/runtime/extensions/__init__.py +0 -0
  48. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka/runtime/extensions/discovery.py +0 -0
  49. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka/runtime/extensions/model.py +0 -0
  50. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka/runtime/extensions/runner.py +0 -0
  51. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka/runtime/extensions/store.py +0 -0
  52. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka/runtime/hashing.py +0 -0
  53. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka/runtime/mapping/__init__.py +0 -0
  54. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka/runtime/mapping/errors.py +0 -0
  55. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka/runtime/mapping/mapping_candidates.py +0 -0
  56. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka/runtime/mapping/model.py +0 -0
  57. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka/runtime/mapping/owner_mapping.py +0 -0
  58. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka/runtime/mapping/pending_relationships.py +0 -0
  59. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka/runtime/mapping/record_mapping.py +0 -0
  60. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka/runtime/planner.py +0 -0
  61. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka/runtime/private_sqlite.py +0 -0
  62. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka/runtime/py.typed +0 -0
  63. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka/runtime/registry.py +0 -0
  64. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka/runtime/safe_local_io.py +0 -0
  65. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka/runtime/spec.py +0 -0
  66. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka/runtime/state.py +0 -0
  67. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka_cli/__main__.py +0 -0
  68. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka_cli/bundle.py +0 -0
  69. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka_cli/commands/__init__.py +0 -0
  70. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka_cli/commands/ai.py +0 -0
  71. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka_cli/commands/auth.py +0 -0
  72. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka_cli/commands/cloud_migrate.py +0 -0
  73. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka_cli/commands/code.py +0 -0
  74. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka_cli/commands/migrate.py +0 -0
  75. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka_cli/commands/profiles.py +0 -0
  76. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka_cli/commands/resources.py +0 -0
  77. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka_cli/commands/skill.py +0 -0
  78. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka_cli/commands/workflows.py +0 -0
  79. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka_cli/config.py +0 -0
  80. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka_cli/mcp/__init__.py +0 -0
  81. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka_cli/mcp/client.py +0 -0
  82. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka_cli/mcp/render.py +0 -0
  83. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka_cli/mcp/server.py +0 -0
  84. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka_cli/output.py +0 -0
  85. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka_cli/state.py +0 -0
  86. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka_connector/__about__.py +0 -0
  87. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka_connector/__init__.py +0 -0
  88. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka_connector/credentials.py +0 -0
  89. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka_connector/errors.py +0 -0
  90. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka_connector/protocols.py +0 -0
  91. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka_connector/provisioning.py +0 -0
  92. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka_connector/py.typed +0 -0
  93. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka_connector/records.py +0 -0
  94. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka_connector/registration.py +0 -0
  95. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka_connector/schema.py +0 -0
  96. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/tests/conftest.py +0 -0
  97. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/tests/fixtures/extension_drf_project/api/__init__.py +0 -0
  98. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/tests/fixtures/extension_drf_project/api/serializers.py +0 -0
  99. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/tests/fixtures/extension_drf_project/api/views.py +0 -0
  100. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/tests/fixtures/extension_drf_project/config/__init__.py +0 -0
  101. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/tests/fixtures/extension_drf_project/config/settings.py +0 -0
  102. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/tests/fixtures/extension_drf_project/config/urls.py +0 -0
  103. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/tests/fixtures/extension_drf_project/manage.py +0 -0
  104. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/tests/fixtures/extension_marketplace/drf-to-fastapi.json +0 -0
  105. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/tests/fixtures/extension_marketplace/marketplace.json +0 -0
  106. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/tests/test_code_bundle.py +0 -0
  107. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/tests/test_code_commands.py +0 -0
  108. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/tests/test_concurrency.py +0 -0
  109. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/tests/test_connector_client.py +0 -0
  110. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/tests/test_connector_host.py +0 -0
  111. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/tests/test_credential_provider.py +0 -0
  112. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/tests/test_engine_e2e.py +0 -0
  113. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/tests/test_engine_execution_adoption.py +0 -0
  114. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/tests/test_engine_pg_to_clickhouse_e2e.py +0 -0
  115. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/tests/test_engine_postgres_e2e.py +0 -0
  116. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/tests/test_execution_continuous.py +0 -0
  117. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/tests/test_execution_exact_scope.py +0 -0
  118. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/tests/test_execution_local_store.py +0 -0
  119. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/tests/test_execution_report_codec.py +0 -0
  120. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/tests/test_execution_routes.py +0 -0
  121. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/tests/test_execution_scope.py +0 -0
  122. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/tests/test_execution_seam.py +0 -0
  123. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/tests/test_execution_validate.py +0 -0
  124. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/tests/test_extension_discovery.py +0 -0
  125. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/tests/test_extension_runner.py +0 -0
  126. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/tests/test_extension_store.py +0 -0
  127. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/tests/test_extension_wheel_acceptance.py +0 -0
  128. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/tests/test_hashing.py +0 -0
  129. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/tests/test_hosted_cli.py +0 -0
  130. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/tests/test_mapping_candidates.py +0 -0
  131. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/tests/test_mapping_owner_mapping.py +0 -0
  132. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/tests/test_mapping_pending_relationships.py +0 -0
  133. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/tests/test_mapping_record_mapping.py +0 -0
  134. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/tests/test_planner.py +0 -0
  135. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/tests/test_registry.py +0 -0
  136. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/tests/test_safe_local_io.py +0 -0
  137. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/tests/test_spec.py +0 -0
  138. {sanka_cli-0.2.3 → sanka_cli-0.2.4}/tests/test_state_store.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sanka-cli
3
- Version: 0.2.3
3
+ Version: 0.2.4
4
4
  Summary: Sanka command-line interface and migration runtime
5
5
  Project-URL: Repository, https://github.com/sankaHQ/sanka
6
6
  Project-URL: Issues, https://github.com/sankaHQ/sanka/issues
@@ -149,3 +149,22 @@ The distribution license expression is
149
149
  `Apache-2.0 AND AGPL-3.0-only`. Both license texts and the historical hosted
150
150
  CLI notice ship in the wheel and source archive. File-level SPDX identifiers
151
151
  are authoritative.
152
+
153
+
154
+ ### Compact lifecycle output
155
+
156
+ Use `--compact-dsl` instead of `--json` on `scan`, `plan`, `apply`, `test`,
157
+ `verify`, and extension management commands when an agent needs a short result.
158
+ The flags are mutually exclusive; `--json` retains its existing contract.
159
+ Compact output starts with `sanka-compact/v1 <command> <outcome> <migration_state>`
160
+ then emits one `key=JSON-value` per line. Strings use JSON escaping, including
161
+ newlines and quotes. Failures, warnings, plan hashes, and limitations are retained.
162
+ Legacy aliases are not repeated. With full artifacts available, generated file
163
+ contents and route source code are replaced by explicit omission counts and file
164
+ names. Read the returned artifact for those details; a compact summary is not a
165
+ replacement for the full report. `data.plan_hash` becomes the `plan_hash` line;
166
+ use that core hash for apply, not a nested extension hash.
167
+
168
+ Keep the lifecycle: scan → plan → apply → test → verify. An applied scaffold with
169
+ manual gaps is not complete. Test establishes generated scope; scenario verification
170
+ checks HTTP and database parity within the supplied fixtures and cases.
@@ -119,3 +119,22 @@ The distribution license expression is
119
119
  `Apache-2.0 AND AGPL-3.0-only`. Both license texts and the historical hosted
120
120
  CLI notice ship in the wheel and source archive. File-level SPDX identifiers
121
121
  are authoritative.
122
+
123
+
124
+ ### Compact lifecycle output
125
+
126
+ Use `--compact-dsl` instead of `--json` on `scan`, `plan`, `apply`, `test`,
127
+ `verify`, and extension management commands when an agent needs a short result.
128
+ The flags are mutually exclusive; `--json` retains its existing contract.
129
+ Compact output starts with `sanka-compact/v1 <command> <outcome> <migration_state>`
130
+ then emits one `key=JSON-value` per line. Strings use JSON escaping, including
131
+ newlines and quotes. Failures, warnings, plan hashes, and limitations are retained.
132
+ Legacy aliases are not repeated. With full artifacts available, generated file
133
+ contents and route source code are replaced by explicit omission counts and file
134
+ names. Read the returned artifact for those details; a compact summary is not a
135
+ replacement for the full report. `data.plan_hash` becomes the `plan_hash` line;
136
+ use that core hash for apply, not a nested extension hash.
137
+
138
+ Keep the lifecycle: scan → plan → apply → test → verify. An applied scaffold with
139
+ manual gaps is not complete. Test establishes generated scope; scenario verification
140
+ checks HTTP and database parity within the supplied fixtures and cases.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sanka-cli"
3
- version = "0.2.3"
3
+ version = "0.2.4"
4
4
  description = "Sanka command-line interface and migration runtime"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -85,17 +85,21 @@ class _CliArgumentParser(argparse.ArgumentParser):
85
85
 
86
86
  def main(argv: list[str] | None = None) -> int:
87
87
  arguments = list(sys.argv[1:] if argv is None else argv)
88
- json_errors = "--json" in arguments
88
+ json_errors = "--json" in arguments or "--compact-dsl" in arguments
89
89
  parser = _build_parser(json_errors=json_errors)
90
90
  try:
91
91
  args = parser.parse_args(arguments)
92
92
  except CliUsageError as error:
93
93
  command = arguments[0] if arguments and arguments[0] in SDK_COMMANDS else "sanka"
94
94
  return _print_cli_error(
95
- argparse.Namespace(command=command, json=json_errors),
95
+ argparse.Namespace(
96
+ command=command, json=json_errors, compact_dsl="--compact-dsl" in arguments
97
+ ),
96
98
  error,
97
99
  exit_code=2,
98
100
  )
101
+ if getattr(args, "compact_dsl", False):
102
+ args.json = True
99
103
  if args.command is None:
100
104
  parser.print_help()
101
105
  return 0
@@ -173,12 +177,17 @@ def _json_result(
173
177
  return envelope
174
178
 
175
179
 
176
- def _print_json(payload: dict[str, Any]) -> None:
177
- print(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True))
180
+ def _print_result(args: argparse.Namespace, payload: dict[str, Any]) -> None:
181
+ if getattr(args, "compact_dsl", False):
182
+ from sanka.cli._compact import render_compact
183
+
184
+ print(render_compact(payload))
185
+ else:
186
+ print(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True))
178
187
 
179
188
 
180
189
  def _print_cli_error(args: argparse.Namespace, error: Exception, *, exit_code: int) -> int:
181
- if getattr(args, "json", False):
190
+ if getattr(args, "json", False) or getattr(args, "compact_dsl", False):
182
191
  code = getattr(error, "code", None)
183
192
  details = getattr(error, "details", None)
184
193
  structured_error: dict[str, Any] = {
@@ -187,13 +196,14 @@ def _print_cli_error(args: argparse.Namespace, error: Exception, *, exit_code: i
187
196
  }
188
197
  if isinstance(details, dict) and details:
189
198
  structured_error["details"] = details
190
- _print_json(
199
+ _print_result(
200
+ args,
191
201
  _json_result(
192
202
  str(getattr(args, "command", "sanka") or "sanka"),
193
203
  {"error": structured_error},
194
204
  outcome="error",
195
205
  migration_state="not_started" if exit_code == 2 else "failed",
196
- )
206
+ ),
197
207
  )
198
208
  else:
199
209
  _terminal(args).failure(str(error))
@@ -221,9 +231,15 @@ def _build_parser(*, json_errors: bool = False) -> argparse.ArgumentParser:
221
231
 
222
232
  def presentation(sub: argparse.ArgumentParser, *, json_option: bool = True) -> None:
223
233
  if json_option:
224
- sub.add_argument(
234
+ formats = sub.add_mutually_exclusive_group()
235
+ formats.add_argument(
225
236
  "--json", action="store_true", help="print one sanka-cli/v1 JSON document"
226
237
  )
238
+ formats.add_argument(
239
+ "--compact-dsl",
240
+ action="store_true",
241
+ help="print a compact result; full generated code stays in artifacts",
242
+ )
227
243
  sub.add_argument("--no-color", action="store_true", help="disable ANSI color")
228
244
  detail = sub.add_mutually_exclusive_group()
229
245
  detail.add_argument("--quiet", action="store_true", help="print only outcome and errors")
@@ -414,7 +430,7 @@ def _build_parser(*, json_errors: bool = False) -> argparse.ArgumentParser:
414
430
  description=(
415
431
  "Check hashes, generated files, route coverage, and configured read-only HTTP "
416
432
  "comparisons. With --scenarios, replay a scenario file against the source "
417
- "application and a candidate FastAPI app and diff status, body, declared "
433
+ "application and a candidate FastAPI or Flask app and diff status, body, declared "
418
434
  "headers, and database state; that mode needs no plan or generated manifest."
419
435
  ),
420
436
  epilog=(
@@ -436,6 +452,7 @@ def _build_parser(*, json_errors: bool = False) -> argparse.ArgumentParser:
436
452
  help="application repository root",
437
453
  )
438
454
  verify.add_argument("--to", help="select an application plan")
455
+ verify.add_argument("--settings", help="source Django settings module for scenario replay")
439
456
  verify.add_argument("--artifact-dir", default=DEFAULT_ARTIFACT_DIR)
440
457
  verify.add_argument("--output", default=None, help="generated FastAPI output directory")
441
458
  verify.add_argument(
@@ -667,8 +684,8 @@ def _extension_result(
667
684
  records: list[dict[str, Any]],
668
685
  ) -> int:
669
686
  data = {"operation": operation, "records": records}
670
- if args.json:
671
- _print_json(_json_result("extension", data, migration_state="not_started"))
687
+ if args.json or getattr(args, "compact_dsl", False):
688
+ _print_result(args, _json_result("extension", data, migration_state="not_started"))
672
689
  return 0
673
690
 
674
691
  terminal = _terminal(args)
@@ -895,8 +912,9 @@ def _print_application_result(
895
912
  *,
896
913
  migration_state: str,
897
914
  ) -> int:
898
- if args.json:
899
- _print_json(
915
+ if args.json or getattr(args, "compact_dsl", False):
916
+ _print_result(
917
+ args,
900
918
  _json_result(
901
919
  command,
902
920
  result.data,
@@ -904,7 +922,7 @@ def _print_application_result(
904
922
  artifacts=list(result.artifacts),
905
923
  limitations=list(result.limitations),
906
924
  next_actions=list(result.next_actions),
907
- )
925
+ ),
908
926
  )
909
927
  else:
910
928
  terminal = _terminal(args)
@@ -926,8 +944,9 @@ async def _cmd_plan(args: argparse.Namespace) -> int:
926
944
  engine = _engine(args.state)
927
945
  run_id = engine.create(spec)
928
946
  migration_plan = await engine.plan(run_id)
929
- if args.json:
930
- _print_json(
947
+ if args.json or getattr(args, "compact_dsl", False):
948
+ _print_result(
949
+ args,
931
950
  _json_result(
932
951
  "plan",
933
952
  {
@@ -953,7 +972,7 @@ async def _cmd_plan(args: argparse.Namespace) -> int:
953
972
  ]
954
973
  )
955
974
  ],
956
- )
975
+ ),
957
976
  )
958
977
  else:
959
978
  _print_plan(run_id, migration_plan)
@@ -995,8 +1014,9 @@ async def _cmd_apply(args: argparse.Namespace) -> int:
995
1014
  if run.plan_json is None:
996
1015
  raise ExecutionError("no plan for this spec yet; run `sanka plan` first")
997
1016
  await engine.apply(run_id, plan_hash=args.plan_hash)
998
- if args.json:
999
- _print_json(
1017
+ if args.json or getattr(args, "compact_dsl", False):
1018
+ _print_result(
1019
+ args,
1000
1020
  _json_result(
1001
1021
  "apply",
1002
1022
  {
@@ -1017,7 +1037,7 @@ async def _cmd_apply(args: argparse.Namespace) -> int:
1017
1037
  ]
1018
1038
  )
1019
1039
  ],
1020
- )
1040
+ ),
1021
1041
  )
1022
1042
  else:
1023
1043
  print(f"run {run_id}: applied")
@@ -1053,8 +1073,9 @@ async def _cmd_verify(args: argparse.Namespace) -> int:
1053
1073
  engine = _engine(args.state)
1054
1074
  run_id = engine.create(spec)
1055
1075
  verify_report = await engine.verify(run_id)
1056
- if args.json:
1057
- _print_json(
1076
+ if args.json or getattr(args, "compact_dsl", False):
1077
+ _print_result(
1078
+ args,
1058
1079
  _json_result(
1059
1080
  "verify",
1060
1081
  {
@@ -1077,7 +1098,7 @@ async def _cmd_verify(args: argparse.Namespace) -> int:
1077
1098
  "verified_within_scope" if verify_report.ok else "verification_failed"
1078
1099
  ),
1079
1100
  artifacts=[str(Path(args.state).resolve())],
1080
- )
1101
+ ),
1081
1102
  )
1082
1103
  else:
1083
1104
  _print_verify(verify_report)
@@ -0,0 +1,50 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-only
2
+ """Model-facing presentation of the existing result, with JSON-escaped values."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ from typing import Any
8
+
9
+
10
+ def _summary(value: Any, *, path: tuple[str, ...] = (), artifacts: bool = False) -> Any:
11
+ if isinstance(value, dict):
12
+ if path in {("files",), ("extension", "files")} and artifacts:
13
+ return {"names": sorted(value), "omitted_file_contents": len(value)}
14
+ result = {}
15
+ for name, item in value.items():
16
+ if name == "extension" and isinstance(item, dict):
17
+ # Core lifecycle responses repeat extension fields; retain differing hashes.
18
+ item = {k: v for k, v in item.items() if k not in value or value[k] != v}
19
+ if not item:
20
+ continue
21
+ if (
22
+ name == "source"
23
+ and {"classification", "method"} <= value.keys()
24
+ and isinstance(item, str)
25
+ and artifacts
26
+ ):
27
+ result["omitted_source_characters"] = len(item)
28
+ else:
29
+ result[name] = _summary(item, path=(*path, name), artifacts=artifacts)
30
+ return result
31
+ if isinstance(value, list):
32
+ return [_summary(item, path=(*path, "[]"), artifacts=artifacts) for item in value]
33
+ return value
34
+
35
+
36
+ def render_compact(payload: dict[str, Any]) -> str:
37
+ """One header, then key=JSON lines. Never truncate failures, warnings or hashes."""
38
+ lines = [" ".join(str(payload[k]) for k in ("command", "outcome", "migration_state"))]
39
+ lines[0] = "sanka-compact/v1 " + lines[0]
40
+ data = _summary(payload["data"], artifacts=bool(payload.get("artifacts")))
41
+ for key, value in data.items():
42
+ # Escape even extension-defined keys so a newline cannot forge another record.
43
+ label = key if key.isidentifier() else json.dumps(key, ensure_ascii=False)
44
+ lines.append(label + "=" + json.dumps(value, ensure_ascii=False, separators=(",", ":")))
45
+ for key in ("artifacts", "limitations", "next_actions"):
46
+ if payload.get(key):
47
+ lines.append(
48
+ key + "=" + json.dumps(payload[key], ensure_ascii=False, separators=(",", ":"))
49
+ )
50
+ return "\n".join(lines)
@@ -820,7 +820,7 @@ class ApplicationLifecycle:
820
820
  written, so the source fingerprint legitimately differs from any reviewed plan and
821
821
  a plan may not exist yet. The extension lock still has to match its recommendation
822
822
  exactly; only the plan, fingerprint, and artifact checks that guard apply and test
823
- are skipped, because replay writes nothing into the project.
823
+ are skipped, because replay only writes verification artifacts in its artifact directory.
824
824
  """
825
825
  explicit_env_names = _canonical_environment_names(explicit_env_names)
826
826
  fingerprint = fingerprint_repository(self.project_root)
@@ -839,7 +839,6 @@ class ApplicationLifecycle:
839
839
  if result.outcome != "success":
840
840
  self._raise_failure(result)
841
841
  data = dict(result.data)
842
- data["extension"] = result.data
843
842
  return ExtensionResult(
844
843
  outcome="success",
845
844
  data=data,
@@ -1,4 +1,4 @@
1
1
  # SPDX-License-Identifier: Apache-2.0
2
2
  __all__ = ["__version__"]
3
3
 
4
- __version__ = "0.2.3"
4
+ __version__ = "0.2.4"
@@ -87,16 +87,43 @@ class SankaApiClient:
87
87
  path: str,
88
88
  *,
89
89
  params: dict[str, Any] | None = None,
90
+ headers: dict[str, str] | None = None,
91
+ max_bytes: int | None = None,
90
92
  ) -> tuple[bytes, dict[str, str]]:
91
93
  """Fetch a non-JSON body (currently only the Custom Code bundle download).
92
94
 
93
95
  Returns headers alongside the payload so the caller can verify the digest the
94
96
  server reports without a second request.
95
97
  """
98
+ request_headers = self._headers()
99
+ request_headers.update(headers or {})
100
+ if max_bytes is not None:
101
+ with self.client.stream(
102
+ method.upper(),
103
+ path,
104
+ headers=request_headers,
105
+ params=params,
106
+ ) as response:
107
+ content = bytearray()
108
+ for chunk in response.iter_bytes(chunk_size=65536):
109
+ if len(content) + len(chunk) > max_bytes:
110
+ raise APIError(
111
+ status_code=413, message="API artifact exceeds its size limit"
112
+ )
113
+ content.extend(chunk)
114
+ if response.status_code >= 400:
115
+ self._raise_for_response(
116
+ httpx.Response(
117
+ response.status_code,
118
+ content=bytes(content),
119
+ headers=response.headers,
120
+ )
121
+ )
122
+ return bytes(content), dict(response.headers)
96
123
  response = self.client.request(
97
124
  method.upper(),
98
125
  path,
99
- headers=self._headers(),
126
+ headers=request_headers,
100
127
  params=params,
101
128
  )
102
129
  if response.status_code >= 400:
@@ -0,0 +1,240 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Workspace-pinned client for hosted repository runs; no hosted implementation."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import base64
7
+ import hashlib
8
+ import io
9
+ import re
10
+ import zipfile
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ import click
15
+
16
+ import sanka_cli.runtime as runtime
17
+ from sanka_cli.state import CLIState
18
+
19
+ ROOT = "/v2/migrate/cloud-runs"
20
+ SOURCE_BYTES = 8 * 1024 * 1024
21
+ ARTIFACT_BYTES = 16 * 1024 * 1024
22
+ WORKSPACE = click.option("--workspace", required=True, help="Exact workspace code (8 digits).")
23
+ RUN = click.argument("run_id", type=click.UUID)
24
+
25
+
26
+ def _headers(workspace: str) -> dict[str, str]:
27
+ if not re.fullmatch(r"\d{8}", workspace):
28
+ raise click.BadParameter("must be an 8-digit workspace code", param_hint="--workspace")
29
+ return {"X-Workspace-Code": workspace}
30
+
31
+
32
+ def _digest(value: str) -> str:
33
+ if not re.fullmatch(r"[a-f0-9]{64}", value):
34
+ raise click.BadParameter("must be a lowercase SHA-256 digest", param_hint="--sha256")
35
+ return value
36
+
37
+
38
+ def _data(payload: dict[str, Any]) -> dict[str, Any]:
39
+ data = payload.get("data", payload)
40
+ if not isinstance(data, dict):
41
+ raise click.ClickException("Cloud API returned an invalid response")
42
+ return data
43
+
44
+
45
+ def _read(state: CLIState, workspace: str, path: str, **params: Any) -> dict[str, Any]:
46
+ return runtime.request_json(state, "GET", path, headers=_headers(workspace), params=params)
47
+
48
+
49
+ @click.group()
50
+ def cloud() -> None:
51
+ """Hosted DRF-to-FastAPI runs, credit receipts, and artifacts (when enabled)."""
52
+
53
+
54
+ @cloud.command("upload")
55
+ @WORKSPACE
56
+ @click.argument("archive", type=click.Path(exists=True, dir_okay=False, path_type=Path))
57
+ @click.option("--yes", is_flag=True, help="Confirm uploading this ZIP to the selected workspace.")
58
+ @click.pass_obj
59
+ def upload(state: CLIState, workspace: str, archive: Path, yes: bool) -> None:
60
+ """Upload an explicit ZIP, returning its source ID and SHA-256; no compute charge."""
61
+ headers = _headers(workspace)
62
+ with archive.open("rb") as stream:
63
+ content = stream.read(SOURCE_BYTES + 1)
64
+ if not content or len(content) > SOURCE_BYTES:
65
+ raise click.ClickException("Source ZIP must contain between 1 byte and 8 MiB")
66
+ try:
67
+ with zipfile.ZipFile(io.BytesIO(content)) as bundle:
68
+ if not bundle.infolist():
69
+ raise ValueError("empty ZIP")
70
+ except (ValueError, zipfile.BadZipFile) as exc:
71
+ raise click.ClickException("Source must be a nonempty ZIP archive") from exc
72
+ sha256 = hashlib.sha256(content).hexdigest()
73
+ if not yes:
74
+ click.confirm(
75
+ f"Upload {archive.name} ({len(content)} bytes, SHA-256 {sha256}) "
76
+ f"to workspace {workspace}? Source retention: 7 days",
77
+ abort=True,
78
+ )
79
+ payload = runtime.request_json(
80
+ state,
81
+ "POST",
82
+ "/v2/migrate/cloud-sources",
83
+ headers=headers,
84
+ json_body={"sha256": sha256, "archive_base64": base64.b64encode(content).decode("ascii")},
85
+ )
86
+ if _data(payload).get("sha256") != sha256:
87
+ raise click.ClickException("Uploaded source digest does not match the selected ZIP")
88
+ runtime.emit_payload(payload, state)
89
+
90
+
91
+ @cloud.command("run")
92
+ @WORKSPACE
93
+ @click.option("--source-id", required=True, type=click.UUID)
94
+ @click.option("--sha256", required=True)
95
+ @click.option("--max-credits", required=True, type=click.IntRange(1, 6000))
96
+ @click.option("--timeout-seconds", default=600, show_default=True, type=click.IntRange(1, 3600))
97
+ @click.option(
98
+ "--settings-module", default=None, help="Django settings module, e.g. config.settings."
99
+ )
100
+ @click.option(
101
+ "--idempotency-key",
102
+ required=True,
103
+ help="Unique intent key (8-200 characters). Reuse with identical inputs after a network error.",
104
+ )
105
+ @click.option("--yes", is_flag=True, help="Confirm the credit hold and paid execution.")
106
+ @click.pass_obj
107
+ def run(
108
+ state: CLIState,
109
+ workspace: str,
110
+ source_id: Any,
111
+ sha256: str,
112
+ max_credits: int,
113
+ timeout_seconds: int,
114
+ settings_module: str | None,
115
+ idempotency_key: str,
116
+ yes: bool,
117
+ ) -> None:
118
+ """Reserve the credit limit and queue one run. Retries keep the same source and key."""
119
+ headers = _headers(workspace)
120
+ _digest(sha256)
121
+ if (
122
+ not 8 <= len(idempotency_key) <= 200
123
+ or not idempotency_key.isascii()
124
+ or any(ord(char) < 33 or ord(char) > 126 for char in idempotency_key)
125
+ ):
126
+ raise click.BadParameter(
127
+ "must be 8-200 visible ASCII characters", param_hint="--idempotency-key"
128
+ )
129
+ if settings_module is not None and (
130
+ len(settings_module) > 200
131
+ or not re.fullmatch(r"[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*", settings_module)
132
+ ):
133
+ raise click.BadParameter("must be a Python module name", param_hint="--settings-module")
134
+ if not yes:
135
+ click.confirm(
136
+ f"Run source {source_id} in workspace {workspace}? "
137
+ f"Reserve up to {max_credits} credits; "
138
+ "100 credits per active worker-minute, unused credits released",
139
+ abort=True,
140
+ )
141
+ headers["Idempotency-Key"] = idempotency_key
142
+ payload = runtime.request_json(
143
+ state,
144
+ "POST",
145
+ ROOT,
146
+ headers=headers,
147
+ json_body={
148
+ "source_id": str(source_id),
149
+ "source_sha256": sha256,
150
+ "max_credits": max_credits,
151
+ "timeout_seconds": timeout_seconds,
152
+ "settings_module": settings_module,
153
+ },
154
+ )
155
+ runtime.emit_payload(payload, state)
156
+
157
+
158
+ @cloud.command("list")
159
+ @WORKSPACE
160
+ @click.option("--cursor", type=click.UUID, default=None)
161
+ @click.pass_obj
162
+ def list_runs(state: CLIState, workspace: str, cursor: Any) -> None:
163
+ params = {"cursor": str(cursor)} if cursor else {}
164
+ runtime.emit_payload(_read(state, workspace, ROOT, limit=20, **params), state)
165
+
166
+
167
+ @cloud.command("status")
168
+ @WORKSPACE
169
+ @RUN
170
+ @click.pass_obj
171
+ def status(state: CLIState, workspace: str, run_id: Any) -> None:
172
+ runtime.emit_payload(_read(state, workspace, f"{ROOT}/{run_id}"), state)
173
+
174
+
175
+ @cloud.command("receipt")
176
+ @WORKSPACE
177
+ @RUN
178
+ @click.pass_obj
179
+ def receipt(state: CLIState, workspace: str, run_id: Any) -> None:
180
+ """Read the immutable receipt, including held, charged, and released credits."""
181
+ runtime.emit_payload(_read(state, workspace, f"{ROOT}/{run_id}/receipt"), state)
182
+
183
+
184
+ @cloud.command("events")
185
+ @WORKSPACE
186
+ @RUN
187
+ @click.option("--cursor", default=0, type=click.IntRange(min=0))
188
+ @click.pass_obj
189
+ def events(state: CLIState, workspace: str, run_id: Any, cursor: int) -> None:
190
+ runtime.emit_payload(_read(state, workspace, f"{ROOT}/{run_id}/events", cursor=cursor), state)
191
+
192
+
193
+ @cloud.command("cancel")
194
+ @WORKSPACE
195
+ @RUN
196
+ @click.pass_obj
197
+ def cancel(state: CLIState, workspace: str, run_id: Any) -> None:
198
+ """Cancel this run; completed active compute can still be charged."""
199
+ runtime.emit_payload(
200
+ runtime.request_json(
201
+ state,
202
+ "POST",
203
+ f"{ROOT}/{run_id}/cancel",
204
+ headers=_headers(workspace),
205
+ ),
206
+ state,
207
+ )
208
+
209
+
210
+ @cloud.command("download")
211
+ @WORKSPACE
212
+ @RUN
213
+ @click.option("--artifact", type=click.Choice(["output.zip", "logs.txt"]), default="output.zip")
214
+ @click.option("--to", "destination", required=True, type=click.Path(dir_okay=False, path_type=Path))
215
+ @click.pass_obj
216
+ def download(
217
+ state: CLIState, workspace: str, run_id: Any, artifact: str, destination: Path
218
+ ) -> None:
219
+ """Verify a retained artifact's digest and save a new file; existing files are preserved."""
220
+ if destination.exists() or destination.is_symlink():
221
+ raise click.ClickException("Destination already exists; choose a new file")
222
+ manifest = _data(_read(state, workspace, f"{ROOT}/{run_id}/artifacts"))
223
+ entries = manifest.get("artifacts", [])
224
+ metadata = next((item for item in entries if item.get("name") == artifact), None)
225
+ if metadata is None:
226
+ raise click.ClickException("Artifact is not available (it may have expired)")
227
+ content, _ = runtime.request_bytes(
228
+ state,
229
+ "GET",
230
+ f"{ROOT}/{run_id}/artifacts/{artifact}",
231
+ headers=_headers(workspace),
232
+ max_bytes=ARTIFACT_BYTES,
233
+ )
234
+ if len(content) != metadata.get("size_bytes") or hashlib.sha256(
235
+ content
236
+ ).hexdigest() != metadata.get("sha256"):
237
+ raise click.ClickException("Artifact size or digest does not match; no file was written")
238
+ with destination.open("xb") as stream:
239
+ stream.write(content)
240
+ runtime.emit_payload({"path": str(destination), "sha256": metadata["sha256"]}, state)
@@ -6,6 +6,7 @@ import click
6
6
  from sanka_cli import __version__
7
7
  from sanka_cli.commands.ai import ai
8
8
  from sanka_cli.commands.auth import auth, auth_login, auth_logout, auth_status
9
+ from sanka_cli.commands.cloud import cloud
9
10
  from sanka_cli.commands.code import code
10
11
  from sanka_cli.commands.migrate import register_migration_passthroughs
11
12
  from sanka_cli.commands.profiles import profiles
@@ -44,6 +45,7 @@ cli.add_command(profiles)
44
45
  cli.add_command(workflows)
45
46
  cli.add_command(ai)
46
47
  cli.add_command(code)
48
+ cli.add_command(cloud)
47
49
  cli.add_command(skill)
48
50
 
49
51
  attach_resource_group(cli, "companies", "/v2/public/companies")
@@ -148,10 +148,17 @@ def request_bytes(
148
148
  path: str,
149
149
  *,
150
150
  params: dict[str, Any] | None = None,
151
+ headers: dict[str, str] | None = None,
152
+ max_bytes: int | None = None,
151
153
  ) -> tuple[bytes, dict[str, str]]:
152
154
  client = build_client(state)
153
155
  try:
154
- return client.request_bytes(method, path, params=params)
156
+ options: dict[str, Any] = {}
157
+ if headers is not None:
158
+ options["headers"] = headers
159
+ if max_bytes is not None:
160
+ options["max_bytes"] = max_bytes
161
+ return client.request_bytes(method, path, params=params, **options)
155
162
  except APIError as exc:
156
163
  handle_api_error(exc)
157
164
  raise