sanka-cli 0.2.1__tar.gz → 0.2.3__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 (135) hide show
  1. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/PKG-INFO +14 -1
  2. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/README.md +13 -0
  3. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/pyproject.toml +1 -1
  4. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/src/sanka/cli/__init__.py +45 -3
  5. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/src/sanka/cli/_output.py +18 -0
  6. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/src/sanka/runtime/extensions/store.py +31 -24
  7. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/src/sanka_cli/__init__.py +1 -1
  8. sanka_cli-0.2.3/src/sanka_cli/commands/skill.py +146 -0
  9. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/src/sanka_cli/main.py +2 -0
  10. sanka_cli-0.2.3/src/sanka_cli/skills/sanka-cli/SKILL.md +62 -0
  11. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/tests/test_cli.py +68 -1
  12. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/tests/test_extension_store.py +76 -0
  13. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/tests/test_mcp.py +2 -2
  14. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/tests/test_sanka_facade.py +1 -1
  15. sanka_cli-0.2.3/tests/test_skill_commands.py +219 -0
  16. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/.gitignore +0 -0
  17. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/LICENSES/AGPL-3.0-only.txt +0 -0
  18. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/LICENSES/Apache-2.0.txt +0 -0
  19. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/NOTICE +0 -0
  20. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/src/sanka/__init__.py +0 -0
  21. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/src/sanka/_client.py +0 -0
  22. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/src/sanka/cli/_research.py +0 -0
  23. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/src/sanka/cli/py.typed +0 -0
  24. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/src/sanka/connector/__init__.py +0 -0
  25. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/src/sanka/connector/py.typed +0 -0
  26. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/src/sanka/runtime/__about__.py +0 -0
  27. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/src/sanka/runtime/__init__.py +0 -0
  28. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/src/sanka/runtime/concurrency.py +0 -0
  29. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/src/sanka/runtime/connector_client.py +0 -0
  30. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/src/sanka/runtime/connector_host.py +0 -0
  31. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/src/sanka/runtime/engine.py +0 -0
  32. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/src/sanka/runtime/execution/__init__.py +0 -0
  33. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/src/sanka/runtime/execution/continuous.py +0 -0
  34. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/src/sanka/runtime/execution/errors.py +0 -0
  35. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/src/sanka/runtime/execution/local.py +0 -0
  36. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/src/sanka/runtime/execution/model.py +0 -0
  37. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/src/sanka/runtime/execution/report_codec.py +0 -0
  38. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/src/sanka/runtime/execution/routes.py +0 -0
  39. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/src/sanka/runtime/execution/scope.py +0 -0
  40. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/src/sanka/runtime/execution/state.py +0 -0
  41. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/src/sanka/runtime/execution/validate.py +0 -0
  42. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/src/sanka/runtime/extensions/__init__.py +0 -0
  43. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/src/sanka/runtime/extensions/discovery.py +0 -0
  44. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/src/sanka/runtime/extensions/lifecycle.py +0 -0
  45. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/src/sanka/runtime/extensions/model.py +0 -0
  46. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/src/sanka/runtime/extensions/runner.py +0 -0
  47. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/src/sanka/runtime/hashing.py +0 -0
  48. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/src/sanka/runtime/mapping/__init__.py +0 -0
  49. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/src/sanka/runtime/mapping/errors.py +0 -0
  50. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/src/sanka/runtime/mapping/mapping_candidates.py +0 -0
  51. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/src/sanka/runtime/mapping/model.py +0 -0
  52. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/src/sanka/runtime/mapping/owner_mapping.py +0 -0
  53. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/src/sanka/runtime/mapping/pending_relationships.py +0 -0
  54. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/src/sanka/runtime/mapping/record_mapping.py +0 -0
  55. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/src/sanka/runtime/planner.py +0 -0
  56. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/src/sanka/runtime/private_sqlite.py +0 -0
  57. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/src/sanka/runtime/py.typed +0 -0
  58. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/src/sanka/runtime/registry.py +0 -0
  59. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/src/sanka/runtime/safe_local_io.py +0 -0
  60. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/src/sanka/runtime/spec.py +0 -0
  61. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/src/sanka/runtime/state.py +0 -0
  62. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/src/sanka_cli/__main__.py +0 -0
  63. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/src/sanka_cli/bundle.py +0 -0
  64. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/src/sanka_cli/client.py +0 -0
  65. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/src/sanka_cli/commands/__init__.py +0 -0
  66. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/src/sanka_cli/commands/ai.py +0 -0
  67. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/src/sanka_cli/commands/auth.py +0 -0
  68. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/src/sanka_cli/commands/cloud_migrate.py +0 -0
  69. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/src/sanka_cli/commands/code.py +0 -0
  70. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/src/sanka_cli/commands/migrate.py +0 -0
  71. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/src/sanka_cli/commands/profiles.py +0 -0
  72. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/src/sanka_cli/commands/resources.py +0 -0
  73. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/src/sanka_cli/commands/workflows.py +0 -0
  74. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/src/sanka_cli/config.py +0 -0
  75. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/src/sanka_cli/mcp/__init__.py +0 -0
  76. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/src/sanka_cli/mcp/client.py +0 -0
  77. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/src/sanka_cli/mcp/render.py +0 -0
  78. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/src/sanka_cli/mcp/server.py +0 -0
  79. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/src/sanka_cli/output.py +0 -0
  80. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/src/sanka_cli/runtime.py +0 -0
  81. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/src/sanka_cli/state.py +0 -0
  82. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/src/sanka_connector/__about__.py +0 -0
  83. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/src/sanka_connector/__init__.py +0 -0
  84. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/src/sanka_connector/credentials.py +0 -0
  85. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/src/sanka_connector/errors.py +0 -0
  86. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/src/sanka_connector/protocols.py +0 -0
  87. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/src/sanka_connector/provisioning.py +0 -0
  88. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/src/sanka_connector/py.typed +0 -0
  89. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/src/sanka_connector/records.py +0 -0
  90. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/src/sanka_connector/registration.py +0 -0
  91. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/src/sanka_connector/schema.py +0 -0
  92. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/tests/conftest.py +0 -0
  93. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/tests/fixtures/extension_drf_project/api/__init__.py +0 -0
  94. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/tests/fixtures/extension_drf_project/api/serializers.py +0 -0
  95. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/tests/fixtures/extension_drf_project/api/views.py +0 -0
  96. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/tests/fixtures/extension_drf_project/config/__init__.py +0 -0
  97. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/tests/fixtures/extension_drf_project/config/settings.py +0 -0
  98. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/tests/fixtures/extension_drf_project/config/urls.py +0 -0
  99. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/tests/fixtures/extension_drf_project/manage.py +0 -0
  100. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/tests/fixtures/extension_marketplace/drf-to-fastapi.json +0 -0
  101. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/tests/fixtures/extension_marketplace/marketplace.json +0 -0
  102. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/tests/test_cli_developer_experience.py +0 -0
  103. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/tests/test_code_bundle.py +0 -0
  104. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/tests/test_code_commands.py +0 -0
  105. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/tests/test_concurrency.py +0 -0
  106. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/tests/test_connector_client.py +0 -0
  107. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/tests/test_connector_host.py +0 -0
  108. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/tests/test_credential_provider.py +0 -0
  109. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/tests/test_engine_e2e.py +0 -0
  110. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/tests/test_engine_execution_adoption.py +0 -0
  111. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/tests/test_engine_pg_to_clickhouse_e2e.py +0 -0
  112. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/tests/test_engine_postgres_e2e.py +0 -0
  113. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/tests/test_execution_continuous.py +0 -0
  114. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/tests/test_execution_exact_scope.py +0 -0
  115. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/tests/test_execution_local_store.py +0 -0
  116. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/tests/test_execution_report_codec.py +0 -0
  117. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/tests/test_execution_routes.py +0 -0
  118. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/tests/test_execution_scope.py +0 -0
  119. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/tests/test_execution_seam.py +0 -0
  120. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/tests/test_execution_validate.py +0 -0
  121. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/tests/test_extension_discovery.py +0 -0
  122. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/tests/test_extension_lifecycle.py +0 -0
  123. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/tests/test_extension_runner.py +0 -0
  124. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/tests/test_extension_wheel_acceptance.py +0 -0
  125. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/tests/test_hashing.py +0 -0
  126. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/tests/test_hosted_cli.py +0 -0
  127. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/tests/test_mapping_candidates.py +0 -0
  128. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/tests/test_mapping_owner_mapping.py +0 -0
  129. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/tests/test_mapping_pending_relationships.py +0 -0
  130. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/tests/test_mapping_record_mapping.py +0 -0
  131. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/tests/test_planner.py +0 -0
  132. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/tests/test_registry.py +0 -0
  133. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/tests/test_safe_local_io.py +0 -0
  134. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/tests/test_spec.py +0 -0
  135. {sanka_cli-0.2.1 → sanka_cli-0.2.3}/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.1
