Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ jobs:
- uses: actions/setup-go@v5
with:
go-version: "1.23"
- uses: actions/setup-python@v5
with:
python-version: "3.14"
- uses: astral-sh/setup-uv@v5
- name: Setup Rust
run: rustup toolchain install stable --profile minimal
- name: Check
Expand Down
27 changes: 26 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ jobs:
- uses: actions/setup-go@v5
with:
go-version: "1.23"
- uses: actions/setup-python@v5
with:
python-version: "3.14"
- uses: astral-sh/setup-uv@v5
- name: Setup Rust
run: rustup toolchain install stable --profile minimal
- name: Check
Expand All @@ -36,7 +40,15 @@ jobs:
VERSION="$version" node -e 'const { readFileSync } = require("node:fs"); const version = process.env.VERSION; const pkg = JSON.parse(readFileSync("ts/package.json", "utf8")); if (pkg.version !== version) { throw new Error(`ts/package.json version ${pkg.version} != ${version}`); }'
rust_version="$(awk -F '"' '/^version = / { print $2; exit }' rust/Cargo.toml)"
test "$rust_version" = "$version"
go_cobra_core_version="$(awk '/github.com\/lathe-cli\/kitup\/go / { print $2; exit }' go-cobra/go.mod)"
python_version="$(python - <<'PY'
from pathlib import Path
import re
text = Path("python/pyproject.toml").read_text()
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)"
test "$go_cobra_core_version" = "v$version"
- name: Check published versions
id: published
Expand All @@ -52,6 +64,11 @@ jobs:
else
echo "crate=false" >> "$GITHUB_OUTPUT"
fi
if curl -fsS "https://pypi.org/pypi/kitup/${version}/json" >/dev/null 2>&1; then
echo "pypi=true" >> "$GITHUB_OUTPUT"
else
echo "pypi=false" >> "$GITHUB_OUTPUT"
fi
- name: Dry run npm package
if: steps.published.outputs.npm != 'true'
run: |
Expand All @@ -62,6 +79,9 @@ jobs:
run: |
cd rust
cargo publish --locked --dry-run
- name: Build Python package
if: steps.published.outputs.pypi != 'true'
run: uv build --directory python
- name: Publish npm package
if: steps.published.outputs.npm != 'true'
run: |
Expand All @@ -74,6 +94,11 @@ jobs:
cargo publish --locked
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
- name: Publish Python package
if: steps.published.outputs.pypi != 'true'
uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: python/dist
- name: Publish Go module tags
run: |
version="${GITHUB_REF_NAME#v}"
Expand Down
1 change: 1 addition & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +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` 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
Expand Down
22 changes: 17 additions & 5 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,21 @@ TS_DIR := ts
GO_DIR := go
GO_COBRA_DIR := go-cobra
RUST_DIR := rust
PYTHON_DIR := python
EXAMPLE_TS_DIR := examples/ts
EXAMPLE_GO_DIR := examples/go
EXAMPLE_RUST_DIR := examples/rust
EXAMPLE_PYTHON_DIR := examples/python
GO_FILES := $(shell find $(GO_DIR) $(GO_COBRA_DIR) $(EXAMPLE_GO_DIR) -name '*.go' -type f)

# ── Quality ──────────────────────────────────────────────────────────────────

.PHONY: check test test-ts test-go test-go-cobra test-rust fmt fmt-ts fmt-go fmt-rust
.PHONY: check test test-ts test-go test-go-cobra test-rust test-python fmt fmt-ts fmt-go fmt-rust fmt-python

check: ## Full parity gate
node scripts/check.mjs

test: test-ts test-go test-go-cobra test-rust ## Run SDK tests
test: test-ts test-go test-go-cobra test-rust test-python ## Run SDK tests

test-ts: ## Run TypeScript tests
pnpm --dir $(TS_DIR) test
Expand All @@ -37,7 +39,10 @@ test-go-cobra: ## Run Go Cobra adapter tests
test-rust: ## Run Rust SDK tests
cargo test --manifest-path $(RUST_DIR)/Cargo.toml

fmt: fmt-ts fmt-go fmt-rust ## Format all SDK code
test-python: ## Run Python SDK tests
cd $(PYTHON_DIR) && uv run pytest tests -q

fmt: fmt-ts fmt-go fmt-rust fmt-python ## Format all SDK code

fmt-ts: ## Format TypeScript code
cd $(TS_DIR) && pnpm exec prettier --write src test ../examples/ts/cli.ts ../scripts/check.mjs ../scripts/prepare-release.mjs
Expand All @@ -49,6 +54,10 @@ fmt-rust: ## Format Rust code
cargo fmt --manifest-path $(RUST_DIR)/Cargo.toml
cargo fmt --manifest-path $(EXAMPLE_RUST_DIR)/Cargo.toml

fmt-python: ## Lint and format Python code
cd $(PYTHON_DIR) && uv run ruff format --check src tests --exclude src/kitup/_hosts_generated.py
cd $(PYTHON_DIR) && uv run ruff check src tests

# ── Generated Data ───────────────────────────────────────────────────────────

.PHONY: generate generate-check
Expand All @@ -61,9 +70,9 @@ generate-check: ## Verify generated host constants

# ── Examples ─────────────────────────────────────────────────────────────────

.PHONY: examples example-ts example-go example-rust
.PHONY: examples example-ts example-go example-rust example-python

examples: example-ts example-go example-rust ## Run all examples
examples: example-ts example-go example-rust example-python ## Run all examples

example-ts: ## Run TypeScript example
tmp="$$(mktemp -d)" && mkdir -p "$$tmp/.codex" && HOME="$$tmp" pnpm --dir $(EXAMPLE_TS_DIR) install-skill
Expand All @@ -74,6 +83,9 @@ example-go: ## Run Go example
example-rust: ## Run Rust example
cd $(EXAMPLE_RUST_DIR) && tmp="$$(mktemp -d)" && mkdir -p "$$tmp/.codex" && CARGO_HOME="$${CARGO_HOME:-$$HOME/.cargo}" RUSTUP_HOME="$${RUSTUP_HOME:-$$HOME/.rustup}" HOME="$$tmp" cargo run --quiet

example-python: ## Run Python example
cd $(EXAMPLE_PYTHON_DIR) && tmp="$$(mktemp -d)" && mkdir -p "$$tmp/.codex" && HOME="$$tmp" uv run python main.py

# ── Release ──────────────────────────────────────────────────────────────────

.PHONY: release-patch release-minor release-major
Expand Down
35 changes: 35 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,41 @@ let result = kitup::run_bundled_skill_install(&kitup::InstallWorkflowOptions {
The workflow result contains the selected agents, dry-run plan, final install report, and cancellation state.
The final install report contains `installed`, `updated`, `skipped`, `conflicts`, and `errors`.

### Python

Install:

```bash
pip install kitup
```

Use the workflow API for user-facing install commands:

```python
from kitup import (
BaseOptions,
InstallOptions,
InstallWorkflowOptions,
directory_bundle,
run_bundled_skill_install,
)

