diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 14f2d09..a99f57a 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -25,3 +25,17 @@ jobs: run: rustup toolchain install stable --profile minimal - name: Check run: make check + python-compat: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - uses: astral-sh/setup-uv@v5 + - name: Test Python SDK + run: uv run --directory python --python ${{ matrix.python-version }} pytest tests -q diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 115804c..8dfa739 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,6 +13,7 @@ permissions: jobs: release: runs-on: ubuntu-latest + environment: pypi steps: - uses: actions/checkout@v4 with: @@ -48,7 +49,7 @@ print(re.search(r'^version = "([^"]+)"$', text, re.M).group(1)) PY )" test "$python_version" = "$version" - go_cobra_core_version="$(awk '/github.com\/samzong\/kitup\/go / { print $2; exit }' go-cobra/go.mod)" + go_cobra_core_version="$(awk '/github.com\/lathe-cli\/kitup\/go / { print $2; exit }' go-cobra/go.mod)" test "$go_cobra_core_version" = "v$version" - name: Check published versions id: published @@ -64,7 +65,7 @@ PY else echo "crate=false" >> "$GITHUB_OUTPUT" fi - if curl -fsS "https://pypi.org/pypi/kitup/${version}/json" >/dev/null 2>&1; then + if curl -fsS "https://pypi.org/pypi/kitup-sdk/${version}/json" >/dev/null 2>&1; then echo "pypi=true" >> "$GITHUB_OUTPUT" else echo "pypi=false" >> "$GITHUB_OUTPUT" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ce6b204..edc06f4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -91,7 +91,7 @@ Do not publish packages from a pull request. Use `make release-patch`, `make release-minor`, or `make release-major` from a clean, up-to-date `main` branch to create the release branch and version commit. Open and merge the release PR manually, then tag `main` manually. The release workflow publishes: - `@kitup/sdk` -- `kitup` on PyPI +- `kitup-sdk` on PyPI - `kitup` on crates.io - `github.com/lathe-cli/kitup/go` through the `go/vX.Y.Z` tag - `github.com/lathe-cli/kitup/go-cobra` through the `go-cobra/vX.Y.Z` tag diff --git a/README.md b/README.md index dca79d1..d96e1d1 100644 --- a/README.md +++ b/README.md @@ -180,7 +180,7 @@ The final install report contains `installed`, `updated`, `skipped`, `conflicts` Install: ```bash -pip install kitup +pip install kitup-sdk ``` Use the workflow API for user-facing install commands: diff --git a/docs/API.md b/docs/API.md index f8a4ebe..e8f965a 100644 --- a/docs/API.md +++ b/docs/API.md @@ -213,7 +213,7 @@ Implemented functions: ## Python -Package: `kitup` +Package: `kitup-sdk` ```python from kitup import ( diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 8f7845e..a67d090 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -3,7 +3,7 @@ `kitup` publishes one version across five package surfaces: - npm: `@kitup/sdk` -- PyPI: `kitup` +- PyPI: `kitup-sdk` - crates.io: `kitup` - Go module: `github.com/lathe-cli/kitup/go` - Go Cobra adapter: `github.com/lathe-cli/kitup/go-cobra` @@ -87,4 +87,4 @@ Run the public install smoke check manually with: scripts/smoke-release.sh X.Y.Z ``` -The smoke check installs from npm, PyPI, crates.io, the public Go module, and the public Go Cobra adapter, then verifies that each SDK can load the default host spec or instantiate its adapter. +The smoke check installs from npm, PyPI (`kitup-sdk`), crates.io, the public Go module, and the public Go Cobra adapter, then verifies that each SDK can load the default host spec or instantiate its adapter. diff --git a/examples/python/pyproject.toml b/examples/python/pyproject.toml index 9b36cbb..8c02ab8 100644 --- a/examples/python/pyproject.toml +++ b/examples/python/pyproject.toml @@ -1,8 +1,8 @@ [project] name = "kitup-example-python" version = "0.0.0" -requires-python = ">=3.14" -dependencies = ["kitup"] +requires-python = ">=3.10" +dependencies = ["kitup-sdk"] [tool.uv.sources] -kitup = { path = "../../python" } +kitup-sdk = { path = "../../python" } diff --git a/python/README.md b/python/README.md index eef8e88..7d476b6 100644 --- a/python/README.md +++ b/python/README.md @@ -5,7 +5,7 @@ Shared installer SDK for bundled Agent Skills. ## Install ```bash -pip install kitup +pip install kitup-sdk ``` ## Use diff --git a/python/pyproject.toml b/python/pyproject.toml index ebba8d5..41e8b3d 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -3,11 +3,11 @@ requires = ["hatchling>=1.27.0"] build-backend = "hatchling.build" [project] -name = "kitup" -version = "0.1.1" +name = "kitup-sdk" +version = "0.1.2" description = "Shared installer SDK for bundled Agent Skills." readme = "README.md" -requires-python = ">=3.14" +requires-python = ">=3.10" license = { text = "MIT" } keywords = ["agent", "skills", "installer", "sdk"] dependencies = [] @@ -27,5 +27,8 @@ dev = [ [tool.pytest.ini_options] testpaths = ["tests"] +[tool.hatch.build.targets.wheel] +packages = ["src/kitup"] + [tool.ruff] -target-version = "py314" +target-version = "py310" diff --git a/python/src/kitup/_metadata.py b/python/src/kitup/_metadata.py index dbf1041..207a823 100644 --- a/python/src/kitup/_metadata.py +++ b/python/src/kitup/_metadata.py @@ -39,7 +39,7 @@ def read_install_metadata(target_dir: Path) -> dict[str, object] | None: return None try: payload = json.loads(metadata_file.read_text(encoding="utf-8")) - except OSError, ValueError: + except (OSError, ValueError): return None if not isinstance(payload, dict): return None diff --git a/python/src/kitup/workflow.py b/python/src/kitup/workflow.py index 5242e7d..e678c57 100644 --- a/python/src/kitup/workflow.py +++ b/python/src/kitup/workflow.py @@ -1,7 +1,6 @@ from __future__ import annotations from dataclasses import replace -import io import sys from typing import Iterable @@ -562,10 +561,27 @@ def _coerce_flag_values(value: object) -> list[str]: class _LineReader: def __init__(self, source: object | None) -> None: - self._lines: list[str] = list(self._iter_lines(source)) + self._stream = ( + source + if source is not None + and not isinstance(source, str | bytes) + and hasattr(source, "readline") + else None + ) + self._lines: list[str] = ( + [] if self._stream is not None else list(self._iter_lines(source)) + ) self._index = 0 def read_line(self) -> str | None: + if self._stream is not None: + line = self._stream.readline() + if isinstance(line, bytes): + line = line.decode("utf-8") + line = str(line) + if line == "": + return None + return line.rstrip("\n").rstrip("\r") if self._index >= len(self._lines): return None line = self._lines[self._index] @@ -584,8 +600,6 @@ def _iter_lines(self, source: object | None) -> Iterable[str]: if isinstance(contents, bytes): return self._split_text(contents.decode("utf-8")) return self._split_text(str(contents)) - if isinstance(source, io.StringIO): - return self._split_text(source.getvalue()) if isinstance(source, Iterable): chunks: list[str] = [] for item in source: diff --git a/python/tests/test_workflow.py b/python/tests/test_workflow.py index 8dac754..c7c9b96 100644 --- a/python/tests/test_workflow.py +++ b/python/tests/test_workflow.py @@ -318,6 +318,14 @@ def isatty(self) -> bool: return False +class _ReadlineOnlyTTYInput(io.StringIO): + def isatty(self) -> bool: + return True + + def read(self) -> str: + raise AssertionError("workflow input must read one line at a time") + + def test_run_bundled_skill_install_uses_stdio_defaults_for_interactive_flow( monkeypatch, tmp_path ): @@ -358,6 +366,44 @@ def test_run_bundled_skill_install_uses_stdio_defaults_for_interactive_flow( assert (workspace / ".agents" / "skills" / "basic" / "SKILL.md").exists() +def test_run_bundled_skill_install_reads_interactive_stream_line_by_line( + tmp_path, +): + home = tmp_path / "home" + workspace = tmp_path / "workspace" + home.mkdir() + workspace.mkdir() + skill = workspace / "skill" + skill.mkdir() + (skill / "SKILL.md").write_text( + "---\nname: basic\ndescription: demo\n---\n", + encoding="utf-8", + ) + + output = io.StringIO() + report = run_bundled_skill_install( + InstallWorkflowOptions( + install=InstallOptions( + base=BaseOptions(home=str(home), cwd=str(workspace)), + app_id="example-cli", + skill_bundle=directory_bundle(str(skill)), + scope="user", + agents=["codex"], + ), + prompt_scope=True, + scope_set=False, + input=_ReadlineOnlyTTYInput("project\ny\n"), + output=output, + ) + ) + + assert report.scope == "project" + assert report.canceled is False + assert "Select install scope:" in output.getvalue() + assert "Proceed? [y/N] " in output.getvalue() + assert (workspace / ".agents" / "skills" / "basic" / "SKILL.md").exists() + + def test_run_bundled_skill_install_infers_tty_from_custom_input_stream( monkeypatch, tmp_path ): diff --git a/scripts/check.mjs b/scripts/check.mjs index 9e787e5..6ecee7e 100755 --- a/scripts/check.mjs +++ b/scripts/check.mjs @@ -8,7 +8,11 @@ const root = new URL("../", import.meta.url); const rootPath = fileURLToPath(root); function readJson(path) { - return JSON.parse(readFileSync(new URL(path, root), "utf8")); + return JSON.parse(readText(path)); +} + +function readText(path) { + return readFileSync(new URL(path, root), "utf8"); } function fail(message) { @@ -186,6 +190,82 @@ function validateFixtures() { readJson("testdata/skills/basic/assets/template.json"); } +function matchOne(path, pattern, label) { + const match = readText(path).match(pattern); + assert(match, `missing ${label}`); + return match[1]; +} + +function validateVersions() { + const version = readJson("ts/package.json").version; + assert( + matchOne("rust/Cargo.toml", /^version = "([^"]+)"$/m, "rust version") === + version, + "rust version drifted", + ); + assert( + matchOne( + "python/pyproject.toml", + /^version = "([^"]+)"$/m, + "python version", + ) === version, + "python version drifted", + ); + assert( + matchOne( + "go-cobra/go.mod", + /^\s*github\.com\/lathe-cli\/kitup\/go v([^\s]+)$/m, + "go-cobra core version", + ) === version, + "go-cobra core version drifted", + ); + assert( + matchOne( + "python/pyproject.toml", + /^name = "([^"]+)"$/m, + "python package name", + ) === "kitup-sdk", + "python package name drifted", + ); + assert( + matchOne( + "python/pyproject.toml", + /^requires-python = "([^"]+)"$/m, + "python requires-python", + ) === ">=3.10", + "python requires-python drifted", + ); +} + +function validateReleaseWorkflow() { + const workflow = readText(".github/workflows/release.yml"); + assert( + workflow.includes("github.com\\/lathe-cli\\/kitup\\/go"), + "release workflow must check the canonical Go module path", + ); + assert( + !workflow.includes("github.com\\/samzong\\/kitup\\/go"), + "release workflow still checks the old Go module path", + ); + assert( + /^\s*environment: pypi$/m.test(workflow), + "release workflow must use the pypi environment", + ); + assert( + workflow.includes("https://pypi.org/pypi/kitup-sdk/"), + "release workflow must check kitup-sdk on PyPI", + ); + assert( + !workflow.includes("https://pypi.org/pypi/kitup/"), + "release workflow still checks the old PyPI package name", + ); + const smoke = readText("scripts/smoke-release.sh"); + assert( + smoke.includes('"kitup-sdk==$version"'), + "release smoke must install kitup-sdk from PyPI", + ); +} + const hostsSpec = readJson("spec/hosts.json"); const cases = readJson("testdata/cases/bundled-skill-install.json"); readJson("spec/hosts.schema.json"); @@ -194,6 +274,8 @@ readJson("testdata/cases.schema.json"); const { ids, aliases } = validateHosts(hostsSpec); validateCases(cases, hostsSpec.hosts); validateFixtures(); +validateVersions(); +validateReleaseWorkflow(); assert(ids.has("kimi-cli"), "kimi-cli must be canonical"); assert(!ids.has("kimi-code-cli"), "kimi-code-cli must not be canonical"); diff --git a/scripts/smoke-release.sh b/scripts/smoke-release.sh index c90767f..45769d3 100755 --- a/scripts/smoke-release.sh +++ b/scripts/smoke-release.sh @@ -100,7 +100,7 @@ GO smoke_python() { dir="$(mktemp -d "$tmp/python.XXXXXX")" cd "$dir" - uv run --with "kitup==$version" python - <<'PY' + uv run --with "kitup-sdk==$version" python - <<'PY' from kitup import load_host_spec spec = load_host_spec()