3
+ Version: 0.2.3
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
@@ -54,6 +54,19 @@ uv tool install 'sanka-cli[mcp]'
54
54
  sanka mcp
55
55
  ```
56
56
 
57
+ Install the bundled `sanka-cli` skill into an AI coding harness:
58
+
59
+ ```bash
60
+ sanka skill install claude
61
+ sanka skill install codex --scope project
62
+ sanka skill install --scope global
63
+ ```
64
+
65
+ The command prompts for project or global scope when `--scope` is omitted.
66
+ Without a harness argument, it installs into every detected Claude Code or
67
+ Codex CLI. Existing matching installs are left unchanged; different content
68
+ requires `--force`.
69
+
57
70
  The base tool does not install framework migrations or provider drivers.
58
71
  Components are immutable GitHub release wheels selected through the extension
59
72
  manager:
@@ -24,6 +24,19 @@ uv tool install 'sanka-cli[mcp]'
24
24
  sanka mcp
25
25
  ```
26
26
 
27
+ Install the bundled `sanka-cli` skill into an AI coding harness:
28
+
29
+ ```bash
30
+ sanka skill install claude
31
+ sanka skill install codex --scope project
32
+ sanka skill install --scope global
33
+ ```
34
+
35
+ The command prompts for project or global scope when `--scope` is omitted.
36
+ Without a harness argument, it installs into every detected Claude Code or
37
+ Codex CLI. Existing matching installs are left unchanged; different content
38
+ requires `--force`.
39
+
27
40
  The base tool does not install framework migrations or provider drivers.