result = run_bundled_skill_install(
InstallWorkflowOptions(
install=InstallOptions(
base=BaseOptions(),
app_id="mycli",
skill_bundle=directory_bundle("./skills/mycli"),
scope="user",
),
stdin_tty=True,
prompt_scope=True,
)
)
```

For non-interactive or embedding scenarios, call `install_bundled_skill`, `plan_bundled_skill`, `update_bundled_skill`, or `uninstall_bundled_skill` directly.

## Docs

- [API](docs/API.md)
Expand Down
98 changes: 98 additions & 0 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,104 @@ Implemented functions:
- `uninstall_bundled_skill(options)`
- `INSTALL_UX`

## Python

Package: `kitup`

```python
from kitup import (
BaseOptions,
InstallOptions,
InstallWorkflowOptions,
classify_install_workflow_exit,
compute_bundle_content_hash,
detect_hosts,
directory_bundle,
files_bundle,
github_bundle,
install_bundled_skill,
install_flag_error,
install_workflow_error,
load_host_spec,
parse_install_flags,
parse_scope_flag,
plan_bundled_skill,
resolve_hosts,
resolve_install_selection,
resolve_install_targets,
run_bundled_skill_install,
uninstall_bundled_skill,
update_bundled_skill,
validate_skill_bundle,
)
```

Primitive install call:

```python
from kitup import BaseOptions, InstallOptions, directory_bundle, install_bundled_skill

report = install_bundled_skill(
InstallOptions(
base=BaseOptions(),
app_id="mycli",
skill_bundle=directory_bundle("./skills/mycli"),
scope="user",
)
)
```

Workflow call:

```python
from kitup import (
BaseOptions,
InstallOptions,
InstallWorkflowOptions,
directory_bundle,
run_bundled_skill_install,
)

