From c4f7b6c4f12efee4b0217812ba8fc213cc68f995 Mon Sep 17 00:00:00 2001 From: spencercjh Date: Wed, 1 Jul 2026 09:43:28 +0000 Subject: [PATCH 01/18] feat(python): scaffold sdk package Signed-off-by: spencercjh --- Makefile | 1 + python/README.md | 3 +++ python/pyproject.toml | 31 ++++++++++++++++++++++++++++ python/src/kitup/__init__.py | 10 +++++++++ python/src/kitup/_hosts_generated.py | 3 +++ python/src/kitup/hosts.py | 27 ++++++++++++++++++++++++ python/src/kitup/types.py | 31 ++++++++++++++++++++++++++++ python/tests/test_hosts.py | 8 +++++++ scripts/sync-hosts.mjs | 9 ++++++++ 9 files changed, 123 insertions(+) create mode 100644 python/README.md create mode 100644 python/pyproject.toml create mode 100644 python/src/kitup/__init__.py create mode 100644 python/src/kitup/_hosts_generated.py create mode 100644 python/src/kitup/hosts.py create mode 100644 python/src/kitup/types.py create mode 100644 python/tests/test_hosts.py diff --git a/Makefile b/Makefile index 3a3ae17..f0eca3a 100644 --- a/Makefile +++ b/Makefile @@ -11,6 +11,7 @@ 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 diff --git a/python/README.md b/python/README.md new file mode 100644 index 0000000..f726d7f --- /dev/null +++ b/python/README.md @@ -0,0 +1,3 @@ +# kitup Python SDK + +Shared installer SDK for bundled Agent Skills. diff --git a/python/pyproject.toml b/python/pyproject.toml new file mode 100644 index 0000000..d1a7ed4 --- /dev/null +++ b/python/pyproject.toml @@ -0,0 +1,31 @@ +[build-system] +requires = ["hatchling>=1.27.0"] +build-backend = "hatchling.build" + +[project] +name = "kitup" +version = "0.1.1" +description = "Shared installer SDK for bundled Agent Skills." +readme = "README.md" +requires-python = ">=3.10" +license = { text = "MIT" } +keywords = ["agent", "skills", "installer", "sdk"] +dependencies = [] + +[project.optional-dependencies] +dev = [ + "pytest>=8.3.0", + "ruff>=0.12.0", +] + +[dependency-groups] +dev = [ + "pytest>=8.3.0", + "ruff>=0.12.0", +] + +[tool.pytest.ini_options] +testpaths = ["tests"] + +[tool.ruff] +target-version = "py310" diff --git a/python/src/kitup/__init__.py b/python/src/kitup/__init__.py new file mode 100644 index 0000000..e6b0575 --- /dev/null +++ b/python/src/kitup/__init__.py @@ -0,0 +1,10 @@ +from .hosts import load_host_spec +from .types import Host, HostSpec, INSTALL_UX, KitupError + +__all__ = [ + "Host", + "HostSpec", + "INSTALL_UX", + "KitupError", + "load_host_spec", +] diff --git a/python/src/kitup/_hosts_generated.py b/python/src/kitup/_hosts_generated.py new file mode 100644 index 0000000..71b68c1 --- /dev/null +++ b/python/src/kitup/_hosts_generated.py @@ -0,0 +1,3 @@ +# Code generated from spec/hosts.json. DO NOT EDIT. + +DEFAULT_HOSTS_SPEC_JSON = "{\"$schema\":\"./hosts.schema.json\",\"schemaVersion\":1,\"hosts\":[{\"id\":\"adal\",\"displayName\":\"AdaL\",\"projectSkillsDirs\":[\".adal/skills\"],\"userSkillsDirs\":[\"~/.adal/skills\"],\"detect\":[\"~/.adal\"],\"status\":\"community\"},{\"id\":\"aider-desk\",\"displayName\":\"AiderDesk\",\"projectSkillsDirs\":[\".aider-desk/skills\"],\"userSkillsDirs\":[\"~/.aider-desk/skills\"],\"detect\":[\"~/.aider-desk\"],\"status\":\"community\"},{\"id\":\"amp\",\"displayName\":\"Amp\",\"projectSkillsDirs\":[\".agents/skills\"],\"userSkillsDirs\":[\"~/.config/agents/skills\"],\"detect\":[\"~/.config/amp\",\"~/.config/agents\"],\"status\":\"community\"},{\"id\":\"antigravity\",\"displayName\":\"Antigravity\",\"projectSkillsDirs\":[\".agents/skills\"],\"userSkillsDirs\":[\"~/.gemini/antigravity/skills\"],\"detect\":[\"~/.gemini/antigravity\"],\"status\":\"community\"},{\"id\":\"antigravity-cli\",\"displayName\":\"Antigravity CLI\",\"projectSkillsDirs\":[\".agents/skills\"],\"userSkillsDirs\":[\"~/.gemini/antigravity-cli/skills\"],\"detect\":[\"~/.gemini/antigravity-cli\"],\"status\":\"community\"},{\"id\":\"astrbot\",\"displayName\":\"AstrBot\",\"projectSkillsDirs\":[\"data/skills\"],\"userSkillsDirs\":[\"~/.astrbot/data/skills\"],\"detect\":[\"~/.astrbot\",\"data/skills\",\"~/.astrbot/data\"],\"status\":\"community\"},{\"id\":\"augment\",\"displayName\":\"Augment\",\"projectSkillsDirs\":[\".augment/skills\"],\"userSkillsDirs\":[\"~/.augment/skills\"],\"detect\":[\"~/.augment\"],\"status\":\"community\"},{\"id\":\"autohand-code\",\"displayName\":\"Autohand Code CLI\",\"projectSkillsDirs\":[\".autohand/skills\"],\"userSkillsDirs\":[\"~/.autohand/skills\"],\"detect\":[\"~/.autohand\"],\"status\":\"community\"},{\"id\":\"bob\",\"displayName\":\"IBM Bob\",\"projectSkillsDirs\":[\".bob/skills\"],\"userSkillsDirs\":[\"~/.bob/skills\"],\"detect\":[\"~/.bob\"],\"status\":\"community\"},{\"id\":\"claude-code\",\"displayName\":\"Claude Code\",\"projectSkillsDirs\":[\".claude/skills\"],\"userSkillsDirs\":[\"~/.claude/skills\"],\"detect\":[\"~/.claude\"],\"status\":\"verified\"},{\"id\":\"cline\",\"displayName\":\"Cline\",\"projectSkillsDirs\":[\".agents/skills\",\".cline/skills\",\".clinerules/skills\",\".claude/skills\"],\"userSkillsDirs\":[\"~/.agents/skills\",\"~/.cline/skills\"],\"detect\":[\"~/.cline\",\"~/.agents\"],\"status\":\"documented\"},{\"id\":\"codearts-agent\",\"displayName\":\"CodeArts Agent\",\"projectSkillsDirs\":[\".codeartsdoer/skills\"],\"userSkillsDirs\":[\"~/.codeartsdoer/skills\"],\"detect\":[\"~/.codeartsdoer\"],\"status\":\"community\"},{\"id\":\"codebuddy\",\"displayName\":\"CodeBuddy\",\"projectSkillsDirs\":[\".codebuddy/skills\"],\"userSkillsDirs\":[\"~/.codebuddy/skills\"],\"detect\":[\"~/.codebuddy\",\".codebuddy\"],\"status\":\"community\"},{\"id\":\"codemaker\",\"displayName\":\"Codemaker\",\"projectSkillsDirs\":[\".codemaker/skills\"],\"userSkillsDirs\":[\"~/.codemaker/skills\"],\"detect\":[\"~/.codemaker\"],\"status\":\"community\"},{\"id\":\"codestudio\",\"displayName\":\"Code Studio\",\"projectSkillsDirs\":[\".codestudio/skills\"],\"userSkillsDirs\":[\"~/.codestudio/skills\"],\"detect\":[\"~/.codestudio\"],\"status\":\"community\"},{\"id\":\"codex\",\"displayName\":\"Codex\",\"projectSkillsDirs\":[\".agents/skills\"],\"userSkillsDirs\":[\"~/.agents/skills\",\"~/.codex/skills\"],\"detect\":[\"~/.codex\",\"~/.agents/skills\",\"~/.agents\"],\"status\":\"verified\",\"notes\":[\"Keep both ~/.agents/skills and ~/.codex/skills for compatibility.\"]},{\"id\":\"command-code\",\"displayName\":\"Command Code\",\"projectSkillsDirs\":[\".commandcode/skills\"],\"userSkillsDirs\":[\"~/.commandcode/skills\"],\"detect\":[\"~/.commandcode\"],\"status\":\"community\"},{\"id\":\"continue\",\"displayName\":\"Continue\",\"projectSkillsDirs\":[\".continue/skills\"],\"userSkillsDirs\":[\"~/.continue/skills\"],\"detect\":[\"~/.continue\",\".continue\"],\"status\":\"community\"},{\"id\":\"cortex\",\"displayName\":\"Cortex Code\",\"projectSkillsDirs\":[\".cortex/skills\"],\"userSkillsDirs\":[\"~/.snowflake/cortex/skills\"],\"detect\":[\"~/.snowflake/cortex\"],\"status\":\"community\"},{\"id\":\"crush\",\"displayName\":\"Crush\",\"projectSkillsDirs\":[\".crush/skills\"],\"userSkillsDirs\":[\"~/.config/crush/skills\"],\"detect\":[\"~/.config/crush\"],\"status\":\"community\"},{\"id\":\"cursor\",\"displayName\":\"Cursor\",\"projectSkillsDirs\":[\".agents/skills\",\".cursor/skills\"],\"userSkillsDirs\":[\"~/.cursor/skills\",\"~/.agents/skills\"],\"detect\":[\"~/.cursor\",\"~/.agents\"],\"status\":\"documented\"},{\"id\":\"deepagents\",\"displayName\":\"Deep Agents\",\"projectSkillsDirs\":[\".agents/skills\"],\"userSkillsDirs\":[\"~/.deepagents/agent/skills\"],\"detect\":[\"~/.deepagents\",\"~/.deepagents/agent\"],\"status\":\"community\"},{\"id\":\"devin\",\"displayName\":\"Devin for Terminal\",\"projectSkillsDirs\":[\".devin/skills\"],\"userSkillsDirs\":[\"~/.config/devin/skills\"],\"detect\":[\"~/.config/devin\"],\"status\":\"community\"},{\"id\":\"dexto\",\"displayName\":\"Dexto\",\"projectSkillsDirs\":[\".agents/skills\"],\"userSkillsDirs\":[\"~/.agents/skills\"],\"detect\":[\"~/.dexto\",\"~/.agents\"],\"status\":\"community\"},{\"id\":\"droid\",\"displayName\":\"Droid\",\"projectSkillsDirs\":[\".factory/skills\"],\"userSkillsDirs\":[\"~/.factory/skills\"],\"detect\":[\"~/.factory\"],\"status\":\"community\"},{\"id\":\"eve\",\"displayName\":\"Eve\",\"projectSkillsDirs\":[\"agent/skills\"],\"userSkillsDirs\":[],\"detect\":[\"agent\",\"package.json\"],\"status\":\"community\",\"notes\":[\"Project-only host; userSkillsDirs is intentionally empty.\",\"Detect from Eve project shape; no global skill directory.\"]},{\"id\":\"firebender\",\"displayName\":\"Firebender\",\"projectSkillsDirs\":[\".agents/skills\"],\"userSkillsDirs\":[\"~/.firebender/skills\"],\"detect\":[\"~/.firebender\"],\"status\":\"community\"},{\"id\":\"forgecode\",\"displayName\":\"ForgeCode\",\"projectSkillsDirs\":[\".forge/skills\"],\"userSkillsDirs\":[\"~/.forge/skills\"],\"detect\":[\"~/.forge\"],\"status\":\"community\"},{\"id\":\"gemini-cli\",\"displayName\":\"Gemini CLI\",\"projectSkillsDirs\":[\".agents/skills\",\".gemini/skills\"],\"userSkillsDirs\":[\"~/.gemini/skills\",\"~/.agents/skills\"],\"detect\":[\"~/.gemini\",\"~/.agents\"],\"status\":\"documented\"},{\"id\":\"github-copilot\",\"displayName\":\"GitHub Copilot\",\"projectSkillsDirs\":[\".agents/skills\",\".github/skills\",\".claude/skills\"],\"userSkillsDirs\":[\"~/.copilot/skills\",\"~/.agents/skills\",\"~/.claude/skills\"],\"detect\":[\"~/.copilot\",\"~/.agents\",\"~/.claude\"],\"status\":\"documented\"},{\"id\":\"goose\",\"displayName\":\"Goose\",\"projectSkillsDirs\":[\".goose/skills\"],\"userSkillsDirs\":[\"~/.config/goose/skills\"],\"detect\":[\"~/.config/goose\"],\"status\":\"community\"},{\"id\":\"hermes-agent\",\"displayName\":\"Hermes Agent\",\"projectSkillsDirs\":[\".hermes/skills\"],\"userSkillsDirs\":[\"~/.hermes/skills\"],\"detect\":[\"~/.hermes\"],\"status\":\"community\"},{\"id\":\"iflow-cli\",\"displayName\":\"iFlow CLI\",\"projectSkillsDirs\":[\".iflow/skills\"],\"userSkillsDirs\":[\"~/.iflow/skills\"],\"detect\":[\"~/.iflow\"],\"status\":\"community\"},{\"id\":\"inference-sh\",\"displayName\":\"inference.sh\",\"projectSkillsDirs\":[\".inferencesh/skills\"],\"userSkillsDirs\":[\"~/.inferencesh/skills\"],\"detect\":[\"~/.inferencesh\"],\"status\":\"community\"},{\"id\":\"jazz\",\"displayName\":\"Jazz\",\"projectSkillsDirs\":[\".jazz/skills\"],\"userSkillsDirs\":[\"~/.jazz/skills\"],\"detect\":[\"~/.jazz\",\".jazz\"],\"status\":\"community\"},{\"id\":\"junie\",\"displayName\":\"Junie\",\"projectSkillsDirs\":[\".junie/skills\"],\"userSkillsDirs\":[\"~/.junie/skills\"],\"detect\":[\"~/.junie\"],\"status\":\"community\"},{\"id\":\"kilo\",\"displayName\":\"Kilo Code\",\"projectSkillsDirs\":[\".kilocode/skills\"],\"userSkillsDirs\":[\"~/.kilocode/skills\"],\"detect\":[\"~/.kilocode\"],\"status\":\"community\"},{\"id\":\"kimi-cli\",\"displayName\":\"Kimi Code CLI\",\"projectSkillsDirs\":[\".agents/skills\"],\"userSkillsDirs\":[\"~/.config/agents/skills\",\"~/.agents/skills\"],\"detect\":[\"~/.config/agents\",\"~/.kimi-code\",\"~/.kimi\",\"~/.agents\"],\"status\":\"community\",\"notes\":[\"kimi-code-cli is an alias for the same Kimi Code CLI path family.\"],\"aliases\":[\"kimi-code-cli\"]},{\"id\":\"kiro-cli\",\"displayName\":\"Kiro CLI\",\"projectSkillsDirs\":[\".kiro/skills\"],\"userSkillsDirs\":[\"~/.kiro/skills\"],\"detect\":[\"~/.kiro\"],\"status\":\"community\"},{\"id\":\"kode\",\"displayName\":\"Kode\",\"projectSkillsDirs\":[\".kode/skills\"],\"userSkillsDirs\":[\"~/.kode/skills\"],\"detect\":[\"~/.kode\"],\"status\":\"community\"},{\"id\":\"lingma\",\"displayName\":\"Lingma\",\"projectSkillsDirs\":[\".lingma/skills\"],\"userSkillsDirs\":[\"~/.lingma/skills\"],\"detect\":[\"~/.lingma\"],\"status\":\"community\"},{\"id\":\"loaf\",\"displayName\":\"Loaf\",\"projectSkillsDirs\":[\".agents/skills\"],\"userSkillsDirs\":[\"~/.agents/skills\"],\"detect\":[\"~/.loaf\",\"~/.agents\"],\"status\":\"community\"},{\"id\":\"mcpjam\",\"displayName\":\"MCPJam\",\"projectSkillsDirs\":[\".mcpjam/skills\"],\"userSkillsDirs\":[\"~/.mcpjam/skills\"],\"detect\":[\"~/.mcpjam\"],\"status\":\"community\"},{\"id\":\"mistral-vibe\",\"displayName\":\"Mistral Vibe\",\"projectSkillsDirs\":[\".vibe/skills\"],\"userSkillsDirs\":[\"~/.vibe/skills\"],\"detect\":[\"~/.vibe\"],\"status\":\"community\"},{\"id\":\"moxby\",\"displayName\":\"Moxby\",\"projectSkillsDirs\":[\".moxby/skills\"],\"userSkillsDirs\":[\"~/.moxby/skills\"],\"detect\":[\"~/.moxby\"],\"status\":\"community\"},{\"id\":\"mux\",\"displayName\":\"Mux\",\"projectSkillsDirs\":[\".mux/skills\"],\"userSkillsDirs\":[\"~/.mux/skills\"],\"detect\":[\"~/.mux\"],\"status\":\"community\"},{\"id\":\"neovate\",\"displayName\":\"Neovate\",\"projectSkillsDirs\":[\".neovate/skills\"],\"userSkillsDirs\":[\"~/.neovate/skills\"],\"detect\":[\"~/.neovate\"],\"status\":\"community\"},{\"id\":\"ona\",\"displayName\":\"Ona\",\"projectSkillsDirs\":[\".ona/skills\"],\"userSkillsDirs\":[\"~/.ona/skills\"],\"detect\":[\"~/.ona\"],\"status\":\"community\"},{\"id\":\"openclaw\",\"displayName\":\"OpenClaw\",\"projectSkillsDirs\":[\"skills\"],\"userSkillsDirs\":[\"~/.openclaw/skills\"],\"detect\":[\"~/.openclaw\",\"~/.clawdbot\",\"~/.moltbot\"],\"status\":\"community\"},{\"id\":\"opencode\",\"displayName\":\"OpenCode\",\"projectSkillsDirs\":[\".agents/skills\",\".opencode/skills\",\".claude/skills\"],\"userSkillsDirs\":[\"~/.config/opencode/skills\",\"~/.agents/skills\",\"~/.claude/skills\"],\"detect\":[\"~/.config/opencode\",\"~/.agents\",\"~/.claude\"],\"status\":\"verified\"},{\"id\":\"openhands\",\"displayName\":\"OpenHands\",\"projectSkillsDirs\":[\".openhands/skills\"],\"userSkillsDirs\":[\"~/.openhands/skills\"],\"detect\":[\"~/.openhands\"],\"status\":\"community\"},{\"id\":\"pi\",\"displayName\":\"Pi\",\"projectSkillsDirs\":[\".pi/skills\"],\"userSkillsDirs\":[\"~/.pi/agent/skills\"],\"detect\":[\"~/.pi/agent\"],\"status\":\"community\"},{\"id\":\"pochi\",\"displayName\":\"Pochi\",\"projectSkillsDirs\":[\".pochi/skills\"],\"userSkillsDirs\":[\"~/.pochi/skills\"],\"detect\":[\"~/.pochi\"],\"status\":\"community\"},{\"id\":\"promptscript\",\"displayName\":\"PromptScript\",\"projectSkillsDirs\":[\".agents/skills\"],\"userSkillsDirs\":[],\"detect\":[\".promptscript\",\"promptscript.yaml\"],\"status\":\"community\",\"notes\":[\"Project-only host; userSkillsDirs is intentionally empty.\"]},{\"id\":\"qoder\",\"displayName\":\"Qoder\",\"projectSkillsDirs\":[\".qoder/skills\"],\"userSkillsDirs\":[\"~/.qoder/skills\"],\"detect\":[\"~/.qoder\"],\"status\":\"community\"},{\"id\":\"qoder-cn\",\"displayName\":\"Qoder CN\",\"projectSkillsDirs\":[\".qoder/skills\"],\"userSkillsDirs\":[\"~/.qoder-cn/skills\"],\"detect\":[\"~/.qoder-cn\"],\"status\":\"community\"},{\"id\":\"qwen-code\",\"displayName\":\"Qwen Code\",\"projectSkillsDirs\":[\".qwen/skills\"],\"userSkillsDirs\":[\"~/.qwen/skills\"],\"detect\":[\"~/.qwen\"],\"status\":\"community\"},{\"id\":\"reasonix\",\"displayName\":\"Reasonix\",\"projectSkillsDirs\":[\".reasonix/skills\"],\"userSkillsDirs\":[\"~/.reasonix/skills\"],\"detect\":[\"~/.reasonix\"],\"status\":\"community\"},{\"id\":\"replit\",\"displayName\":\"Replit\",\"projectSkillsDirs\":[\".agents/skills\"],\"userSkillsDirs\":[\"~/.config/agents/skills\"],\"detect\":[\".replit\",\"~/.config/agents\"],\"status\":\"community\"},{\"id\":\"roo\",\"displayName\":\"Roo Code\",\"aliases\":[\"roo-code\"],\"projectSkillsDirs\":[\".roo/skills\",\".agents/skills\"],\"userSkillsDirs\":[\"~/.roo/skills\",\"~/.agents/skills\"],\"detect\":[\"~/.roo\",\"~/.agents\"],\"status\":\"documented\"},{\"id\":\"rovodev\",\"displayName\":\"Rovo Dev\",\"projectSkillsDirs\":[\".rovodev/skills\"],\"userSkillsDirs\":[\"~/.rovodev/skills\"],\"detect\":[\"~/.rovodev\"],\"status\":\"community\"},{\"id\":\"tabnine-cli\",\"displayName\":\"Tabnine CLI\",\"projectSkillsDirs\":[\".tabnine/agent/skills\"],\"userSkillsDirs\":[\"~/.tabnine/agent/skills\"],\"detect\":[\"~/.tabnine\",\"~/.tabnine/agent\"],\"status\":\"community\"},{\"id\":\"terramind\",\"displayName\":\"Terramind\",\"projectSkillsDirs\":[\".terramind/skills\"],\"userSkillsDirs\":[\"~/.terramind/skills\"],\"detect\":[\"~/.terramind\"],\"status\":\"community\"},{\"id\":\"tinycloud\",\"displayName\":\"Tinycloud\",\"projectSkillsDirs\":[\".tinycloud/skills\"],\"userSkillsDirs\":[\"~/.tinycloud/skills\"],\"detect\":[\"~/.tinycloud\"],\"status\":\"community\"},{\"id\":\"trae\",\"displayName\":\"Trae\",\"projectSkillsDirs\":[\".trae/skills\"],\"userSkillsDirs\":[\"~/.trae/skills\"],\"detect\":[\"~/.trae\"],\"status\":\"community\"},{\"id\":\"trae-cn\",\"displayName\":\"Trae CN\",\"projectSkillsDirs\":[\".trae/skills\"],\"userSkillsDirs\":[\"~/.trae-cn/skills\"],\"detect\":[\"~/.trae-cn\"],\"status\":\"community\"},{\"id\":\"universal\",\"displayName\":\"Universal\",\"projectSkillsDirs\":[\".agents/skills\"],\"userSkillsDirs\":[\"~/.agents/skills\",\"~/.config/agents/skills\"],\"detect\":[\"~/.agents\",\"~/.config/agents\"],\"status\":\"community\"},{\"id\":\"warp\",\"displayName\":\"Warp\",\"projectSkillsDirs\":[\".agents/skills\",\".warp/skills\",\".claude/skills\",\".codex/skills\",\".cursor/skills\",\".gemini/skills\",\".copilot/skills\",\".factory/skills\",\".github/skills\",\".opencode/skills\"],\"userSkillsDirs\":[\"~/.agents/skills\",\"~/.warp/skills\",\"~/.claude/skills\",\"~/.codex/skills\",\"~/.cursor/skills\",\"~/.gemini/skills\",\"~/.copilot/skills\",\"~/.factory/skills\",\"~/.github/skills\",\"~/.opencode/skills\"],\"detect\":[\"~/.warp\",\"~/.agents\",\"~/.claude\",\"~/.codex\",\"~/.cursor\",\"~/.gemini\",\"~/.copilot\",\"~/.factory\",\"~/.github\",\"~/.opencode\"],\"status\":\"documented\"},{\"id\":\"windsurf\",\"displayName\":\"Windsurf\",\"projectSkillsDirs\":[\".windsurf/skills\"],\"userSkillsDirs\":[\"~/.codeium/windsurf/skills\"],\"detect\":[\"~/.codeium/windsurf\"],\"status\":\"community\"},{\"id\":\"zed\",\"displayName\":\"Zed\",\"projectSkillsDirs\":[\".agents/skills\"],\"userSkillsDirs\":[\"~/.agents/skills\"],\"detect\":[\"~/.config/zed\",\"~/.agents\"],\"status\":\"community\"},{\"id\":\"zencoder\",\"displayName\":\"Zencoder\",\"projectSkillsDirs\":[\".zencoder/skills\"],\"userSkillsDirs\":[\"~/.zencoder/skills\"],\"detect\":[\"~/.zencoder\"],\"status\":\"community\"},{\"id\":\"zenflow\",\"displayName\":\"Zenflow\",\"projectSkillsDirs\":[\".zencoder/skills\"],\"userSkillsDirs\":[\"~/.zencoder/skills\"],\"detect\":[\"~/.zencoder\"],\"status\":\"community\"}]}" diff --git a/python/src/kitup/hosts.py b/python/src/kitup/hosts.py new file mode 100644 index 0000000..676f3f9 --- /dev/null +++ b/python/src/kitup/hosts.py @@ -0,0 +1,27 @@ +import json +from pathlib import Path + +from ._hosts_generated import DEFAULT_HOSTS_SPEC_JSON +from .types import Host, HostSpec + + +def load_host_spec(hosts_file: str | None = None) -> HostSpec: + raw = json.loads( + Path(hosts_file).read_text() if hosts_file else DEFAULT_HOSTS_SPEC_JSON + ) + return HostSpec( + schema_version=raw["schemaVersion"], + hosts=[ + Host( + id=item["id"], + display_name=item["displayName"], + aliases=item.get("aliases", []), + project_skills_dirs=item["projectSkillsDirs"], + user_skills_dirs=item["userSkillsDirs"], + detect=item["detect"], + status=item["status"], + notes=item.get("notes", []), + ) + for item in raw["hosts"] + ], + ) diff --git a/python/src/kitup/types.py b/python/src/kitup/types.py new file mode 100644 index 0000000..a20c331 --- /dev/null +++ b/python/src/kitup/types.py @@ -0,0 +1,31 @@ +from dataclasses import dataclass, field + + +class KitupError(Exception): + pass + + +@dataclass(frozen=True) +class Host: + id: str + display_name: str + project_skills_dirs: list[str] + user_skills_dirs: list[str] + detect: list[str] + status: str + aliases: list[str] = field(default_factory=list) + notes: list[str] = field(default_factory=list) + + +@dataclass(frozen=True) +class HostSpec: + hosts: list[Host] + schema_version: int = 1 + + +INSTALL_UX = { + "skill_use": "skill", + "install_use": "install", + "scope_prompt": "Scope (user/project)", + "agents_prompt": "Agents (numbers, ids, comma-separated, empty cancels)", +} diff --git a/python/tests/test_hosts.py b/python/tests/test_hosts.py new file mode 100644 index 0000000..2f43877 --- /dev/null +++ b/python/tests/test_hosts.py @@ -0,0 +1,8 @@ +from kitup import load_host_spec + + +def test_load_host_spec_uses_baked_default_when_no_override(): + spec = load_host_spec() + assert spec.schema_version == 1 + assert len(spec.hosts) == 72 + assert spec.hosts[0].id == "adal" diff --git a/scripts/sync-hosts.mjs b/scripts/sync-hosts.mjs index 22d9ab5..9fd5eb0 100644 --- a/scripts/sync-hosts.mjs +++ b/scripts/sync-hosts.mjs @@ -18,6 +18,15 @@ const files = new Map([ "rust/src/hosts_generated.rs", `// Code generated from spec/hosts.json. DO NOT EDIT.\n\npub(crate) const DEFAULT_HOSTS_SPEC_JSON: &str = ${rustString(hosts)};\n`, ], + [ + "python/src/kitup/_hosts_generated.py", + [ + "# Code generated from spec/hosts.json. DO NOT EDIT.", + "", + `DEFAULT_HOSTS_SPEC_JSON = ${JSON.stringify(hosts)}`, + "", + ].join("\n"), + ], ]); let drifted = false; From 86ba46331f8d9ab1140daf691176dbeb041b312c Mon Sep 17 00:00:00 2001 From: spencercjh Date: Wed, 1 Jul 2026 09:51:09 +0000 Subject: [PATCH 02/18] feat(python): add bundle primitives Signed-off-by: spencercjh --- python/src/kitup/__init__.py | 29 ++++++- python/src/kitup/_github.py | 89 +++++++++++++++++++ python/src/kitup/_paths.py | 40 +++++++++ python/src/kitup/bundle.py | 155 +++++++++++++++++++++++++++++++++ python/src/kitup/types.py | 45 ++++++++++ python/tests/test_bundle.py | 160 +++++++++++++++++++++++++++++++++++ 6 files changed, 517 insertions(+), 1 deletion(-) create mode 100644 python/src/kitup/_github.py create mode 100644 python/src/kitup/_paths.py create mode 100644 python/src/kitup/bundle.py create mode 100644 python/tests/test_bundle.py diff --git a/python/src/kitup/__init__.py b/python/src/kitup/__init__.py index e6b0575..26cf001 100644 --- a/python/src/kitup/__init__.py +++ b/python/src/kitup/__init__.py @@ -1,10 +1,37 @@ +from .bundle import ( + compute_bundle_content_hash, + directory_bundle, + files_bundle, + github_bundle, + validate_skill_bundle, +) from .hosts import load_host_spec -from .types import Host, HostSpec, INSTALL_UX, KitupError +from .types import ( + BundleFile, + GitHubBundleOptions, + Host, + HostSpec, + INSTALL_UX, + KitupError, + NormalizedSkillBundle, + SkillFile, + SkillInfo, +) __all__ = [ + "BundleFile", + "GitHubBundleOptions", "Host", "HostSpec", "INSTALL_UX", "KitupError", + "NormalizedSkillBundle", + "SkillFile", + "SkillInfo", + "compute_bundle_content_hash", + "directory_bundle", + "files_bundle", + "github_bundle", "load_host_spec", + "validate_skill_bundle", ] diff --git a/python/src/kitup/_github.py b/python/src/kitup/_github.py new file mode 100644 index 0000000..45fc4d6 --- /dev/null +++ b/python/src/kitup/_github.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +import json +import os +import urllib.parse +import urllib.request + +from ._paths import trim_github_path +from .types import GitHubBundleOptions, KitupError, SkillFile + + +def fetch_github_directory(options: GitHubBundleOptions) -> list[SkillFile]: + root = trim_github_path(options.path) + if not options.owner or not options.repo or not root or not options.ref: + raise KitupError("invalid github bundle") + + api_base = _env_base_url("KITUP_GITHUB_API_BASE_URL", "https://api.github.com") + raw_base = _env_base_url( + "KITUP_GITHUB_RAW_BASE_URL", + "https://raw.githubusercontent.com", + ) + + commit = github_json( + f"{api_base}/repos/{_encode_path_part(options.owner)}/" + f"{_encode_path_part(options.repo)}/commits/{_encode_path_part(options.ref)}" + ) + resolved_commit = str(commit.get("sha") or "") + tree_sha = str(((commit.get("commit") or {}).get("tree") or {}).get("sha") or "") + if not resolved_commit or not tree_sha: + raise KitupError("invalid github commit") + + tree = github_json( + f"{api_base}/repos/{_encode_path_part(options.owner)}/" + f"{_encode_path_part(options.repo)}/git/trees/{_encode_path_part(tree_sha)}" + "?recursive=1" + ) + + prefix = f"{root}/" + files: list[SkillFile] = [] + for item in tree.get("tree") or []: + if not isinstance(item, dict): + continue + path = str(item.get("path") or "") + if item.get("type") != "blob" or not path.startswith(prefix): + continue + url = ( + f"{raw_base}/{_encode_path_part(options.owner)}/" + f"{_encode_path_part(options.repo)}/" + f"{_encode_path_part(resolved_commit)}/{_encode_path(path)}" + ) + files.append( + SkillFile( + path=path[len(prefix) :], + contents=github_bytes(url), + mode=0o755 if item.get("mode") == "100755" else 0o644, + ) + ) + + if not files: + raise KitupError("github bundle path not found") + + return files + + +def github_json(url: str) -> dict[str, object]: + with urllib.request.urlopen(_request(url), timeout=30) as response: + return json.loads(response.read().decode("utf-8")) + + +def github_bytes(url: str) -> bytes: + with urllib.request.urlopen(_request(url), timeout=30) as response: + return response.read() + + +def _request(url: str) -> urllib.request.Request: + return urllib.request.Request(url, headers={"User-Agent": "kitup"}) + + +def _env_base_url(name: str, fallback: str) -> str: + value = os.environ.get(name, "").rstrip("/") + return value or fallback + + +def _encode_path(path: str) -> str: + return "/".join(_encode_path_part(part) for part in path.split("/")) + + +def _encode_path_part(part: str) -> str: + return urllib.parse.quote(part, safe="") diff --git a/python/src/kitup/_paths.py b/python/src/kitup/_paths.py new file mode 100644 index 0000000..fee6b2a --- /dev/null +++ b/python/src/kitup/_paths.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from pathlib import Path + +from .types import KitupError + + +def skip_name(name: str) -> bool: + return ( + name == ".git" + or name == ".kitup.json" + or name == ".DS_Store" + or name.endswith(".swp") + or name.endswith("~") + ) + + +def normalize_bundle_path(value: str) -> str | None: + if not value or "\\" in value or value.startswith("/") or value[1:2] == ":": + raise KitupError(f"invalid skill file path: {value}") + + parts = value.split("/") + for part in parts: + if not part or part in {".", ".."}: + raise KitupError(f"invalid skill file path: {value}") + if skip_name(part): + return None + + return "/".join(parts) + + +def resolve_path(path: str, *, cwd: str | None = None) -> Path: + resolved = Path(path) + if not resolved.is_absolute() and cwd is not None: + resolved = Path(cwd) / resolved + return resolved + + +def trim_github_path(path: str) -> str: + return path.strip("/") diff --git a/python/src/kitup/bundle.py b/python/src/kitup/bundle.py new file mode 100644 index 0000000..fa5d39a --- /dev/null +++ b/python/src/kitup/bundle.py @@ -0,0 +1,155 @@ +from __future__ import annotations + +import hashlib +import os +import re +from dataclasses import dataclass +from pathlib import Path + +from ._github import fetch_github_directory +from ._paths import normalize_bundle_path, resolve_path, skip_name +from .types import ( + BundleFile, + GitHubBundleOptions, + KitupError, + NormalizedSkillBundle, + SkillFile, + SkillInfo, +) + + +@dataclass(frozen=True) +class DirectoryBundle: + path: str + + +@dataclass(frozen=True) +class FilesBundle: + files: list[SkillFile] + + +@dataclass(frozen=True) +class GitHubBundle: + options: GitHubBundleOptions + + +SkillBundle = DirectoryBundle | FilesBundle | GitHubBundle + + +def directory_bundle(path: str) -> DirectoryBundle: + return DirectoryBundle(path=path) + + +def files_bundle(files: list[SkillFile]) -> FilesBundle: + return FilesBundle(files=files) + + +def github_bundle(options: GitHubBundleOptions) -> GitHubBundle: + return GitHubBundle(options=options) + + +def validate_skill_bundle(bundle: SkillBundle, cwd: str | None = None) -> SkillInfo: + try: + normalized = normalize_skill_bundle(bundle, cwd=cwd) + except Exception: + return SkillInfo(valid=False, error_code="invalid-skill-bundle") + + skill_md = normalized.by_path.get("SKILL.md") + if skill_md is None: + return SkillInfo(valid=False, error_code="missing-skill-md") + + try: + content = skill_md.bytes.decode("utf-8") + except UnicodeDecodeError: + return SkillInfo(valid=False, error_code="invalid-frontmatter") + + match = re.match(r"^---\r?\n([\s\S]*?)\r?\n---\r?\n", content) + if match is None: + return SkillInfo(valid=False, error_code="invalid-frontmatter") + + fields = _parse_frontmatter(match.group(1)) + skill_name = fields.get("name", "") + description = fields.get("description", "") + if not _valid_skill_name(skill_name) or not description or len(description) > 1024: + return SkillInfo(valid=False, error_code="invalid-frontmatter") + + return SkillInfo(valid=True, skill_name=skill_name, description=description) + + +def compute_bundle_content_hash(bundle: SkillBundle, cwd: str | None = None) -> str: + normalized = normalize_skill_bundle(bundle, cwd=cwd) + digest = hashlib.sha256() + for file in normalized.files: + digest.update(file.path.encode("utf-8")) + digest.update(b"\0") + digest.update(file.bytes) + digest.update(b"\0") + return f"sha256:{digest.hexdigest()}" + + +def normalize_skill_bundle(bundle: SkillBundle, cwd: str | None = None) -> NormalizedSkillBundle: + if isinstance(bundle, DirectoryBundle): + return normalize_directory_bundle(bundle.path, cwd=cwd) + if isinstance(bundle, FilesBundle): + return normalize_files_bundle(bundle.files) + if isinstance(bundle, GitHubBundle): + return normalize_files_bundle(fetch_github_directory(bundle.options)) + raise KitupError(f"unsupported bundle: {type(bundle)!r}") + + +def normalize_directory_bundle(path: str, cwd: str | None = None) -> NormalizedSkillBundle: + root = resolve_path(path, cwd=cwd) + if not root.is_dir(): + raise KitupError(f"invalid bundle directory: {root}") + files: list[SkillFile] = [] + + for current_root, dirnames, filenames in os.walk(root): + dirnames[:] = sorted(name for name in dirnames if not skip_name(name)) + for filename in sorted(filenames): + if skip_name(filename): + continue + source = Path(current_root) / filename + relative = source.relative_to(root).as_posix() + files.append( + SkillFile( + path=relative, + contents=source.read_bytes(), + mode=source.stat().st_mode & 0o777, + ) + ) + + return normalize_files_bundle(files) + + +def normalize_files_bundle(files: list[SkillFile]) -> NormalizedSkillBundle: + by_path: dict[str, BundleFile] = {} + for file in files: + normalized_path = normalize_bundle_path(file.path) + if normalized_path is None: + continue + if normalized_path in by_path: + raise KitupError(f"duplicate skill file: {normalized_path}") + by_path[normalized_path] = BundleFile( + path=normalized_path, + bytes=file.contents.encode("utf-8") + if isinstance(file.contents, str) + else file.contents, + mode=file.mode or 0o644, + ) + + normalized_files = [by_path[path] for path in sorted(by_path)] + return NormalizedSkillBundle(files=normalized_files, by_path=by_path) + + +def _parse_frontmatter(content: str) -> dict[str, str]: + fields: dict[str, str] = {} + for line in content.splitlines(): + if ":" not in line: + continue + key, value = line.split(":", 1) + fields[key] = value.strip() + return fields + + +def _valid_skill_name(name: str) -> bool: + return re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", name) is not None diff --git a/python/src/kitup/types.py b/python/src/kitup/types.py index a20c331..9e90c92 100644 --- a/python/src/kitup/types.py +++ b/python/src/kitup/types.py @@ -1,10 +1,14 @@ from dataclasses import dataclass, field +from typing import Literal class KitupError(Exception): pass +Scope = Literal["user", "project"] + + @dataclass(frozen=True) class Host: id: str @@ -23,6 +27,47 @@ class HostSpec: schema_version: int = 1 +@dataclass(frozen=True) +class SkillInfo: + valid: bool + skill_name: str | None = None + description: str | None = None + error_code: Literal[ + "missing-skill-md", + "invalid-frontmatter", + "invalid-skill-bundle", + ] | None = None + + +@dataclass(frozen=True) +class SkillFile: + path: str + contents: str | bytes + mode: int = 0o644 + + +@dataclass(frozen=True) +class GitHubBundleOptions: + owner: str + repo: str + path: str + ref: str + + +@dataclass(frozen=True) +class BundleFile: + path: str + bytes: bytes + mode: int = 0o644 + + +@dataclass(frozen=True) +class NormalizedSkillBundle: + files: list[BundleFile] + by_path: dict[str, BundleFile] + label: str | None = None + + INSTALL_UX = { "skill_use": "skill", "install_use": "install", diff --git a/python/tests/test_bundle.py b/python/tests/test_bundle.py new file mode 100644 index 0000000..5a944ec --- /dev/null +++ b/python/tests/test_bundle.py @@ -0,0 +1,160 @@ +import hashlib + +from kitup import ( + compute_bundle_content_hash, + directory_bundle, + files_bundle, + github_bundle, + validate_skill_bundle, +) +from kitup.types import GitHubBundleOptions, SkillFile + + +def _skill_md(*, name: str = "basic", description: str = "demo") -> str: + return f"---\nname: {name}\ndescription: {description}\n---\n" + + +def test_validate_skill_bundle_rejects_parent_segments(): + bundle = files_bundle([SkillFile(path="../bad.txt", contents="x")]) + + result = validate_skill_bundle(bundle) + + assert result.valid is False + assert result.error_code == "invalid-skill-bundle" + + +def test_validate_skill_bundle_rejects_missing_directory(tmp_path): + bundle = directory_bundle(str(tmp_path / "missing")) + + result = validate_skill_bundle(bundle) + + assert result.valid is False + assert result.error_code == "invalid-skill-bundle" + + +def test_validate_skill_bundle_requires_frontmatter(): + bundle = files_bundle([SkillFile(path="SKILL.md", contents="name: basic\n")]) + + result = validate_skill_bundle(bundle) + + assert result.valid is False + assert result.error_code == "invalid-frontmatter" + + +def test_validate_skill_bundle_accepts_valid_skill_file(): + bundle = files_bundle([SkillFile(path="SKILL.md", contents=_skill_md())]) + + result = validate_skill_bundle(bundle) + + assert result.valid is True + assert result.skill_name == "basic" + assert result.description == "demo" + assert result.error_code is None + + +def test_compute_bundle_content_hash_ignores_kitup_metadata(tmp_path): + root = tmp_path / "skill" + root.mkdir() + (root / "SKILL.md").write_text(_skill_md(), encoding="utf-8") + (root / ".kitup.json").write_text('{"ignored": true}', encoding="utf-8") + + with_metadata = compute_bundle_content_hash(directory_bundle(str(root))) + + (root / ".kitup.json").write_text('{"ignored": false}', encoding="utf-8") + without_metadata = compute_bundle_content_hash(directory_bundle(str(root))) + + expected = "sha256:" + hashlib.sha256(b"SKILL.md\x00" + _skill_md().encode("utf-8") + b"\x00").hexdigest() + assert with_metadata == without_metadata == expected + + +def test_compute_bundle_content_hash_ignores_editor_junk_files(tmp_path): + root = tmp_path / "skill" + root.mkdir() + (root / "SKILL.md").write_text(_skill_md(), encoding="utf-8") + (root / "notes.txt~").write_text("backup", encoding="utf-8") + (root / "scratch.swp").write_text("swap", encoding="utf-8") + (root / ".DS_Store").write_text("junk", encoding="utf-8") + + digest = compute_bundle_content_hash(directory_bundle(str(root))) + + expected = "sha256:" + hashlib.sha256(b"SKILL.md\x00" + _skill_md().encode("utf-8") + b"\x00").hexdigest() + assert digest == expected + + +def test_github_bundle_uses_fetched_relative_paths(monkeypatch): + class _Response: + def __init__(self, payload: bytes): + self._payload = payload + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def read(self) -> bytes: + return self._payload + + payloads = { + "https://api.github.com/repos/acme/skills/commits/v1": ( + b'{"sha":"abc123","commit":{"tree":{"sha":"tree123"}}}' + ), + "https://api.github.com/repos/acme/skills/git/trees/tree123?recursive=1": ( + b'{"tree":[{"path":"skills/basic/SKILL.md","type":"blob","mode":"100644"},' + b'{"path":"skills/basic/bin/run.sh","type":"blob","mode":"100755"},' + b'{"path":"skills/other/SKILL.md","type":"blob","mode":"100644"}]}' + ), + "https://raw.githubusercontent.com/acme/skills/abc123/skills/basic/SKILL.md": _skill_md().encode( + "utf-8" + ), + "https://raw.githubusercontent.com/acme/skills/abc123/skills/basic/bin/run.sh": b"#!/bin/sh\n", + } + + def fake_urlopen(url, timeout=30): + key = getattr(url, "full_url", url) + payload = payloads.get(key) + if payload is None: + raise AssertionError(f"unexpected network call: {key!r} timeout={timeout!r}") + return _Response(payload) + + monkeypatch.setattr("kitup._github.urllib.request.urlopen", fake_urlopen) + + bundle = github_bundle( + GitHubBundleOptions(owner="acme", repo="skills", path="skills/basic", ref="v1") + ) + + result = validate_skill_bundle(bundle) + + assert result.valid is True + digest = compute_bundle_content_hash(bundle) + expected = hashlib.sha256() + expected.update(b"SKILL.md\x00") + expected.update(_skill_md().encode("utf-8")) + expected.update(b"\x00") + expected.update(b"bin/run.sh\x00") + expected.update(b"#!/bin/sh\n") + expected.update(b"\x00") + assert digest == f"sha256:{expected.hexdigest()}" + + +def test_validate_skill_bundle_rejects_duplicate_paths(): + bundle = files_bundle( + [ + SkillFile(path="SKILL.md", contents=_skill_md()), + SkillFile(path="nested/../SKILL.md", contents=_skill_md(description="other")), + ] + ) + + result = validate_skill_bundle(bundle) + + assert result.valid is False + assert result.error_code == "invalid-skill-bundle" + + +def test_validate_skill_bundle_rejects_github_bundle_without_root_path(): + bundle = github_bundle(GitHubBundleOptions(owner="acme", repo="skills", path="/", ref="main")) + + result = validate_skill_bundle(bundle) + + assert result.valid is False + assert result.error_code == "invalid-skill-bundle" From 60e18f3d0b7618a54d8718fca7eb47f4375e88aa Mon Sep 17 00:00:00 2001 From: spencercjh Date: Wed, 1 Jul 2026 09:56:14 +0000 Subject: [PATCH 03/18] feat(python): add bundle primitives Signed-off-by: spencercjh --- python/src/kitup/__init__.py | 17 +---------------- python/tests/test_bundle.py | 2 +- 2 files changed, 2 insertions(+), 17 deletions(-) diff --git a/python/src/kitup/__init__.py b/python/src/kitup/__init__.py index 26cf001..9c7cce0 100644 --- a/python/src/kitup/__init__.py +++ b/python/src/kitup/__init__.py @@ -6,28 +6,13 @@ validate_skill_bundle, ) from .hosts import load_host_spec -from .types import ( - BundleFile, - GitHubBundleOptions, - Host, - HostSpec, - INSTALL_UX, - KitupError, - NormalizedSkillBundle, - SkillFile, - SkillInfo, -) +from .types import Host, HostSpec, INSTALL_UX, KitupError __all__ = [ - "BundleFile", - "GitHubBundleOptions", "Host", "HostSpec", "INSTALL_UX", "KitupError", - "NormalizedSkillBundle", - "SkillFile", - "SkillInfo", "compute_bundle_content_hash", "directory_bundle", "files_bundle", diff --git a/python/tests/test_bundle.py b/python/tests/test_bundle.py index 5a944ec..c37a265 100644 --- a/python/tests/test_bundle.py +++ b/python/tests/test_bundle.py @@ -141,7 +141,7 @@ def test_validate_skill_bundle_rejects_duplicate_paths(): bundle = files_bundle( [ SkillFile(path="SKILL.md", contents=_skill_md()), - SkillFile(path="nested/../SKILL.md", contents=_skill_md(description="other")), + SkillFile(path="SKILL.md", contents=_skill_md(description="other")), ] ) From 06815c32737ab683c5cf68785e7f86cba60124a8 Mon Sep 17 00:00:00 2001 From: spencercjh Date: Wed, 1 Jul 2026 10:05:06 +0000 Subject: [PATCH 04/18] feat(python): add host resolution Signed-off-by: spencercjh --- python/src/kitup/__init__.py | 10 ++- python/src/kitup/hosts.py | 73 +++++++++++++++++++- python/src/kitup/install.py | 63 +++++++++++++++++ python/src/kitup/types.py | 37 ++++++++++ python/tests/test_hosts.py | 79 ++++++++++++++++++++- python/tests/test_install.py | 130 +++++++++++++++++++++++++++++++++++ 6 files changed, 388 insertions(+), 4 deletions(-) create mode 100644 python/src/kitup/install.py create mode 100644 python/tests/test_install.py diff --git a/python/src/kitup/__init__.py b/python/src/kitup/__init__.py index 9c7cce0..9da8c01 100644 --- a/python/src/kitup/__init__.py +++ b/python/src/kitup/__init__.py @@ -5,18 +5,24 @@ github_bundle, validate_skill_bundle, ) -from .hosts import load_host_spec -from .types import Host, HostSpec, INSTALL_UX, KitupError +from .hosts import detect_hosts, load_host_spec, resolve_hosts +from .install import resolve_install_targets +from .types import BaseOptions, Host, HostSpec, INSTALL_UX, KitupError, TargetGroup __all__ = [ + "BaseOptions", "Host", "HostSpec", "INSTALL_UX", "KitupError", + "TargetGroup", "compute_bundle_content_hash", + "detect_hosts", "directory_bundle", "files_bundle", "github_bundle", "load_host_spec", + "resolve_hosts", + "resolve_install_targets", "validate_skill_bundle", ] diff --git a/python/src/kitup/hosts.py b/python/src/kitup/hosts.py index 676f3f9..655f022 100644 --- a/python/src/kitup/hosts.py +++ b/python/src/kitup/hosts.py @@ -2,7 +2,9 @@ from pathlib import Path from ._hosts_generated import DEFAULT_HOSTS_SPEC_JSON -from .types import Host, HostSpec +from .types import BaseOptions, Host, HostSpec, Scope + +_GENERIC_DETECT_PATHS = {"~/.agents", "~/.agents/skills", "~/.config/agents"} def load_host_spec(hosts_file: str | None = None) -> HostSpec: @@ -25,3 +27,72 @@ def load_host_spec(hosts_file: str | None = None) -> HostSpec: for item in raw["hosts"] ], ) + + +def resolve_hosts( + agents: str | list[str] | None, hosts: list[Host] +) -> tuple[list[Host], list[dict[str, str]]]: + if agents == "*": + return list(hosts), [] + if agents in (None, "auto"): + return [], [] + + ids = [agents] if isinstance(agents, str) else list(agents) + by_name: dict[str, Host] = {} + for host in hosts: + by_name[host.id] = host + for alias in host.aliases: + by_name[alias] = host + + seen: set[str] = set() + resolved: list[Host] = [] + errors: list[dict[str, str]] = [] + for host_id in ids: + host = by_name.get(host_id) + if host is None: + errors.append({"agent": host_id, "reason": "unknown-host"}) + continue + if host.id in seen: + continue + seen.add(host.id) + resolved.append(host) + return resolved, errors + + +def detect_hosts(options: BaseOptions, scope: Scope | None = None) -> list[Host]: + spec = load_host_spec(options.hosts_file) + home = Path(options.home).expanduser() if options.home else Path.home() + cwd = Path(options.cwd) if options.cwd else Path.cwd() + detected: list[Host] = [] + for host in spec.hosts: + if not host.detect: + continue + first_detect = host.detect[0] + if first_detect in _GENERIC_DETECT_PATHS: + continue + if _expand_host_path(first_detect, home=home, cwd=cwd).exists(): + detected.append(host) + + if scope is not None: + detected.sort( + key=lambda host: ( + str(_canonical_scope_path(host, scope=scope, home=home, cwd=cwd) or ""), + host.id, + ) + ) + return detected + + +def _canonical_scope_path( + host: Host, *, scope: Scope, home: Path, cwd: Path +) -> Path | None: + paths = host.user_skills_dirs if scope == "user" else host.project_skills_dirs + if not paths: + return None + return _expand_host_path(paths[0], home=home, cwd=cwd) + + +def _expand_host_path(path: str, *, home: Path, cwd: Path) -> Path: + if path.startswith("~/"): + return home / path[2:] + return cwd / path diff --git a/python/src/kitup/install.py b/python/src/kitup/install.py new file mode 100644 index 0000000..593932a --- /dev/null +++ b/python/src/kitup/install.py @@ -0,0 +1,63 @@ +from pathlib import Path + +from .hosts import detect_hosts, load_host_spec, resolve_hosts +from .types import BaseOptions, Host, Scope, TargetGroup + + +def expand_host_path(path: str, *, home: Path, cwd: Path) -> Path: + if path.startswith("~/"): + return home / path[2:] + return cwd / path + + +def choose_scope_path(host: Host, *, scope: Scope, home: Path, cwd: Path) -> Path | None: + paths = host.user_skills_dirs if scope == "user" else host.project_skills_dirs + for path in paths: + expanded = expand_host_path(path, home=home, cwd=cwd) + if expanded.exists(): + return expanded + if not paths: + return None + return expand_host_path(paths[0], home=home, cwd=cwd) + + +def resolve_install_targets( + options: BaseOptions, + agents: str | list[str] | None, + scope: Scope, + skill_name: str, +) -> tuple[list[TargetGroup], list[dict[str, str]], list[str]]: + spec = load_host_spec(options.hosts_file) + home = Path(options.home).expanduser() if options.home else Path.home() + cwd = Path(options.cwd) if options.cwd else Path.cwd() + selected: list[Host] + errors: list[dict[str, str]] + if agents in (None, "auto"): + selected = detect_hosts(options, scope) + errors = [] + else: + selected, errors = resolve_hosts(agents, spec.hosts) + + by_target: dict[str, TargetGroup] = {} + for host in selected: + root = choose_scope_path(host, scope=scope, home=home, cwd=cwd) + if root is None: + errors.append( + { + "hostId": host.id, + "skillName": skill_name, + "scope": scope, + "reason": "unsupported-scope", + } + ) + continue + target_dir = str(root / skill_name) + group = by_target.get(target_dir) + if group is None: + group = TargetGroup(skill_name=skill_name, target_dir=target_dir) + by_target[target_dir] = group + group.host_ids.append(host.id) + + targets = [by_target[path] for path in sorted(by_target)] + detected_host_ids = [host_id for target in targets for host_id in target.host_ids] + return targets, errors, detected_host_ids diff --git a/python/src/kitup/types.py b/python/src/kitup/types.py index 9e90c92..f188719 100644 --- a/python/src/kitup/types.py +++ b/python/src/kitup/types.py @@ -9,6 +9,13 @@ class KitupError(Exception): Scope = Literal["user", "project"] +@dataclass(frozen=True) +class BaseOptions: + home: str | None = None + cwd: str | None = None + hosts_file: str | None = None + + @dataclass(frozen=True) class Host: id: str @@ -27,6 +34,13 @@ class HostSpec: schema_version: int = 1 +@dataclass +class TargetGroup: + host_ids: list[str] = field(default_factory=list) + skill_name: str = "" + target_dir: str = "" + + @dataclass(frozen=True) class SkillInfo: valid: bool @@ -68,6 +82,29 @@ class NormalizedSkillBundle: label: str | None = None +TargetResult = dict[str, object] +TargetSkip = dict[str, object] +TargetConflict = dict[str, object] +TargetError = dict[str, str] + + +@dataclass(frozen=True) +class InstallReport: + installed: list[TargetResult] = field(default_factory=list) + updated: list[TargetResult] = field(default_factory=list) + skipped: list[TargetSkip] = field(default_factory=list) + conflicts: list[TargetConflict] = field(default_factory=list) + errors: list[TargetError] = field(default_factory=list) + + +@dataclass(frozen=True) +class UninstallReport: + removed: list[TargetResult] = field(default_factory=list) + skipped: list[TargetSkip] = field(default_factory=list) + conflicts: list[TargetConflict] = field(default_factory=list) + errors: list[TargetError] = field(default_factory=list) + + INSTALL_UX = { "skill_use": "skill", "install_use": "install", diff --git a/python/tests/test_hosts.py b/python/tests/test_hosts.py index 2f43877..57fd46d 100644 --- a/python/tests/test_hosts.py +++ b/python/tests/test_hosts.py @@ -1,4 +1,7 @@ -from kitup import load_host_spec +import json + +from kitup import detect_hosts, load_host_spec, resolve_hosts +from kitup.types import BaseOptions def test_load_host_spec_uses_baked_default_when_no_override(): @@ -6,3 +9,77 @@ def test_load_host_spec_uses_baked_default_when_no_override(): assert spec.schema_version == 1 assert len(spec.hosts) == 72 assert spec.hosts[0].id == "adal" + + +def test_resolve_hosts_maps_kimi_alias_to_canonical_id(): + spec = load_host_spec() + + hosts, errors = resolve_hosts(["kimi-code-cli"], spec.hosts) + + assert [host.id for host in hosts] == ["kimi-cli"] + assert errors == [] + + +def test_resolve_hosts_reports_unknown_ids(): + spec = load_host_spec() + + hosts, errors = resolve_hosts(["missing-agent"], spec.hosts) + + assert hosts == [] + assert errors == [{"agent": "missing-agent", "reason": "unknown-host"}] + + +def test_detect_hosts_skips_generic_detect_paths_and_sorts_by_scope_path(tmp_path): + home = tmp_path / "home" + workspace = tmp_path / "workspace" + home.mkdir() + workspace.mkdir() + (home / ".claude").mkdir() + (home / ".codex").mkdir() + + hosts_file = tmp_path / "hosts.json" + hosts_file.write_text( + json.dumps( + { + "$schema": "./hosts.schema.json", + "schemaVersion": 1, + "hosts": [ + { + "id": "generic", + "displayName": "Generic", + "projectSkillsDirs": [".agents/skills"], + "userSkillsDirs": ["~/.agents/skills"], + "detect": ["~/.agents"], + "status": "community", + }, + { + "id": "claude-code", + "displayName": "Claude Code", + "projectSkillsDirs": [".claude/skills"], + "userSkillsDirs": ["~/.claude/skills"], + "detect": ["~/.claude"], + "status": "verified", + }, + { + "id": "codex", + "displayName": "Codex", + "projectSkillsDirs": [".agents/skills"], + "userSkillsDirs": ["~/.agents/skills", "~/.codex/skills"], + "detect": ["~/.codex"], + "status": "verified", + }, + ], + } + ) + ) + + hosts = detect_hosts( + BaseOptions( + home=str(home), + cwd=str(workspace), + hosts_file=str(hosts_file), + ), + scope="user", + ) + + assert [host.id for host in hosts] == ["codex", "claude-code"] diff --git a/python/tests/test_install.py b/python/tests/test_install.py new file mode 100644 index 0000000..47908f6 --- /dev/null +++ b/python/tests/test_install.py @@ -0,0 +1,130 @@ +import json + +from kitup import resolve_install_targets +from kitup.types import BaseOptions + + +def test_resolve_install_targets_prefers_first_existing_user_dir(tmp_path): + home = tmp_path / "home" + workspace = tmp_path / "workspace" + home.mkdir() + workspace.mkdir() + (home / ".agents" / "skills").mkdir(parents=True) + + targets, errors, detected = resolve_install_targets( + BaseOptions(home=str(home), cwd=str(workspace)), + ["codex"], + "user", + "basic", + ) + + assert [ + (target.host_ids, target.target_dir) + for target in targets + ] == [(["codex"], str(home / ".agents" / "skills" / "basic"))] + assert errors == [] + assert detected == ["codex"] + + +def test_resolve_install_targets_groups_hosts_by_shared_target_dir(tmp_path): + home = tmp_path / "home" + workspace = tmp_path / "workspace" + home.mkdir() + workspace.mkdir() + (home / ".agents" / "skills").mkdir(parents=True) + + targets, errors, detected = resolve_install_targets( + BaseOptions(home=str(home), cwd=str(workspace)), + ["codex", "warp", "gemini-cli"], + "user", + "basic", + ) + + assert [ + (target.host_ids, target.target_dir) + for target in targets + ] == [ + ( + ["codex", "warp", "gemini-cli"], + str(home / ".agents" / "skills" / "basic"), + ) + ] + assert errors == [] + assert detected == ["codex", "warp", "gemini-cli"] + + +def test_resolve_install_targets_auto_detects_supported_hosts(tmp_path): + home = tmp_path / "home" + workspace = tmp_path / "workspace" + home.mkdir() + workspace.mkdir() + (home / ".codex").mkdir() + (home / ".claude").mkdir() + (home / ".agents" / "skills").mkdir(parents=True) + (home / ".claude" / "skills").mkdir(parents=True) + + targets, errors, detected = resolve_install_targets( + BaseOptions(home=str(home), cwd=str(workspace)), + "auto", + "user", + "basic", + ) + + assert [ + (target.host_ids, target.target_dir) + for target in targets + ] == [ + (["codex"], str(home / ".agents" / "skills" / "basic")), + (["claude-code"], str(home / ".claude" / "skills" / "basic")), + ] + assert errors == [] + assert detected == ["codex", "claude-code"] + + +def test_resolve_install_targets_reports_unsupported_scope(tmp_path): + home = tmp_path / "home" + workspace = tmp_path / "workspace" + home.mkdir() + workspace.mkdir() + + hosts_file = tmp_path / "hosts.json" + hosts_file.write_text( + json.dumps( + { + "$schema": "./hosts.schema.json", + "schemaVersion": 1, + "hosts": [ + { + "id": "eve", + "displayName": "Eve", + "projectSkillsDirs": ["agent/skills"], + "userSkillsDirs": [], + "detect": ["agent", "package.json"], + "status": "community", + } + ], + } + ) + ) + + targets, errors, detected = resolve_install_targets( + BaseOptions( + home=str(home), + cwd=str(workspace), + hosts_file=str(hosts_file), + ), + ["eve"], + "user", + "basic", + ) + + assert targets == [] + assert errors == [ + { + "hostId": "eve", + "skillName": "basic", + "scope": "user", + "reason": "unsupported-scope", + } + ] + assert detected == [] From 9099d421c4ebf26fbb39e24dc06dee4b3a731151 Mon Sep 17 00:00:00 2001 From: spencercjh Date: Wed, 1 Jul 2026 10:10:44 +0000 Subject: [PATCH 05/18] fix(python): narrow host planning Signed-off-by: spencercjh --- python/src/kitup/hosts.py | 17 ++++++++---- python/src/kitup/install.py | 19 +++---------- python/tests/test_hosts.py | 18 ++++++++++++ python/tests/test_install.py | 54 ++++++++++++++++++++++-------------- 4 files changed, 66 insertions(+), 42 deletions(-) diff --git a/python/src/kitup/hosts.py b/python/src/kitup/hosts.py index 655f022..002ec76 100644 --- a/python/src/kitup/hosts.py +++ b/python/src/kitup/hosts.py @@ -65,12 +65,7 @@ def detect_hosts(options: BaseOptions, scope: Scope | None = None) -> list[Host] cwd = Path(options.cwd) if options.cwd else Path.cwd() detected: list[Host] = [] for host in spec.hosts: - if not host.detect: - continue - first_detect = host.detect[0] - if first_detect in _GENERIC_DETECT_PATHS: - continue - if _expand_host_path(first_detect, home=home, cwd=cwd).exists(): + if _first_specific_detect_path(host, home=home, cwd=cwd) is not None: detected.append(host) if scope is not None: @@ -92,6 +87,16 @@ def _canonical_scope_path( return _expand_host_path(paths[0], home=home, cwd=cwd) +def _first_specific_detect_path(host: Host, *, home: Path, cwd: Path) -> Path | None: + for path in host.detect: + if path in _GENERIC_DETECT_PATHS: + continue + expanded = _expand_host_path(path, home=home, cwd=cwd) + if expanded.exists(): + return expanded + return None + + def _expand_host_path(path: str, *, home: Path, cwd: Path) -> Path: if path.startswith("~/"): return home / path[2:] diff --git a/python/src/kitup/install.py b/python/src/kitup/install.py index 593932a..08baada 100644 --- a/python/src/kitup/install.py +++ b/python/src/kitup/install.py @@ -26,30 +26,21 @@ def resolve_install_targets( agents: str | list[str] | None, scope: Scope, skill_name: str, -) -> tuple[list[TargetGroup], list[dict[str, str]], list[str]]: +) -> list[TargetGroup]: spec = load_host_spec(options.hosts_file) home = Path(options.home).expanduser() if options.home else Path.home() cwd = Path(options.cwd) if options.cwd else Path.cwd() - selected: list[Host] - errors: list[dict[str, str]] if agents in (None, "auto"): selected = detect_hosts(options, scope) - errors = [] else: selected, errors = resolve_hosts(agents, spec.hosts) + if errors: + return [] by_target: dict[str, TargetGroup] = {} for host in selected: root = choose_scope_path(host, scope=scope, home=home, cwd=cwd) if root is None: - errors.append( - { - "hostId": host.id, - "skillName": skill_name, - "scope": scope, - "reason": "unsupported-scope", - } - ) continue target_dir = str(root / skill_name) group = by_target.get(target_dir) @@ -58,6 +49,4 @@ def resolve_install_targets( by_target[target_dir] = group group.host_ids.append(host.id) - targets = [by_target[path] for path in sorted(by_target)] - detected_host_ids = [host_id for target in targets for host_id in target.host_ids] - return targets, errors, detected_host_ids + return [by_target[path] for path in sorted(by_target)] diff --git a/python/tests/test_hosts.py b/python/tests/test_hosts.py index 57fd46d..549ec87 100644 --- a/python/tests/test_hosts.py +++ b/python/tests/test_hosts.py @@ -83,3 +83,21 @@ def test_detect_hosts_skips_generic_detect_paths_and_sorts_by_scope_path(tmp_pat ) assert [host.id for host in hosts] == ["codex", "claude-code"] + + +def test_detect_hosts_uses_first_existing_specific_path_for_kimi_cli(tmp_path): + home = tmp_path / "home" + workspace = tmp_path / "workspace" + home.mkdir() + workspace.mkdir() + (home / ".kimi").mkdir() + + hosts = detect_hosts( + BaseOptions( + home=str(home), + cwd=str(workspace), + ), + scope="user", + ) + + assert "kimi-cli" in [host.id for host in hosts] diff --git a/python/tests/test_install.py b/python/tests/test_install.py index 47908f6..b331b36 100644 --- a/python/tests/test_install.py +++ b/python/tests/test_install.py @@ -11,7 +11,7 @@ def test_resolve_install_targets_prefers_first_existing_user_dir(tmp_path): workspace.mkdir() (home / ".agents" / "skills").mkdir(parents=True) - targets, errors, detected = resolve_install_targets( + targets = resolve_install_targets( BaseOptions(home=str(home), cwd=str(workspace)), ["codex"], "user", @@ -22,8 +22,6 @@ def test_resolve_install_targets_prefers_first_existing_user_dir(tmp_path): (target.host_ids, target.target_dir) for target in targets ] == [(["codex"], str(home / ".agents" / "skills" / "basic"))] - assert errors == [] - assert detected == ["codex"] def test_resolve_install_targets_groups_hosts_by_shared_target_dir(tmp_path): @@ -33,7 +31,7 @@ def test_resolve_install_targets_groups_hosts_by_shared_target_dir(tmp_path): workspace.mkdir() (home / ".agents" / "skills").mkdir(parents=True) - targets, errors, detected = resolve_install_targets( + targets = resolve_install_targets( BaseOptions(home=str(home), cwd=str(workspace)), ["codex", "warp", "gemini-cli"], "user", @@ -49,8 +47,6 @@ def test_resolve_install_targets_groups_hosts_by_shared_target_dir(tmp_path): str(home / ".agents" / "skills" / "basic"), ) ] - assert errors == [] - assert detected == ["codex", "warp", "gemini-cli"] def test_resolve_install_targets_auto_detects_supported_hosts(tmp_path): @@ -62,9 +58,36 @@ def test_resolve_install_targets_auto_detects_supported_hosts(tmp_path): (home / ".claude").mkdir() (home / ".agents" / "skills").mkdir(parents=True) (home / ".claude" / "skills").mkdir(parents=True) + hosts_file = tmp_path / "hosts.json" + hosts_file.write_text( + json.dumps( + { + "$schema": "./hosts.schema.json", + "schemaVersion": 1, + "hosts": [ + { + "id": "codex", + "displayName": "Codex", + "projectSkillsDirs": [".agents/skills"], + "userSkillsDirs": ["~/.agents/skills", "~/.codex/skills"], + "detect": ["~/.codex", "~/.agents/skills", "~/.agents"], + "status": "verified", + }, + { + "id": "claude-code", + "displayName": "Claude Code", + "projectSkillsDirs": [".claude/skills"], + "userSkillsDirs": ["~/.claude/skills"], + "detect": ["~/.claude"], + "status": "verified", + }, + ], + } + ) + ) - targets, errors, detected = resolve_install_targets( - BaseOptions(home=str(home), cwd=str(workspace)), + targets = resolve_install_targets( + BaseOptions(home=str(home), cwd=str(workspace), hosts_file=str(hosts_file)), "auto", "user", "basic", @@ -77,11 +100,9 @@ def test_resolve_install_targets_auto_detects_supported_hosts(tmp_path): (["codex"], str(home / ".agents" / "skills" / "basic")), (["claude-code"], str(home / ".claude" / "skills" / "basic")), ] - assert errors == [] - assert detected == ["codex", "claude-code"] -def test_resolve_install_targets_reports_unsupported_scope(tmp_path): +def test_resolve_install_targets_skips_hosts_without_scope_path(tmp_path): home = tmp_path / "home" workspace = tmp_path / "workspace" home.mkdir() @@ -107,7 +128,7 @@ def test_resolve_install_targets_reports_unsupported_scope(tmp_path): ) ) - targets, errors, detected = resolve_install_targets( + targets = resolve_install_targets( BaseOptions( home=str(home), cwd=str(workspace), @@ -119,12 +140,3 @@ def test_resolve_install_targets_reports_unsupported_scope(tmp_path): ) assert targets == [] - assert errors == [ - { - "hostId": "eve", - "skillName": "basic", - "scope": "user", - "reason": "unsupported-scope", - } - ] - assert detected == [] From cb844e453ed39ae50b58b72c32b6f6ac73487345 Mon Sep 17 00:00:00 2001 From: spencercjh Date: Wed, 1 Jul 2026 10:18:32 +0000 Subject: [PATCH 06/18] feat(python): add install lifecycle Signed-off-by: spencercjh --- python/src/kitup/_metadata.py | 35 ++++++++ python/src/kitup/bundle.py | 10 +++ python/src/kitup/install.py | 160 +++++++++++++++++++++++++++++++++- python/src/kitup/types.py | 108 +++++++++++++++++++++-- python/tests/test_install.py | 121 ++++++++++++++++++++++++- 5 files changed, 423 insertions(+), 11 deletions(-) create mode 100644 python/src/kitup/_metadata.py diff --git a/python/src/kitup/_metadata.py b/python/src/kitup/_metadata.py new file mode 100644 index 0000000..3807ed4 --- /dev/null +++ b/python/src/kitup/_metadata.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +import json +from pathlib import Path + + +def write_install_metadata( + target_dir: Path, + *, + app_id: str, + skill_name: str, + digest: str, + source: str, +) -> None: + payload = { + "schemaVersion": 1, + "appId": app_id, + "skillName": skill_name, + "source": source, + "hash": digest, + } + (target_dir / ".kitup.json").write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + + +def read_install_metadata(target_dir: Path) -> dict[str, object] | None: + metadata_file = target_dir / ".kitup.json" + if not metadata_file.exists(): + return None + try: + payload = json.loads(metadata_file.read_text(encoding="utf-8")) + except (OSError, ValueError): + return None + if not isinstance(payload, dict): + return None + return payload diff --git a/python/src/kitup/bundle.py b/python/src/kitup/bundle.py index fa5d39a..7ca6519 100644 --- a/python/src/kitup/bundle.py +++ b/python/src/kitup/bundle.py @@ -141,6 +141,16 @@ def normalize_files_bundle(files: list[SkillFile]) -> NormalizedSkillBundle: return NormalizedSkillBundle(files=normalized_files, by_path=by_path) +def copy_normalized_bundle(files: list[BundleFile], target_dir: str | Path) -> None: + destination_root = Path(target_dir) + destination_root.mkdir(parents=True, exist_ok=True) + for file in files: + destination = destination_root / file.path + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_bytes(file.bytes) + destination.chmod(file.mode) + + def _parse_frontmatter(content: str) -> dict[str, str]: fields: dict[str, str] = {} for line in content.splitlines(): diff --git a/python/src/kitup/install.py b/python/src/kitup/install.py index 08baada..7c127a2 100644 --- a/python/src/kitup/install.py +++ b/python/src/kitup/install.py @@ -1,7 +1,29 @@ +from __future__ import annotations + +import shutil from pathlib import Path +from ._metadata import read_install_metadata, write_install_metadata +from .bundle import ( + copy_normalized_bundle, + compute_bundle_content_hash, + normalize_skill_bundle, + validate_skill_bundle, +) from .hosts import detect_hosts, load_host_spec, resolve_hosts -from .types import BaseOptions, Host, Scope, TargetGroup +from .types import ( + BaseOptions, + Host, + InstallOptions, + InstallReport, + Scope, + TargetError, + TargetGroup, + TargetResult, + TargetStatus, + UninstallOptions, + UninstallReport, +) def expand_host_path(path: str, *, home: Path, cwd: Path) -> Path: @@ -50,3 +72,139 @@ def resolve_install_targets( group.host_ids.append(host.id) return [by_target[path] for path in sorted(by_target)] + + +def empty_install_report(errors: list[TargetError] | None = None) -> InstallReport: + return InstallReport(errors=errors or []) + + +def empty_uninstall_report(errors: list[TargetError] | None = None) -> UninstallReport: + return UninstallReport(errors=errors or []) + + +def target_result(target: TargetGroup) -> TargetResult: + if len(target.host_ids) == 1: + return TargetResult( + host_id=target.host_ids[0], + skill_name=target.skill_name, + target_dir=target.target_dir, + ) + return TargetResult( + host_ids=list(target.host_ids), + skill_name=target.skill_name, + target_dir=target.target_dir, + ) + + +def target_status(target: TargetGroup, reason: str) -> TargetStatus: + result = target_result(target) + return TargetStatus( + host_id=result.host_id, + host_ids=result.host_ids, + skill_name=result.skill_name, + target_dir=result.target_dir, + reason=reason, + ) + + +def plan_bundled_skill(options: InstallOptions) -> InstallReport: + return install_or_plan(options, write=False) + + +def install_bundled_skill(options: InstallOptions) -> InstallReport: + return install_or_plan(options, write=True) + + +def update_bundled_skill(options: InstallOptions) -> InstallReport: + return install_bundled_skill(options) + + +def install_or_plan(options: InstallOptions, *, write: bool) -> InstallReport: + info = validate_skill_bundle(options.skill_bundle, cwd=options.base.cwd) + if not info.valid or not info.skill_name: + return empty_install_report([TargetError(reason=info.error_code or "invalid-skill-bundle")]) + + normalized = normalize_skill_bundle(options.skill_bundle, cwd=options.base.cwd) + digest = compute_bundle_content_hash(options.skill_bundle, cwd=options.base.cwd) + report = empty_install_report() + for target in resolve_install_targets( + options.base, + options.agents, + options.scope, + info.skill_name, + ): + result = target_result(target) + target_dir = Path(target.target_dir) + metadata_file = target_dir / ".kitup.json" + metadata = read_install_metadata(target_dir) + + if not target_dir.exists(): + if write: + copy_normalized_bundle(normalized.files, target_dir) + write_install_metadata( + target_dir, + app_id=options.app_id, + skill_name=info.skill_name, + digest=digest, + source="bundled", + ) + report.installed.append(result) + continue + + if metadata is None and metadata_file.exists(): + report.conflicts.append(target_status(target, "unmanaged")) + continue + if metadata is None: + report.conflicts.append(target_status(target, "unmanaged")) + continue + if metadata.get("appId") != options.app_id: + report.conflicts.append(target_status(target, "owner-mismatch")) + continue + if metadata.get("hash") == digest: + report.skipped.append(target_status(target, "unchanged")) + continue + + if write: + copy_normalized_bundle(normalized.files, target_dir) + write_install_metadata( + target_dir, + app_id=options.app_id, + skill_name=info.skill_name, + digest=digest, + source=str(metadata.get("source", "bundled")), + ) + report.updated.append(result) + + return report + + +def uninstall_bundled_skill(options: UninstallOptions) -> UninstallReport: + report = empty_uninstall_report() + for target in resolve_install_targets( + options.base, + options.agents, + options.scope, + options.skill_name, + ): + result = target_result(target) + target_dir = Path(target.target_dir) + metadata_file = target_dir / ".kitup.json" + metadata = read_install_metadata(target_dir) + + if not target_dir.exists(): + report.skipped.append(target_status(target, "missing")) + continue + if metadata is None and metadata_file.exists(): + report.conflicts.append(target_status(target, "unmanaged")) + continue + if metadata is None: + report.conflicts.append(target_status(target, "unmanaged")) + continue + if metadata.get("appId") != options.app_id: + report.conflicts.append(target_status(target, "owner-mismatch")) + continue + + shutil.rmtree(target_dir) + report.removed.append(result) + + return report diff --git a/python/src/kitup/types.py b/python/src/kitup/types.py index f188719..8248337 100644 --- a/python/src/kitup/types.py +++ b/python/src/kitup/types.py @@ -82,29 +82,121 @@ class NormalizedSkillBundle: label: str | None = None -TargetResult = dict[str, object] -TargetSkip = dict[str, object] -TargetConflict = dict[str, object] -TargetError = dict[str, str] +@dataclass(frozen=True) +class InstallOptions: + base: BaseOptions + app_id: str + skill_bundle: object + scope: Scope + agents: str | list[str] = "auto" + + +@dataclass(frozen=True) +class UninstallOptions: + base: BaseOptions + app_id: str + skill_name: str + scope: Scope + agents: str | list[str] = "auto" + + +@dataclass(frozen=True) +class TargetResult: + skill_name: str + target_dir: str + host_id: str | None = None + host_ids: list[str] | None = None + + +@dataclass(frozen=True) +class TargetStatus(TargetResult): + reason: str = "" + + +@dataclass(frozen=True) +class TargetError: + reason: str + agent: str | None = None @dataclass(frozen=True) class InstallReport: installed: list[TargetResult] = field(default_factory=list) updated: list[TargetResult] = field(default_factory=list) - skipped: list[TargetSkip] = field(default_factory=list) - conflicts: list[TargetConflict] = field(default_factory=list) + skipped: list[TargetStatus] = field(default_factory=list) + conflicts: list[TargetStatus] = field(default_factory=list) errors: list[TargetError] = field(default_factory=list) @dataclass(frozen=True) class UninstallReport: removed: list[TargetResult] = field(default_factory=list) - skipped: list[TargetSkip] = field(default_factory=list) - conflicts: list[TargetConflict] = field(default_factory=list) + skipped: list[TargetStatus] = field(default_factory=list) + conflicts: list[TargetStatus] = field(default_factory=list) errors: list[TargetError] = field(default_factory=list) +@dataclass +class InstallSelection: + action: str + selected_host_ids: list[str] + candidate_host_ids: list[str] + detected_host_ids: list[str] + needs_confirmation: bool + errors: list[TargetError] + + +@dataclass(frozen=True) +class InstallSelectionOptions: + base: BaseOptions + scope: Scope + agents: str | list[str] = "auto" + yes: bool = False + stdin_tty: bool = False + current_agent: str | None = None + + +@dataclass(frozen=True) +class InstallWorkflowOptions: + install: InstallOptions + yes: bool = False + dry_run: bool = False + stdin_tty: bool = False + current_agent: str | None = None + default_scope: Scope = "user" + scope_set: bool = False + prompt_scope: bool = False + input: object | None = None + output: object | None = None + + +@dataclass(frozen=True) +class InstallWorkflowExit: + ok: bool + code: str + message: str + + +@dataclass +class InstallWorkflowReport: + selection: InstallSelection + scope: str + plan: InstallReport + report: InstallReport + canceled: bool + dry_run: bool + + +@dataclass(frozen=True) +class ParsedInstallFlags: + scope: Scope + scope_set: bool + agents: str | list[str] + yes: bool + dry_run: bool + errors: list[TargetError] + + INSTALL_UX = { "skill_use": "skill", "install_use": "install", diff --git a/python/tests/test_install.py b/python/tests/test_install.py index b331b36..690486e 100644 --- a/python/tests/test_install.py +++ b/python/tests/test_install.py @@ -1,7 +1,13 @@ import json -from kitup import resolve_install_targets -from kitup.types import BaseOptions +from kitup import directory_bundle, resolve_install_targets +from kitup.bundle import compute_bundle_content_hash +from kitup.install import ( + install_bundled_skill, + uninstall_bundled_skill, + update_bundled_skill, +) +from kitup.types import BaseOptions, InstallOptions, UninstallOptions def test_resolve_install_targets_prefers_first_existing_user_dir(tmp_path): @@ -140,3 +146,114 @@ def test_resolve_install_targets_skips_hosts_without_scope_path(tmp_path): ) assert targets == [] + + +def test_install_update_uninstall_round_trip(tmp_path): + skill = tmp_path / "skill" + skill.mkdir() + (skill / "SKILL.md").write_text( + "---\nname: basic\ndescription: demo\n---\n", + encoding="utf-8", + ) + (skill / "bin").mkdir() + script = skill / "bin" / "run.sh" + script.write_text("#!/bin/sh\necho updated\n", encoding="utf-8") + script.chmod(0o755) + + home = tmp_path / "home" + workspace = tmp_path / "workspace" + home.mkdir() + workspace.mkdir() + + install_options = InstallOptions( + base=BaseOptions(home=str(home), cwd=str(workspace)), + app_id="kitup-python-test", + skill_bundle=directory_bundle(str(skill)), + scope="user", + agents=["codex"], + ) + + install_report = install_bundled_skill(install_options) + + assert len(install_report.installed) == 1 + + target = home / ".agents" / "skills" / "basic" + assert (target / "SKILL.md").read_text(encoding="utf-8").startswith("---\nname: basic\n") + assert (target / "bin" / "run.sh").read_text(encoding="utf-8") == "#!/bin/sh\necho updated\n" + assert (target / "bin" / "run.sh").stat().st_mode & 0o777 == 0o755 + assert json.loads((target / ".kitup.json").read_text(encoding="utf-8")) == { + "schemaVersion": 1, + "appId": "kitup-python-test", + "skillName": "basic", + "source": "bundled", + "hash": compute_bundle_content_hash(directory_bundle(str(skill))), + } + + script.write_text("#!/bin/sh\necho second\n", encoding="utf-8") + + update_report = update_bundled_skill(install_options) + + assert len(update_report.updated) == 1 + assert (target / "bin" / "run.sh").read_text(encoding="utf-8") == "#!/bin/sh\necho second\n" + + unchanged_report = update_bundled_skill(install_options) + + assert unchanged_report.skipped[0].reason == "unchanged" + + uninstall_report = uninstall_bundled_skill( + UninstallOptions( + base=BaseOptions(home=str(home), cwd=str(workspace)), + app_id="kitup-python-test", + skill_name="basic", + scope="user", + agents=["codex"], + ) + ) + + assert len(uninstall_report.removed) == 1 + assert not target.exists() + + +def test_install_lifecycle_reports_owner_mismatch_and_missing(tmp_path): + skill = tmp_path / "skill" + skill.mkdir() + (skill / "SKILL.md").write_text( + "---\nname: basic\ndescription: demo\n---\n", + encoding="utf-8", + ) + home = tmp_path / "home" + workspace = tmp_path / "workspace" + home.mkdir() + workspace.mkdir() + + install_bundled_skill( + InstallOptions( + base=BaseOptions(home=str(home), cwd=str(workspace)), + app_id="kitup-python-test", + skill_bundle=directory_bundle(str(skill)), + scope="user", + agents=["codex"], + ) + ) + + conflict_report = install_bundled_skill( + InstallOptions( + base=BaseOptions(home=str(home), cwd=str(workspace)), + app_id="other-app", + skill_bundle=directory_bundle(str(skill)), + scope="user", + agents=["codex"], + ) + ) + missing_report = uninstall_bundled_skill( + UninstallOptions( + base=BaseOptions(home=str(home), cwd=str(workspace)), + app_id="kitup-python-test", + skill_name="missing", + scope="user", + agents=["codex"], + ) + ) + + assert conflict_report.conflicts[0].reason == "owner-mismatch" + assert missing_report.skipped[0].reason == "missing" From c692b654d6409ecd983152cfc5c17bd484fc9cb3 Mon Sep 17 00:00:00 2001 From: spencercjh Date: Wed, 1 Jul 2026 10:32:47 +0000 Subject: [PATCH 07/18] fix(python): replace managed bundle updates Signed-off-by: spencercjh --- python/src/kitup/__init__.py | 10 +++++- python/src/kitup/install.py | 67 +++++++++++++++++++++++++++++++++--- python/tests/test_install.py | 20 +++++++++-- 3 files changed, 89 insertions(+), 8 deletions(-) diff --git a/python/src/kitup/__init__.py b/python/src/kitup/__init__.py index 9da8c01..cfa6deb 100644 --- a/python/src/kitup/__init__.py +++ b/python/src/kitup/__init__.py @@ -6,7 +6,12 @@ validate_skill_bundle, ) from .hosts import detect_hosts, load_host_spec, resolve_hosts -from .install import resolve_install_targets +from .install import ( + install_bundled_skill, + resolve_install_targets, + uninstall_bundled_skill, + update_bundled_skill, +) from .types import BaseOptions, Host, HostSpec, INSTALL_UX, KitupError, TargetGroup __all__ = [ @@ -21,8 +26,11 @@ "directory_bundle", "files_bundle", "github_bundle", + "install_bundled_skill", "load_host_spec", "resolve_hosts", "resolve_install_targets", + "uninstall_bundled_skill", + "update_bundled_skill", "validate_skill_bundle", ] diff --git a/python/src/kitup/install.py b/python/src/kitup/install.py index 7c127a2..1311784 100644 --- a/python/src/kitup/install.py +++ b/python/src/kitup/install.py @@ -1,6 +1,7 @@ from __future__ import annotations import shutil +import tempfile from pathlib import Path from ._metadata import read_install_metadata, write_install_metadata @@ -13,6 +14,7 @@ from .hosts import detect_hosts, load_host_spec, resolve_hosts from .types import ( BaseOptions, + BundleFile, Host, InstallOptions, InstallReport, @@ -119,6 +121,61 @@ def update_bundled_skill(options: InstallOptions) -> InstallReport: return install_bundled_skill(options) +def write_managed_bundle( + target_dir: Path, + *, + files: list[BundleFile], + app_id: str, + skill_name: str, + digest: str, + source: str, + replace: bool, +) -> None: + if not replace: + copy_normalized_bundle(files, target_dir) + write_install_metadata( + target_dir, + app_id=app_id, + skill_name=skill_name, + digest=digest, + source=source, + ) + return + + target_dir.parent.mkdir(parents=True, exist_ok=True) + staged_dir = Path( + tempfile.mkdtemp( + prefix=f".{target_dir.name}.kitup-", + dir=target_dir.parent, + ) + ) + backup_dir: Path | None = None + try: + copy_normalized_bundle(files, staged_dir) + write_install_metadata( + staged_dir, + app_id=app_id, + skill_name=skill_name, + digest=digest, + source=source, + ) + backup_dir = Path( + tempfile.mkdtemp( + prefix=f".{target_dir.name}.kitup-old-", + dir=target_dir.parent, + ) + ) + backup_dir.rmdir() + target_dir.replace(backup_dir) + staged_dir.replace(target_dir) + shutil.rmtree(backup_dir) + except Exception: + if backup_dir is not None and backup_dir.exists() and not target_dir.exists(): + backup_dir.replace(target_dir) + shutil.rmtree(staged_dir, ignore_errors=True) + raise + + def install_or_plan(options: InstallOptions, *, write: bool) -> InstallReport: info = validate_skill_bundle(options.skill_bundle, cwd=options.base.cwd) if not info.valid or not info.skill_name: @@ -140,13 +197,14 @@ def install_or_plan(options: InstallOptions, *, write: bool) -> InstallReport: if not target_dir.exists(): if write: - copy_normalized_bundle(normalized.files, target_dir) - write_install_metadata( + write_managed_bundle( target_dir, app_id=options.app_id, skill_name=info.skill_name, digest=digest, source="bundled", + files=normalized.files, + replace=False, ) report.installed.append(result) continue @@ -165,13 +223,14 @@ def install_or_plan(options: InstallOptions, *, write: bool) -> InstallReport: continue if write: - copy_normalized_bundle(normalized.files, target_dir) - write_install_metadata( + write_managed_bundle( target_dir, app_id=options.app_id, skill_name=info.skill_name, digest=digest, source=str(metadata.get("source", "bundled")), + files=normalized.files, + replace=True, ) report.updated.append(result) diff --git a/python/tests/test_install.py b/python/tests/test_install.py index 690486e..d2db64e 100644 --- a/python/tests/test_install.py +++ b/python/tests/test_install.py @@ -1,12 +1,14 @@ import json -from kitup import directory_bundle, resolve_install_targets -from kitup.bundle import compute_bundle_content_hash -from kitup.install import ( +import kitup +from kitup import ( + directory_bundle, install_bundled_skill, + resolve_install_targets, uninstall_bundled_skill, update_bundled_skill, ) +from kitup.bundle import compute_bundle_content_hash from kitup.types import BaseOptions, InstallOptions, UninstallOptions @@ -159,6 +161,8 @@ def test_install_update_uninstall_round_trip(tmp_path): script = skill / "bin" / "run.sh" script.write_text("#!/bin/sh\necho updated\n", encoding="utf-8") script.chmod(0o755) + legacy = skill / "legacy.txt" + legacy.write_text("remove me on update\n", encoding="utf-8") home = tmp_path / "home" workspace = tmp_path / "workspace" @@ -181,6 +185,7 @@ def test_install_update_uninstall_round_trip(tmp_path): assert (target / "SKILL.md").read_text(encoding="utf-8").startswith("---\nname: basic\n") assert (target / "bin" / "run.sh").read_text(encoding="utf-8") == "#!/bin/sh\necho updated\n" assert (target / "bin" / "run.sh").stat().st_mode & 0o777 == 0o755 + assert (target / "legacy.txt").read_text(encoding="utf-8") == "remove me on update\n" assert json.loads((target / ".kitup.json").read_text(encoding="utf-8")) == { "schemaVersion": 1, "appId": "kitup-python-test", @@ -190,11 +195,13 @@ def test_install_update_uninstall_round_trip(tmp_path): } script.write_text("#!/bin/sh\necho second\n", encoding="utf-8") + legacy.unlink() update_report = update_bundled_skill(install_options) assert len(update_report.updated) == 1 assert (target / "bin" / "run.sh").read_text(encoding="utf-8") == "#!/bin/sh\necho second\n" + assert not (target / "legacy.txt").exists() unchanged_report = update_bundled_skill(install_options) @@ -257,3 +264,10 @@ def test_install_lifecycle_reports_owner_mismatch_and_missing(tmp_path): assert conflict_report.conflicts[0].reason == "owner-mismatch" assert missing_report.skipped[0].reason == "missing" + + +def test_install_lifecycle_is_re_exported_from_top_level_package(): + assert kitup.directory_bundle is directory_bundle + assert kitup.install_bundled_skill is install_bundled_skill + assert kitup.update_bundled_skill is update_bundled_skill + assert kitup.uninstall_bundled_skill is uninstall_bundled_skill From 688cf6c02a549c413332a969fd87b3dec06bdca8 Mon Sep 17 00:00:00 2001 From: spencercjh Date: Wed, 1 Jul 2026 10:38:34 +0000 Subject: [PATCH 08/18] fix(python): re-export task 4 lifecycle types Signed-off-by: spencercjh --- python/src/kitup/__init__.py | 37 +++++++++++++++++++++++++++++++- python/tests/test_install.py | 41 +++++++++++++++++++++++++++++++++++- 2 files changed, 76 insertions(+), 2 deletions(-) diff --git a/python/src/kitup/__init__.py b/python/src/kitup/__init__.py index cfa6deb..5380f1c 100644 --- a/python/src/kitup/__init__.py +++ b/python/src/kitup/__init__.py @@ -8,19 +8,53 @@ from .hosts import detect_hosts, load_host_spec, resolve_hosts from .install import ( install_bundled_skill, + plan_bundled_skill, resolve_install_targets, uninstall_bundled_skill, update_bundled_skill, ) -from .types import BaseOptions, Host, HostSpec, INSTALL_UX, KitupError, TargetGroup +from .types import ( + BaseOptions, + Host, + HostSpec, + INSTALL_UX, + InstallOptions, + InstallReport, + InstallSelection, + InstallSelectionOptions, + InstallWorkflowExit, + InstallWorkflowOptions, + InstallWorkflowReport, + KitupError, + ParsedInstallFlags, + TargetError, + TargetGroup, + TargetResult, + TargetStatus, + UninstallOptions, + UninstallReport, +) __all__ = [ "BaseOptions", "Host", "HostSpec", "INSTALL_UX", + "InstallOptions", + "InstallReport", + "InstallSelection", + "InstallSelectionOptions", + "InstallWorkflowExit", + "InstallWorkflowOptions", + "InstallWorkflowReport", "KitupError", + "ParsedInstallFlags", + "TargetError", "TargetGroup", + "TargetResult", + "TargetStatus", + "UninstallOptions", + "UninstallReport", "compute_bundle_content_hash", "detect_hosts", "directory_bundle", @@ -28,6 +62,7 @@ "github_bundle", "install_bundled_skill", "load_host_spec", + "plan_bundled_skill", "resolve_hosts", "resolve_install_targets", "uninstall_bundled_skill", diff --git a/python/tests/test_install.py b/python/tests/test_install.py index d2db64e..b7d1e9f 100644 --- a/python/tests/test_install.py +++ b/python/tests/test_install.py @@ -4,12 +4,28 @@ from kitup import ( directory_bundle, install_bundled_skill, + plan_bundled_skill, resolve_install_targets, uninstall_bundled_skill, update_bundled_skill, ) from kitup.bundle import compute_bundle_content_hash -from kitup.types import BaseOptions, InstallOptions, UninstallOptions +from kitup.types import ( + BaseOptions, + InstallOptions, + InstallReport, + InstallSelection, + InstallSelectionOptions, + InstallWorkflowExit, + InstallWorkflowOptions, + InstallWorkflowReport, + ParsedInstallFlags, + TargetError, + TargetResult, + TargetStatus, + UninstallOptions, + UninstallReport, +) def test_resolve_install_targets_prefers_first_existing_user_dir(tmp_path): @@ -268,6 +284,29 @@ def test_install_lifecycle_reports_owner_mismatch_and_missing(tmp_path): def test_install_lifecycle_is_re_exported_from_top_level_package(): assert kitup.directory_bundle is directory_bundle + assert kitup.plan_bundled_skill is plan_bundled_skill assert kitup.install_bundled_skill is install_bundled_skill assert kitup.update_bundled_skill is update_bundled_skill assert kitup.uninstall_bundled_skill is uninstall_bundled_skill + assert kitup.InstallOptions is InstallOptions + assert kitup.UninstallOptions is UninstallOptions + assert kitup.InstallReport is InstallReport + assert kitup.UninstallReport is UninstallReport + assert kitup.TargetResult is TargetResult + assert kitup.TargetStatus is TargetStatus + assert kitup.TargetError is TargetError + assert kitup.InstallSelection is InstallSelection + assert kitup.InstallSelectionOptions is InstallSelectionOptions + assert kitup.InstallWorkflowOptions is InstallWorkflowOptions + assert kitup.InstallWorkflowExit is InstallWorkflowExit + assert kitup.InstallWorkflowReport is InstallWorkflowReport + assert kitup.ParsedInstallFlags is ParsedInstallFlags + + for name in [ + "BundleFile", + "GitHubBundleOptions", + "NormalizedSkillBundle", + "SkillFile", + "SkillInfo", + ]: + assert not hasattr(kitup, name) From 20f43fc00eb6942148f65ad31bc8fada750ee8f0 Mon Sep 17 00:00:00 2001 From: spencercjh Date: Wed, 1 Jul 2026 10:49:16 +0000 Subject: [PATCH 09/18] feat(python): add install workflow helpers Signed-off-by: spencercjh --- python/src/kitup/__init__.py | 20 ++ python/src/kitup/_github.py | 20 +- python/src/kitup/_metadata.py | 9 + python/src/kitup/hosts.py | 17 +- python/src/kitup/install.py | 151 ++++++--- python/src/kitup/types.py | 22 +- python/src/kitup/workflow.py | 586 ++++++++++++++++++++++++++++++++++ python/tests/golden_test.py | 517 ++++++++++++++++++++++++++++++ python/tests/test_hosts.py | 4 +- python/tests/test_workflow.py | 243 ++++++++++++++ 10 files changed, 1529 insertions(+), 60 deletions(-) create mode 100644 python/src/kitup/workflow.py create mode 100644 python/tests/golden_test.py create mode 100644 python/tests/test_workflow.py diff --git a/python/src/kitup/__init__.py b/python/src/kitup/__init__.py index 5380f1c..909d3cf 100644 --- a/python/src/kitup/__init__.py +++ b/python/src/kitup/__init__.py @@ -13,6 +13,17 @@ uninstall_bundled_skill, update_bundled_skill, ) +from .workflow import ( + agent_selector_from_flags, + classify_install_workflow_exit, + install_flag_error, + install_workflow_error, + parse_install_flags, + parse_scope_flag, + resolve_install_selection, + run_bundled_skill_install, + run_bundled_skill_install_with_io, +) from .types import ( BaseOptions, Host, @@ -60,11 +71,20 @@ "directory_bundle", "files_bundle", "github_bundle", + "agent_selector_from_flags", + "classify_install_workflow_exit", "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", + "run_bundled_skill_install_with_io", "uninstall_bundled_skill", "update_bundled_skill", "validate_skill_bundle", diff --git a/python/src/kitup/_github.py b/python/src/kitup/_github.py index 45fc4d6..b7d7a66 100644 --- a/python/src/kitup/_github.py +++ b/python/src/kitup/_github.py @@ -10,6 +10,13 @@ def fetch_github_directory(options: GitHubBundleOptions) -> list[SkillFile]: + files, _ = fetch_github_directory_with_metadata(options) + return files + + +def fetch_github_directory_with_metadata( + options: GitHubBundleOptions, +) -> tuple[list[SkillFile], dict[str, object]]: root = trim_github_path(options.path) if not options.owner or not options.repo or not root or not options.ref: raise KitupError("invalid github bundle") @@ -59,7 +66,18 @@ def fetch_github_directory(options: GitHubBundleOptions) -> list[SkillFile]: if not files: raise KitupError("github bundle path not found") - return files + return files, { + "source": "github", + "source_id": f"github:{options.owner}/{options.repo}/{root}", + "version": options.ref, + "provenance": { + "owner": options.owner, + "repo": options.repo, + "path": root, + "ref": options.ref, + "resolvedCommit": resolved_commit, + }, + } def github_json(url: str) -> dict[str, object]: diff --git a/python/src/kitup/_metadata.py b/python/src/kitup/_metadata.py index 3807ed4..41f9619 100644 --- a/python/src/kitup/_metadata.py +++ b/python/src/kitup/_metadata.py @@ -11,6 +11,9 @@ def write_install_metadata( skill_name: str, digest: str, source: str, + source_id: str | None = None, + version: str | None = None, + provenance: dict[str, object] | None = None, ) -> None: payload = { "schemaVersion": 1, @@ -19,6 +22,12 @@ def write_install_metadata( "source": source, "hash": digest, } + if source_id is not None: + payload["sourceId"] = source_id + if version is not None: + payload["version"] = version + if provenance is not None: + payload["provenance"] = provenance (target_dir / ".kitup.json").write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") diff --git a/python/src/kitup/hosts.py b/python/src/kitup/hosts.py index 002ec76..1726fb4 100644 --- a/python/src/kitup/hosts.py +++ b/python/src/kitup/hosts.py @@ -65,7 +65,7 @@ def detect_hosts(options: BaseOptions, scope: Scope | None = None) -> list[Host] cwd = Path(options.cwd) if options.cwd else Path.cwd() detected: list[Host] = [] for host in spec.hosts: - if _first_specific_detect_path(host, home=home, cwd=cwd) is not None: + if _primary_detect_path_exists(host, home=home, cwd=cwd): detected.append(host) if scope is not None: @@ -87,14 +87,13 @@ def _canonical_scope_path( return _expand_host_path(paths[0], home=home, cwd=cwd) -def _first_specific_detect_path(host: Host, *, home: Path, cwd: Path) -> Path | None: - for path in host.detect: - if path in _GENERIC_DETECT_PATHS: - continue - expanded = _expand_host_path(path, home=home, cwd=cwd) - if expanded.exists(): - return expanded - return None +def _primary_detect_path_exists(host: Host, *, home: Path, cwd: Path) -> bool: + if not host.detect: + return False + path = host.detect[0] + if path in _GENERIC_DETECT_PATHS: + return False + return _expand_host_path(path, home=home, cwd=cwd).exists() def _expand_host_path(path: str, *, home: Path, cwd: Path) -> Path: diff --git a/python/src/kitup/install.py b/python/src/kitup/install.py index 1311784..9fa37bd 100644 --- a/python/src/kitup/install.py +++ b/python/src/kitup/install.py @@ -4,11 +4,16 @@ import tempfile from pathlib import Path +from ._github import fetch_github_directory_with_metadata from ._metadata import read_install_metadata, write_install_metadata from .bundle import ( + DirectoryBundle, + FilesBundle, + GitHubBundle, copy_normalized_bundle, compute_bundle_content_hash, - normalize_skill_bundle, + normalize_directory_bundle, + normalize_files_bundle, validate_skill_bundle, ) from .hosts import detect_hosts, load_host_spec, resolve_hosts @@ -51,29 +56,8 @@ def resolve_install_targets( scope: Scope, skill_name: str, ) -> list[TargetGroup]: - spec = load_host_spec(options.hosts_file) - home = Path(options.home).expanduser() if options.home else Path.home() - cwd = Path(options.cwd) if options.cwd else Path.cwd() - if agents in (None, "auto"): - selected = detect_hosts(options, scope) - else: - selected, errors = resolve_hosts(agents, spec.hosts) - if errors: - return [] - - by_target: dict[str, TargetGroup] = {} - for host in selected: - root = choose_scope_path(host, scope=scope, home=home, cwd=cwd) - if root is None: - continue - target_dir = str(root / skill_name) - group = by_target.get(target_dir) - if group is None: - group = TargetGroup(skill_name=skill_name, target_dir=target_dir) - by_target[target_dir] = group - group.host_ids.append(host.id) - - return [by_target[path] for path in sorted(by_target)] + targets, _ = _resolve_install_targets_with_errors(options, agents, scope, skill_name) + return targets def empty_install_report(errors: list[TargetError] | None = None) -> InstallReport: @@ -128,7 +112,7 @@ def write_managed_bundle( app_id: str, skill_name: str, digest: str, - source: str, + metadata: dict[str, object], replace: bool, ) -> None: if not replace: @@ -138,7 +122,10 @@ def write_managed_bundle( app_id=app_id, skill_name=skill_name, digest=digest, - source=source, + source=str(metadata["source"]), + source_id=_metadata_text(metadata, "source_id"), + version=_metadata_text(metadata, "version"), + provenance=_metadata_provenance(metadata), ) return @@ -157,7 +144,10 @@ def write_managed_bundle( app_id=app_id, skill_name=skill_name, digest=digest, - source=source, + source=str(metadata["source"]), + source_id=_metadata_text(metadata, "source_id"), + version=_metadata_text(metadata, "version"), + provenance=_metadata_provenance(metadata), ) backup_dir = Path( tempfile.mkdtemp( @@ -177,19 +167,31 @@ def write_managed_bundle( def install_or_plan(options: InstallOptions, *, write: bool) -> InstallReport: + try: + normalized, bundle_metadata = _resolve_bundle_and_metadata( + options.skill_bundle, cwd=options.base.cwd + ) + except Exception: + reason = ( + "bundle-resolve-failed" + if isinstance(options.skill_bundle, GitHubBundle) + else "invalid-skill-bundle" + ) + return empty_install_report([TargetError(reason=reason)]) + info = validate_skill_bundle(options.skill_bundle, cwd=options.base.cwd) if not info.valid or not info.skill_name: return empty_install_report([TargetError(reason=info.error_code or "invalid-skill-bundle")]) - normalized = normalize_skill_bundle(options.skill_bundle, cwd=options.base.cwd) digest = compute_bundle_content_hash(options.skill_bundle, cwd=options.base.cwd) - report = empty_install_report() - for target in resolve_install_targets( + targets, errors = _resolve_install_targets_with_errors( options.base, options.agents, options.scope, info.skill_name, - ): + ) + report = empty_install_report(errors) + for target in targets: result = target_result(target) target_dir = Path(target.target_dir) metadata_file = target_dir / ".kitup.json" @@ -202,7 +204,7 @@ def install_or_plan(options: InstallOptions, *, write: bool) -> InstallReport: app_id=options.app_id, skill_name=info.skill_name, digest=digest, - source="bundled", + metadata=bundle_metadata, files=normalized.files, replace=False, ) @@ -223,28 +225,29 @@ def install_or_plan(options: InstallOptions, *, write: bool) -> InstallReport: continue if write: - write_managed_bundle( - target_dir, - app_id=options.app_id, - skill_name=info.skill_name, - digest=digest, - source=str(metadata.get("source", "bundled")), - files=normalized.files, - replace=True, - ) + write_managed_bundle( + target_dir, + app_id=options.app_id, + skill_name=info.skill_name, + digest=digest, + metadata=bundle_metadata, + files=normalized.files, + replace=True, + ) report.updated.append(result) return report def uninstall_bundled_skill(options: UninstallOptions) -> UninstallReport: - report = empty_uninstall_report() - for target in resolve_install_targets( + targets, errors = _resolve_install_targets_with_errors( options.base, options.agents, options.scope, options.skill_name, - ): + ) + report = empty_uninstall_report(errors) + for target in targets: result = target_result(target) target_dir = Path(target.target_dir) metadata_file = target_dir / ".kitup.json" @@ -267,3 +270,63 @@ def uninstall_bundled_skill(options: UninstallOptions) -> UninstallReport: report.removed.append(result) return report + + +def _resolve_bundle_and_metadata(skill_bundle: object, *, cwd: str | None) -> tuple[object, dict[str, object]]: + if isinstance(skill_bundle, DirectoryBundle): + return normalize_directory_bundle(skill_bundle.path, cwd=cwd), {"source": "bundled"} + if isinstance(skill_bundle, FilesBundle): + return normalize_files_bundle(skill_bundle.files), {"source": "bundled"} + if isinstance(skill_bundle, GitHubBundle): + files, metadata = fetch_github_directory_with_metadata(skill_bundle.options) + return normalize_files_bundle(files), metadata + raise TypeError(f"unsupported bundle: {type(skill_bundle)!r}") + + +def _resolve_install_targets_with_errors( + options: BaseOptions, + agents: str | list[str] | None, + scope: Scope, + skill_name: str, +) -> tuple[list[TargetGroup], list[TargetError]]: + spec = load_host_spec(options.hosts_file) + home = Path(options.home).expanduser() if options.home else Path.home() + cwd = Path(options.cwd) if options.cwd else Path.cwd() + if agents in (None, "auto"): + selected = detect_hosts(options, scope) + errors: list[TargetError] = [] + else: + selected, resolution_errors = resolve_hosts(agents, spec.hosts) + errors = [TargetError(reason=error["reason"], agent=error["agent"]) for error in resolution_errors] + + by_target: dict[str, TargetGroup] = {} + for host in selected: + root = choose_scope_path(host, scope=scope, home=home, cwd=cwd) + if root is None: + errors.append( + TargetError( + reason="unsupported-scope", + host_id=host.id, + skill_name=skill_name, + scope=scope, + ) + ) + continue + target_dir = str(root / skill_name) + group = by_target.get(target_dir) + if group is None: + group = TargetGroup(skill_name=skill_name, target_dir=target_dir) + by_target[target_dir] = group + group.host_ids.append(host.id) + + return [by_target[path] for path in sorted(by_target)], errors + + +def _metadata_text(metadata: dict[str, object], key: str) -> str | None: + value = metadata.get(key) + return value if isinstance(value, str) else None + + +def _metadata_provenance(metadata: dict[str, object]) -> dict[str, object] | None: + value = metadata.get("provenance") + return value if isinstance(value, dict) else None diff --git a/python/src/kitup/types.py b/python/src/kitup/types.py index 8248337..fcdf248 100644 --- a/python/src/kitup/types.py +++ b/python/src/kitup/types.py @@ -117,6 +117,9 @@ class TargetStatus(TargetResult): class TargetError: reason: str agent: str | None = None + host_id: str | None = None + skill_name: str | None = None + scope: Scope | None = None @dataclass(frozen=True) @@ -138,12 +141,12 @@ class UninstallReport: @dataclass class InstallSelection: - action: str + action: Literal["install", "select-agents", "error"] selected_host_ids: list[str] candidate_host_ids: list[str] detected_host_ids: list[str] needs_confirmation: bool - errors: list[TargetError] + errors: list[dict[str, str]] @dataclass(frozen=True) @@ -180,7 +183,7 @@ class InstallWorkflowExit: @dataclass class InstallWorkflowReport: selection: InstallSelection - scope: str + scope: Scope | Literal[""] plan: InstallReport report: InstallReport canceled: bool @@ -194,12 +197,23 @@ class ParsedInstallFlags: agents: str | list[str] yes: bool dry_run: bool - errors: list[TargetError] + errors: list[dict[str, str]] INSTALL_UX = { "skill_use": "skill", "install_use": "install", + "select_scope": "Select install scope:", "scope_prompt": "Scope (user/project)", + "invalid_scope_selection": "Invalid scope selection.", + "select_agents": "Select agents:", "agents_prompt": "Agents (numbers, ids, comma-separated, empty cancels)", + "invalid_agent_selection": "Invalid agent selection.", + "proceed": "Proceed? [y/N] ", + "error_prefix": "kitup:", + "canceled": "Installation canceled.", + "selection_error": "Agent selection failed.", + "conflict": "Installation has conflicts.", + "failed": "Installation failed.", + "invalid_flags": "Invalid install flags.", } diff --git a/python/src/kitup/workflow.py b/python/src/kitup/workflow.py new file mode 100644 index 0000000..031c3d1 --- /dev/null +++ b/python/src/kitup/workflow.py @@ -0,0 +1,586 @@ +from __future__ import annotations + +from dataclasses import replace +import io +from typing import Iterable + +from .hosts import detect_hosts, load_host_spec, resolve_hosts +from .install import install_bundled_skill, plan_bundled_skill +from .types import ( + INSTALL_UX, + InstallOptions, + InstallReport, + InstallSelection, + InstallSelectionOptions, + InstallWorkflowExit, + InstallWorkflowOptions, + InstallWorkflowReport, + KitupError, + ParsedInstallFlags, + Scope, +) + + +def split_flag_values(values: list[str]) -> list[str]: + return [ + part.strip() + for value in values + for part in value.replace(",", " ").split() + if part.strip() + ] + + +def dedupe(values: list[str]) -> list[str]: + seen: set[str] = set() + result: list[str] = [] + for value in values: + if value not in seen: + seen.add(value) + result.append(value) + return result + + +def parse_scope_flag( + value: str | None, errors: list[dict[str, str]] | None = None +) -> Scope: + issues = errors if errors is not None else [] + if value in (None, "", "user"): + return "user" + if value == "project": + return "project" + issues.append({"flag": "scope", "reason": "invalid-scope", "value": value}) + return "user" + + +def agent_selector_from_flags( + values: list[str], errors: list[dict[str, str]] | None = None +) -> str | list[str]: + issues = errors if errors is not None else [] + agents = split_flag_values(values) + if not agents: + return "auto" + if "*" in agents: + if len(agents) > 1: + issues.append( + { + "flag": "agent", + "reason": "agent-star-must-be-alone", + "value": ",".join(agents), + } + ) + return "*" + return dedupe(agents) + + +def parse_install_flags(flags: dict[str, object]) -> ParsedInstallFlags: + errors: list[dict[str, str]] = [] + agents = flags.get("agents") + return ParsedInstallFlags( + scope=parse_scope_flag(_coerce_optional_text(flags.get("scope")), errors), + scope_set=bool(flags.get("scopeSet", "scope" in flags)), + agents=agent_selector_from_flags(_coerce_flag_values(agents), errors), + yes=bool(flags.get("yes")), + dry_run=bool(flags.get("dryRun")), + errors=errors, + ) + + +def resolve_install_selection(options: InstallSelectionOptions) -> InstallSelection: + spec = load_host_spec(options.base.hosts_file) + stdin_tty = options.stdin_tty + explicit_agents = options.agents not in (None, "auto") + + if options.current_agent and not explicit_agents: + selected, errors = resolve_hosts([options.current_agent], spec.hosts) + selected = _add_universal_host(selected, spec.hosts) + return _install_selection( + [host.id for host in selected], + [], + stdin_tty and not options.yes, + errors, + ) + + if explicit_agents: + if options.agents == "*": + return _install_selection( + [host.id for host in spec.hosts], + [], + stdin_tty and not options.yes, + ) + selected, errors = resolve_hosts(options.agents, spec.hosts) + return _install_selection( + [host.id for host in selected], + [], + stdin_tty and not options.yes, + errors, + ) + + detected = detect_hosts(options.base, scope=options.scope) + detected_host_ids = [host.id for host in detected] + + if not stdin_tty and not options.yes: + return _error_selection( + [{"reason": "agent-selection-required"}], detected_host_ids + ) + if options.yes: + if not detected_host_ids: + return _error_selection([{"reason": "no-detected-hosts"}], []) + return _install_selection(detected_host_ids, detected_host_ids, False) + if not detected_host_ids: + return _select_agents_selection( + [host.id for host in spec.hosts], detected_host_ids, [] + ) + if len(detected_host_ids) == 1: + return _install_selection(detected_host_ids, detected_host_ids, True) + return _select_agents_selection(detected_host_ids, detected_host_ids, []) + + +def classify_install_workflow_exit(report: InstallWorkflowReport | dict[str, object]) -> InstallWorkflowExit: + if _workflow_value(report, "canceled"): + return InstallWorkflowExit(ok=False, code="canceled", message=INSTALL_UX["canceled"]) + selection = _workflow_value(report, "selection") + if _workflow_value(selection, "errors"): + return InstallWorkflowExit( + ok=False, + code="selection-error", + message=INSTALL_UX["selection_error"], + ) + run_report = _workflow_value(report, "report") + if _workflow_value(run_report, "conflicts"): + return InstallWorkflowExit( + ok=False, + code="conflict", + message=INSTALL_UX["conflict"], + ) + if _workflow_value(run_report, "errors"): + return InstallWorkflowExit( + ok=False, + code="error", + message=INSTALL_UX["failed"], + ) + return InstallWorkflowExit(ok=True, code="ok", message="") + + +def install_flag_error(errors: list[dict[str, str]]) -> Exception | None: + return None if not errors else KitupError(INSTALL_UX["invalid_flags"]) + + +def install_workflow_error( + report: InstallWorkflowReport | dict[str, object] +) -> Exception | None: + exit_info = classify_install_workflow_exit(report) + return None if exit_info.ok or exit_info.code == "canceled" else KitupError(exit_info.message) + + +def run_bundled_skill_install(options: InstallWorkflowOptions) -> InstallWorkflowReport: + return run_bundled_skill_install_with_io(options, options.input, options.output) + + +def run_bundled_skill_install_with_io( + options: InstallWorkflowOptions, + input: object | None, + output: object | None, +) -> InstallWorkflowReport: + reader = _LineReader(input) + writer = _coerce_output(output) + scope, scope_error = _resolve_workflow_scope( + reader=reader, + output=writer, + requested=options.install.scope, + scope_set=options.scope_set, + prompt_scope=options.prompt_scope, + configured_default=options.default_scope, + yes=options.yes, + stdin_tty=options.stdin_tty, + ) + if scope_error is not None: + _render_selection_errors(writer, scope_error) + return InstallWorkflowReport( + selection=scope_error, + scope=scope, + plan=empty_install_report(), + report=empty_install_report(), + canceled=False, + dry_run=options.dry_run, + ) + + selection = resolve_install_selection( + InstallSelectionOptions( + base=options.install.base, + scope=scope, + agents=options.install.agents, + yes=options.yes, + stdin_tty=options.stdin_tty, + current_agent=options.current_agent, + ) + ) + if selection.action == "error": + _render_selection_errors(writer, selection) + return InstallWorkflowReport( + selection=selection, + scope=scope, + plan=empty_install_report(), + report=empty_install_report(), + canceled=False, + dry_run=options.dry_run, + ) + if selection.action == "select-agents": + hosts = load_host_spec(options.install.base.hosts_file).hosts + selected_host_ids = _prompt_agent_selection(reader, writer, selection, hosts) + selection = _install_selection( + selected_host_ids, + selection.detected_host_ids, + options.stdin_tty and not options.yes, + ) + if not selected_host_ids: + return InstallWorkflowReport( + selection=selection, + scope=scope, + plan=empty_install_report(), + report=empty_install_report(), + canceled=True, + dry_run=options.dry_run, + ) + + install_options = replace( + options.install, + scope=scope, + agents=selection.selected_host_ids, + ) + plan = plan_bundled_skill(install_options) + if not _has_visible_install_plan(plan): + return InstallWorkflowReport( + selection=selection, + scope=scope, + plan=plan, + report=plan, + canceled=False, + dry_run=options.dry_run, + ) + + _render_install_summary(writer, plan) + if options.dry_run: + return InstallWorkflowReport( + selection=selection, + scope=scope, + plan=plan, + report=plan, + canceled=False, + dry_run=True, + ) + if not _has_install_writes(plan): + return InstallWorkflowReport( + selection=selection, + scope=scope, + plan=plan, + report=plan, + canceled=False, + dry_run=False, + ) + if selection.needs_confirmation and not _prompt_confirmation(reader, writer): + return InstallWorkflowReport( + selection=selection, + scope=scope, + plan=plan, + report=empty_install_report(), + canceled=True, + dry_run=False, + ) + + report = install_bundled_skill(install_options) + return InstallWorkflowReport( + selection=selection, + scope=scope, + plan=plan, + report=report, + canceled=False, + dry_run=False, + ) + + +def empty_install_report(errors: list[object] | None = None) -> InstallReport: + return InstallReport(errors=list(errors or [])) + + +def _resolve_workflow_scope( + *, + reader: "_LineReader", + output: "_OutputWriter", + requested: Scope, + scope_set: bool, + prompt_scope: bool, + configured_default: Scope, + yes: bool, + stdin_tty: bool, +) -> tuple[Scope | str, InstallSelection | None]: + default_scope = configured_default or "user" + scope = requested or default_scope + if scope_set or not prompt_scope: + return scope, None + if yes: + return default_scope, None + if not stdin_tty: + return "", _error_selection([{"reason": "scope-selection-required"}], []) + return _prompt_scope_selection(reader, output, default_scope), None + + +def _prompt_scope_selection( + reader: "_LineReader", + output: "_OutputWriter", + default_scope: Scope, +) -> Scope: + while True: + _write_line(output, INSTALL_UX["select_scope"]) + _write_line(output, " 1. user") + _write_line(output, " 2. project") + output.write(f"{INSTALL_UX['scope_prompt']} [{default_scope}]: ") + selected = _parse_scope_selection(reader.read_line() or "", default_scope) + if selected is not None: + return selected + _write_line(output, INSTALL_UX["invalid_scope_selection"]) + + +def _parse_scope_selection(line: str, default_scope: Scope) -> Scope | None: + value = line.strip().lower() + if value == "": + return default_scope + if value in {"1", "u", "user"}: + return "user" + if value in {"2", "p", "project"}: + return "project" + return None + + +def _prompt_agent_selection( + reader: "_LineReader", + output: "_OutputWriter", + selection: InstallSelection, + hosts: list[object], +) -> list[str]: + candidates = [ + host + for host_id in selection.candidate_host_ids + for host in hosts + if getattr(host, "id", None) == host_id + ] + while True: + _write_line(output, INSTALL_UX["select_agents"]) + for index, host in enumerate(candidates, start=1): + _write_line(output, f" {index}. {host.display_name} ({host.id})") + current = ",".join(selection.selected_host_ids) + suffix = f" [{current}]" if current else "" + output.write(f"{INSTALL_UX['agents_prompt']}{suffix}: ") + selected = _parse_agent_selection(reader.read_line() or "", selection, candidates) + if selected is not None: + return selected + _write_line(output, INSTALL_UX["invalid_agent_selection"]) + + +def _parse_agent_selection( + line: str, selection: InstallSelection, candidates: list[object] +) -> list[str] | None: + trimmed = line.strip() + if trimmed == "": + return list(selection.selected_host_ids) + if trimmed == "*": + return [host.id for host in candidates] + + by_name: dict[str, str] = {} + for index, host in enumerate(candidates, start=1): + by_name[str(index)] = host.id + by_name[host.id] = host.id + for alias in host.aliases: + by_name[alias] = host.id + + selected: list[str] = [] + seen: set[str] = set() + for part in split_flag_values([trimmed]): + host_id = by_name.get(part) + if host_id is None: + return None + if host_id not in seen: + seen.add(host_id) + selected.append(host_id) + return selected + + +def _prompt_confirmation(reader: "_LineReader", output: "_OutputWriter") -> bool: + output.write(INSTALL_UX["proceed"]) + line = (reader.read_line() or "").strip().lower() + return line in {"y", "yes"} + + +def _render_install_summary(output: "_OutputWriter", report: InstallReport) -> None: + for item in [*report.installed, *report.updated]: + for host_id in _summary_hosts(item): + _write_line(output, f" - {item.skill_name} -> {item.target_dir} ({host_id})") + + +def _summary_hosts(item: object) -> list[str]: + host_id = getattr(item, "host_id", None) + if host_id is not None: + return [host_id] + host_ids = getattr(item, "host_ids", None) + return list(host_ids or []) + + +def _render_selection_errors(output: "_OutputWriter", selection: InstallSelection) -> None: + for error in selection.errors: + _write_line(output, f"{INSTALL_UX['error_prefix']} {error['reason']}") + + +def _write_line(output: "_OutputWriter", line: str) -> None: + output.write(f"{line}\n") + + +def _has_visible_install_plan(report: InstallReport) -> bool: + return ( + len(report.installed) + + len(report.updated) + + len(report.conflicts) + + len(report.errors) + > 0 + ) + + +def _has_install_writes(report: InstallReport) -> bool: + return len(report.installed) + len(report.updated) > 0 + + +def _install_selection( + selected_host_ids: list[str], + detected_host_ids: list[str], + needs_confirmation: bool, + errors: list[dict[str, str]] | None = None, +) -> InstallSelection: + issues = list(errors or []) + return InstallSelection( + action="error" if issues else "install", + selected_host_ids=selected_host_ids, + candidate_host_ids=[], + detected_host_ids=detected_host_ids, + needs_confirmation=False if issues else needs_confirmation, + errors=issues, + ) + + +def _select_agents_selection( + candidate_host_ids: list[str], + detected_host_ids: list[str], + selected_host_ids: list[str], +) -> InstallSelection: + return InstallSelection( + action="select-agents", + selected_host_ids=selected_host_ids, + candidate_host_ids=candidate_host_ids, + detected_host_ids=detected_host_ids, + needs_confirmation=True, + errors=[], + ) + + +def _error_selection( + errors: list[dict[str, str]], detected_host_ids: list[str] +) -> InstallSelection: + return InstallSelection( + action="error", + selected_host_ids=[], + candidate_host_ids=[], + detected_host_ids=detected_host_ids, + needs_confirmation=False, + errors=list(errors), + ) + + +def _add_universal_host(selected: list[object], hosts: list[object]) -> list[object]: + result = list(selected) + if any(host.id == "universal" for host in result): + return result + universal = next((host for host in hosts if host.id == "universal"), None) + if universal is not None: + result.append(universal) + return result + + +def _workflow_value(value: object, key: str) -> object: + if isinstance(value, dict): + return value.get(key) + return getattr(value, key) + + +def _coerce_optional_text(value: object) -> str | None: + return value if isinstance(value, str) else None + + +def _coerce_flag_values(value: object) -> list[str]: + if value is None: + return [] + if isinstance(value, list): + return [item for item in value if isinstance(item, str)] + if isinstance(value, tuple): + return [item for item in value if isinstance(item, str)] + if isinstance(value, str): + return [value] + return [] + + +class _LineReader: + def __init__(self, source: object | None) -> None: + self._lines: list[str] = list(self._iter_lines(source)) + self._index = 0 + + def read_line(self) -> str | None: + if self._index >= len(self._lines): + return None + line = self._lines[self._index] + self._index += 1 + return line + + def _iter_lines(self, source: object | None) -> Iterable[str]: + if source is None: + return [] + if isinstance(source, bytes): + return self._split_text(source.decode("utf-8")) + if isinstance(source, str): + return self._split_text(source) + if hasattr(source, "read"): + contents = source.read() + 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: + if isinstance(item, bytes): + chunks.append(item.decode("utf-8")) + else: + chunks.append(str(item)) + return self._split_text("".join(chunks)) + return [] + + @staticmethod + def _split_text(text: str) -> list[str]: + if text == "": + return [] + lines = text.splitlines() + if text.endswith(("\n", "\r")): + return [line.rstrip("\r") for line in lines] + if not lines: + return [text.rstrip("\r")] + return [line.rstrip("\r") for line in lines] + + +class _OutputWriter: + def __init__(self, target: object | None) -> None: + self._target = target + + def write(self, chunk: str) -> None: + if self._target is None: + return + self._target.write(chunk) + + +def _coerce_output(output: object | None) -> _OutputWriter: + return _OutputWriter(output) diff --git a/python/tests/golden_test.py b/python/tests/golden_test.py new file mode 100644 index 0000000..3b86512 --- /dev/null +++ b/python/tests/golden_test.py @@ -0,0 +1,517 @@ +from __future__ import annotations + +from dataclasses import asdict +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +import io +import json +import os +from pathlib import Path +import shutil +import tempfile +import threading + +from kitup import ( + BaseOptions, + InstallOptions, + InstallSelectionOptions, + InstallWorkflowOptions, + ParsedInstallFlags, + UninstallOptions, + classify_install_workflow_exit, + compute_bundle_content_hash, + detect_hosts, + directory_bundle, + files_bundle, + github_bundle, + install_bundled_skill, + load_host_spec, + parse_install_flags, + plan_bundled_skill, + resolve_hosts, + resolve_install_selection, + run_bundled_skill_install_with_io, + uninstall_bundled_skill, + update_bundled_skill, + validate_skill_bundle, +) +from kitup.types import GitHubBundleOptions, SkillFile + + +def test_golden_cases(): + cases = json.loads(repo_path("testdata/cases/bundled-skill-install.json").read_text())["cases"] + for case in cases: + root = Path(tempfile.mkdtemp(prefix=f"kitup-{case['id']}-")) + home = root / "home" + workspace = root / "workspace" + home.mkdir(parents=True) + workspace.mkdir(parents=True) + server = None + env_backup = None + try: + server, env_backup = setup_given(case, home, workspace) + run_case(case, home, workspace) + finally: + if server is not None: + server.shutdown() + server.server_close() + if env_backup is not None: + restore_github_env(env_backup) + shutil.rmtree(root, ignore_errors=True) + + +def run_case(case, home: Path, workspace: Path) -> None: + operation = case["operation"] + if operation == "resolve-hosts": + spec = load_host_spec(repo_path(case["given"]["hostsFile"])) + hosts, errors = resolve_hosts(case["options"]["agents"], spec.hosts) + expected = case["expected"] + if "count" in expected: + assert len(hosts) == expected["count"] + if "hostIds" in expected: + assert [host.id for host in hosts] == expected["hostIds"] + if "resolvedHostIds" in expected: + assert [host.id for host in hosts] == expected["resolvedHostIds"] + if "errors" in expected: + assert errors == expected["errors"] + return + + if operation == "validate": + result = validate_skill_bundle(skill_bundle_from_case(case)) + assert result.valid == case["expected"]["valid"] + assert result.error_code == case["expected"].get("errorCode") + return + + if operation == "parse-install-flags": + parsed = parse_install_flags(case["options"]) + assert normalize_parsed_flags(parsed) == case["expected"]["parsed"] + return + + if operation == "resolve-install-selection": + selection = resolve_install_selection( + selection_options_from_case(case, home, workspace) + ) + assert_selection( + normalize_value(selection), + expand_value(case["expected"]["selection"], home, workspace), + ) + return + + if operation == "run-install-workflow": + input_stream = io.StringIO(case["options"].get("input", "")) + output_stream = io.StringIO() + workflow = run_bundled_skill_install_with_io( + workflow_options_from_case(case, home, workspace), + input_stream, + output_stream, + ) + assert_workflow( + normalize_value(workflow), + expand_value(case["expected"].get("workflow"), home, workspace), + ) + if "exit" in case["expected"]: + assert normalize_value(classify_install_workflow_exit(workflow)) == case["expected"]["exit"] + assert_output(output_stream.getvalue(), case["expected"].get("output")) + assert_output_contains(output_stream.getvalue(), case["expected"].get("outputContains")) + if "report" in case["expected"]: + assert normalize_value(workflow.report) == camel_to_snake_dict( + expand_value(case["expected"]["report"], home, workspace) + ) + assert_expected_files(case, home, workspace) + assert_expected_metadata(case, home, workspace) + return + + if "detectedHosts" in case["expected"]: + hosts = detect_hosts( + BaseOptions( + home=str(home), + cwd=str(workspace), + hosts_file=repo_path("spec/hosts.json"), + ), + case["options"]["scope"], + ) + assert [host.id for host in hosts] == case["expected"]["detectedHosts"] + + report = run_report_case(case, home, workspace) + if "report" in case["expected"]: + assert normalize_value(report) == camel_to_snake_dict( + expand_value(case["expected"]["report"], home, workspace) + ) + assert_expected_write_counts(case, report, home, workspace) + assert_expected_files(case, home, workspace) + assert_expected_metadata(case, home, workspace) + + +def run_report_case(case, home: Path, workspace: Path): + operation = case["operation"] + if operation == "uninstall": + return uninstall_bundled_skill(uninstall_options_from_case(case, home, workspace)) + install_options = install_options_from_case(case, home, workspace) + if operation == "update": + return update_bundled_skill(install_options) + if operation == "plan": + return plan_bundled_skill(install_options) + if operation == "install": + return install_bundled_skill(install_options) + raise AssertionError(f"unsupported operation: {operation}") + + +def setup_given(case, home: Path, workspace: Path): + for value in case["given"].get("dirs", []): + expand_path(value, home, workspace).mkdir(parents=True, exist_ok=True) + for path, value in case["given"].get("files", {}).items(): + write_fixture_file(expand_path(path, home, workspace), value) + if "copySkillBundleTo" in case["given"]: + target = expand_path(case["given"]["copySkillBundleTo"], home, workspace) + shutil.rmtree(target, ignore_errors=True) + shutil.copytree(case_skill_bundle_dir(case), target) + if "metadata" in case["given"]: + write_metadata_fixture(case, home, workspace, case["given"]["metadata"]) + github = case["given"].get("github") + if github is None: + return None, None + return start_github_fixture(github) + + +def assert_expected_files(case, home: Path, workspace: Path) -> None: + for value in case["expected"].get("filesPresent", []): + path = expand_path(value, home, workspace) + assert path.exists(), f"expected file to exist: {path}" + for value in case["expected"].get("filesAbsent", []): + path = expand_path(value, home, workspace) + assert not path.exists(), f"expected file to be absent: {path}" + + +def assert_expected_metadata(case, home: Path, workspace: Path) -> None: + metadata = case["expected"].get("metadata") + if metadata is None: + return + path = expand_path(metadata["path"], home, workspace) + actual = json.loads(path.read_text()) + for key, value in metadata["fields"].items(): + assert actual[key] == value + assert actual["hash"] == expected_bundle_hash(case, metadata["hash"]) + + +def assert_selection(actual, expected) -> None: + actual_value = dict(actual) + expected_value = dict(expected) + if "selectedCount" in expected_value: + assert len(actual_value["selected_host_ids"]) == expected_value["selectedCount"] + actual_value.pop("selected_host_ids") + expected_value.pop("selectedCount") + if "candidateCount" in expected_value: + assert len(actual_value["candidate_host_ids"]) == expected_value["candidateCount"] + actual_value.pop("candidate_host_ids") + expected_value.pop("candidateCount") + assert actual_value == camel_to_snake_dict(expected_value) + + +def assert_workflow(actual, expected) -> None: + if expected is None: + return + for key, value in camel_to_snake_dict(expected).items(): + assert actual[key] == value + + +def assert_output_contains(actual: str, expected) -> None: + if expected is None: + return + for value in expected: + assert value in actual, f"expected output to contain {value!r}, got:\n{actual}" + + +def assert_output(actual: str, expected) -> None: + if expected is None: + return + assert actual == expected + + +def assert_expected_write_counts(case, report, home: Path, workspace: Path) -> None: + expected = case["expected"].get("writeCountByTargetDir") + if expected is None: + return + actual: dict[str, int] = {} + normalized = normalize_value(report) + for key in ["installed", "updated"]: + for item in normalized[key]: + target_dir = item["target_dir"] + actual[target_dir] = actual.get(target_dir, 0) + 1 + assert actual == expand_value(expected, home, workspace) + + +def write_metadata_fixture(case, home: Path, workspace: Path, metadata) -> None: + fields = dict(metadata["fields"]) + fields["hash"] = expected_bundle_hash(case, metadata["hash"]) + write_fixture_file(expand_path(metadata["path"], home, workspace), fields) + + +def expected_bundle_hash(case, marker: str) -> str: + if marker == "from-skill-bundle-dir": + return compute_bundle_content_hash(directory_bundle(str(case_skill_bundle_dir(case)))) + if marker == "from-skill-files": + return compute_bundle_content_hash(files_bundle(skill_files(case["options"]["skillFiles"]))) + if marker == "from-github-bundle": + return compute_bundle_content_hash(files_bundle(github_skill_files(case))) + return marker + + +def write_fixture_file(path: Path, value) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + if isinstance(value, str): + path.write_text(value, encoding="utf-8") + else: + path.write_text(json.dumps(value, indent=2) + "\n", encoding="utf-8") + + +def start_github_fixture(github): + owner = github["owner"] + repo = github["repo"] + ref_name = github["ref"] + commit = github["commit"] + tree_sha = github["treeSha"] + files = github["files"] + + class Handler(BaseHTTPRequestHandler): + def do_GET(self): + path = self.path.split("?", 1)[0] + commit_path = f"/repos/{owner}/{repo}/commits/{ref_name}" + tree_path = f"/repos/{owner}/{repo}/git/trees/{tree_sha}" + if path == commit_path: + self._write_json({"sha": commit, "commit": {"tree": {"sha": tree_sha}}}) + return + if path == tree_path: + self._write_json( + { + "tree": [ + { + "path": file_path, + "type": "blob", + "mode": "100755" if file_path.endswith(".sh") else "100644", + } + for file_path in files + ] + } + ) + return + raw_prefix = f"/{owner}/{repo}/{commit}/" + if path.startswith(raw_prefix): + relative = path[len(raw_prefix) :] + if relative in files: + self._write_bytes(files[relative].encode("utf-8")) + return + self.send_response(404) + self.send_header("content-type", "text/plain") + self.end_headers() + self.wfile.write(b"not found") + + def log_message(self, format, *args): + return + + def _write_json(self, value): + payload = json.dumps(value).encode("utf-8") + self.send_response(200) + self.send_header("content-type", "application/json") + self.send_header("content-length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + def _write_bytes(self, value: bytes): + self.send_response(200) + self.send_header("content-type", "application/octet-stream") + self.send_header("content-length", str(len(value))) + self.end_headers() + self.wfile.write(value) + + server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + + base_url = f"http://127.0.0.1:{server.server_port}" + env_backup = { + "KITUP_GITHUB_API_BASE_URL": os.environ.get("KITUP_GITHUB_API_BASE_URL"), + "KITUP_GITHUB_RAW_BASE_URL": os.environ.get("KITUP_GITHUB_RAW_BASE_URL"), + } + os.environ["KITUP_GITHUB_API_BASE_URL"] = base_url + os.environ["KITUP_GITHUB_RAW_BASE_URL"] = base_url + return server, env_backup + + +def restore_github_env(env_backup) -> None: + for key, value in env_backup.items(): + if value is None: + os.environ.pop(key, None) + else: + os.environ[key] = value + + +def install_options_from_case(case, home: Path, workspace: Path) -> InstallOptions: + return InstallOptions( + base=BaseOptions( + home=str(home), + cwd=str(workspace), + hosts_file=repo_path("spec/hosts.json"), + ), + app_id=case["options"]["appId"], + skill_bundle=skill_bundle_from_case(case), + scope=case["options"].get("scope", "user"), + agents=case["options"].get("agents", "auto"), + ) + + +def uninstall_options_from_case(case, home: Path, workspace: Path) -> UninstallOptions: + return UninstallOptions( + base=BaseOptions( + home=str(home), + cwd=str(workspace), + hosts_file=repo_path("spec/hosts.json"), + ), + app_id=case["options"]["appId"], + skill_name=case["options"]["skillName"], + scope=case["options"]["scope"], + agents=case["options"].get("agents", "auto"), + ) + + +def selection_options_from_case(case, home: Path, workspace: Path) -> InstallSelectionOptions: + return InstallSelectionOptions( + base=BaseOptions( + home=str(home), + cwd=str(workspace), + hosts_file=repo_path("spec/hosts.json"), + ), + scope=case["options"].get("scope", "user"), + agents=case["options"].get("agents", "auto"), + yes=case["options"].get("yes", False), + stdin_tty=case["options"].get("stdinTTY", False), + current_agent=case["options"].get("currentAgent"), + ) + + +def workflow_options_from_case(case, home: Path, workspace: Path) -> InstallWorkflowOptions: + return InstallWorkflowOptions( + install=install_options_from_case(case, home, workspace), + yes=case["options"].get("yes", False), + dry_run=case["options"].get("dryRun", False), + stdin_tty=case["options"].get("stdinTTY", False), + current_agent=case["options"].get("currentAgent"), + default_scope=case["options"].get("defaultScope", "user"), + scope_set=case["options"].get("scopeSet", "scope" in case["options"]), + prompt_scope=case["options"].get("promptScope", False), + ) + + +def skill_bundle_from_case(case) -> object: + if "skillFiles" in case["options"]: + return files_bundle(skill_files(case["options"]["skillFiles"])) + if "skillBundleDir" in case["options"]: + return directory_bundle(str(repo_path(case["options"]["skillBundleDir"]))) + if "githubBundle" in case["options"]: + bundle = case["options"]["githubBundle"] + return github_bundle( + GitHubBundleOptions( + owner=bundle["owner"], + repo=bundle["repo"], + path=bundle["path"], + ref=bundle["ref"], + ) + ) + raise AssertionError(f"missing skill bundle for case {case['id']}") + + +def skill_files(values) -> list[SkillFile]: + return [ + SkillFile(path=value["path"], contents=value["contents"]) + for value in values + ] + + +def github_skill_files(case) -> list[SkillFile]: + root = f"{case['options']['githubBundle']['path'].strip('/')}/" + return [ + SkillFile(path=path[len(root) :], contents=contents) + for path, contents in case["given"]["github"]["files"].items() + if path.startswith(root) + ] + + +def case_skill_bundle_dir(case) -> Path: + if "skillBundleDir" in case["options"]: + return repo_path(case["options"]["skillBundleDir"]) + return repo_path(f"testdata/skills/{case['options']['skillName']}") + + +def repo_path(path: str) -> Path: + repo = Path(__file__).resolve().parents[2] + target = Path(path) + return target if target.is_absolute() else repo / target + + +def expand_value(value, home: Path, workspace: Path): + if isinstance(value, str): + if "$HOME" in value or "$WORKSPACE" in value: + return str(expand_path(value, home, workspace)) + return value + if isinstance(value, list): + return [expand_value(item, home, workspace) for item in value] + if isinstance(value, dict): + return { + str(expand_path(key, home, workspace)): expand_value(item, home, workspace) + for key, item in value.items() + } + return value + + +def expand_path(value: str, home: Path, workspace: Path) -> Path: + return Path( + value.replace("$HOME", str(home)).replace("$WORKSPACE", str(workspace)) + ) + + +def normalize_value(value): + if hasattr(value, "__dataclass_fields__"): + return normalize_value(asdict(value)) + if isinstance(value, Path): + return str(value) + if isinstance(value, list): + return [normalize_value(item) for item in value] + if isinstance(value, dict): + return { + key: normalize_value(item) + for key, item in value.items() + if item is not None + } + return value + + +def normalize_parsed_flags(parsed: ParsedInstallFlags) -> dict[str, object]: + return { + "scope": parsed.scope, + "scopeSet": parsed.scope_set, + "agentKind": "explicit" if isinstance(parsed.agents, list) else parsed.agents, + "agentIds": parsed.agents if isinstance(parsed.agents, list) else [], + "yes": parsed.yes, + "dryRun": parsed.dry_run, + "errors": parsed.errors, + } + + +def camel_to_snake_dict(value): + if isinstance(value, list): + return [camel_to_snake_dict(item) for item in value] + if not isinstance(value, dict): + return value + return { + camel_to_snake(key): camel_to_snake_dict(item) + for key, item in value.items() + } + + +def camel_to_snake(value: str) -> str: + result: list[str] = [] + for char in value: + if char.isupper(): + result.append("_") + result.append(char.lower()) + else: + result.append(char) + return "".join(result).lstrip("_") diff --git a/python/tests/test_hosts.py b/python/tests/test_hosts.py index 549ec87..3323ec4 100644 --- a/python/tests/test_hosts.py +++ b/python/tests/test_hosts.py @@ -85,7 +85,7 @@ def test_detect_hosts_skips_generic_detect_paths_and_sorts_by_scope_path(tmp_pat assert [host.id for host in hosts] == ["codex", "claude-code"] -def test_detect_hosts_uses_first_existing_specific_path_for_kimi_cli(tmp_path): +def test_detect_hosts_does_not_scan_past_primary_generic_detect_path(tmp_path): home = tmp_path / "home" workspace = tmp_path / "workspace" home.mkdir() @@ -100,4 +100,4 @@ def test_detect_hosts_uses_first_existing_specific_path_for_kimi_cli(tmp_path): scope="user", ) - assert "kimi-cli" in [host.id for host in hosts] + assert "kimi-cli" not in [host.id for host in hosts] diff --git a/python/tests/test_workflow.py b/python/tests/test_workflow.py new file mode 100644 index 0000000..c96038c --- /dev/null +++ b/python/tests/test_workflow.py @@ -0,0 +1,243 @@ +import io +import json + +import kitup +from kitup import ( + BaseOptions, + InstallOptions, + InstallSelectionOptions, + InstallWorkflowOptions, + agent_selector_from_flags, + classify_install_workflow_exit, + directory_bundle, + install_flag_error, + install_workflow_error, + parse_install_flags, + parse_scope_flag, + resolve_install_selection, + run_bundled_skill_install_with_io, +) +from kitup.workflow import split_flag_values + + +def write_hosts_file(path, hosts) -> None: + path.write_text( + json.dumps( + { + "$schema": "./hosts.schema.json", + "schemaVersion": 1, + "hosts": hosts, + } + ), + encoding="utf-8", + ) + + +def test_parse_install_flags_defaults_to_user_auto(): + parsed = parse_install_flags({}) + + assert parsed.scope == "user" + assert parsed.scope_set is False + assert parsed.agents == "auto" + assert parsed.yes is False + assert parsed.dry_run is False + assert parsed.errors == [] + + +def test_parse_install_flags_explicit_scope_agents_and_errors(): + parsed = parse_install_flags( + { + "scope": "global", + "agents": ["*", "codex,claude-code"], + "yes": True, + "dryRun": True, + } + ) + + assert parsed.scope == "user" + assert parsed.scope_set is True + assert parsed.agents == "*" + assert parsed.yes is True + assert parsed.dry_run is True + assert parsed.errors == [ + {"flag": "scope", "reason": "invalid-scope", "value": "global"}, + { + "flag": "agent", + "reason": "agent-star-must-be-alone", + "value": "*,codex,claude-code", + }, + ] + + +def test_flag_helpers_normalize_lists(): + errors: list[dict[str, str]] = [] + + assert split_flag_values(["codex,claude-code", " codex "]) == [ + "codex", + "claude-code", + "codex", + ] + assert agent_selector_from_flags(["codex,claude-code", "codex"], errors) == [ + "codex", + "claude-code", + ] + assert parse_scope_flag("project", errors) == "project" + assert errors == [] + + +def test_resolve_install_selection_requires_agents_without_tty_or_yes(tmp_path): + home = tmp_path / "home" + workspace = tmp_path / "workspace" + hosts_file = tmp_path / "hosts.json" + home.mkdir() + workspace.mkdir() + (home / ".codex").mkdir() + write_hosts_file( + hosts_file, + [ + { + "id": "codex", + "displayName": "Codex", + "projectSkillsDirs": [".agents/skills"], + "userSkillsDirs": ["~/.agents/skills"], + "detect": ["~/.codex"], + "status": "verified", + } + ], + ) + + selection = resolve_install_selection( + InstallSelectionOptions( + base=BaseOptions(home=str(home), cwd=str(workspace), hosts_file=str(hosts_file)), + scope="user", + stdin_tty=False, + yes=False, + ) + ) + + assert selection.action == "error" + assert selection.detected_host_ids == ["codex"] + assert selection.errors == [{"reason": "agent-selection-required"}] + + +def test_resolve_install_selection_tty_prompts_for_multiple_detected_hosts(tmp_path): + home = tmp_path / "home" + workspace = tmp_path / "workspace" + hosts_file = tmp_path / "hosts.json" + home.mkdir() + workspace.mkdir() + (home / ".codex").mkdir() + (home / ".claude").mkdir() + write_hosts_file( + hosts_file, + [ + { + "id": "codex", + "displayName": "Codex", + "projectSkillsDirs": [".agents/skills"], + "userSkillsDirs": ["~/.agents/skills"], + "detect": ["~/.codex"], + "status": "verified", + }, + { + "id": "claude-code", + "displayName": "Claude Code", + "projectSkillsDirs": [".claude/skills"], + "userSkillsDirs": ["~/.claude/skills"], + "detect": ["~/.claude"], + "status": "verified", + }, + ], + ) + + selection = resolve_install_selection( + InstallSelectionOptions( + base=BaseOptions(home=str(home), cwd=str(workspace), hosts_file=str(hosts_file)), + scope="user", + stdin_tty=True, + yes=False, + ) + ) + + assert selection.action == "select-agents" + assert selection.candidate_host_ids == ["codex", "claude-code"] + assert selection.detected_host_ids == ["codex", "claude-code"] + assert selection.needs_confirmation is True + + +def test_classify_install_workflow_exit_reports_conflict(): + exit_info = classify_install_workflow_exit( + { + "canceled": False, + "selection": {"errors": []}, + "report": {"conflicts": [{}], "errors": []}, + } + ) + + assert exit_info.ok is False + assert exit_info.code == "conflict" + assert exit_info.message == "Installation has conflicts." + + +def test_error_helpers_map_flag_and_workflow_failures(): + assert str(install_flag_error([{"reason": "invalid-scope"}])) == "Invalid install flags." + assert install_workflow_error( + { + "canceled": False, + "selection": {"errors": [{"reason": "scope-selection-required"}]}, + "report": {"conflicts": [], "errors": []}, + } + ).args[0] == "Agent selection failed." + assert install_workflow_error( + { + "canceled": True, + "selection": {"errors": []}, + "report": {"conflicts": [], "errors": []}, + } + ) is None + + +def test_run_bundled_skill_install_scope_prompt_and_top_level_exports(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_with_io( + 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"], + ), + stdin_tty=True, + prompt_scope=True, + scope_set=False, + ), + io.StringIO("project\ny\n"), + 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() + + assert kitup.parse_install_flags is parse_install_flags + assert kitup.parse_scope_flag is parse_scope_flag + assert kitup.agent_selector_from_flags is agent_selector_from_flags + assert kitup.resolve_install_selection is resolve_install_selection + assert kitup.classify_install_workflow_exit is classify_install_workflow_exit + assert kitup.install_flag_error is install_flag_error + assert kitup.install_workflow_error is install_workflow_error + assert kitup.run_bundled_skill_install_with_io is run_bundled_skill_install_with_io From a77aabdea8cc94e2416b5d32823f2dcd079d6658 Mon Sep 17 00:00:00 2001 From: spencercjh Date: Wed, 1 Jul 2026 11:12:38 +0000 Subject: [PATCH 10/18] fix(python): align workflow selection and github snapshots Signed-off-by: spencercjh --- python/src/kitup/bundle.py | 8 +++ python/src/kitup/install.py | 8 +-- python/src/kitup/workflow.py | 3 +- python/tests/test_workflow.py | 102 ++++++++++++++++++++++++++++++++++ 4 files changed, 116 insertions(+), 5 deletions(-) diff --git a/python/src/kitup/bundle.py b/python/src/kitup/bundle.py index 7ca6519..d58b4e7 100644 --- a/python/src/kitup/bundle.py +++ b/python/src/kitup/bundle.py @@ -54,6 +54,10 @@ def validate_skill_bundle(bundle: SkillBundle, cwd: str | None = None) -> SkillI except Exception: return SkillInfo(valid=False, error_code="invalid-skill-bundle") + return validate_normalized_skill_bundle(normalized) + + +def validate_normalized_skill_bundle(normalized: NormalizedSkillBundle) -> SkillInfo: skill_md = normalized.by_path.get("SKILL.md") if skill_md is None: return SkillInfo(valid=False, error_code="missing-skill-md") @@ -78,6 +82,10 @@ def validate_skill_bundle(bundle: SkillBundle, cwd: str | None = None) -> SkillI def compute_bundle_content_hash(bundle: SkillBundle, cwd: str | None = None) -> str: normalized = normalize_skill_bundle(bundle, cwd=cwd) + return compute_normalized_bundle_content_hash(normalized) + + +def compute_normalized_bundle_content_hash(normalized: NormalizedSkillBundle) -> str: digest = hashlib.sha256() for file in normalized.files: digest.update(file.path.encode("utf-8")) diff --git a/python/src/kitup/install.py b/python/src/kitup/install.py index 9fa37bd..6b363e7 100644 --- a/python/src/kitup/install.py +++ b/python/src/kitup/install.py @@ -11,10 +11,10 @@ FilesBundle, GitHubBundle, copy_normalized_bundle, - compute_bundle_content_hash, + compute_normalized_bundle_content_hash, normalize_directory_bundle, normalize_files_bundle, - validate_skill_bundle, + validate_normalized_skill_bundle, ) from .hosts import detect_hosts, load_host_spec, resolve_hosts from .types import ( @@ -179,11 +179,11 @@ def install_or_plan(options: InstallOptions, *, write: bool) -> InstallReport: ) return empty_install_report([TargetError(reason=reason)]) - info = validate_skill_bundle(options.skill_bundle, cwd=options.base.cwd) + info = validate_normalized_skill_bundle(normalized) if not info.valid or not info.skill_name: return empty_install_report([TargetError(reason=info.error_code or "invalid-skill-bundle")]) - digest = compute_bundle_content_hash(options.skill_bundle, cwd=options.base.cwd) + digest = compute_normalized_bundle_content_hash(normalized) targets, errors = _resolve_install_targets_with_errors( options.base, options.agents, diff --git a/python/src/kitup/workflow.py b/python/src/kitup/workflow.py index 031c3d1..6791fd4 100644 --- a/python/src/kitup/workflow.py +++ b/python/src/kitup/workflow.py @@ -108,11 +108,12 @@ def resolve_install_selection(options: InstallSelectionOptions) -> InstallSelect stdin_tty and not options.yes, ) selected, errors = resolve_hosts(options.agents, spec.hosts) + if errors: + return _error_selection(errors, []) return _install_selection( [host.id for host in selected], [], stdin_tty and not options.yes, - errors, ) detected = detect_hosts(options.base, scope=options.scope) diff --git a/python/tests/test_workflow.py b/python/tests/test_workflow.py index c96038c..e81f20b 100644 --- a/python/tests/test_workflow.py +++ b/python/tests/test_workflow.py @@ -10,13 +10,16 @@ agent_selector_from_flags, classify_install_workflow_exit, directory_bundle, + github_bundle, install_flag_error, install_workflow_error, parse_install_flags, + plan_bundled_skill, parse_scope_flag, resolve_install_selection, run_bundled_skill_install_with_io, ) +from kitup.types import GitHubBundleOptions, SkillFile from kitup.workflow import split_flag_values @@ -165,6 +168,45 @@ def test_resolve_install_selection_tty_prompts_for_multiple_detected_hosts(tmp_p assert selection.needs_confirmation is True +def test_resolve_install_selection_explicit_agents_with_unknown_host_is_pure_error( + tmp_path, +): + home = tmp_path / "home" + workspace = tmp_path / "workspace" + hosts_file = tmp_path / "hosts.json" + home.mkdir() + workspace.mkdir() + write_hosts_file( + hosts_file, + [ + { + "id": "codex", + "displayName": "Codex", + "projectSkillsDirs": [".agents/skills"], + "userSkillsDirs": ["~/.agents/skills"], + "detect": ["~/.codex"], + "status": "verified", + } + ], + ) + + selection = resolve_install_selection( + InstallSelectionOptions( + base=BaseOptions(home=str(home), cwd=str(workspace), hosts_file=str(hosts_file)), + scope="user", + agents=["codex", "missing-agent"], + stdin_tty=False, + yes=False, + ) + ) + + assert selection.action == "error" + assert selection.selected_host_ids == [] + assert selection.candidate_host_ids == [] + assert selection.detected_host_ids == [] + assert selection.errors == [{"agent": "missing-agent", "reason": "unknown-host"}] + + def test_classify_install_workflow_exit_reports_conflict(): exit_info = classify_install_workflow_exit( { @@ -241,3 +283,63 @@ def test_run_bundled_skill_install_scope_prompt_and_top_level_exports(tmp_path): assert kitup.install_flag_error is install_flag_error assert kitup.install_workflow_error is install_workflow_error assert kitup.run_bundled_skill_install_with_io is run_bundled_skill_install_with_io + + +def test_plan_bundled_skill_uses_single_github_snapshot(monkeypatch, tmp_path): + home = tmp_path / "home" + workspace = tmp_path / "workspace" + home.mkdir() + workspace.mkdir() + + fetch_calls: list[str] = [] + + def fake_fetch_with_metadata(options): + fetch_calls.append(f"{options.owner}/{options.repo}@{options.ref}") + return ( + [ + SkillFile( + path="SKILL.md", + contents="---\nname: github-basic\ndescription: demo\n---\n", + ), + SkillFile(path="references/guide.md", contents="Guide\n"), + ], + { + "source": "github", + "source_id": "github:acme/skills/skills/github-basic", + "version": "main", + "provenance": { + "owner": "acme", + "repo": "skills", + "path": "skills/github-basic", + "ref": "main", + "resolvedCommit": "abc123", + }, + }, + ) + + def unexpected_refetch(_options): + raise AssertionError("bundle was re-fetched after snapshot resolution") + + monkeypatch.setattr("kitup.install.fetch_github_directory_with_metadata", fake_fetch_with_metadata) + monkeypatch.setattr("kitup.bundle.fetch_github_directory", unexpected_refetch) + + report = plan_bundled_skill( + InstallOptions( + base=BaseOptions(home=str(home), cwd=str(workspace)), + app_id="example-cli", + skill_bundle=github_bundle( + GitHubBundleOptions( + owner="acme", + repo="skills", + path="skills/github-basic", + ref="main", + ) + ), + scope="user", + agents=["codex"], + ) + ) + + assert fetch_calls == ["acme/skills@main"] + assert report.errors == [] + assert [item.skill_name for item in report.installed] == ["github-basic"] From 8286755f6f05ba8313befa29fdd2c16d21ccc5f2 Mon Sep 17 00:00:00 2001 From: spencercjh Date: Wed, 1 Jul 2026 11:21:06 +0000 Subject: [PATCH 11/18] build(python): wire checks and docs Signed-off-by: spencercjh --- .github/workflows/check.yml | 4 ++ Makefile | 20 ++++++-- README.md | 30 +++++++++++ docs/API.md | 91 ++++++++++++++++++++++++++++++++++ examples/README.md | 5 +- examples/python/main.py | 18 +++++++ examples/python/pyproject.toml | 8 +++ python/README.md | 30 +++++++++++ python/src/kitup/_metadata.py | 4 +- python/src/kitup/bundle.py | 8 ++- python/src/kitup/install.py | 43 ++++++++++------ python/src/kitup/types.py | 13 +++-- python/src/kitup/workflow.py | 29 ++++++++--- python/tests/golden_test.py | 51 ++++++++++++------- python/tests/test_bundle.py | 22 ++++++-- python/tests/test_install.py | 35 +++++++------ python/tests/test_workflow.py | 55 +++++++++++++------- scripts/check.mjs | 34 +++++++++++++ 18 files changed, 407 insertions(+), 93 deletions(-) create mode 100644 examples/python/main.py create mode 100644 examples/python/pyproject.toml diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index b2aedba..84d267f 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -17,6 +17,10 @@ jobs: - uses: actions/setup-go@v5 with: go-version: "1.23" + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - uses: astral-sh/setup-uv@v5 - name: Setup Rust run: rustup toolchain install stable --profile minimal - name: Check diff --git a/Makefile b/Makefile index f0eca3a..1738e44 100644 --- a/Makefile +++ b/Makefile @@ -19,12 +19,12 @@ GO_FILES := $(shell find $(GO_DIR) $(GO_COBRA_DIR) $(EXAMPLE_GO_DIR) -name '*.go # ── 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 @@ -38,7 +38,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 @@ -50,6 +53,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 @@ -62,9 +69,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 @@ -75,6 +82,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 diff --git a/README.md b/README.md index e930f6c..7872d7e 100644 --- a/README.md +++ b/README.md @@ -175,6 +175,36 @@ 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 directory_bundle, run_bundled_skill_install +from kitup.types import BaseOptions, InstallOptions, InstallWorkflowOptions + +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) diff --git a/docs/API.md b/docs/API.md index 61d679a..cd2fe54 100644 --- a/docs/API.md +++ b/docs/API.md @@ -211,6 +211,97 @@ Implemented functions: - `uninstall_bundled_skill(options)` - `INSTALL_UX` +## Python + +Package: `kitup` + +```python +from kitup import ( + 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 directory_bundle, install_bundled_skill +from kitup.types import BaseOptions, InstallOptions + +report = install_bundled_skill( + InstallOptions( + base=BaseOptions(), + app_id="mycli", + skill_bundle=directory_bundle("./skills/mycli"), + scope="user", + ) +) +``` + +Workflow call: + +```python +from kitup import directory_bundle, run_bundled_skill_install +from kitup.types import BaseOptions, InstallOptions, InstallWorkflowOptions + +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: diff --git a/examples/README.md b/examples/README.md index cbd036d..2a13e6a 100644 --- a/examples/README.md +++ b/examples/README.md @@ -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. diff --git a/examples/python/main.py b/examples/python/main.py new file mode 100644 index 0000000..ee87ea5 --- /dev/null +++ b/examples/python/main.py @@ -0,0 +1,18 @@ +from dataclasses import asdict +import json + +from kitup import directory_bundle, install_bundled_skill +from kitup.types import BaseOptions, InstallOptions + + +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) diff --git a/examples/python/pyproject.toml b/examples/python/pyproject.toml new file mode 100644 index 0000000..35c3492 --- /dev/null +++ b/examples/python/pyproject.toml @@ -0,0 +1,8 @@ +[project] +name = "kitup-example-python" +version = "0.0.0" +requires-python = ">=3.10" +dependencies = ["kitup"] + +[tool.uv.sources] +kitup = { path = "../../python" } diff --git a/python/README.md b/python/README.md index f726d7f..6b48b98 100644 --- a/python/README.md +++ b/python/README.md @@ -1,3 +1,33 @@ # kitup Python SDK Shared installer SDK for bundled Agent Skills. + +## Install + +```bash +pip install kitup +``` + +## Use + +Use the workflow API for user-facing install commands: + +```python +from kitup import directory_bundle, run_bundled_skill_install +from kitup.types import BaseOptions, InstallOptions, InstallWorkflowOptions + +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, + ) +) +``` + +Call `install_bundled_skill` when your CLI already knows the target scope and agents and does not need the interactive workflow surface. diff --git a/python/src/kitup/_metadata.py b/python/src/kitup/_metadata.py index 41f9619..207a823 100644 --- a/python/src/kitup/_metadata.py +++ b/python/src/kitup/_metadata.py @@ -28,7 +28,9 @@ def write_install_metadata( payload["version"] = version if provenance is not None: payload["provenance"] = provenance - (target_dir / ".kitup.json").write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + (target_dir / ".kitup.json").write_text( + json.dumps(payload, indent=2) + "\n", encoding="utf-8" + ) def read_install_metadata(target_dir: Path) -> dict[str, object] | None: diff --git a/python/src/kitup/bundle.py b/python/src/kitup/bundle.py index d58b4e7..c85bfb4 100644 --- a/python/src/kitup/bundle.py +++ b/python/src/kitup/bundle.py @@ -95,7 +95,9 @@ def compute_normalized_bundle_content_hash(normalized: NormalizedSkillBundle) -> return f"sha256:{digest.hexdigest()}" -def normalize_skill_bundle(bundle: SkillBundle, cwd: str | None = None) -> NormalizedSkillBundle: +def normalize_skill_bundle( + bundle: SkillBundle, cwd: str | None = None +) -> NormalizedSkillBundle: if isinstance(bundle, DirectoryBundle): return normalize_directory_bundle(bundle.path, cwd=cwd) if isinstance(bundle, FilesBundle): @@ -105,7 +107,9 @@ def normalize_skill_bundle(bundle: SkillBundle, cwd: str | None = None) -> Norma raise KitupError(f"unsupported bundle: {type(bundle)!r}") -def normalize_directory_bundle(path: str, cwd: str | None = None) -> NormalizedSkillBundle: +def normalize_directory_bundle( + path: str, cwd: str | None = None +) -> NormalizedSkillBundle: root = resolve_path(path, cwd=cwd) if not root.is_dir(): raise KitupError(f"invalid bundle directory: {root}") diff --git a/python/src/kitup/install.py b/python/src/kitup/install.py index 6b363e7..ce11275 100644 --- a/python/src/kitup/install.py +++ b/python/src/kitup/install.py @@ -39,7 +39,9 @@ def expand_host_path(path: str, *, home: Path, cwd: Path) -> Path: return cwd / path -def choose_scope_path(host: Host, *, scope: Scope, home: Path, cwd: Path) -> Path | None: +def choose_scope_path( + host: Host, *, scope: Scope, home: Path, cwd: Path +) -> Path | None: paths = host.user_skills_dirs if scope == "user" else host.project_skills_dirs for path in paths: expanded = expand_host_path(path, home=home, cwd=cwd) @@ -56,7 +58,9 @@ def resolve_install_targets( scope: Scope, skill_name: str, ) -> list[TargetGroup]: - targets, _ = _resolve_install_targets_with_errors(options, agents, scope, skill_name) + targets, _ = _resolve_install_targets_with_errors( + options, agents, scope, skill_name + ) return targets @@ -181,7 +185,9 @@ def install_or_plan(options: InstallOptions, *, write: bool) -> InstallReport: info = validate_normalized_skill_bundle(normalized) if not info.valid or not info.skill_name: - return empty_install_report([TargetError(reason=info.error_code or "invalid-skill-bundle")]) + return empty_install_report( + [TargetError(reason=info.error_code or "invalid-skill-bundle")] + ) digest = compute_normalized_bundle_content_hash(normalized) targets, errors = _resolve_install_targets_with_errors( @@ -225,15 +231,15 @@ def install_or_plan(options: InstallOptions, *, write: bool) -> InstallReport: continue if write: - write_managed_bundle( - target_dir, - app_id=options.app_id, - skill_name=info.skill_name, - digest=digest, - metadata=bundle_metadata, - files=normalized.files, - replace=True, - ) + write_managed_bundle( + target_dir, + app_id=options.app_id, + skill_name=info.skill_name, + digest=digest, + metadata=bundle_metadata, + files=normalized.files, + replace=True, + ) report.updated.append(result) return report @@ -272,9 +278,13 @@ def uninstall_bundled_skill(options: UninstallOptions) -> UninstallReport: return report -def _resolve_bundle_and_metadata(skill_bundle: object, *, cwd: str | None) -> tuple[object, dict[str, object]]: +def _resolve_bundle_and_metadata( + skill_bundle: object, *, cwd: str | None +) -> tuple[object, dict[str, object]]: if isinstance(skill_bundle, DirectoryBundle): - return normalize_directory_bundle(skill_bundle.path, cwd=cwd), {"source": "bundled"} + return normalize_directory_bundle(skill_bundle.path, cwd=cwd), { + "source": "bundled" + } if isinstance(skill_bundle, FilesBundle): return normalize_files_bundle(skill_bundle.files), {"source": "bundled"} if isinstance(skill_bundle, GitHubBundle): @@ -297,7 +307,10 @@ def _resolve_install_targets_with_errors( errors: list[TargetError] = [] else: selected, resolution_errors = resolve_hosts(agents, spec.hosts) - errors = [TargetError(reason=error["reason"], agent=error["agent"]) for error in resolution_errors] + errors = [ + TargetError(reason=error["reason"], agent=error["agent"]) + for error in resolution_errors + ] by_target: dict[str, TargetGroup] = {} for host in selected: diff --git a/python/src/kitup/types.py b/python/src/kitup/types.py index fcdf248..24eba3f 100644 --- a/python/src/kitup/types.py +++ b/python/src/kitup/types.py @@ -46,11 +46,14 @@ class SkillInfo: valid: bool skill_name: str | None = None description: str | None = None - error_code: Literal[ - "missing-skill-md", - "invalid-frontmatter", - "invalid-skill-bundle", - ] | None = None + error_code: ( + Literal[ + "missing-skill-md", + "invalid-frontmatter", + "invalid-skill-bundle", + ] + | None + ) = None @dataclass(frozen=True) diff --git a/python/src/kitup/workflow.py b/python/src/kitup/workflow.py index 6791fd4..9144825 100644 --- a/python/src/kitup/workflow.py +++ b/python/src/kitup/workflow.py @@ -8,7 +8,6 @@ from .install import install_bundled_skill, plan_bundled_skill from .types import ( INSTALL_UX, - InstallOptions, InstallReport, InstallSelection, InstallSelectionOptions, @@ -136,9 +135,13 @@ def resolve_install_selection(options: InstallSelectionOptions) -> InstallSelect return _select_agents_selection(detected_host_ids, detected_host_ids, []) -def classify_install_workflow_exit(report: InstallWorkflowReport | dict[str, object]) -> InstallWorkflowExit: +def classify_install_workflow_exit( + report: InstallWorkflowReport | dict[str, object], +) -> InstallWorkflowExit: if _workflow_value(report, "canceled"): - return InstallWorkflowExit(ok=False, code="canceled", message=INSTALL_UX["canceled"]) + return InstallWorkflowExit( + ok=False, code="canceled", message=INSTALL_UX["canceled"] + ) selection = _workflow_value(report, "selection") if _workflow_value(selection, "errors"): return InstallWorkflowExit( @@ -167,10 +170,14 @@ def install_flag_error(errors: list[dict[str, str]]) -> Exception | None: def install_workflow_error( - report: InstallWorkflowReport | dict[str, object] + report: InstallWorkflowReport | dict[str, object], ) -> Exception | None: exit_info = classify_install_workflow_exit(report) - return None if exit_info.ok or exit_info.code == "canceled" else KitupError(exit_info.message) + return ( + None + if exit_info.ok or exit_info.code == "canceled" + else KitupError(exit_info.message) + ) def run_bundled_skill_install(options: InstallWorkflowOptions) -> InstallWorkflowReport: @@ -371,7 +378,9 @@ def _prompt_agent_selection( current = ",".join(selection.selected_host_ids) suffix = f" [{current}]" if current else "" output.write(f"{INSTALL_UX['agents_prompt']}{suffix}: ") - selected = _parse_agent_selection(reader.read_line() or "", selection, candidates) + selected = _parse_agent_selection( + reader.read_line() or "", selection, candidates + ) if selected is not None: return selected _write_line(output, INSTALL_UX["invalid_agent_selection"]) @@ -414,7 +423,9 @@ def _prompt_confirmation(reader: "_LineReader", output: "_OutputWriter") -> bool def _render_install_summary(output: "_OutputWriter", report: InstallReport) -> None: for item in [*report.installed, *report.updated]: for host_id in _summary_hosts(item): - _write_line(output, f" - {item.skill_name} -> {item.target_dir} ({host_id})") + _write_line( + output, f" - {item.skill_name} -> {item.target_dir} ({host_id})" + ) def _summary_hosts(item: object) -> list[str]: @@ -425,7 +436,9 @@ def _summary_hosts(item: object) -> list[str]: return list(host_ids or []) -def _render_selection_errors(output: "_OutputWriter", selection: InstallSelection) -> None: +def _render_selection_errors( + output: "_OutputWriter", selection: InstallSelection +) -> None: for error in selection.errors: _write_line(output, f"{INSTALL_UX['error_prefix']} {error['reason']}") diff --git a/python/tests/golden_test.py b/python/tests/golden_test.py index 3b86512..418f787 100644 --- a/python/tests/golden_test.py +++ b/python/tests/golden_test.py @@ -38,7 +38,9 @@ def test_golden_cases(): - cases = json.loads(repo_path("testdata/cases/bundled-skill-install.json").read_text())["cases"] + cases = json.loads( + repo_path("testdata/cases/bundled-skill-install.json").read_text() + )["cases"] for case in cases: root = Path(tempfile.mkdtemp(prefix=f"kitup-{case['id']}-")) home = root / "home" @@ -109,9 +111,14 @@ def run_case(case, home: Path, workspace: Path) -> None: expand_value(case["expected"].get("workflow"), home, workspace), ) if "exit" in case["expected"]: - assert normalize_value(classify_install_workflow_exit(workflow)) == case["expected"]["exit"] + assert ( + normalize_value(classify_install_workflow_exit(workflow)) + == case["expected"]["exit"] + ) assert_output(output_stream.getvalue(), case["expected"].get("output")) - assert_output_contains(output_stream.getvalue(), case["expected"].get("outputContains")) + assert_output_contains( + output_stream.getvalue(), case["expected"].get("outputContains") + ) if "report" in case["expected"]: assert normalize_value(workflow.report) == camel_to_snake_dict( expand_value(case["expected"]["report"], home, workspace) @@ -144,7 +151,9 @@ def run_case(case, home: Path, workspace: Path) -> None: def run_report_case(case, home: Path, workspace: Path): operation = case["operation"] if operation == "uninstall": - return uninstall_bundled_skill(uninstall_options_from_case(case, home, workspace)) + return uninstall_bundled_skill( + uninstall_options_from_case(case, home, workspace) + ) install_options = install_options_from_case(case, home, workspace) if operation == "update": return update_bundled_skill(install_options) @@ -200,7 +209,9 @@ def assert_selection(actual, expected) -> None: actual_value.pop("selected_host_ids") expected_value.pop("selectedCount") if "candidateCount" in expected_value: - assert len(actual_value["candidate_host_ids"]) == expected_value["candidateCount"] + assert ( + len(actual_value["candidate_host_ids"]) == expected_value["candidateCount"] + ) actual_value.pop("candidate_host_ids") expected_value.pop("candidateCount") assert actual_value == camel_to_snake_dict(expected_value) @@ -247,9 +258,13 @@ def write_metadata_fixture(case, home: Path, workspace: Path, metadata) -> None: def expected_bundle_hash(case, marker: str) -> str: if marker == "from-skill-bundle-dir": - return compute_bundle_content_hash(directory_bundle(str(case_skill_bundle_dir(case)))) + return compute_bundle_content_hash( + directory_bundle(str(case_skill_bundle_dir(case))) + ) if marker == "from-skill-files": - return compute_bundle_content_hash(files_bundle(skill_files(case["options"]["skillFiles"]))) + return compute_bundle_content_hash( + files_bundle(skill_files(case["options"]["skillFiles"])) + ) if marker == "from-github-bundle": return compute_bundle_content_hash(files_bundle(github_skill_files(case))) return marker @@ -286,7 +301,9 @@ def do_GET(self): { "path": file_path, "type": "blob", - "mode": "100755" if file_path.endswith(".sh") else "100644", + "mode": "100755" + if file_path.endswith(".sh") + else "100644", } for file_path in files ] @@ -372,7 +389,9 @@ def uninstall_options_from_case(case, home: Path, workspace: Path) -> UninstallO ) -def selection_options_from_case(case, home: Path, workspace: Path) -> InstallSelectionOptions: +def selection_options_from_case( + case, home: Path, workspace: Path +) -> InstallSelectionOptions: return InstallSelectionOptions( base=BaseOptions( home=str(home), @@ -387,7 +406,9 @@ def selection_options_from_case(case, home: Path, workspace: Path) -> InstallSel ) -def workflow_options_from_case(case, home: Path, workspace: Path) -> InstallWorkflowOptions: +def workflow_options_from_case( + case, home: Path, workspace: Path +) -> InstallWorkflowOptions: return InstallWorkflowOptions( install=install_options_from_case(case, home, workspace), yes=case["options"].get("yes", False), @@ -420,8 +441,7 @@ def skill_bundle_from_case(case) -> object: def skill_files(values) -> list[SkillFile]: return [ - SkillFile(path=value["path"], contents=value["contents"]) - for value in values + SkillFile(path=value["path"], contents=value["contents"]) for value in values ] @@ -462,9 +482,7 @@ def expand_value(value, home: Path, workspace: Path): def expand_path(value: str, home: Path, workspace: Path) -> Path: - return Path( - value.replace("$HOME", str(home)).replace("$WORKSPACE", str(workspace)) - ) + return Path(value.replace("$HOME", str(home)).replace("$WORKSPACE", str(workspace))) def normalize_value(value): @@ -501,8 +519,7 @@ def camel_to_snake_dict(value): if not isinstance(value, dict): return value return { - camel_to_snake(key): camel_to_snake_dict(item) - for key, item in value.items() + camel_to_snake(key): camel_to_snake_dict(item) for key, item in value.items() } diff --git a/python/tests/test_bundle.py b/python/tests/test_bundle.py index c37a265..bf4a6a8 100644 --- a/python/tests/test_bundle.py +++ b/python/tests/test_bundle.py @@ -63,7 +63,12 @@ def test_compute_bundle_content_hash_ignores_kitup_metadata(tmp_path): (root / ".kitup.json").write_text('{"ignored": false}', encoding="utf-8") without_metadata = compute_bundle_content_hash(directory_bundle(str(root))) - expected = "sha256:" + hashlib.sha256(b"SKILL.md\x00" + _skill_md().encode("utf-8") + b"\x00").hexdigest() + expected = ( + "sha256:" + + hashlib.sha256( + b"SKILL.md\x00" + _skill_md().encode("utf-8") + b"\x00" + ).hexdigest() + ) assert with_metadata == without_metadata == expected @@ -77,7 +82,12 @@ def test_compute_bundle_content_hash_ignores_editor_junk_files(tmp_path): digest = compute_bundle_content_hash(directory_bundle(str(root))) - expected = "sha256:" + hashlib.sha256(b"SKILL.md\x00" + _skill_md().encode("utf-8") + b"\x00").hexdigest() + expected = ( + "sha256:" + + hashlib.sha256( + b"SKILL.md\x00" + _skill_md().encode("utf-8") + b"\x00" + ).hexdigest() + ) assert digest == expected @@ -114,7 +124,9 @@ def fake_urlopen(url, timeout=30): key = getattr(url, "full_url", url) payload = payloads.get(key) if payload is None: - raise AssertionError(f"unexpected network call: {key!r} timeout={timeout!r}") + raise AssertionError( + f"unexpected network call: {key!r} timeout={timeout!r}" + ) return _Response(payload) monkeypatch.setattr("kitup._github.urllib.request.urlopen", fake_urlopen) @@ -152,7 +164,9 @@ def test_validate_skill_bundle_rejects_duplicate_paths(): def test_validate_skill_bundle_rejects_github_bundle_without_root_path(): - bundle = github_bundle(GitHubBundleOptions(owner="acme", repo="skills", path="/", ref="main")) + bundle = github_bundle( + GitHubBundleOptions(owner="acme", repo="skills", path="/", ref="main") + ) result = validate_skill_bundle(bundle) diff --git a/python/tests/test_install.py b/python/tests/test_install.py index b7d1e9f..b47c5ff 100644 --- a/python/tests/test_install.py +++ b/python/tests/test_install.py @@ -42,10 +42,9 @@ def test_resolve_install_targets_prefers_first_existing_user_dir(tmp_path): "basic", ) - assert [ - (target.host_ids, target.target_dir) - for target in targets - ] == [(["codex"], str(home / ".agents" / "skills" / "basic"))] + assert [(target.host_ids, target.target_dir) for target in targets] == [ + (["codex"], str(home / ".agents" / "skills" / "basic")) + ] def test_resolve_install_targets_groups_hosts_by_shared_target_dir(tmp_path): @@ -62,10 +61,7 @@ def test_resolve_install_targets_groups_hosts_by_shared_target_dir(tmp_path): "basic", ) - assert [ - (target.host_ids, target.target_dir) - for target in targets - ] == [ + assert [(target.host_ids, target.target_dir) for target in targets] == [ ( ["codex", "warp", "gemini-cli"], str(home / ".agents" / "skills" / "basic"), @@ -117,10 +113,7 @@ def test_resolve_install_targets_auto_detects_supported_hosts(tmp_path): "basic", ) - assert [ - (target.host_ids, target.target_dir) - for target in targets - ] == [ + assert [(target.host_ids, target.target_dir) for target in targets] == [ (["codex"], str(home / ".agents" / "skills" / "basic")), (["claude-code"], str(home / ".claude" / "skills" / "basic")), ] @@ -198,10 +191,18 @@ def test_install_update_uninstall_round_trip(tmp_path): assert len(install_report.installed) == 1 target = home / ".agents" / "skills" / "basic" - assert (target / "SKILL.md").read_text(encoding="utf-8").startswith("---\nname: basic\n") - assert (target / "bin" / "run.sh").read_text(encoding="utf-8") == "#!/bin/sh\necho updated\n" + assert ( + (target / "SKILL.md") + .read_text(encoding="utf-8") + .startswith("---\nname: basic\n") + ) + assert (target / "bin" / "run.sh").read_text( + encoding="utf-8" + ) == "#!/bin/sh\necho updated\n" assert (target / "bin" / "run.sh").stat().st_mode & 0o777 == 0o755 - assert (target / "legacy.txt").read_text(encoding="utf-8") == "remove me on update\n" + assert (target / "legacy.txt").read_text( + encoding="utf-8" + ) == "remove me on update\n" assert json.loads((target / ".kitup.json").read_text(encoding="utf-8")) == { "schemaVersion": 1, "appId": "kitup-python-test", @@ -216,7 +217,9 @@ def test_install_update_uninstall_round_trip(tmp_path): update_report = update_bundled_skill(install_options) assert len(update_report.updated) == 1 - assert (target / "bin" / "run.sh").read_text(encoding="utf-8") == "#!/bin/sh\necho second\n" + assert (target / "bin" / "run.sh").read_text( + encoding="utf-8" + ) == "#!/bin/sh\necho second\n" assert not (target / "legacy.txt").exists() unchanged_report = update_bundled_skill(install_options) diff --git a/python/tests/test_workflow.py b/python/tests/test_workflow.py index e81f20b..1033283 100644 --- a/python/tests/test_workflow.py +++ b/python/tests/test_workflow.py @@ -111,7 +111,9 @@ def test_resolve_install_selection_requires_agents_without_tty_or_yes(tmp_path): selection = resolve_install_selection( InstallSelectionOptions( - base=BaseOptions(home=str(home), cwd=str(workspace), hosts_file=str(hosts_file)), + base=BaseOptions( + home=str(home), cwd=str(workspace), hosts_file=str(hosts_file) + ), scope="user", stdin_tty=False, yes=False, @@ -155,7 +157,9 @@ def test_resolve_install_selection_tty_prompts_for_multiple_detected_hosts(tmp_p selection = resolve_install_selection( InstallSelectionOptions( - base=BaseOptions(home=str(home), cwd=str(workspace), hosts_file=str(hosts_file)), + base=BaseOptions( + home=str(home), cwd=str(workspace), hosts_file=str(hosts_file) + ), scope="user", stdin_tty=True, yes=False, @@ -192,7 +196,9 @@ def test_resolve_install_selection_explicit_agents_with_unknown_host_is_pure_err selection = resolve_install_selection( InstallSelectionOptions( - base=BaseOptions(home=str(home), cwd=str(workspace), hosts_file=str(hosts_file)), + base=BaseOptions( + home=str(home), cwd=str(workspace), hosts_file=str(hosts_file) + ), scope="user", agents=["codex", "missing-agent"], stdin_tty=False, @@ -222,21 +228,30 @@ def test_classify_install_workflow_exit_reports_conflict(): def test_error_helpers_map_flag_and_workflow_failures(): - assert str(install_flag_error([{"reason": "invalid-scope"}])) == "Invalid install flags." - assert install_workflow_error( - { - "canceled": False, - "selection": {"errors": [{"reason": "scope-selection-required"}]}, - "report": {"conflicts": [], "errors": []}, - } - ).args[0] == "Agent selection failed." - assert install_workflow_error( - { - "canceled": True, - "selection": {"errors": []}, - "report": {"conflicts": [], "errors": []}, - } - ) is None + assert ( + str(install_flag_error([{"reason": "invalid-scope"}])) + == "Invalid install flags." + ) + assert ( + install_workflow_error( + { + "canceled": False, + "selection": {"errors": [{"reason": "scope-selection-required"}]}, + "report": {"conflicts": [], "errors": []}, + } + ).args[0] + == "Agent selection failed." + ) + assert ( + install_workflow_error( + { + "canceled": True, + "selection": {"errors": []}, + "report": {"conflicts": [], "errors": []}, + } + ) + is None + ) def test_run_bundled_skill_install_scope_prompt_and_top_level_exports(tmp_path): @@ -320,7 +335,9 @@ def fake_fetch_with_metadata(options): def unexpected_refetch(_options): raise AssertionError("bundle was re-fetched after snapshot resolution") - monkeypatch.setattr("kitup.install.fetch_github_directory_with_metadata", fake_fetch_with_metadata) + monkeypatch.setattr( + "kitup.install.fetch_github_directory_with_metadata", fake_fetch_with_metadata + ) monkeypatch.setattr("kitup.bundle.fetch_github_directory", unexpected_refetch) report = plan_bundled_skill( diff --git a/scripts/check.mjs b/scripts/check.mjs index 37d82c8..9e787e5 100755 --- a/scripts/check.mjs +++ b/scripts/check.mjs @@ -253,6 +253,33 @@ for (const [name, command, args, cwd, env] of [ ], rootPath, ], + [ + "python-format", + "uv", + [ + "run", + "ruff", + "format", + "--check", + "src", + "tests", + "--exclude", + "src/kitup/_hosts_generated.py", + ], + new URL("../python/", import.meta.url), + ], + [ + "python-lint", + "uv", + ["run", "ruff", "check", "src", "tests"], + new URL("../python/", import.meta.url), + ], + [ + "python", + "uv", + ["run", "pytest", "tests", "-q"], + new URL("../python/", import.meta.url), + ], [ "example-ts", "pnpm", @@ -274,6 +301,13 @@ for (const [name, command, args, cwd, env] of [ new URL("../examples/rust/", import.meta.url), detectedEnv("kitup-example-rust-"), ], + [ + "example-python", + "uv", + ["run", "python", "main.py"], + new URL("../examples/python/", import.meta.url), + detectedEnv("kitup-example-python-"), + ], ]) { console.log(`\n==> ${name}`); const result = spawnSync(command, args, { From fd3e2072ba8a09e1175ac8c2197d96f31739d9e1 Mon Sep 17 00:00:00 2001 From: spencercjh Date: Wed, 1 Jul 2026 11:31:24 +0000 Subject: [PATCH 12/18] fix(python): correct example lane and docs Signed-off-by: spencercjh --- Makefile | 1 + README.md | 9 +++++++-- docs/API.md | 15 +++++++++++---- examples/python/main.py | 3 +-- python/README.md | 9 +++++++-- 5 files changed, 27 insertions(+), 10 deletions(-) diff --git a/Makefile b/Makefile index 1738e44..59a40c6 100644 --- a/Makefile +++ b/Makefile @@ -15,6 +15,7 @@ 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 ────────────────────────────────────────────────────────────────── diff --git a/README.md b/README.md index 7872d7e..dca79d1 100644 --- a/README.md +++ b/README.md @@ -186,8 +186,13 @@ pip install kitup Use the workflow API for user-facing install commands: ```python -from kitup import directory_bundle, run_bundled_skill_install -from kitup.types import BaseOptions, InstallOptions, InstallWorkflowOptions +from kitup import ( + BaseOptions, + InstallOptions, + InstallWorkflowOptions, + directory_bundle, + run_bundled_skill_install, +) result = run_bundled_skill_install( InstallWorkflowOptions( diff --git a/docs/API.md b/docs/API.md index cd2fe54..f8a4ebe 100644 --- a/docs/API.md +++ b/docs/API.md @@ -217,6 +217,9 @@ Package: `kitup` ```python from kitup import ( + BaseOptions, + InstallOptions, + InstallWorkflowOptions, classify_install_workflow_exit, compute_bundle_content_hash, detect_hosts, @@ -243,8 +246,7 @@ from kitup import ( Primitive install call: ```python -from kitup import directory_bundle, install_bundled_skill -from kitup.types import BaseOptions, InstallOptions +from kitup import BaseOptions, InstallOptions, directory_bundle, install_bundled_skill report = install_bundled_skill( InstallOptions( @@ -259,8 +261,13 @@ report = install_bundled_skill( Workflow call: ```python -from kitup import directory_bundle, run_bundled_skill_install -from kitup.types import BaseOptions, InstallOptions, InstallWorkflowOptions +from kitup import ( + BaseOptions, + InstallOptions, + InstallWorkflowOptions, + directory_bundle, + run_bundled_skill_install, +) workflow = run_bundled_skill_install( InstallWorkflowOptions( diff --git a/examples/python/main.py b/examples/python/main.py index ee87ea5..a5d7618 100644 --- a/examples/python/main.py +++ b/examples/python/main.py @@ -1,8 +1,7 @@ from dataclasses import asdict import json -from kitup import directory_bundle, install_bundled_skill -from kitup.types import BaseOptions, InstallOptions +from kitup import BaseOptions, InstallOptions, directory_bundle, install_bundled_skill report = install_bundled_skill( diff --git a/python/README.md b/python/README.md index 6b48b98..eef8e88 100644 --- a/python/README.md +++ b/python/README.md @@ -13,8 +13,13 @@ pip install kitup Use the workflow API for user-facing install commands: ```python -from kitup import directory_bundle, run_bundled_skill_install -from kitup.types import BaseOptions, InstallOptions, InstallWorkflowOptions +from kitup import ( + BaseOptions, + InstallOptions, + InstallWorkflowOptions, + directory_bundle, + run_bundled_skill_install, +) workflow = run_bundled_skill_install( InstallWorkflowOptions( From e9b59a3e5a7949a411bd92aefbd3de03ea418b52 Mon Sep 17 00:00:00 2001 From: spencercjh Date: Wed, 1 Jul 2026 11:40:44 +0000 Subject: [PATCH 13/18] build(release): add python package publishing Signed-off-by: spencercjh --- .github/workflows/release.yml | 27 ++++++++++++++++++++++++++- CONTRIBUTING.md | 1 + docs/RELEASE.md | 11 +++++++---- scripts/prepare-release.mjs | 7 +++++++ scripts/smoke-release.sh | 16 ++++++++++++++++ 5 files changed, 57 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5179ec2..3152362 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,6 +26,10 @@ jobs: - uses: actions/setup-go@v5 with: go-version: "1.23" + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - uses: astral-sh/setup-uv@v5 - name: Setup Rust run: rustup toolchain install stable --profile minimal - name: Check @@ -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 @@ -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: | @@ -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: | @@ -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}" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b5688c5..ce6b204 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 49879be..8f7845e 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -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` @@ -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` @@ -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 @@ -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. @@ -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. diff --git a/scripts/prepare-release.mjs b/scripts/prepare-release.mjs index 439d4b3..90a4a09 100755 --- a/scripts/prepare-release.mjs +++ b/scripts/prepare-release.mjs @@ -28,6 +28,7 @@ const cargoPath = "rust/Cargo.toml"; const goCobraModPath = "go-cobra/go.mod"; const rustLockPath = "rust/Cargo.lock"; const exampleRustLockPath = "examples/rust/Cargo.lock"; +const pythonPackagePath = "python/pyproject.toml"; const pkg = JSON.parse(read(packagePath)); const currentVersion = pkg.version; const nextVersion = bumpVersion(currentVersion, bump); @@ -39,6 +40,7 @@ const changedFiles = [ rustLockPath, exampleRustLockPath, goCobraModPath, + pythonPackagePath, ]; if (!dryRun) { @@ -49,6 +51,11 @@ if (!dryRun) { pkg.version = nextVersion; write(packagePath, `${JSON.stringify(pkg, null, 2)}\n`); replaceOne(cargoPath, /^version = "([^"]+)"$/m, `version = "${nextVersion}"`); +replaceOne( + pythonPackagePath, + /^version = "([^"]+)"$/m, + `version = "${nextVersion}"`, +); replaceOne( goCobraModPath, /(github\.com\/lathe-cli\/kitup\/go\s+)v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?/, diff --git a/scripts/smoke-release.sh b/scripts/smoke-release.sh index 9fb6443..deaef69 100755 --- a/scripts/smoke-release.sh +++ b/scripts/smoke-release.sh @@ -97,7 +97,23 @@ GO go run . } +smoke_python() { + dir="$(mktemp -d "$tmp/python.XXXXXX")" + cd "$dir" + python -m venv .venv + . .venv/bin/activate + python -m pip install "kitup==$version" >/dev/null + python - <<'PY' +from kitup import load_host_spec + +spec = load_host_spec() +assert len(spec.hosts) == 72 +print(f"python ok: {len(spec.hosts)}") +PY +} + retry npm smoke_npm retry rust smoke_rust retry go smoke_go retry go-cobra smoke_go_cobra +retry python smoke_python From a25e248a7deebd1d5bf7b5e3ac571f6d768ccbaf Mon Sep 17 00:00:00 2001 From: spencercjh Date: Wed, 1 Jul 2026 12:00:20 +0000 Subject: [PATCH 14/18] fix(python): restore workflow stdio defaults Signed-off-by: spencercjh --- python/src/kitup/__init__.py | 4 +++ python/src/kitup/types.py | 2 +- python/src/kitup/workflow.py | 13 ++++++++- python/tests/test_install.py | 6 ++-- python/tests/test_workflow.py | 52 ++++++++++++++++++++++++++++++++++- 5 files changed, 72 insertions(+), 5 deletions(-) diff --git a/python/src/kitup/__init__.py b/python/src/kitup/__init__.py index 909d3cf..ae9876b 100644 --- a/python/src/kitup/__init__.py +++ b/python/src/kitup/__init__.py @@ -26,6 +26,7 @@ ) from .types import ( BaseOptions, + GitHubBundleOptions, Host, HostSpec, INSTALL_UX, @@ -38,6 +39,7 @@ InstallWorkflowReport, KitupError, ParsedInstallFlags, + SkillFile, TargetError, TargetGroup, TargetResult, @@ -48,6 +50,7 @@ __all__ = [ "BaseOptions", + "GitHubBundleOptions", "Host", "HostSpec", "INSTALL_UX", @@ -60,6 +63,7 @@ "InstallWorkflowReport", "KitupError", "ParsedInstallFlags", + "SkillFile", "TargetError", "TargetGroup", "TargetResult", diff --git a/python/src/kitup/types.py b/python/src/kitup/types.py index 24eba3f..9df507c 100644 --- a/python/src/kitup/types.py +++ b/python/src/kitup/types.py @@ -167,7 +167,7 @@ class InstallWorkflowOptions: install: InstallOptions yes: bool = False dry_run: bool = False - stdin_tty: bool = False + stdin_tty: bool | None = None current_agent: str | None = None default_scope: Scope = "user" scope_set: bool = False diff --git a/python/src/kitup/workflow.py b/python/src/kitup/workflow.py index 9144825..e2ea8ac 100644 --- a/python/src/kitup/workflow.py +++ b/python/src/kitup/workflow.py @@ -2,6 +2,7 @@ from dataclasses import replace import io +import sys from typing import Iterable from .hosts import detect_hosts, load_host_spec, resolve_hosts @@ -181,7 +182,17 @@ def install_workflow_error( def run_bundled_skill_install(options: InstallWorkflowOptions) -> InstallWorkflowReport: - return run_bundled_skill_install_with_io(options, options.input, options.output) + stdin_tty = ( + options.stdin_tty + if options.stdin_tty is not None + else bool(getattr(sys.stdin, "isatty", lambda: False)()) + ) + runtime_options = replace(options, stdin_tty=stdin_tty) + input_source = options.input if options.input is not None else sys.stdin + output_target = options.output if options.output is not None else sys.stdout + return run_bundled_skill_install_with_io( + runtime_options, input_source, output_target + ) def run_bundled_skill_install_with_io( diff --git a/python/tests/test_install.py b/python/tests/test_install.py index b47c5ff..9a59179 100644 --- a/python/tests/test_install.py +++ b/python/tests/test_install.py @@ -2,6 +2,8 @@ import kitup from kitup import ( + GitHubBundleOptions, + SkillFile, directory_bundle, install_bundled_skill, plan_bundled_skill, @@ -304,12 +306,12 @@ def test_install_lifecycle_is_re_exported_from_top_level_package(): assert kitup.InstallWorkflowExit is InstallWorkflowExit assert kitup.InstallWorkflowReport is InstallWorkflowReport assert kitup.ParsedInstallFlags is ParsedInstallFlags + assert kitup.GitHubBundleOptions is GitHubBundleOptions + assert kitup.SkillFile is SkillFile for name in [ "BundleFile", - "GitHubBundleOptions", "NormalizedSkillBundle", - "SkillFile", "SkillInfo", ]: assert not hasattr(kitup, name) diff --git a/python/tests/test_workflow.py b/python/tests/test_workflow.py index 1033283..7f54376 100644 --- a/python/tests/test_workflow.py +++ b/python/tests/test_workflow.py @@ -4,9 +4,11 @@ import kitup from kitup import ( BaseOptions, + GitHubBundleOptions, InstallOptions, InstallSelectionOptions, InstallWorkflowOptions, + SkillFile, agent_selector_from_flags, classify_install_workflow_exit, directory_bundle, @@ -17,9 +19,9 @@ plan_bundled_skill, parse_scope_flag, resolve_install_selection, + run_bundled_skill_install, run_bundled_skill_install_with_io, ) -from kitup.types import GitHubBundleOptions, SkillFile from kitup.workflow import split_flag_values @@ -297,7 +299,55 @@ def test_run_bundled_skill_install_scope_prompt_and_top_level_exports(tmp_path): assert kitup.classify_install_workflow_exit is classify_install_workflow_exit assert kitup.install_flag_error is install_flag_error assert kitup.install_workflow_error is install_workflow_error + assert kitup.run_bundled_skill_install is run_bundled_skill_install assert kitup.run_bundled_skill_install_with_io is run_bundled_skill_install_with_io + assert kitup.GitHubBundleOptions is GitHubBundleOptions + assert kitup.SkillFile is SkillFile + + +class _TTYInput(io.StringIO): + def isatty(self) -> bool: + return True + + +def test_run_bundled_skill_install_uses_stdio_defaults_for_interactive_flow( + monkeypatch, 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", + ) + + stdin = _TTYInput("project\ny\n") + stdout = io.StringIO() + monkeypatch.setattr("sys.stdin", stdin) + monkeypatch.setattr("sys.stdout", stdout) + + 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, + ) + ) + + assert report.scope == "project" + assert report.canceled is False + assert "Select install scope:" in stdout.getvalue() + assert "Proceed? [y/N] " in stdout.getvalue() + assert (workspace / ".agents" / "skills" / "basic" / "SKILL.md").exists() def test_plan_bundled_skill_uses_single_github_snapshot(monkeypatch, tmp_path): From b087a517e0c0db3da196e331a086abc411bda516 Mon Sep 17 00:00:00 2001 From: spencercjh Date: Wed, 1 Jul 2026 12:12:39 +0000 Subject: [PATCH 15/18] fix(python): infer tty from workflow input Signed-off-by: spencercjh --- python/src/kitup/workflow.py | 6 ++--- python/tests/test_workflow.py | 45 +++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/python/src/kitup/workflow.py b/python/src/kitup/workflow.py index e2ea8ac..63addbd 100644 --- a/python/src/kitup/workflow.py +++ b/python/src/kitup/workflow.py @@ -182,14 +182,14 @@ def install_workflow_error( def run_bundled_skill_install(options: InstallWorkflowOptions) -> InstallWorkflowReport: + input_source = options.input if options.input is not None else sys.stdin + output_target = options.output if options.output is not None else sys.stdout stdin_tty = ( options.stdin_tty if options.stdin_tty is not None - else bool(getattr(sys.stdin, "isatty", lambda: False)()) + else bool(getattr(input_source, "isatty", lambda: False)()) ) runtime_options = replace(options, stdin_tty=stdin_tty) - input_source = options.input if options.input is not None else sys.stdin - output_target = options.output if options.output is not None else sys.stdout return run_bundled_skill_install_with_io( runtime_options, input_source, output_target ) diff --git a/python/tests/test_workflow.py b/python/tests/test_workflow.py index 7f54376..349e7a9 100644 --- a/python/tests/test_workflow.py +++ b/python/tests/test_workflow.py @@ -310,6 +310,11 @@ def isatty(self) -> bool: return True +class _NonTTYInput(io.StringIO): + def isatty(self) -> bool: + return False + + def test_run_bundled_skill_install_uses_stdio_defaults_for_interactive_flow( monkeypatch, tmp_path ): @@ -350,6 +355,46 @@ 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_infers_tty_from_custom_input_stream( + monkeypatch, 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", + ) + + monkeypatch.setattr("sys.stdin", _NonTTYInput("")) + stdout = 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=_TTYInput("project\ny\n"), + output=stdout, + ) + ) + + assert report.scope == "project" + assert report.canceled is False + assert "Select install scope:" in stdout.getvalue() + assert "Proceed? [y/N] " in stdout.getvalue() + assert (workspace / ".agents" / "skills" / "basic" / "SKILL.md").exists() + + def test_plan_bundled_skill_uses_single_github_snapshot(monkeypatch, tmp_path): home = tmp_path / "home" workspace = tmp_path / "workspace" From f82545ecdd0d212741320509826fa9974cc1a1a2 Mon Sep 17 00:00:00 2001 From: spencercjh Date: Thu, 2 Jul 2026 08:49:31 +0000 Subject: [PATCH 16/18] build(python): target python 3.14 Signed-off-by: spencercjh --- .github/workflows/check.yml | 2 +- .github/workflows/release.yml | 2 +- examples/python/pyproject.toml | 2 +- python/pyproject.toml | 4 ++-- python/src/kitup/_metadata.py | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 84d267f..14f2d09 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -19,7 +19,7 @@ jobs: go-version: "1.23" - uses: actions/setup-python@v5 with: - python-version: "3.12" + python-version: "3.14" - uses: astral-sh/setup-uv@v5 - name: Setup Rust run: rustup toolchain install stable --profile minimal diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3152362..115804c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,7 +28,7 @@ jobs: go-version: "1.23" - uses: actions/setup-python@v5 with: - python-version: "3.12" + python-version: "3.14" - uses: astral-sh/setup-uv@v5 - name: Setup Rust run: rustup toolchain install stable --profile minimal diff --git a/examples/python/pyproject.toml b/examples/python/pyproject.toml index 35c3492..9b36cbb 100644 --- a/examples/python/pyproject.toml +++ b/examples/python/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "kitup-example-python" version = "0.0.0" -requires-python = ">=3.10" +requires-python = ">=3.14" dependencies = ["kitup"] [tool.uv.sources] diff --git a/python/pyproject.toml b/python/pyproject.toml index d1a7ed4..ebba8d5 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -7,7 +7,7 @@ name = "kitup" version = "0.1.1" description = "Shared installer SDK for bundled Agent Skills." readme = "README.md" -requires-python = ">=3.10" +requires-python = ">=3.14" license = { text = "MIT" } keywords = ["agent", "skills", "installer", "sdk"] dependencies = [] @@ -28,4 +28,4 @@ dev = [ testpaths = ["tests"] [tool.ruff] -target-version = "py310" +target-version = "py314" diff --git a/python/src/kitup/_metadata.py b/python/src/kitup/_metadata.py index 207a823..dbf1041 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 From 2f161ac1ff7d40c21b2dc51fd22e550646bed8b3 Mon Sep 17 00:00:00 2001 From: spencercjh Date: Thu, 2 Jul 2026 08:49:38 +0000 Subject: [PATCH 17/18] fix(python): restore install parity Signed-off-by: spencercjh --- python/src/kitup/install.py | 45 ++++++++++++++++++++++-- python/src/kitup/types.py | 2 ++ python/src/kitup/workflow.py | 13 ++++++- python/tests/golden_test.py | 2 ++ python/tests/test_install.py | 65 +++++++++++++++++++++++++++++++++++ python/tests/test_workflow.py | 3 ++ 6 files changed, 126 insertions(+), 4 deletions(-) diff --git a/python/src/kitup/install.py b/python/src/kitup/install.py index ce11275..91a3aea 100644 --- a/python/src/kitup/install.py +++ b/python/src/kitup/install.py @@ -218,13 +218,52 @@ def install_or_plan(options: InstallOptions, *, write: bool) -> InstallReport: continue if metadata is None and metadata_file.exists(): - report.conflicts.append(target_status(target, "unmanaged")) + if options.force: + if write: + write_managed_bundle( + target_dir, + app_id=options.app_id, + skill_name=info.skill_name, + digest=digest, + metadata=bundle_metadata, + files=normalized.files, + replace=True, + ) + report.updated.append(result) + else: + report.conflicts.append(target_status(target, "unmanaged")) continue if metadata is None: - report.conflicts.append(target_status(target, "unmanaged")) + if options.force: + if write: + write_managed_bundle( + target_dir, + app_id=options.app_id, + skill_name=info.skill_name, + digest=digest, + metadata=bundle_metadata, + files=normalized.files, + replace=True, + ) + report.updated.append(result) + else: + report.conflicts.append(target_status(target, "unmanaged")) continue if metadata.get("appId") != options.app_id: - report.conflicts.append(target_status(target, "owner-mismatch")) + if options.force: + if write: + write_managed_bundle( + target_dir, + app_id=options.app_id, + skill_name=info.skill_name, + digest=digest, + metadata=bundle_metadata, + files=normalized.files, + replace=True, + ) + report.updated.append(result) + else: + report.conflicts.append(target_status(target, "owner-mismatch")) continue if metadata.get("hash") == digest: report.skipped.append(target_status(target, "unchanged")) diff --git a/python/src/kitup/types.py b/python/src/kitup/types.py index 9df507c..1f6e41d 100644 --- a/python/src/kitup/types.py +++ b/python/src/kitup/types.py @@ -92,6 +92,7 @@ class InstallOptions: skill_bundle: object scope: Scope agents: str | list[str] = "auto" + force: bool = False @dataclass(frozen=True) @@ -200,6 +201,7 @@ class ParsedInstallFlags: agents: str | list[str] yes: bool dry_run: bool + force: bool errors: list[dict[str, str]] diff --git a/python/src/kitup/workflow.py b/python/src/kitup/workflow.py index 63addbd..5242e7d 100644 --- a/python/src/kitup/workflow.py +++ b/python/src/kitup/workflow.py @@ -81,6 +81,7 @@ def parse_install_flags(flags: dict[str, object]) -> ParsedInstallFlags: agents=agent_selector_from_flags(_coerce_flag_values(agents), errors), yes=bool(flags.get("yes")), dry_run=bool(flags.get("dryRun")), + force=bool(flags.get("force")), errors=errors, ) @@ -277,8 +278,8 @@ def run_bundled_skill_install_with_io( dry_run=options.dry_run, ) - _render_install_summary(writer, plan) if options.dry_run: + _render_install_summary(writer, plan) return InstallWorkflowReport( selection=selection, scope=scope, @@ -287,6 +288,16 @@ def run_bundled_skill_install_with_io( canceled=False, dry_run=True, ) + if len(plan.conflicts) + len(plan.errors) > 0: + return InstallWorkflowReport( + selection=selection, + scope=scope, + plan=plan, + report=replace(plan, installed=[], updated=[]), + canceled=False, + dry_run=False, + ) + _render_install_summary(writer, plan) if not _has_install_writes(plan): return InstallWorkflowReport( selection=selection, diff --git a/python/tests/golden_test.py b/python/tests/golden_test.py index 418f787..69a330a 100644 --- a/python/tests/golden_test.py +++ b/python/tests/golden_test.py @@ -372,6 +372,7 @@ def install_options_from_case(case, home: Path, workspace: Path) -> InstallOptio skill_bundle=skill_bundle_from_case(case), scope=case["options"].get("scope", "user"), agents=case["options"].get("agents", "auto"), + force=case["options"].get("force", False), ) @@ -509,6 +510,7 @@ def normalize_parsed_flags(parsed: ParsedInstallFlags) -> dict[str, object]: "agentIds": parsed.agents if isinstance(parsed.agents, list) else [], "yes": parsed.yes, "dryRun": parsed.dry_run, + "force": parsed.force, "errors": parsed.errors, } diff --git a/python/tests/test_install.py b/python/tests/test_install.py index 9a59179..2deaee3 100644 --- a/python/tests/test_install.py +++ b/python/tests/test_install.py @@ -287,6 +287,71 @@ def test_install_lifecycle_reports_owner_mismatch_and_missing(tmp_path): assert missing_report.skipped[0].reason == "missing" +def test_install_force_overwrites_unmanaged_and_owner_mismatch(tmp_path): + skill = tmp_path / "skill" + skill.mkdir() + (skill / "SKILL.md").write_text( + "---\nname: basic\ndescription: demo\n---\n", + encoding="utf-8", + ) + home = tmp_path / "home" + workspace = tmp_path / "workspace" + home.mkdir() + workspace.mkdir() + + codex_target = home / ".agents" / "skills" / "basic" + codex_target.mkdir(parents=True) + (codex_target / "SKILL.md").write_text( + "---\nname: basic\ndescription: unmanaged\n---\n", + encoding="utf-8", + ) + + claude_target = home / ".claude" / "skills" / "basic" + claude_target.mkdir(parents=True) + (claude_target / "SKILL.md").write_text( + "---\nname: basic\ndescription: other owner\n---\n", + encoding="utf-8", + ) + (claude_target / ".kitup.json").write_text( + json.dumps( + { + "schemaVersion": 1, + "appId": "other-app", + "skillName": "basic", + "source": "bundled", + "hash": "sha256:old", + } + ) + + "\n", + encoding="utf-8", + ) + + report = install_bundled_skill( + InstallOptions( + base=BaseOptions(home=str(home), cwd=str(workspace)), + app_id="kitup-python-test", + skill_bundle=directory_bundle(str(skill)), + scope="user", + agents=["codex", "claude-code"], + force=True, + ) + ) + + assert [(item.host_id, item.target_dir) for item in report.updated] == [ + ("codex", str(codex_target)), + ("claude-code", str(claude_target)), + ] + assert report.conflicts == [] + assert ( + json.loads((codex_target / ".kitup.json").read_text(encoding="utf-8"))["appId"] + == "kitup-python-test" + ) + assert ( + json.loads((claude_target / ".kitup.json").read_text(encoding="utf-8"))["appId"] + == "kitup-python-test" + ) + + def test_install_lifecycle_is_re_exported_from_top_level_package(): assert kitup.directory_bundle is directory_bundle assert kitup.plan_bundled_skill is plan_bundled_skill diff --git a/python/tests/test_workflow.py b/python/tests/test_workflow.py index 349e7a9..8dac754 100644 --- a/python/tests/test_workflow.py +++ b/python/tests/test_workflow.py @@ -46,6 +46,7 @@ def test_parse_install_flags_defaults_to_user_auto(): assert parsed.agents == "auto" assert parsed.yes is False assert parsed.dry_run is False + assert parsed.force is False assert parsed.errors == [] @@ -56,6 +57,7 @@ def test_parse_install_flags_explicit_scope_agents_and_errors(): "agents": ["*", "codex,claude-code"], "yes": True, "dryRun": True, + "force": True, } ) @@ -64,6 +66,7 @@ def test_parse_install_flags_explicit_scope_agents_and_errors(): assert parsed.agents == "*" assert parsed.yes is True assert parsed.dry_run is True + assert parsed.force is True assert parsed.errors == [ {"flag": "scope", "reason": "invalid-scope", "value": "global"}, { From a75be258277676cf828f47aa706b3f5f13b4d6be Mon Sep 17 00:00:00 2001 From: spencercjh Date: Thu, 2 Jul 2026 09:53:02 +0000 Subject: [PATCH 18/18] build(python): switch smoke and format to uv Signed-off-by: spencercjh --- scripts/smoke-release.sh | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/scripts/smoke-release.sh b/scripts/smoke-release.sh index deaef69..c90767f 100755 --- a/scripts/smoke-release.sh +++ b/scripts/smoke-release.sh @@ -100,10 +100,7 @@ GO smoke_python() { dir="$(mktemp -d "$tmp/python.XXXXXX")" cd "$dir" - python -m venv .venv - . .venv/bin/activate - python -m pip install "kitup==$version" >/dev/null - python - <<'PY' + uv run --with "kitup==$version" python - <<'PY' from kitup import load_host_spec spec = load_host_spec()