28
41
  Components are immutable GitHub release wheels selected through the extension
29
42
  manager:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sanka-cli"
3
- version = "0.2.1"
3
+ version = "0.2.3"
4
4
  description = "Sanka command-line interface and migration runtime"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -669,9 +669,51 @@ def _extension_result(
669
669
  data = {"operation": operation, "records": records}
670
670
  if args.json:
671
671
  _print_json(_json_result("extension", data, migration_state="not_started"))
672
- else:
673
- for record in records:
674
- print(json.dumps(record, ensure_ascii=False, sort_keys=True))
672
+ return 0
673
+
674
+ terminal = _terminal(args)
675
+ if operation == "list":
676
+ terminal.heading("Extensions")
677
+ terminal.table(
678
+ ("Extension", "Kind", "Version", "Status", "Marketplace"),
679
+ [
680
+ (
681
+ str(record.get("id", "")),
682
+ str(record.get("kind", "")),
683
+ str(record.get("version", "")),
684
+ ", ".join(map(str, record.get("status", []))),
685
+ str(record.get("marketplace", "")),
686
+ )
687
+ for record in records
688
+ ],
689
+ )
690
+ elif operation == "marketplace_list":
691
+ terminal.heading("Marketplaces")
692
+ terminal.table(
693
+ ("Marketplace", "Source", "Commit", "Trusted"),
694
+ [
695
+ (
696
+ str(record.get("name", "")),
697
+ str(record.get("source", "")),
698
+ str(record.get("resolved_commit") or "-")[:12],
699
+ "yes" if record.get("trusted") else "no",
700
+ )
701
+ for record in records
702
+ ],
703
+ )
704
+ elif operation == "add":
705
+ record = records[0]
706
+ terminal.success(f"Installed {record['id']} {record['version']}")
707
+ elif operation == "remove":
708
+ terminal.success(f"Removed {records[0]['id']}")
709
+ elif operation == "marketplace_add":
710
+ terminal.success(f"Added marketplace {records[0]['name']}")
711
+ elif operation == "marketplace_upgrade":
712
+ terminal.success(
713
+ "Updated marketplace " + ", ".join(str(record["name"]) for record in records)
714
+ )
715
+ elif operation == "marketplace_remove":
716
+ terminal.success(f"Removed marketplace {records[0]['name']}")
675
717
  return 0
676
718
 
677
719
 
@@ -57,6 +57,24 @@ class TerminalOutput:
57
57
  if not self.quiet:
58
58
  print(self.style(value, "cyan"), file=self.stdout)
59
59
 
60
+ def table(self, headers: tuple[str, ...], rows: list[tuple[str, ...]]) -> None:
61
+ if self.quiet:
62
+ return
63
+ widths = [
64
+ max(len(header), *(len(row[index]) for row in rows))
65
+ for index, header in enumerate(headers)
66
+ ]
67
+ print(
68
+ " ".join(header.ljust(width) for header, width in zip(headers, widths, strict=True)),
69
+ file=self.stdout,
70
+ )
71
+ print(" ".join("-" * width for width in widths), file=self.stdout)
72
+ for row in rows:
73
+ print(
74
+ " ".join(value.ljust(width) for value, width in zip(row, widths, strict=True)),
75
+ file=self.stdout,
76
+ )
77
+
60
78
  def success(self, value: str) -> None:
61
79
  print(self.style(f"✓ OK {value}", "green"), file=self.stdout)
62
80
 
@@ -529,6 +529,13 @@ def _create_venv(
529
529
  )
530
530
 
531
531
 
532
+ def _venv_launcher_names() -> tuple[str, ...]:
533
+ names = ("python", "python3", f"python{sys.version_info.major}.{sys.version_info.minor}")
534
+ if sys.version_info[:2] == (3, 14) and sys.getfilesystemencoding() == "utf-8":
535
+ names += ("𝜋thon",)
536
+ return names
537
+
538
+
532
539
  def _normalize_venv_launchers(environment_descriptor: int) -> None:
533
540
  if os.name == "nt":
534
541
  return
@@ -543,10 +550,9 @@ def _normalize_venv_launchers(environment_descriptor: int) -> None:
543
550
  )