workflow = run_bundled_skill_install(
InstallWorkflowOptions(
install=InstallOptions(
base=BaseOptions(),
app_id="mycli",
skill_bundle=directory_bundle("./skills/mycli"),
scope="user",
),
stdin_tty=True,
prompt_scope=True,
)
)
```

Implemented functions:

- `load_host_spec(hosts_file=None)`
- `resolve_hosts(agents, hosts)`
- `detect_hosts(options, scope=None)`
- `resolve_install_selection(options)`
- `resolve_install_targets(options, agents, scope, skill_name)`
- `validate_skill_bundle(bundle, cwd=None)`
- `compute_bundle_content_hash(bundle, cwd=None)`
- `directory_bundle(path)`
- `files_bundle(files)`
- `github_bundle(options)`
- `parse_install_flags(flags)`
- `agent_selector_from_flags(values, errors)`
- `parse_scope_flag(value, errors)`
- `classify_install_workflow_exit(report)`
- `install_workflow_error(report)`
- `install_flag_error(errors)`
- `run_bundled_skill_install(options)`
- `run_bundled_skill_install_with_io(options, input, output)`
- `plan_bundled_skill(options)`
- `install_bundled_skill(options)`
- `update_bundled_skill(options)`
- `uninstall_bundled_skill(options)`
- `INSTALL_UX`

## Options

Install options use the same concepts across languages:
Expand Down
11 changes: 7 additions & 4 deletions docs/RELEASE.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
# Release

`kitup` publishes one version across four package surfaces:
`kitup` publishes one version across five package surfaces:

- npm: `@kitup/sdk`
- PyPI: `kitup`
- crates.io: `kitup`
- Go module: `github.com/lathe-cli/kitup/go`
- Go Cobra adapter: `github.com/lathe-cli/kitup/go-cobra`
Expand All @@ -29,6 +30,7 @@ make release-major
The release target creates `release/vX.Y.Z`, updates:

- `ts/package.json`
- `python/pyproject.toml`
- `rust/Cargo.toml`
- `rust/Cargo.lock`
- `examples/rust/Cargo.lock`
Expand All @@ -51,7 +53,7 @@ git push origin vX.Y.Z

Do not tag the release branch. Do not publish packages by hand during the normal flow.

The release workflow publishes npm and crates.io packages, creates the `go/vX.Y.Z` and `go-cobra/vX.Y.Z` tags, creates the GitHub Release, and runs the public install smoke check.
The release workflow publishes npm, PyPI, and crates.io packages, creates the `go/vX.Y.Z` and `go-cobra/vX.Y.Z` tags, creates the GitHub Release, and runs the public install smoke check.

## First npm Release

Expand All @@ -64,13 +66,14 @@ cd ts
npm publish --access public
```

Then rerun the failed release workflow. The workflow detects already-published npm and crate versions and skips them.
Then rerun the failed release workflow. The workflow detects already-published npm, PyPI, and crate versions and skips them.

## Recovery

The release workflow is resumable:

- If npm already has the version, npm publish is skipped.
- If PyPI already has the version, Python build and publish are skipped.
- If crates.io already has the version, crate publish is skipped.
- If `go/vX.Y.Z` or `go-cobra/vX.Y.Z` already exists, the workflow verifies that it points at the release commit.

Expand All @@ -84,4 +87,4 @@ Run the public install smoke check manually with:
scripts/smoke-release.sh X.Y.Z
```

The smoke check installs from npm, 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, 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.
5 changes: 4 additions & 1 deletion examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ tmp="$(mktemp -d)" && mkdir -p "$tmp/.codex" && HOME="$tmp" go run .

cd examples/rust
tmp="$(mktemp -d)" && mkdir -p "$tmp/.codex" && CARGO_HOME="${CARGO_HOME:-$HOME/.cargo}" RUSTUP_HOME="${RUSTUP_HOME:-$HOME/.rustup}" HOME="$tmp" cargo run --quiet

cd examples/python
tmp="$(mktemp -d)" && mkdir -p "$tmp/.codex" && HOME="$tmp" uv run python main.py
```

Production CLIs should call the workflow API before installing so explicit agents, `*`, `--yes`, TTY, non-TTY, summary confirmation, and cancellation behavior stay aligned across languages.
Production CLIs should call the workflow API before installing so explicit agents, `*`, `--yes`, TTY, non-TTY, summary confirmation, scope prompting, and cancellation behavior stay aligned across languages.
17 changes: 17 additions & 0 deletions examples/python/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
from dataclasses import asdict
import json

from kitup import BaseOptions, InstallOptions, directory_bundle, install_bundled_skill


report = install_bundled_skill(
InstallOptions(
base=BaseOptions(),
app_id="kitup-example-python",
skill_bundle=directory_bundle("../../skills/kitup"),
scope="user",
)
)
print(json.dumps(asdict(report)))
if report.errors or report.conflicts:
raise SystemExit(1)
8 changes: 8 additions & 0 deletions examples/python/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
[project]
name = "kitup-example-python"
version = "0.0.0"
requires-python = ">=3.14"
dependencies = ["kitup"]

[tool.uv.sources]
kitup = { path = "../../python" }
Loading