sanka-cli 0.2.1__tar.gz → 0.2.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/PKG-INFO +14 -1
  2. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/README.md +13 -0
  3. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/pyproject.toml +1 -1
  4. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/src/sanka_cli/__init__.py +1 -1
  5. sanka_cli-0.2.2/src/sanka_cli/commands/skill.py +146 -0
  6. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/src/sanka_cli/main.py +2 -0
  7. sanka_cli-0.2.2/src/sanka_cli/skills/sanka-cli/SKILL.md +62 -0
  8. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/tests/test_cli.py +1 -1
  9. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/tests/test_mcp.py +2 -2
  10. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/tests/test_sanka_facade.py +1 -1
  11. sanka_cli-0.2.2/tests/test_skill_commands.py +219 -0
  12. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/.gitignore +0 -0
  13. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/LICENSES/AGPL-3.0-only.txt +0 -0
  14. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/LICENSES/Apache-2.0.txt +0 -0
  15. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/NOTICE +0 -0
  16. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/src/sanka/__init__.py +0 -0
  17. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/src/sanka/_client.py +0 -0
  18. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/src/sanka/cli/__init__.py +0 -0
  19. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/src/sanka/cli/_output.py +0 -0
  20. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/src/sanka/cli/_research.py +0 -0
  21. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/src/sanka/cli/py.typed +0 -0
  22. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/src/sanka/connector/__init__.py +0 -0
  23. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/src/sanka/connector/py.typed +0 -0
  24. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/src/sanka/runtime/__about__.py +0 -0
  25. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/src/sanka/runtime/__init__.py +0 -0
  26. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/src/sanka/runtime/concurrency.py +0 -0
  27. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/src/sanka/runtime/connector_client.py +0 -0
  28. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/src/sanka/runtime/connector_host.py +0 -0
  29. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/src/sanka/runtime/engine.py +0 -0
  30. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/src/sanka/runtime/execution/__init__.py +0 -0
  31. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/src/sanka/runtime/execution/continuous.py +0 -0
  32. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/src/sanka/runtime/execution/errors.py +0 -0
  33. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/src/sanka/runtime/execution/local.py +0 -0
  34. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/src/sanka/runtime/execution/model.py +0 -0
  35. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/src/sanka/runtime/execution/report_codec.py +0 -0
  36. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/src/sanka/runtime/execution/routes.py +0 -0
  37. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/src/sanka/runtime/execution/scope.py +0 -0
  38. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/src/sanka/runtime/execution/state.py +0 -0
  39. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/src/sanka/runtime/execution/validate.py +0 -0
  40. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/src/sanka/runtime/extensions/__init__.py +0 -0
  41. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/src/sanka/runtime/extensions/discovery.py +0 -0
  42. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/src/sanka/runtime/extensions/lifecycle.py +0 -0
  43. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/src/sanka/runtime/extensions/model.py +0 -0
  44. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/src/sanka/runtime/extensions/runner.py +0 -0
  45. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/src/sanka/runtime/extensions/store.py +0 -0
  46. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/src/sanka/runtime/hashing.py +0 -0
  47. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/src/sanka/runtime/mapping/__init__.py +0 -0
  48. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/src/sanka/runtime/mapping/errors.py +0 -0
  49. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/src/sanka/runtime/mapping/mapping_candidates.py +0 -0
  50. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/src/sanka/runtime/mapping/model.py +0 -0
  51. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/src/sanka/runtime/mapping/owner_mapping.py +0 -0
  52. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/src/sanka/runtime/mapping/pending_relationships.py +0 -0
  53. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/src/sanka/runtime/mapping/record_mapping.py +0 -0
  54. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/src/sanka/runtime/planner.py +0 -0
  55. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/src/sanka/runtime/private_sqlite.py +0 -0
  56. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/src/sanka/runtime/py.typed +0 -0
  57. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/src/sanka/runtime/registry.py +0 -0
  58. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/src/sanka/runtime/safe_local_io.py +0 -0
  59. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/src/sanka/runtime/spec.py +0 -0
  60. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/src/sanka/runtime/state.py +0 -0
  61. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/src/sanka_cli/__main__.py +0 -0
  62. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/src/sanka_cli/bundle.py +0 -0
  63. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/src/sanka_cli/client.py +0 -0
  64. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/src/sanka_cli/commands/__init__.py +0 -0
  65. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/src/sanka_cli/commands/ai.py +0 -0
  66. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/src/sanka_cli/commands/auth.py +0 -0
  67. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/src/sanka_cli/commands/cloud_migrate.py +0 -0
  68. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/src/sanka_cli/commands/code.py +0 -0
  69. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/src/sanka_cli/commands/migrate.py +0 -0
  70. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/src/sanka_cli/commands/profiles.py +0 -0
  71. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/src/sanka_cli/commands/resources.py +0 -0
  72. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/src/sanka_cli/commands/workflows.py +0 -0
  73. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/src/sanka_cli/config.py +0 -0
  74. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/src/sanka_cli/mcp/__init__.py +0 -0
  75. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/src/sanka_cli/mcp/client.py +0 -0
  76. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/src/sanka_cli/mcp/render.py +0 -0
  77. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/src/sanka_cli/mcp/server.py +0 -0
  78. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/src/sanka_cli/output.py +0 -0
  79. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/src/sanka_cli/runtime.py +0 -0
  80. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/src/sanka_cli/state.py +0 -0
  81. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/src/sanka_connector/__about__.py +0 -0
  82. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/src/sanka_connector/__init__.py +0 -0
  83. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/src/sanka_connector/credentials.py +0 -0
  84. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/src/sanka_connector/errors.py +0 -0
  85. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/src/sanka_connector/protocols.py +0 -0
  86. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/src/sanka_connector/provisioning.py +0 -0
  87. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/src/sanka_connector/py.typed +0 -0
  88. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/src/sanka_connector/records.py +0 -0
  89. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/src/sanka_connector/registration.py +0 -0
  90. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/src/sanka_connector/schema.py +0 -0
  91. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/tests/conftest.py +0 -0
  92. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/tests/fixtures/extension_drf_project/api/__init__.py +0 -0
  93. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/tests/fixtures/extension_drf_project/api/serializers.py +0 -0
  94. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/tests/fixtures/extension_drf_project/api/views.py +0 -0
  95. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/tests/fixtures/extension_drf_project/config/__init__.py +0 -0
  96. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/tests/fixtures/extension_drf_project/config/settings.py +0 -0
  97. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/tests/fixtures/extension_drf_project/config/urls.py +0 -0
  98. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/tests/fixtures/extension_drf_project/manage.py +0 -0
  99. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/tests/fixtures/extension_marketplace/drf-to-fastapi.json +0 -0
  100. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/tests/fixtures/extension_marketplace/marketplace.json +0 -0
  101. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/tests/test_cli_developer_experience.py +0 -0
  102. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/tests/test_code_bundle.py +0 -0
  103. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/tests/test_code_commands.py +0 -0
  104. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/tests/test_concurrency.py +0 -0
  105. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/tests/test_connector_client.py +0 -0
  106. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/tests/test_connector_host.py +0 -0
  107. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/tests/test_credential_provider.py +0 -0
  108. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/tests/test_engine_e2e.py +0 -0
  109. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/tests/test_engine_execution_adoption.py +0 -0
  110. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/tests/test_engine_pg_to_clickhouse_e2e.py +0 -0
  111. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/tests/test_engine_postgres_e2e.py +0 -0
  112. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/tests/test_execution_continuous.py +0 -0
  113. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/tests/test_execution_exact_scope.py +0 -0
  114. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/tests/test_execution_local_store.py +0 -0
  115. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/tests/test_execution_report_codec.py +0 -0
  116. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/tests/test_execution_routes.py +0 -0
  117. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/tests/test_execution_scope.py +0 -0
  118. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/tests/test_execution_seam.py +0 -0
  119. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/tests/test_execution_validate.py +0 -0
  120. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/tests/test_extension_discovery.py +0 -0
  121. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/tests/test_extension_lifecycle.py +0 -0
  122. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/tests/test_extension_runner.py +0 -0
  123. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/tests/test_extension_store.py +0 -0
  124. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/tests/test_extension_wheel_acceptance.py +0 -0
  125. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/tests/test_hashing.py +0 -0
  126. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/tests/test_hosted_cli.py +0 -0
  127. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/tests/test_mapping_candidates.py +0 -0
  128. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/tests/test_mapping_owner_mapping.py +0 -0
  129. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/tests/test_mapping_pending_relationships.py +0 -0
  130. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/tests/test_mapping_record_mapping.py +0 -0
  131. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/tests/test_planner.py +0 -0
  132. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/tests/test_registry.py +0 -0
  133. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/tests/test_safe_local_io.py +0 -0
  134. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/tests/test_spec.py +0 -0
  135. {sanka_cli-0.2.1 → sanka_cli-0.2.2}/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.2
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.2"
4
4
  description = "Sanka command-line interface and migration runtime"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -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.2"
