Skip to content

Commit b7d22b6

Browse files
committed
Add typed handles, exports, bundle APIs, and tests
Introduce typed resource handles and export result types, refactor Bundle to expose file/skill/prompt/toolset/agent_spec accessors and selection logic, and add comprehensive tests and examples. Key changes: - New modules: src/musher/_handles.py (FileHandle, SkillHandle, PromptHandle, ToolsetHandle, AgentSpecHandle, BundleSelection) and src/musher/_export.py (ClaudePluginExport, OpenAILocalSkill, OpenAIInlineSkill). - Refactor src/musher/_bundle.py to build typed handles from raw assets, add file()/files(), skill()/skills(), prompt()/prompts(), toolset()/toolsets(), agent_spec()/agent_specs(), select(), and stubs for export/install/write_lockfile; internal helper functions for handle construction added. - Export new types and handles from src/musher/__init__.py. - Adjust AssetType names in src/musher/_types.py (AGENT_SPEC, TOOLSET) and update tests in tests/test_types.py. - Add many unit tests: tests/test_handles.py, tests/test_export.py, tests/test_bundle.py (updated) to cover the new APIs and behaviors. - Add example scripts demonstrating exports, skill installation, selections, and lockfile usage (examples/*.py) and a lefthook.yml example. - Devcontainer changes: removed the Claude devcontainer feature and added base_install_claude_code() to .devcontainer/scripts/lib/base-setup.sh to install Claude Code via its installer; small API update in examples/pull_bundle.py (asset->file, assets->files, file.text()). Note: export/install methods are currently stubs raising NotImplementedError; tests assert that behavior where applicable.
1 parent a1d952b commit b7d22b6

18 files changed

Lines changed: 1168 additions & 53 deletions

.devcontainer/devcontainer.json

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,9 +54,6 @@
5454
// --- Linting ---
5555
"ghcr.io/lukewiwa/features/shellcheck:0": { "version": "stable" },
5656

57-
// --- AI tooling ---
58-
"ghcr.io/anthropics/devcontainer-features/claude-code:1": {}
59-
6057
// --- Optional: Infrastructure tooling (uncomment as needed) ---
6158
// "ghcr.io/devcontainers-extra/features/opentofu:1": {},
6259
// "ghcr.io/devcontainers-extra/features/digitalocean-cli:1": {},

.devcontainer/scripts/lib/base-setup.sh

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,23 @@ base_fix_nvm_permissions() {
6666
fix_nvm_permissions
6767
}
6868

69+
# --- Claude Code ---
70+
71+
# Installs Claude Code via the native installer if not already present.
72+
#
73+
# Outputs:
74+
# Writes progress to stderr via log()
75+
# Returns:
76+
# 0 on success, non-zero on failure
77+
base_install_claude_code() {
78+
if has_cmd claude; then
79+
log "Claude Code already installed, skipping"
80+
return 0
81+
fi
82+
log "Installing Claude Code via native installer..."
83+
curl -fsSL https://claude.ai/install.sh | bash
84+
}
85+
6986
# --- Codex CLI ---
7087

7188
# Installs the Codex CLI if not already present.
@@ -121,6 +138,7 @@ base_setup() {
121138
base_setup_config_dirs
122139
base_setup_cache_dirs
123140
base_fix_nvm_permissions
141+
base_install_claude_code
124142
base_install_codex
125143
base_install_lefthook
126144
base_verify_tools

examples/claude_install_skills.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
"""Example: Install skills to a Claude skills directory.
2+
3+
Replaces fragile symlink-based workflows with a real directory install.
4+
Handles file copying, cleanup of stale skills, and directory structure.
5+
"""
6+
7+
from pathlib import Path
8+
9+
import musher
10+
11+
musher.configure(token="your-token-here")
12+
13+
# Pull the bundle (will raise NotImplementedError until pull is implemented)
14+
bundle = musher.pull("acme/agent-toolkit:2.0.0")
15+
16+
# Install all skills to the Claude skills directory, cleaning up old versions
17+
skills_dir = Path.home() / ".claude" / "skills"
18+
bundle.install_claude_skills(skills_dir, clean=True)
19+
20+
print(f"Installed {len(bundle.skills())} skills to {skills_dir}")
21+
for skill in bundle.skills():
22+
print(f" {skill.name}: {skill.description}")
23+
print(f" Files: {len(skill.files())}")

examples/claude_safe_skills.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
"""Example: Select a subset of skills and export as a Claude plugin.
2+
3+
Demonstrates per-session skill narrowing — only expose the skills your agent
4+
actually needs, reducing the attack surface and avoiding tool overload.
5+
"""
6+
7+
from pathlib import Path
8+
9+
import musher
10+
11+
musher.configure(token="your-token-here")
12+
13+
# Pull the full bundle (will raise NotImplementedError until pull is implemented)
14+
bundle = musher.pull("acme/agent-toolkit:2.0.0")
15+
16+
# Select only the skills needed for this session
17+
selection = bundle.select(skills=["web-search", "calculator"])
18+
19+
# Export the selection as a Claude plugin
20+
plugin = selection.export_claude_plugin("safe-tools", dest=Path("./plugins"))
21+
print(f"Plugin exported to: {plugin.path}")
22+
23+
# Verify only 2 skills are present
24+
for skill in selection.skills():
25+
print(f" Skill: {skill.name}{skill.description}")

examples/openai_local_hosted.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
"""Example: Export a skill for both OpenAI local-path and inline-zip formats.
2+
3+
Demonstrates dual-format export from a single skill — useful when you need
4+
the same skill available as a local directory (for development) and as an
5+
inline zip (for deployment/sharing).
6+
"""
7+
8+
import musher
9+
10+
musher.configure(token="your-token-here")
11+
12+
# Pull the bundle (will raise NotImplementedError until pull is implemented)
13+
bundle = musher.pull("acme/agent-toolkit:2.0.0")
14+
15+
# Get a single skill
16+
search = bundle.skill("web-search")
17+
18+
# Export as local directory for development
19+
local = search.export_openai_local_skill()
20+
print(f"Local skill: {local.name} at {local.path}")
21+
print(f" Registration dict: {local.to_dict()}")
22+
23+
# Export as inline zip for deployment
24+
inline = search.export_openai_inline_skill()
25+
print(f"Inline skill: {inline.name}")
26+
print(f" Base64 size: {len(inline.content_base64)} chars")
27+
print(f" Registration dict: {inline.to_dict()}")

examples/pinned_release.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
"""Example: Digest-pinned pull with verification and lockfile.
2+
3+
Demonstrates pulling a specific bundle version by digest, verifying integrity,
4+
and writing a lockfile for reproducible deployments.
5+
"""
6+
7+
import musher
8+
9+
musher.configure(token="your-token-here")
10+
11+
# Pull by digest for reproducibility (will raise NotImplementedError until pull is implemented)
12+
bundle = musher.pull("acme/agent-toolkit@sha256:abc123def456")
13+
14+
# Verify all asset checksums match the manifest
15+
bundle.verify()
16+
print(f"Bundle verified: {bundle.ref} v{bundle.version}")
17+
18+
# Write a lockfile for CI/CD reproducibility
19+
lockfile_path = bundle.write_lockfile()
20+
print(f"Lockfile written to: {lockfile_path}")
21+
22+
# Inspect contents
23+
for fh in bundle.files():
24+
print(f" {fh.logical_path} ({fh.media_type or 'unknown'})")

examples/pull_bundle.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""Example: Pull a bundle and list its assets."""
1+
"""Example: Pull a bundle and list its files."""
22

33
import musher
44

@@ -8,11 +8,11 @@
88
# Pull a bundle (will raise NotImplementedError until implemented)
99
bundle = musher.pull("myorg/my-bundle:1.0.0")
1010

11-
# List all assets
12-
for asset in bundle.assets():
13-
print(f"{asset.logical_path} ({asset.asset_type}): {asset.size_bytes} bytes")
11+
# List all files
12+
for fh in bundle.files():
13+
print(f"{fh.logical_path} ({fh.media_type or 'unknown'})")
1414

15-
# Get a specific asset
16-
prompt = bundle.asset("prompts/main.txt")
15+
# Get a specific file
16+
prompt = bundle.file("prompts/main.txt")
1717
if prompt:
18-
print(f"Prompt content: {prompt.content.decode()}")
18+
print(f"Prompt content: {prompt.text()}")

examples/pydantic_ai_prompts.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
"""Example: Use versioned prompts and toolsets from a Musher bundle.
2+
3+
Demonstrates pulling prompt templates and tool configurations from a bundle
4+
for use with PydanticAI or similar frameworks that consume structured config.
5+
"""
6+
7+
import musher
8+
9+
musher.configure(token="your-token-here")
10+
11+
# Pull a bundle with prompts and toolsets (will raise NotImplementedError until pull is implemented)
12+
bundle = musher.pull("acme/prompt-library:1.2.0")
13+
14+
# Access versioned prompts by name
15+
system_prompt = bundle.prompt("system")
16+
print(f"System prompt: {system_prompt.text()[:80]}...")
17+
18+
# List all available prompts
19+
print(f"\nAvailable prompts ({len(bundle.prompts())}):")
20+
for p in bundle.prompts():
21+
print(f" {p.name}: {p.text()[:60]}...")
22+
23+
# Access toolset configuration
24+
print(f"\nAvailable toolsets ({len(bundle.toolsets())}):")
25+
for t in bundle.toolsets():
26+
config = t.parse_json()
27+
print(f" {t.name}: {config}")
28+
29+
# Access agent specs
30+
print(f"\nAvailable agent specs ({len(bundle.agent_specs())}):")
31+
for a in bundle.agent_specs():
32+
spec = a.parse_json()
33+
print(f" {a.name}: {spec.get('name', 'unnamed')}")

lefthook.yml

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# EXAMPLE USAGE:
2+
#
3+
# Refer for explanation to following link:
4+
# https://lefthook.dev/configuration/
5+
#
6+
# pre-push:
7+
# jobs:
8+
# - name: packages audit
9+
# tags:
10+
# - frontend
11+
# - security
12+
# run: yarn audit
13+
#
14+
# - name: gems audit
15+
# tags:
16+
# - backend
17+
# - security
18+
# run: bundle audit
19+
#
20+
# pre-commit:
21+
# parallel: true
22+
# jobs:
23+
# - run: yarn eslint {staged_files}
24+
# glob: "*.{js,ts,jsx,tsx}"
25+
#
26+
# - name: rubocop
27+
# glob: "*.rb"
28+
# exclude:
29+
# - config/application.rb
30+
# - config/routes.rb
31+
# run: bundle exec rubocop --force-exclusion -- {all_files}
32+
#
33+
# - name: govet
34+
# files: git ls-files -m
35+
# glob: "*.go"
36+
# run: go vet -- {files}
37+
#
38+
# - script: "hello.js"
39+
# runner: node
40+
#
41+
# - script: "hello.go"
42+
# runner: go run

src/musher/__init__.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,15 @@
1414
RegistryError,
1515
VersionNotFoundError,
1616
)
17+
from musher._export import ClaudePluginExport, OpenAIInlineSkill, OpenAILocalSkill
18+
from musher._handles import (
19+
AgentSpecHandle,
20+
BundleSelection,
21+
FileHandle,
22+
PromptHandle,
23+
SkillHandle,
24+
ToolsetHandle,
25+
)
1726
from musher._types import (
1827
OCI_MEDIA_TYPE_ASSET,
1928
OCI_MEDIA_TYPE_CONFIG,
@@ -29,6 +38,7 @@
2938
"OCI_MEDIA_TYPE_CONFIG",
3039
# Errors
3140
"APIError",
41+
"AgentSpecHandle",
3242
# Models
3343
"Asset",
3444
# Types
@@ -39,20 +49,28 @@
3949
"Bundle",
4050
"BundleNotFoundError",
4151
"BundleRef",
52+
"BundleSelection",
4253
"BundleSourceType",
4354
"BundleVersionState",
4455
"BundleVisibility",
4556
"CacheError",
57+
"ClaudePluginExport",
4658
"Client",
59+
"FileHandle",
4760
"IntegrityError",
4861
"Manifest",
4962
"ManifestAsset",
5063
# Config
5164
"MusherConfig",
5265
"MusherError",
66+
"OpenAIInlineSkill",
67+
"OpenAILocalSkill",
68+
"PromptHandle",
5369
"RateLimitError",
5470
"RegistryError",
5571
"ResolveResult",
72+
"SkillHandle",
73+
"ToolsetHandle",
5674
"VersionNotFoundError",
5775
"configure",
5876
"get_config",

0 commit comments

Comments
 (0)