544
551
  os.unlink("lib64", dir_fd=environment_descriptor)
545
552
  interpreter = Path(sys.executable).resolve()
546
- names = ("python", "python3", f"python{sys.version_info.major}.{sys.version_info.minor}")
547
553
  with _directory_at(environment_descriptor, "bin", Path("bin")) as bin_descriptor:
548
554
  assert bin_descriptor is not None
549
- for name in names:
555
+ for name in _venv_launcher_names():
550
556
  with suppress(FileNotFoundError):
551
557
  status = os.stat(name, dir_fd=bin_descriptor, follow_symlinks=False)
552
558
  if not (stat.S_ISLNK(status.st_mode) or stat.S_ISREG(status.st_mode)):
@@ -2830,24 +2836,29 @@ class ExtensionStore:
2830
2836
  missing_ok=True,
2831
2837
  ) as existing:
2832
2838
  if existing is not None:
2833
- digest = content_hash(
2834
- _tree_records(
2835
- existing,
2836
- error_code="SANKA_EXTENSION_PATH",
2837
- subject="Extension environment",
2838
- allowed_symlinks=self._environment_symlinks(),
2839
- )
2840
- )
2841
- if digest == expected_digest:
2842
- try:
2843
- self._verify_environment_artifacts(
2844
- root, executable, wheels, providers=providers
2839
+ try:
2840
+ digest = content_hash(
2841
+ _tree_records(
2842
+ existing,
2843
+ error_code="SANKA_EXTENSION_PATH",
2844
+ subject="Extension environment",
2845
+ allowed_symlinks=self._environment_symlinks(),
2845
2846
  )
2846
- except ExtensionError as error:
2847
- if error.code != "SANKA_EXTENSION_HASH_MISMATCH":
2848
- raise
2849
- else:
2850
- return root
2847
+ )
2848
+ except ExtensionError as error:
2849
+ if error.code != "SANKA_EXTENSION_PATH" or expected_digest is not None:
2850
+ raise
2851
+ else:
2852
+ if digest == expected_digest:
2853
+ try:
2854
+ self._verify_environment_artifacts(
2855
+ root, executable, wheels, providers=providers
2856
+ )
2857
+ except ExtensionError as error:
2858
+ if error.code != "SANKA_EXTENSION_HASH_MISMATCH":
2859
+ raise
2860
+ else:
2861
+ return root
2851
2862
  replace_existing = True
2852
2863
  if replace_existing:
2853
2864
  shutil.rmtree(environment_name, dir_fd=environments)
@@ -3212,11 +3223,7 @@ class ExtensionStore:
3212
3223
  def _environment_symlinks() -> dict[str, Path]:
3213
3224
  interpreter = Path(sys.executable).resolve()
3214
3225
  return dict.fromkeys(
3215
- (
3216
- "bin/python",
3217
- "bin/python3",
3218
- f"bin/python{sys.version_info.major}.{sys.version_info.minor}",
3219
- ),
3226
+ (f"bin/{name}" for name in _venv_launcher_names()),
3220
3227
  interpreter,
3221
3228
  )
3222
3229
 
@@ -1,4 +1,4 @@
1
1
  # SPDX-License-Identifier: Apache-2.0
2
2
  __all__ = ["__version__"]
3
3
 
4
- __version__ = "0.2.1"
4
+ __version__ = "0.2.3"
@@ -0,0 +1,146 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import os
6
+ import shutil
7
+ import tempfile
8
+ from importlib import resources
9
+ from pathlib import Path
10
+
11
+ import click
12
+
13
+ import sanka_cli.runtime as runtime
14
+ from sanka_cli.state import CLIState
15
+
16
+ SKILL_NAME = "sanka-cli"
17
+ HARNESSES = ("claude", "codex")
18
+ CONFIG_ROOTS = {
19
+ "claude": ("CLAUDE_CONFIG_DIR", ".claude"),
20
+ "codex": ("CODEX_HOME", ".codex"),
21
+ }
22
+
23
+
24
+ def _skill_content() -> bytes:
25
+ return resources.files("sanka_cli").joinpath("skills", SKILL_NAME, "SKILL.md").read_bytes()
26
+
27
+
28
+ def _target_directory(harness: str, scope: str, project_directory: Path) -> Path:
29
+ environment_name, default_directory = CONFIG_ROOTS[harness]
30
+ if scope == "project":
31
+ root = project_directory.resolve() / f".{harness}"
32
+ else:
33
+ configured_root = os.environ.get(environment_name)
34
+ root = (
35
+ Path(configured_root).expanduser()
36
+ if configured_root
37
+ else Path.home() / default_directory
38
+ )
39
+ return root / "skills" / SKILL_NAME
40
+
41
+
42
+ def _reject_project_symlinks(project_directory: Path, target: Path) -> None:
43
+ current = project_directory.resolve()
44
+ for part in (*target.relative_to(current).parts, "SKILL.md"):
45
+ current /= part
46
+ if current.is_symlink():
47
+ raise click.ClickException(
48
+ f"Refusing project installation through symbolic link: {current}"
49
+ )
50
+
51
+
52
+ def _planned_status(target: Path, content: bytes, *, force: bool) -> str:
53
+ skill_file = target / "SKILL.md"
54
+ try:
55
+ if skill_file.is_file() and skill_file.read_bytes() == content:
56
+ return "unchanged"
57
+ if target.exists() and not force:
58
+ raise click.ClickException(
59
+ f"{target} already exists with different content; "
60
+ "rerun with --force to replace SKILL.md."
61
+ )
62
+ return "updated" if target.exists() else "installed"
63
+ except OSError as exc:
64
+ raise click.ClickException(f"Could not inspect {target}: {exc}") from exc
65
+
66
+
67
+ def _write_skill(target: Path, content: bytes) -> None:
68
+ temporary_path: Path | None = None
69
+ try:
70
+ target.mkdir(parents=True, exist_ok=True)
71
+ with tempfile.NamedTemporaryFile(
72
+ mode="wb", dir=target, prefix=".SKILL.md.", delete=False
73
+ ) as file:
74
+ file.write(content)
75
+ temporary_path = Path(file.name)
76
+ os.replace(temporary_path, target / "SKILL.md")
77
+ except OSError as exc:
78
+ raise click.ClickException(f"Could not install {SKILL_NAME} at {target}: {exc}") from exc
79
+ finally:
80
+ if temporary_path is not None:
81
+ temporary_path.unlink(missing_ok=True)
82
+
83
+
84
+ @click.group()
85
+ def skill() -> None:
86
+ """Install Sanka skills into supported AI coding harnesses."""
87
+
88
+
89
+ @skill.command("install")
90
+ @click.argument("harness", required=False, type=click.Choice(HARNESSES))
91
+ @click.option("--scope", type=click.Choice(["project", "global"]), default=None)
92
+ @click.option(
93
+ "--project-dir",
94
+ type=click.Path(exists=True, file_okay=False, path_type=Path),
95
+ default=Path("."),
96
+ show_default=True,
97
+ )
98
+ @click.option("--force", is_flag=True, help="Replace a different installed SKILL.md.")
99
+ @click.pass_obj
100
+ def install_skill(
101
+ state: CLIState,
102
+ harness: str | None,
103
+ scope: str | None,
104
+ project_dir: Path,
105
+ force: bool,
106
+ ) -> None:
107
+ """Install the sanka-cli skill for Claude Code, Codex, or detected harnesses."""
108
+ harnesses = [harness] if harness else [name for name in HARNESSES if shutil.which(name)]
109
+ if not harnesses:
110
+ raise click.ClickException(
111
+ "No supported harness detected. Pass `claude` or `codex` explicitly."
112
+ )
113
+ resolved_scope = scope or click.prompt(
114
+ "Install scope",
115
+ type=click.Choice(["project", "global"]),
116
+ default="project",
117
+ err=True,
118
+ )
119
+ content = _skill_content()
120
+ plans: list[tuple[str, Path, str]] = []
121
+ for harness_name in harnesses:
122
+ target = _target_directory(harness_name, resolved_scope, project_dir)
123
+ if resolved_scope == "project":
124
+ _reject_project_symlinks(project_dir, target)
125
+ plans.append((harness_name, target, _planned_status(target, content, force=force)))
126
+
127
+ installations = []
128
+ for harness_name, target, status in plans:
129
+ if status != "unchanged":
130
+ _write_skill(target, content)
131
+ installations.append(
132
+ {
133
+ "harness": harness_name,
134
+ "path": str(target),
135
+ "status": status,
136
+ }
137
+ )
138
+ runtime.emit_payload(
139
+ {
140
+ "skill": SKILL_NAME,
141
+ "scope": resolved_scope,
142
+ "content_sha256": hashlib.sha256(content).hexdigest(),
143
+ "installations": installations,
144
+ },
145
+ state,
146
+ )
@@ -10,6 +10,7 @@ from sanka_cli.commands.code import code
10
10
  from sanka_cli.commands.migrate import register_migration_passthroughs
11
11
  from sanka_cli.commands.profiles import profiles
12
12
  from sanka_cli.commands.resources import attach_resource_group
13
+ from sanka_cli.commands.skill import skill
13
14
  from sanka_cli.commands.workflows import workflows
14
15
  from sanka_cli.output import print_error
15
16
  from sanka_cli.state import CLIState
@@ -43,6 +44,7 @@ cli.add_command(profiles)
43
44
  cli.add_command(workflows)
44
45
  cli.add_command(ai)
45
46
  cli.add_command(code)
47
+ cli.add_command(skill)
46
48
 
47
49
  attach_resource_group(cli, "companies", "/v2/public/companies")
48
50
  attach_resource_group(cli, "contacts", "/v2/public/contacts")
@@ -0,0 +1,62 @@
1
+ ---
2
+ name: sanka-cli
3
+ description: Use when inspecting, planning, applying, testing, verifying, or monitoring application and data migrations with the sanka command-line interface.
4
+ ---
5
+
6
+ # Sanka CLI
7
+
8
+ Use `sanka` for application modernization and data movement. Derive the migration mode and destination from the user's task; never assume FastAPI or any other target.
9
+
10
+ ## Choose the migration mode
11
+
12
+ Run `sanka --help`, the selected command's `--help`, and `sanka extension list --json` before choosing options. Do not guess target names, component IDs, or flags.
13
+
14
+ - **Application migration:** a source repository is converted to a task-selected framework or runtime. Use target-specific flags only when the task and command help require them.
15
+ - **Data migration:** records move between task-selected systems described by `sanka.yaml`. Use environment references for credentials and install only the required trusted extensions after authorization.
16
+
17
+ ## Application migration
18
+
19
+ ```bash
20
+ sanka scan . --json
21
+ sanka plan . --json
22
+ sanka apply --root . --plan-hash <reviewed-plan-hash> --json
23
+ sanka test . --json
24
+ sanka verify . --json
25
+ ```
26
+
27
+ Add task-specific plan, output, or target options from `sanka plan --help`; the sequence does not imply a particular destination.
28
+
29
+ ## Data migration
30
+
31
+ Define the source and target in `sanka.yaml`. This is the basic shape; use exact types and options reported by installed extensions:
32
+
33
+ ```yaml
34
+ source:
35
+ type: <source-type>
36
+ connection: <path-or-environment-reference>
37
+ target:
38
+ type: <target-type>
39
+ connection: <path-or-environment-reference>
40
+ ```
41
+
42
+ Then run:
43
+
44
+ ```bash
45
+ sanka plan --json
46
+ sanka validate --json
47
+ sanka apply --plan-hash <reviewed-plan-hash> --json
48
+ sanka verify --json
49
+ sanka status
50
+ ```
51
+
52
+ `validate` checks source records against the plan without writing to the destination. Review its result before applying. Use `sanka extension --help` when a required source, target, or migration capability is not installed.
53
+
54
+ ## Safety boundary
55
+
56
+ - Planning must not write to the destination. Review the source, target, mappings, readiness, limitations, output path, and exact `plan_hash`.
57
+ - Get explicit user authorization immediately before `apply`; apply only the reviewed hash.
58
+ - Treat source behavior and the task contract as authoritative. Generated applications require parity verification; data `apply` performs real destination writes.
59
+ - If pre-write testing or validation fails, fix the plan or configuration and re-plan. If post-write verification fails, inspect `status`; never rerun `apply` blindly. Any changed plan requires review and authorization of its new hash.
60
+ - Never silently switch between local and hosted execution. Use the configured profile only when the task selects hosted operation.
61
+ - Use `--bench-candidate <path>` on `sanka apply` only when a benchmark task requests that artifact layout.
62
+ - Do not expose credentials in command arguments, logs, generated files, or reports.
@@ -163,6 +163,73 @@ def test_extension_management_uses_stable_json_envelopes(
163
163
  ]
164
164
 
165
165
 
166
+ def test_extension_lists_use_human_readable_tables(
167
+ tmp_path: Path,
168
+ monkeypatch: pytest.MonkeyPatch,
169
+ capsys: pytest.CaptureFixture[str],
170
+ ) -> None:
171
+ class Record:
172
+ def __init__(self, data: dict[str, object]) -> None:
173
+ self.data = data
174
+
175
+ def to_dict(self) -> dict[str, object]:
176
+ return self.data
177
+
178
+ class Store:
179
+ def __init__(self, project_root: Path) -> None:
180
+ assert project_root == tmp_path
181
+
182
+ def list_extensions(self) -> tuple[Record, ...]:
183
+ return (
184
+ Record(
185
+ {
186
+ "id": "sanka/drf-to-fastapi",
187
+ "kind": "migration",
188
+ "version": "0.1.0a3",
189
+ "status": ["installed", "enabled"],
190
+ "marketplace": "official",
191
+ "wheels": [{"sha256": "secret-noise"}],
192
+ }
193
+ ),
194
+ )
195
+
196
+ def marketplaces(self) -> tuple[Record, ...]:
197
+ return (
198
+ Record(
199
+ {
200
+ "name": "official",
201
+ "source": "https://github.com/sankaHQ/extensions.git",
202
+ "resolved_commit": "a44ab1794924d45e8eeaeec456ce5c28c1c17d56",
203
+ "trusted": True,
204
+ "snapshot_root": "/private/cache/noise",
205
+ }
206
+ ),
207
+ )
208
+
209
+ monkeypatch.chdir(tmp_path)
210
+ monkeypatch.setattr(cli, "ExtensionStore", Store)
211
+
212
+ assert main(["extension", "list", "--no-color"]) == 0
213
+ extensions = capsys.readouterr().out
214
+ assert "Extension" in extensions
215
+ assert "sanka/drf-to-fastapi" in extensions
216
+ assert "installed, enabled" in extensions
217
+ assert "wheels" not in extensions
218
+ assert "secret-noise" not in extensions
219
+ assert not extensions.lstrip().startswith("{")
220
+
221
+ assert main(["extension", "marketplace", "list", "--no-color"]) == 0
222
+ marketplaces = capsys.readouterr().out
223
+ assert "Marketplace" in marketplaces
224
+ assert "official" in marketplaces
225
+ assert "github.com/sankaHQ/extensions" in marketplaces
226
+ assert "a44ab179492" in marketplaces
227
+ assert "yes" in marketplaces
228
+ assert "snapshot_root" not in marketplaces
229
+ assert "private/cache/noise" not in marketplaces
230
+ assert not marketplaces.lstrip().startswith("{")
231
+
232
+
166
233
  def test_extension_errors_preserve_stable_code_and_details(
167
234
  tmp_path: Path,
168
235
  monkeypatch: pytest.MonkeyPatch,
@@ -550,7 +617,7 @@ def test_research_client_uses_public_branded_base_and_unwraps_data() -> None:
550
617
  "after": ["2027-01"],
551
618
  "locale": ["ja"],
552
619
  }
553
- assert request.get_header("User-agent") == "sanka-cli/0.2.1"
620
+ assert request.get_header("User-agent") == "sanka-cli/0.2.3"
554
621
  assert timeout == 10.0
555
622
 
556
623
 
@@ -1748,6 +1748,82 @@ def test_venv_normalization_rejects_a_nonstandard_lib64_alias(tmp_path: Path) ->
1748
1748
  os.close(descriptor)
1749
1749
 
1750
1750
 
1751
+ @pytest.mark.skipif(os.name != "posix", reason="POSIX venv launcher sealing only")
1752
+ def test_venv_normalization_accepts_python_314_unicode_launcher(
1753
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
1754
+ ) -> None:
1755
+ environment = tmp_path / "environment"
1756
+ (environment / "bin").mkdir(parents=True)
1757
+ (environment / "bin" / "𝜋thon").symlink_to("python3.14")
1758
+
1759
+ class VersionInfo(tuple[object, ...]):
1760
+ major = 3
1761
+ minor = 14
1762
+
1763
+ version_info = VersionInfo((3, 14, 0, "final", 0))
1764
+ monkeypatch.setattr(sys, "version_info", version_info)
1765
+ monkeypatch.setattr(sys, "getfilesystemencoding", lambda: "utf-8")
1766
+
1767
+ descriptor = os.open(environment, os.O_RDONLY)
1768
+ try:
1769
+ extension_store._normalize_venv_launchers(descriptor)
1770
+ extension_store._tree_records(
1771
+ descriptor,
1772
+ error_code="SANKA_EXTENSION_PATH",
1773
+ subject="Extension environment",
1774
+ allowed_symlinks=ExtensionStore._environment_symlinks(),
1775
+ )
1776
+ finally:
1777
+ os.close(descriptor)
1778
+
1779
+ launcher = environment / "bin" / "𝜋thon"
1780
+ assert launcher.is_symlink()
1781
+ assert Path(os.readlink(launcher)) == Path(sys.executable).resolve()
1782
+
1783
+
1784
+ @pytest.mark.skipif(os.name != "posix", reason="POSIX venv launcher sealing only")
1785
+ def test_add_rebuilds_an_orphaned_environment_with_an_unverified_symlink(
1786
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
1787
+ ) -> None:
1788
+ source, wheel = _marketplace(tmp_path / "source")
1789
+ store = ExtensionStore(tmp_path / "project", user_root=tmp_path / "home")
1790
+ store.add_marketplace(source, name="fixtures", trust=True)
1791
+ manifest = load_marketplace(store.marketplaces()[0].snapshot_root)[0]
1792
+ _responses(monkeypatch, {manifest.wheels[0].name: wheel})
1793
+ environment = store.user_root / "environments" / store._manifest_artifact_digest(manifest)
1794
+ (environment / "bin").mkdir(parents=True)
1795
+ stale_launcher = environment / "bin" / "stale-python"
1796
+ stale_launcher.symlink_to("python")
1797
+
1798
+ lock = store.add_extension("example/demo")
1799
+
1800
+ assert lock.artifact_digest == environment.name
1801
+ assert not stale_launcher.exists() and not stale_launcher.is_symlink()
1802
+ assert store.resolve_locked("example/demo") == lock
1803
+
1804
+
1805
+ @pytest.mark.skipif(os.name != "posix", reason="POSIX venv launcher sealing only")
1806
+ def test_add_rejects_an_unverified_symlink_in_a_recorded_environment(
1807
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
1808
+ ) -> None:
1809
+ source, wheel = _marketplace(tmp_path / "source")
1810
+ store = ExtensionStore(tmp_path / "project", user_root=tmp_path / "home")
1811
+ store.add_marketplace(source, name="fixtures", trust=True)
1812
+ manifest = load_marketplace(store.marketplaces()[0].snapshot_root)[0]
1813
+ _responses(monkeypatch, {manifest.wheels[0].name: wheel})
1814
+ lock = store.add_extension("example/demo")
1815
+ stale_launcher = (
1816
+ store.user_root / "environments" / lock.artifact_digest / "bin" / "stale-python"
1817
+ )
1818
+ stale_launcher.symlink_to("python")
1819
+
1820
+ with pytest.raises(ExtensionError) as raised:
1821
+ store.add_extension("example/demo")
1822
+
1823
+ assert raised.value.code == "SANKA_EXTENSION_PATH"
1824
+ assert stale_launcher.is_symlink()
1825
+
1826
+
1751
1827
  @pytest.mark.skipif(os.name != "posix", reason="POSIX venv launcher sealing only")
1752
1828
  def test_real_posix_materializer_installs_without_a_venv_test_hook(
1753
1829
  tmp_path: Path, monkeypatch: pytest.MonkeyPatch
@@ -21,7 +21,7 @@ def test_root_version_uses_unified_public_version(runner: CliRunner) -> None:
21
21
  result = runner.invoke(cli, ["--version"])
22
22
 
23
23
  assert result.exit_code == 0
24
- assert result.output == "sanka, version 0.2.1\n"
24
+ assert result.output == "sanka, version 0.2.3\n"
25
25
 
26
26
 
27
27
  def test_mcp_client_uses_the_cli_version_in_its_user_agent() -> None:
@@ -29,7 +29,7 @@ def test_mcp_client_uses_the_cli_version_in_its_user_agent() -> None:
29
29
 
30
30
  client = SankaMigrateApiClient()
31
31
 
32
- assert client._client.headers["User-Agent"] == "sanka-cli/0.2.1"
32
+ assert client._client.headers["User-Agent"] == "sanka-cli/0.2.3"
33
33
 
34
34
 
35
35
  def test_mcp_without_extra_has_install_hint(
@@ -35,7 +35,7 @@ def test_public_facade_version_matches_unified_distribution() -> None:
35
35
  == sanka_cli.__version__
36
36
  == sanka_cli.mcp.__version__
37
37
  == distribution_version("sanka-cli")
38
- == "0.2.1"
38
+ == "0.2.3"
39
39
  )
40
40
 
41
41