@@ -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.
@@ -550,7 +550,7 @@ def test_research_client_uses_public_branded_base_and_unwraps_data() -> None:
550
550
  "after": ["2027-01"],
551
551
  "locale": ["ja"],
552
552
  }
553
- assert request.get_header("User-agent") == "sanka-cli/0.2.1"
553
+ assert request.get_header("User-agent") == "sanka-cli/0.2.2"
554
554
  assert timeout == 10.0
555
555
 
556
556
 
@@ -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.2\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.2"
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.2"
39
39
  )
40
40
 
41
41
 
@@ -0,0 +1,219 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-only
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from pathlib import Path
6
+
7
+ import pytest
8
+ from click.testing import CliRunner
9
+
10
+ from sanka_cli.main import cli
11
+
12
+
13
+ @pytest.fixture
14
+ def runner() -> CliRunner:
15
+ return CliRunner()
16
+
17
+
18
+ def test_install_claude_skill_in_project(runner: CliRunner, tmp_path: Path) -> None:
19
+ result = runner.invoke(
20
+ cli,
21
+ [
22
+ "--output",
23
+ "json",
24
+ "skill",
25
+ "install",
26
+ "claude",
27
+ "--scope",
28
+ "project",
29
+ "--project-dir",
30
+ str(tmp_path),
31
+ ],
32
+ )
33
+
34
+ assert result.exit_code == 0, result.output
35
+ skill_file = tmp_path / ".claude" / "skills" / "sanka-cli" / "SKILL.md"
36
+ assert skill_file.is_file()
37
+ assert skill_file.read_text().startswith("---\nname: sanka-cli\n")
38
+ assert json.loads(result.output)["installations"] == [
39
+ {
40
+ "harness": "claude",
41
+ "path": str(skill_file.parent),
42
+ "status": "installed",
43
+ }
44
+ ]
45
+
46
+
47
+ @pytest.mark.parametrize(
48
+ ("harness", "environment_name", "config_directory"),
49
+ [
50
+ ("claude", "CLAUDE_CONFIG_DIR", ".claude-test"),
51
+ ("codex", "CODEX_HOME", ".codex-test"),
52
+ ],
53
+ )
54
+ def test_install_skill_globally(
55
+ runner: CliRunner,
56
+ monkeypatch: pytest.MonkeyPatch,
57
+ tmp_path: Path,
58
+ harness: str,
59
+ environment_name: str,
60
+ config_directory: str,
61
+ ) -> None:
62
+ config_root = tmp_path / config_directory
63
+ monkeypatch.setenv(environment_name, str(config_root))
64
+
65
+ result = runner.invoke(
66
+ cli,
67
+ ["--output", "json", "skill", "install", harness, "--scope", "global"],
68
+ )
69
+
70
+ assert result.exit_code == 0, result.output
71
+ assert (config_root / "skills" / "sanka-cli" / "SKILL.md").is_file()
72
+
73
+
74
+ def test_install_prompts_for_scope(runner: CliRunner, tmp_path: Path) -> None:
75
+ result = runner.invoke(
76
+ cli,
77
+ ["--output", "json", "skill", "install", "codex", "--project-dir", str(tmp_path)],
78
+ input="project\n",
79
+ )
80
+
81
+ assert result.exit_code == 0, result.output
82
+ assert "Install scope (project, global) [project]:" in result.stderr
83
+ assert json.loads(result.stdout)["scope"] == "project"
84
+ assert (tmp_path / ".codex" / "skills" / "sanka-cli" / "SKILL.md").is_file()
85
+
86
+
87
+ def test_install_without_harness_detects_available_harnesses(
88
+ runner: CliRunner,
89
+ monkeypatch: pytest.MonkeyPatch,
90
+ tmp_path: Path,
91
+ ) -> None:
92
+ bin_directory = tmp_path / "bin"
93
+ bin_directory.mkdir()
94
+ for executable_name in ("claude", "codex"):
95
+ executable = bin_directory / executable_name
96
+ executable.touch(mode=0o755)
97
+ monkeypatch.setenv("PATH", str(bin_directory))
98
+
99
+ result = runner.invoke(
100
+ cli,
101
+ [
102
+ "--output",
103
+ "json",
104
+ "skill",
105
+ "install",
106
+ "--scope",
107
+ "project",
108
+ "--project-dir",
109
+ str(tmp_path),
110
+ ],
111
+ )
112
+
113
+ assert result.exit_code == 0, result.output
114
+ payload = json.loads(result.output)
115
+ assert [item["harness"] for item in payload["installations"]] == ["claude", "codex"]
116
+
117
+
118
+ def test_install_is_idempotent_and_refuses_conflicts_without_force(
119
+ runner: CliRunner,
120
+ tmp_path: Path,
121
+ ) -> None:
122
+ arguments = [
123
+ "--output",
124
+ "json",
125
+ "skill",
126
+ "install",
127
+ "claude",
128
+ "--scope",
129
+ "project",
130
+ "--project-dir",
131
+ str(tmp_path),
132
+ ]
133
+ assert runner.invoke(cli, arguments).exit_code == 0
134
+
135
+ unchanged = runner.invoke(cli, arguments)
136
+ assert unchanged.exit_code == 0
137
+ assert json.loads(unchanged.output)["installations"][0]["status"] == "unchanged"
138
+
139
+ skill_file = tmp_path / ".claude" / "skills" / "sanka-cli" / "SKILL.md"
140
+ skill_file.write_text("local changes\n")
141
+ conflict = runner.invoke(cli, arguments)
142
+ assert conflict.exit_code == 1
143
+ assert "already exists with different content" in conflict.output
144
+ assert skill_file.read_text() == "local changes\n"
145
+
146
+ updated = runner.invoke(cli, [*arguments, "--force"])
147
+ assert updated.exit_code == 0
148
+ assert json.loads(updated.output)["installations"][0]["status"] == "updated"
149
+ assert skill_file.read_text().startswith("---\nname: sanka-cli\n")
150
+
151
+
152
+ def test_detected_harness_conflict_does_not_partially_install(
153
+ runner: CliRunner,
154
+ monkeypatch: pytest.MonkeyPatch,
155
+ tmp_path: Path,
156
+ ) -> None:
157
+ bin_directory = tmp_path / "bin"
158
+ bin_directory.mkdir()
159
+ for executable_name in ("claude", "codex"):
160
+ executable = bin_directory / executable_name
161
+ executable.touch(mode=0o755)
162
+ monkeypatch.setenv("PATH", str(bin_directory))
163
+ codex_skill = tmp_path / ".codex" / "skills" / "sanka-cli" / "SKILL.md"
164
+ codex_skill.parent.mkdir(parents=True)
165
+ codex_skill.write_text("local changes\n")
166
+
167
+ result = runner.invoke(
168
+ cli,
169
+ [
170
+ "skill",
171
+ "install",
172
+ "--scope",
173
+ "project",
174
+ "--project-dir",
175
+ str(tmp_path),
176
+ ],
177
+ )
178
+
179
+ assert result.exit_code == 1
180
+ assert not (tmp_path / ".claude" / "skills" / "sanka-cli").exists()
181
+ assert codex_skill.read_text() == "local changes\n"
182
+
183
+
184
+ @pytest.mark.parametrize("symlink_target", ["directory", "file"])
185
+ def test_project_install_refuses_symlinked_destination(
186
+ runner: CliRunner,
187
+ tmp_path: Path,
188
+ symlink_target: str,
189
+ ) -> None:
190
+ outside_directory = tmp_path / "outside"
191
+ outside_directory.mkdir()
192
+ outside_file = outside_directory / "outside.md"
193
+ outside_file.write_text("keep me\n")
194
+ project = tmp_path / "project"
195
+ skill_file = project / ".claude" / "skills" / "sanka-cli" / "SKILL.md"
196
+ if symlink_target == "directory":
197
+ skill_file.parent.parent.mkdir(parents=True)
198
+ skill_file.parent.symlink_to(outside_directory, target_is_directory=True)
199
+ else:
200
+ skill_file.parent.mkdir(parents=True)
201
+ skill_file.symlink_to(outside_file)
202
+
203
+ result = runner.invoke(
204
+ cli,
205
+ [
206
+ "skill",
207
+ "install",
208
+ "claude",
209
+ "--scope",
210
+ "project",
211
+ "--project-dir",
212
+ str(project),
213
+ "--force",
214
+ ],
215
+ )
216
+
217
+ assert result.exit_code == 1
218
+ assert "symbolic link" in result.output
219
+ assert outside_file.read_text() == "keep me\n"
File without changes
File without changes
File without changes
File without changes