skillchef 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- skillchef/__init__.py +1 -0
- skillchef/cli.py +63 -0
- skillchef/commands/__init__.py +1 -0
- skillchef/commands/common.py +27 -0
- skillchef/commands/cook_cmd.py +37 -0
- skillchef/commands/flavor_cmd.py +32 -0
- skillchef/commands/init_cmd.py +48 -0
- skillchef/commands/list_cmd.py +8 -0
- skillchef/commands/remove_cmd.py +16 -0
- skillchef/commands/sync_cmd.py +115 -0
- skillchef/config.py +50 -0
- skillchef/llm.py +80 -0
- skillchef/merge.py +47 -0
- skillchef/remote.py +106 -0
- skillchef/store.py +138 -0
- skillchef/ui.py +185 -0
- skillchef-0.2.0.dist-info/METADATA +14 -0
- skillchef-0.2.0.dist-info/RECORD +20 -0
- skillchef-0.2.0.dist-info/WHEEL +4 -0
- skillchef-0.2.0.dist-info/entry_points.txt +2 -0
skillchef/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
skillchef/cli.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
|
|
5
|
+
from skillchef.commands import (
|
|
6
|
+
cook_cmd,
|
|
7
|
+
flavor_cmd,
|
|
8
|
+
init_cmd,
|
|
9
|
+
list_cmd as list_command,
|
|
10
|
+
remove_cmd,
|
|
11
|
+
sync_cmd,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@click.group()
|
|
16
|
+
def main() -> None:
|
|
17
|
+
"""skillchef — cook, flavor & sync your agent skills."""
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@main.command()
|
|
22
|
+
def init() -> None:
|
|
23
|
+
"""First-time setup: platforms, editor, model."""
|
|
24
|
+
init_cmd.run()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@main.command()
|
|
28
|
+
@click.argument("source")
|
|
29
|
+
def cook(source: str) -> None:
|
|
30
|
+
"""Import a skill from a remote source or local path."""
|
|
31
|
+
cook_cmd.run(source)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@main.command()
|
|
35
|
+
@click.argument("skill_name", required=False)
|
|
36
|
+
@click.option("--no-ai", is_flag=True, help="Disable automatic AI merge proposals.")
|
|
37
|
+
def sync(skill_name: str | None, no_ai: bool) -> None:
|
|
38
|
+
"""Check remotes for updates and merge."""
|
|
39
|
+
sync_cmd.run(skill_name, no_ai)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@main.command()
|
|
43
|
+
@click.argument("skill_name", required=False)
|
|
44
|
+
def flavor(skill_name: str | None) -> None:
|
|
45
|
+
"""Add or edit a local flavor for a skill."""
|
|
46
|
+
flavor_cmd.run(skill_name)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@main.command(name="list")
|
|
50
|
+
def list_cmd() -> None:
|
|
51
|
+
"""List all managed skills."""
|
|
52
|
+
list_command.run()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@main.command()
|
|
56
|
+
@click.argument("skill_name")
|
|
57
|
+
def remove(skill_name: str) -> None:
|
|
58
|
+
"""Remove a managed skill."""
|
|
59
|
+
remove_cmd.run(skill_name)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
if __name__ == "__main__":
|
|
63
|
+
main()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Namespaced command implementations for the Skillchef CLI."""
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
import subprocess
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from skillchef import config, ui
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def ensure_config() -> dict[str, Any]:
|
|
12
|
+
cfg = config.load()
|
|
13
|
+
if not cfg.get("platforms"):
|
|
14
|
+
ui.warn("No config found. Run [bold]skillchef init[/bold] first.")
|
|
15
|
+
raise SystemExit(1)
|
|
16
|
+
return cfg
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def open_editor(path: Path) -> None:
|
|
20
|
+
cfg = config.load()
|
|
21
|
+
ed = config.editor(cfg)
|
|
22
|
+
subprocess.call([ed, str(path)])
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def cleanup_fetched(fetched_dir: Path) -> None:
|
|
26
|
+
root = fetched_dir.parent if fetched_dir.name == "skill" else fetched_dir
|
|
27
|
+
shutil.rmtree(root, ignore_errors=True)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from skillchef import config, merge, remote, store, ui
|
|
4
|
+
|
|
5
|
+
from .common import cleanup_fetched, ensure_config
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def run(source: str) -> None:
|
|
9
|
+
ui.banner()
|
|
10
|
+
cfg = ensure_config()
|
|
11
|
+
|
|
12
|
+
ui.info(f"Fetching from {source}...")
|
|
13
|
+
try:
|
|
14
|
+
fetched_dir, remote_type = remote.fetch(source)
|
|
15
|
+
except Exception as e:
|
|
16
|
+
ui.error(f"Failed to fetch: {e}")
|
|
17
|
+
raise SystemExit(1)
|
|
18
|
+
|
|
19
|
+
skill_md = fetched_dir / "SKILL.md"
|
|
20
|
+
default_name = fetched_dir.name
|
|
21
|
+
if skill_md.exists():
|
|
22
|
+
front, _ = merge.split_frontmatter(skill_md.read_text())
|
|
23
|
+
if "name:" in front:
|
|
24
|
+
for line in front.splitlines():
|
|
25
|
+
if line.strip().startswith("name:"):
|
|
26
|
+
default_name = line.split(":", 1)[1].strip().strip('"').strip("'")
|
|
27
|
+
break
|
|
28
|
+
|
|
29
|
+
name = ui.ask("Skill name", default=default_name)
|
|
30
|
+
platforms = ui.multi_choose("Target platforms", cfg.get("platforms", list(config.PLATFORMS.keys())))
|
|
31
|
+
|
|
32
|
+
store.cook(name, fetched_dir, source, remote_type, platforms)
|
|
33
|
+
cleanup_fetched(fetched_dir)
|
|
34
|
+
|
|
35
|
+
ui.success(f"Cooked [bold]{name}[/bold]!")
|
|
36
|
+
for p in platforms:
|
|
37
|
+
ui.info(f"Symlinked → {config.platform_skill_dir(p) / name}")
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from skillchef import merge, store, ui
|
|
4
|
+
|
|
5
|
+
from .common import ensure_config, open_editor
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def run(skill_name: str | None) -> None:
|
|
9
|
+
ui.banner()
|
|
10
|
+
ensure_config()
|
|
11
|
+
|
|
12
|
+
skills = store.list_skills()
|
|
13
|
+
if not skills:
|
|
14
|
+
ui.info("No skills cooked yet.")
|
|
15
|
+
return
|
|
16
|
+
|
|
17
|
+
if not skill_name:
|
|
18
|
+
names = [s["name"] for s in skills]
|
|
19
|
+
skill_name = ui.choose("Which skill?", names)
|
|
20
|
+
|
|
21
|
+
fp = store.flavor_path(skill_name)
|
|
22
|
+
if not fp.exists():
|
|
23
|
+
fp.write_text("# Add your local flavor below\n\n")
|
|
24
|
+
|
|
25
|
+
old_live = store.live_skill_text(skill_name)
|
|
26
|
+
open_editor(fp)
|
|
27
|
+
store.rebuild_live(skill_name)
|
|
28
|
+
new_live = store.live_skill_text(skill_name)
|
|
29
|
+
|
|
30
|
+
diff_lines = merge.diff_texts(old_live, new_live, "before", "after")
|
|
31
|
+
ui.show_diff(diff_lines)
|
|
32
|
+
ui.success(f"Flavor saved for [bold]{skill_name}[/bold]")
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
from skillchef import config, ui
|
|
6
|
+
from skillchef.llm import detect_keys
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def run() -> None:
|
|
10
|
+
ui.banner()
|
|
11
|
+
ui.console.print()
|
|
12
|
+
|
|
13
|
+
ui.info("Scanning for agent platforms...\n")
|
|
14
|
+
ui.show_platforms(config.PLATFORMS)
|
|
15
|
+
ui.console.print()
|
|
16
|
+
|
|
17
|
+
detected = detect_keys()
|
|
18
|
+
ui.show_detected_keys(detected)
|
|
19
|
+
ui.console.print()
|
|
20
|
+
|
|
21
|
+
platforms = ui.multi_choose(
|
|
22
|
+
"Which platforms do you use?",
|
|
23
|
+
list(config.PLATFORMS.keys()),
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
editor = ui.ask("Preferred editor", default=os.environ.get("EDITOR", "vim"))
|
|
27
|
+
|
|
28
|
+
default_model = "anthropic/claude-sonnet-4-20250514"
|
|
29
|
+
selected_key_env = ""
|
|
30
|
+
if detected:
|
|
31
|
+
if len(detected) == 1:
|
|
32
|
+
selected_key_env, provider = detected[0]
|
|
33
|
+
else:
|
|
34
|
+
label_to_env = {f"{provider} ({env_var})": env_var for env_var, provider in detected}
|
|
35
|
+
key_choices = list(label_to_env.keys())
|
|
36
|
+
selected_label = ui.choose("Multiple LLM keys found. Which key should AI merge use?", key_choices)
|
|
37
|
+
selected_key_env = label_to_env[selected_label]
|
|
38
|
+
provider = next(p for env_var, p in detected if env_var == selected_key_env)
|
|
39
|
+
ui.info(f"AI merge will use [bold]{selected_key_env}[/bold] ({provider})")
|
|
40
|
+
model = ui.ask("AI model for semantic merge", default=default_model)
|
|
41
|
+
|
|
42
|
+
cfg = {"platforms": platforms, "editor": editor, "model": model, "llm_api_key_env": selected_key_env}
|
|
43
|
+
config.save(cfg)
|
|
44
|
+
|
|
45
|
+
ui.console.print()
|
|
46
|
+
ui.show_config_summary(cfg)
|
|
47
|
+
ui.console.print()
|
|
48
|
+
ui.success(f"Config saved to [bold]{config.CONFIG_PATH}[/bold]")
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from skillchef import store, ui
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def run(skill_name: str) -> None:
|
|
7
|
+
ui.banner()
|
|
8
|
+
try:
|
|
9
|
+
store.load_meta(skill_name)
|
|
10
|
+
except FileNotFoundError:
|
|
11
|
+
ui.error(f"Skill '{skill_name}' not found.")
|
|
12
|
+
raise SystemExit(1)
|
|
13
|
+
|
|
14
|
+
if ui.confirm(f"Remove [bold]{skill_name}[/bold]?", default=False):
|
|
15
|
+
store.remove(skill_name)
|
|
16
|
+
ui.success(f"Removed {skill_name}")
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from concurrent.futures import Future, ThreadPoolExecutor
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from skillchef import config, merge, remote, store, ui
|
|
7
|
+
from skillchef.llm import selected_key, semantic_merge
|
|
8
|
+
|
|
9
|
+
from .common import cleanup_fetched, ensure_config, open_editor
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def run(skill_name: str | None, no_ai: bool) -> None:
|
|
13
|
+
ui.banner()
|
|
14
|
+
ensure_config()
|
|
15
|
+
|
|
16
|
+
cfg = config.load()
|
|
17
|
+
key = selected_key(cfg.get("llm_api_key_env", ""))
|
|
18
|
+
ai_available = key is not None and not no_ai
|
|
19
|
+
if ai_available and key:
|
|
20
|
+
ui.info(f"Using [bold]{key[0]}[/bold] ({key[1]}) for semantic merge")
|
|
21
|
+
|
|
22
|
+
skills = store.list_skills()
|
|
23
|
+
if not skills:
|
|
24
|
+
ui.info("No skills to sync.")
|
|
25
|
+
return
|
|
26
|
+
|
|
27
|
+
if skill_name:
|
|
28
|
+
skills = [s for s in skills if s["name"] == skill_name]
|
|
29
|
+
if not skills:
|
|
30
|
+
ui.error(f"Skill '{skill_name}' not found.")
|
|
31
|
+
raise SystemExit(1)
|
|
32
|
+
|
|
33
|
+
for meta in skills:
|
|
34
|
+
_sync_one(meta, ai_available=ai_available)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _sync_one(meta: dict[str, Any], ai_available: bool = False) -> None:
|
|
38
|
+
name = meta["name"]
|
|
39
|
+
ui.info(f"Syncing [bold]{name}[/bold]...")
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
fetched_dir, _ = remote.fetch(meta["remote_url"])
|
|
43
|
+
except Exception as e:
|
|
44
|
+
ui.warn(f" Could not fetch {name}: {e}")
|
|
45
|
+
return
|
|
46
|
+
|
|
47
|
+
new_hash = store.hash_dir(fetched_dir)
|
|
48
|
+
if new_hash == meta.get("base_sha256"):
|
|
49
|
+
ui.success(f" {name}: up to date")
|
|
50
|
+
cleanup_fetched(fetched_dir)
|
|
51
|
+
return
|
|
52
|
+
|
|
53
|
+
old_base = store.base_skill_text(name)
|
|
54
|
+
new_remote = (fetched_dir / "SKILL.md").read_text() if (fetched_dir / "SKILL.md").exists() else ""
|
|
55
|
+
diff_lines = merge.diff_texts(old_base, new_remote, "base (current)", "remote (new)")
|
|
56
|
+
|
|
57
|
+
ai_future: Future[str] | None = None
|
|
58
|
+
flavor_text = ""
|
|
59
|
+
if store.has_flavor(name) and ai_available:
|
|
60
|
+
flavor_text = store.flavor_path(name).read_text()
|
|
61
|
+
executor = ThreadPoolExecutor(max_workers=1)
|
|
62
|
+
ai_future = executor.submit(semantic_merge, old_base, new_remote, flavor_text)
|
|
63
|
+
|
|
64
|
+
ui.show_diff(diff_lines)
|
|
65
|
+
|
|
66
|
+
if not store.has_flavor(name):
|
|
67
|
+
if ui.confirm("Accept update?"):
|
|
68
|
+
store.update_base(name, fetched_dir)
|
|
69
|
+
store.rebuild_live(name)
|
|
70
|
+
ui.success(f" {name}: updated")
|
|
71
|
+
else:
|
|
72
|
+
ui.info(f" {name}: skipped")
|
|
73
|
+
else:
|
|
74
|
+
if not flavor_text:
|
|
75
|
+
flavor_text = store.flavor_path(name).read_text()
|
|
76
|
+
|
|
77
|
+
ai_result = _resolve_ai_future(ai_future)
|
|
78
|
+
choices = ["accept + re-apply flavor", "keep current", "manual edit"]
|
|
79
|
+
if ai_result:
|
|
80
|
+
choices.insert(0, "accept ai merge")
|
|
81
|
+
ui.info("AI proposed a semantic merge:")
|
|
82
|
+
ai_diff = merge.diff_texts(store.live_skill_text(name), ai_result, "current", "ai proposed")
|
|
83
|
+
ui.show_diff(ai_diff)
|
|
84
|
+
|
|
85
|
+
action = ui.choose("How to handle?", choices)
|
|
86
|
+
|
|
87
|
+
if action == "accept ai merge" and ai_result:
|
|
88
|
+
store.update_base(name, fetched_dir)
|
|
89
|
+
live_md = store.skill_dir(name) / "live" / "SKILL.md"
|
|
90
|
+
live_md.write_text(ai_result)
|
|
91
|
+
ui.success(f" {name}: AI merged")
|
|
92
|
+
elif action == "accept + re-apply flavor":
|
|
93
|
+
store.update_base(name, fetched_dir)
|
|
94
|
+
store.rebuild_live(name)
|
|
95
|
+
ui.success(f" {name}: rebased with flavor")
|
|
96
|
+
elif action == "keep current":
|
|
97
|
+
ui.info(f" {name}: skipped")
|
|
98
|
+
elif action == "manual edit":
|
|
99
|
+
store.update_base(name, fetched_dir)
|
|
100
|
+
store.rebuild_live(name)
|
|
101
|
+
open_editor(store.skill_dir(name) / "live" / "SKILL.md")
|
|
102
|
+
ui.success(f" {name}: manually merged")
|
|
103
|
+
|
|
104
|
+
cleanup_fetched(fetched_dir)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _resolve_ai_future(future: Future[str] | None) -> str | None:
|
|
108
|
+
if future is None:
|
|
109
|
+
return None
|
|
110
|
+
try:
|
|
111
|
+
with ui.spinner("Waiting for AI merge proposal..."):
|
|
112
|
+
return future.result(timeout=60)
|
|
113
|
+
except Exception as e:
|
|
114
|
+
ui.warn(f" AI merge failed: {e}")
|
|
115
|
+
return None
|
skillchef/config.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import tomllib
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import tomli_w
|
|
9
|
+
|
|
10
|
+
SKILLCHEF_HOME = Path.home() / ".skillchef"
|
|
11
|
+
CONFIG_PATH = SKILLCHEF_HOME / "config.toml"
|
|
12
|
+
STORE_DIR = SKILLCHEF_HOME / "store"
|
|
13
|
+
|
|
14
|
+
PLATFORMS: dict[str, Path] = {
|
|
15
|
+
"codex": Path.home() / ".codex" / "skills",
|
|
16
|
+
"cursor": Path.home() / ".cursor" / "skills",
|
|
17
|
+
"claude-code": Path.home() / ".claude" / "skills",
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
DEFAULT_CONFIG: dict[str, Any] = {
|
|
21
|
+
"platforms": [],
|
|
22
|
+
"editor": "",
|
|
23
|
+
"model": "anthropic/claude-sonnet-4-20250514",
|
|
24
|
+
"llm_api_key_env": "",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def load() -> dict[str, Any]:
|
|
29
|
+
if not CONFIG_PATH.exists():
|
|
30
|
+
return dict(DEFAULT_CONFIG)
|
|
31
|
+
return tomllib.loads(CONFIG_PATH.read_text())
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def save(cfg: dict[str, Any]) -> None:
|
|
35
|
+
SKILLCHEF_HOME.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
CONFIG_PATH.write_bytes(tomli_w.dumps(cfg).encode())
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def editor(cfg: dict[str, Any] | None = None) -> str:
|
|
40
|
+
cfg = cfg or load()
|
|
41
|
+
return cfg.get("editor") or os.environ.get("EDITOR", "vim")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def platform_skill_dir(platform: str) -> Path:
|
|
45
|
+
return PLATFORMS[platform]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def ensure_store() -> Path:
|
|
49
|
+
STORE_DIR.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
return STORE_DIR
|
skillchef/llm.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
from litellm import completion
|
|
6
|
+
|
|
7
|
+
from skillchef import config
|
|
8
|
+
|
|
9
|
+
LLM_KEY_MAP = [
|
|
10
|
+
("ANTHROPIC_API_KEY", "Anthropic"),
|
|
11
|
+
("OPENAI_API_KEY", "OpenAI"),
|
|
12
|
+
("GEMINI_API_KEY", "Google Gemini"),
|
|
13
|
+
("MISTRAL_API_KEY", "Mistral"),
|
|
14
|
+
("COHERE_API_KEY", "Cohere"),
|
|
15
|
+
("OLLAMA_API_BASE", "Ollama (local)"),
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
MERGE_PROMPT = """You are merging an agent skill file. The upstream base has changed.
|
|
19
|
+
The user has a local "flavor" (customization) applied on top of the old base.
|
|
20
|
+
|
|
21
|
+
Your job: produce a single merged SKILL.md that incorporates BOTH the new upstream
|
|
22
|
+
changes AND the user's local flavor. Preserve the intent of both sides.
|
|
23
|
+
|
|
24
|
+
Return ONLY the merged file content, no explanation.
|
|
25
|
+
|
|
26
|
+
=== OLD BASE ===
|
|
27
|
+
{old_base}
|
|
28
|
+
|
|
29
|
+
=== NEW REMOTE (upstream update) ===
|
|
30
|
+
{new_remote}
|
|
31
|
+
|
|
32
|
+
=== USER'S LOCAL FLAVOR ===
|
|
33
|
+
{flavor}
|
|
34
|
+
|
|
35
|
+
=== MERGED RESULT ==="""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def detect_keys() -> list[tuple[str, str]]:
|
|
39
|
+
return [(k, v) for k, v in LLM_KEY_MAP if os.environ.get(k)]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def selected_key(preferred_env_var: str | None = None) -> tuple[str, str] | None:
|
|
43
|
+
keys = detect_keys()
|
|
44
|
+
if not keys:
|
|
45
|
+
return None
|
|
46
|
+
if preferred_env_var:
|
|
47
|
+
for env_var, provider in keys:
|
|
48
|
+
if env_var == preferred_env_var:
|
|
49
|
+
return env_var, provider
|
|
50
|
+
return keys[0]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def has_llm() -> bool:
|
|
54
|
+
return len(detect_keys()) > 0
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def semantic_merge(old_base: str, new_remote: str, flavor: str, model: str | None = None) -> str:
|
|
58
|
+
cfg = config.load()
|
|
59
|
+
model = model or cfg.get("model", "anthropic/claude-sonnet-4-20250514")
|
|
60
|
+
configured_env = cfg.get("llm_api_key_env", "")
|
|
61
|
+
key = selected_key(configured_env)
|
|
62
|
+
|
|
63
|
+
completion_kwargs: dict[str, str | int | list[dict[str, str]]] = {}
|
|
64
|
+
if key:
|
|
65
|
+
env_var, _provider = key
|
|
66
|
+
value = os.environ.get(env_var, "")
|
|
67
|
+
if value:
|
|
68
|
+
if env_var == "OLLAMA_API_BASE":
|
|
69
|
+
completion_kwargs["api_base"] = value
|
|
70
|
+
else:
|
|
71
|
+
completion_kwargs["api_key"] = value
|
|
72
|
+
|
|
73
|
+
prompt = MERGE_PROMPT.format(old_base=old_base, new_remote=new_remote, flavor=flavor)
|
|
74
|
+
resp = completion(
|
|
75
|
+
model=model,
|
|
76
|
+
messages=[{"role": "user", "content": prompt}],
|
|
77
|
+
temperature=0,
|
|
78
|
+
**completion_kwargs,
|
|
79
|
+
)
|
|
80
|
+
return resp.choices[0].message.content.strip()
|
skillchef/merge.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import difflib
|
|
4
|
+
import re
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?\n)---\s*\n", re.DOTALL)
|
|
8
|
+
FLAVOR_HEADER = "\n\n## Local Flavor\n\n"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def split_frontmatter(text: str) -> tuple[str, str]:
|
|
12
|
+
m = FRONTMATTER_RE.match(text)
|
|
13
|
+
if m:
|
|
14
|
+
return text[: m.end()], text[m.end() :]
|
|
15
|
+
return "", text
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def merge_skill(live_skill_path: Path, flavor_path: Path) -> None:
|
|
19
|
+
base_text = live_skill_path.read_text()
|
|
20
|
+
flavor_text = flavor_path.read_text().strip()
|
|
21
|
+
if not flavor_text:
|
|
22
|
+
return
|
|
23
|
+
front, body = split_frontmatter(base_text)
|
|
24
|
+
body = body.rstrip()
|
|
25
|
+
merged = front + body + FLAVOR_HEADER + flavor_text + "\n"
|
|
26
|
+
live_skill_path.write_text(merged)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def diff_texts(old: str, new: str, label_old: str = "old", label_new: str = "new") -> list[str]:
|
|
30
|
+
return list(difflib.unified_diff(
|
|
31
|
+
old.splitlines(keepends=True),
|
|
32
|
+
new.splitlines(keepends=True),
|
|
33
|
+
fromfile=label_old,
|
|
34
|
+
tofile=label_new,
|
|
35
|
+
))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def three_way_summary(old_base: str, new_remote: str, flavor: str) -> str:
|
|
39
|
+
lines = []
|
|
40
|
+
base_diff = diff_texts(old_base, new_remote, "base (old)", "remote (new)")
|
|
41
|
+
if base_diff:
|
|
42
|
+
lines.append("=== Upstream changes ===")
|
|
43
|
+
lines.extend(base_diff)
|
|
44
|
+
if flavor.strip():
|
|
45
|
+
lines.append("\n=== Your flavor ===")
|
|
46
|
+
lines.append(flavor)
|
|
47
|
+
return "".join(lines)
|
skillchef/remote.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import shutil
|
|
5
|
+
import tempfile
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from urllib.parse import urlparse
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
GITHUB_TREE_RE = re.compile(
|
|
12
|
+
r"github\.com/(?P<owner>[^/]+)/(?P<repo>[^/]+)/tree/(?P<ref>[^/]+)/(?P<path>.+)"
|
|
13
|
+
)
|
|
14
|
+
GITHUB_BLOB_RE = re.compile(
|
|
15
|
+
r"github\.com/(?P<owner>[^/]+)/(?P<repo>[^/]+)/blob/(?P<ref>[^/]+)/(?P<path>.+)"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def classify(source: str) -> str:
|
|
20
|
+
if Path(source).exists():
|
|
21
|
+
return "local"
|
|
22
|
+
parsed = urlparse(source)
|
|
23
|
+
if "github.com" in (parsed.hostname or ""):
|
|
24
|
+
return "github"
|
|
25
|
+
if parsed.scheme in ("http", "https"):
|
|
26
|
+
return "http"
|
|
27
|
+
raise ValueError(f"Cannot classify source: {source}")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def fetch(source: str) -> tuple[Path, str]:
|
|
31
|
+
"""Fetch skill content into a temp directory.
|
|
32
|
+
Returns (temp_dir_path, remote_type).
|
|
33
|
+
Caller is responsible for cleanup.
|
|
34
|
+
"""
|
|
35
|
+
kind = classify(source)
|
|
36
|
+
if kind == "local":
|
|
37
|
+
return _fetch_local(source), kind
|
|
38
|
+
if kind == "github":
|
|
39
|
+
return _fetch_github(source), kind
|
|
40
|
+
return _fetch_http(source), kind
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _fetch_local(source: str) -> Path:
|
|
44
|
+
src = Path(source).resolve()
|
|
45
|
+
tmp = Path(tempfile.mkdtemp(prefix="skillchef-"))
|
|
46
|
+
if src.is_dir():
|
|
47
|
+
shutil.copytree(src, tmp / "skill", dirs_exist_ok=True)
|
|
48
|
+
return tmp / "skill"
|
|
49
|
+
tmp_skill = tmp / "skill"
|
|
50
|
+
tmp_skill.mkdir()
|
|
51
|
+
shutil.copy2(src, tmp_skill / src.name)
|
|
52
|
+
return tmp_skill
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _fetch_github(source: str) -> Path:
|
|
56
|
+
m = GITHUB_TREE_RE.search(source) or GITHUB_BLOB_RE.search(source)
|
|
57
|
+
if not m:
|
|
58
|
+
raise ValueError(f"Cannot parse GitHub URL: {source}")
|
|
59
|
+
owner, repo, ref, path = m.group("owner"), m.group("repo"), m.group("ref"), m.group("path")
|
|
60
|
+
return _fetch_github_dir(owner, repo, ref, path)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _fetch_github_dir(owner: str, repo: str, ref: str, path: str) -> Path:
|
|
64
|
+
api_url = f"https://api.github.com/repos/{owner}/{repo}/contents/{path}?ref={ref}"
|
|
65
|
+
tmp = Path(tempfile.mkdtemp(prefix="skillchef-"))
|
|
66
|
+
skill_dir = tmp / "skill"
|
|
67
|
+
skill_dir.mkdir()
|
|
68
|
+
_download_github_path(api_url, skill_dir)
|
|
69
|
+
return skill_dir
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _download_github_path(api_url: str, dest: Path) -> None:
|
|
73
|
+
with httpx.Client(follow_redirects=True, timeout=30) as client:
|
|
74
|
+
resp = client.get(api_url, headers={"Accept": "application/vnd.github.v3+json"})
|
|
75
|
+
resp.raise_for_status()
|
|
76
|
+
data = resp.json()
|
|
77
|
+
|
|
78
|
+
if isinstance(data, dict) and data.get("type") == "file":
|
|
79
|
+
_download_raw(data["download_url"], dest / data["name"])
|
|
80
|
+
return
|
|
81
|
+
|
|
82
|
+
if isinstance(data, list):
|
|
83
|
+
for item in data:
|
|
84
|
+
if item["type"] == "file":
|
|
85
|
+
_download_raw(item["download_url"], dest / item["name"])
|
|
86
|
+
elif item["type"] == "dir":
|
|
87
|
+
sub = dest / item["name"]
|
|
88
|
+
sub.mkdir(exist_ok=True)
|
|
89
|
+
_download_github_path(item["url"], sub)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _download_raw(url: str, dest: Path) -> None:
|
|
93
|
+
with httpx.Client(follow_redirects=True, timeout=30) as client:
|
|
94
|
+
resp = client.get(url)
|
|
95
|
+
resp.raise_for_status()
|
|
96
|
+
dest.write_bytes(resp.content)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _fetch_http(source: str) -> Path:
|
|
100
|
+
tmp = Path(tempfile.mkdtemp(prefix="skillchef-"))
|
|
101
|
+
skill_dir = tmp / "skill"
|
|
102
|
+
skill_dir.mkdir()
|
|
103
|
+
parsed = urlparse(source)
|
|
104
|
+
filename = Path(parsed.path).name or "SKILL.md"
|
|
105
|
+
_download_raw(source, skill_dir / filename)
|
|
106
|
+
return skill_dir
|
skillchef/store.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import shutil
|
|
5
|
+
import tomllib
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import tomli_w
|
|
11
|
+
|
|
12
|
+
from skillchef import config
|
|
13
|
+
from skillchef.merge import merge_skill
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def skill_dir(name: str) -> Path:
|
|
17
|
+
return config.ensure_store() / name
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def list_skills() -> list[dict[str, Any]]:
|
|
21
|
+
store = config.STORE_DIR
|
|
22
|
+
if not store.exists():
|
|
23
|
+
return []
|
|
24
|
+
skills = []
|
|
25
|
+
for d in sorted(store.iterdir()):
|
|
26
|
+
meta_path = d / "meta.toml"
|
|
27
|
+
if d.is_dir() and meta_path.exists():
|
|
28
|
+
skills.append(load_meta(d.name))
|
|
29
|
+
return skills
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def load_meta(name: str) -> dict[str, Any]:
|
|
33
|
+
meta_path = skill_dir(name) / "meta.toml"
|
|
34
|
+
return tomllib.loads(meta_path.read_text())
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def save_meta(name: str, meta: dict[str, Any]) -> None:
|
|
38
|
+
meta_path = skill_dir(name) / "meta.toml"
|
|
39
|
+
meta_path.write_bytes(tomli_w.dumps(meta).encode())
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def cook(name: str, fetched_dir: Path, remote_url: str, remote_type: str, platforms: list[str]) -> Path:
|
|
43
|
+
"""Install a fetched skill into the store."""
|
|
44
|
+
sd = skill_dir(name)
|
|
45
|
+
base_dir = sd / "base"
|
|
46
|
+
live_dir = sd / "live"
|
|
47
|
+
|
|
48
|
+
if sd.exists():
|
|
49
|
+
shutil.rmtree(sd)
|
|
50
|
+
sd.mkdir(parents=True)
|
|
51
|
+
|
|
52
|
+
shutil.copytree(fetched_dir, base_dir)
|
|
53
|
+
shutil.copytree(base_dir, live_dir)
|
|
54
|
+
|
|
55
|
+
meta = {
|
|
56
|
+
"name": name,
|
|
57
|
+
"remote_url": remote_url,
|
|
58
|
+
"remote_type": remote_type,
|
|
59
|
+
"base_sha256": hash_dir(base_dir),
|
|
60
|
+
"last_sync": datetime.now(timezone.utc).isoformat(),
|
|
61
|
+
"platforms": platforms,
|
|
62
|
+
}
|
|
63
|
+
save_meta(name, meta)
|
|
64
|
+
_create_symlinks(name, platforms)
|
|
65
|
+
return sd
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def remove(name: str) -> None:
|
|
69
|
+
meta = load_meta(name)
|
|
70
|
+
_remove_symlinks(name, meta.get("platforms", []))
|
|
71
|
+
shutil.rmtree(skill_dir(name))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def update_base(name: str, fetched_dir: Path) -> None:
|
|
75
|
+
sd = skill_dir(name)
|
|
76
|
+
base_dir = sd / "base"
|
|
77
|
+
if base_dir.exists():
|
|
78
|
+
shutil.rmtree(base_dir)
|
|
79
|
+
shutil.copytree(fetched_dir, base_dir)
|
|
80
|
+
meta = load_meta(name)
|
|
81
|
+
meta["base_sha256"] = hash_dir(base_dir)
|
|
82
|
+
meta["last_sync"] = datetime.now(timezone.utc).isoformat()
|
|
83
|
+
save_meta(name, meta)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def rebuild_live(name: str) -> None:
|
|
87
|
+
sd = skill_dir(name)
|
|
88
|
+
live_dir = sd / "live"
|
|
89
|
+
if live_dir.exists():
|
|
90
|
+
shutil.rmtree(live_dir)
|
|
91
|
+
shutil.copytree(sd / "base", live_dir)
|
|
92
|
+
flavor_path = sd / "flavor.md"
|
|
93
|
+
if flavor_path.exists():
|
|
94
|
+
merge_skill(live_dir / "SKILL.md", flavor_path)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def has_flavor(name: str) -> bool:
|
|
98
|
+
return (skill_dir(name) / "flavor.md").exists()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def flavor_path(name: str) -> Path:
|
|
102
|
+
return skill_dir(name) / "flavor.md"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def base_skill_text(name: str) -> str:
|
|
106
|
+
return (skill_dir(name) / "base" / "SKILL.md").read_text()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def live_skill_text(name: str) -> str:
|
|
110
|
+
return (skill_dir(name) / "live" / "SKILL.md").read_text()
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def hash_dir(path: Path) -> str:
|
|
114
|
+
h = hashlib.sha256()
|
|
115
|
+
for f in sorted(path.rglob("*")):
|
|
116
|
+
if f.is_file():
|
|
117
|
+
h.update(f.relative_to(path).as_posix().encode())
|
|
118
|
+
h.update(f.read_bytes())
|
|
119
|
+
return h.hexdigest()
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _create_symlinks(name: str, platforms: list[str]) -> None:
|
|
123
|
+
live_dir = skill_dir(name) / "live"
|
|
124
|
+
for p in platforms:
|
|
125
|
+
target = config.platform_skill_dir(p) / name
|
|
126
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
127
|
+
if target.exists() or target.is_symlink():
|
|
128
|
+
target.unlink() if target.is_symlink() else shutil.rmtree(target)
|
|
129
|
+
target.symlink_to(live_dir)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _remove_symlinks(name: str, platforms: list[str]) -> None:
|
|
133
|
+
for p in platforms:
|
|
134
|
+
target = config.platform_skill_dir(p) / name
|
|
135
|
+
if target.is_symlink():
|
|
136
|
+
target.unlink()
|
|
137
|
+
elif target.exists():
|
|
138
|
+
shutil.rmtree(target)
|
skillchef/ui.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from typing import Any, Callable
|
|
5
|
+
|
|
6
|
+
from questionary import Choice
|
|
7
|
+
import questionary
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
from rich.columns import Columns
|
|
10
|
+
from rich.prompt import Prompt, Confirm
|
|
11
|
+
from rich.syntax import Syntax
|
|
12
|
+
from rich.table import Table
|
|
13
|
+
from rich.panel import Panel
|
|
14
|
+
from rich.text import Text
|
|
15
|
+
|
|
16
|
+
console = Console()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def banner() -> None:
|
|
20
|
+
console.print(Panel.fit("[bold]skillchef[/bold] · cook, flavor & sync your skills", border_style="dim"))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def success(msg: str) -> None:
|
|
24
|
+
console.print(f"[green]✓[/green] {msg}")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def info(msg: str) -> None:
|
|
28
|
+
console.print(f"[dim]→[/dim] {msg}")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def warn(msg: str) -> None:
|
|
32
|
+
console.print(f"[yellow]![/yellow] {msg}")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def error(msg: str) -> None:
|
|
36
|
+
console.print(f"[red]✗[/red] {msg}")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def ask(prompt: str, default: str = "") -> str:
|
|
40
|
+
return Prompt.ask(f" {prompt}", default=default, console=console)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def confirm(prompt: str, default: bool = True) -> bool:
|
|
44
|
+
return Confirm.ask(f" {prompt}", default=default, console=console)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def choose(prompt: str, choices: list[str]) -> str:
|
|
48
|
+
if _can_use_interactive_selector():
|
|
49
|
+
selected = questionary.select(
|
|
50
|
+
f" {prompt}",
|
|
51
|
+
choices=choices,
|
|
52
|
+
qmark="",
|
|
53
|
+
).ask()
|
|
54
|
+
if selected:
|
|
55
|
+
return selected
|
|
56
|
+
|
|
57
|
+
for i, c in enumerate(choices, 1):
|
|
58
|
+
console.print(f" [dim]{i}.[/dim] {c}")
|
|
59
|
+
while True:
|
|
60
|
+
val = ask(prompt)
|
|
61
|
+
if val in choices:
|
|
62
|
+
return val
|
|
63
|
+
try:
|
|
64
|
+
idx = int(val) - 1
|
|
65
|
+
if 0 <= idx < len(choices):
|
|
66
|
+
return choices[idx]
|
|
67
|
+
except ValueError:
|
|
68
|
+
pass
|
|
69
|
+
warn("Invalid choice, try again")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def multi_choose(prompt: str, choices: list[str]) -> list[str]:
|
|
73
|
+
if _can_use_interactive_selector():
|
|
74
|
+
selected = questionary.checkbox(
|
|
75
|
+
f" {prompt}",
|
|
76
|
+
choices=[Choice(title=c, value=c, checked=True) for c in choices],
|
|
77
|
+
qmark="",
|
|
78
|
+
).ask()
|
|
79
|
+
return list(selected) if selected else choices
|
|
80
|
+
|
|
81
|
+
for i, c in enumerate(choices, 1):
|
|
82
|
+
console.print(f" [dim]{i}.[/dim] {c}")
|
|
83
|
+
raw = ask(f"{prompt} (comma-separated numbers)")
|
|
84
|
+
selected = []
|
|
85
|
+
for part in raw.split(","):
|
|
86
|
+
part = part.strip()
|
|
87
|
+
if part in choices:
|
|
88
|
+
selected.append(part)
|
|
89
|
+
else:
|
|
90
|
+
try:
|
|
91
|
+
idx = int(part) - 1
|
|
92
|
+
if 0 <= idx < len(choices):
|
|
93
|
+
selected.append(choices[idx])
|
|
94
|
+
except ValueError:
|
|
95
|
+
pass
|
|
96
|
+
return selected or choices
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def show_diff(diff_lines: list[str]) -> None:
|
|
100
|
+
if not diff_lines:
|
|
101
|
+
info("No differences")
|
|
102
|
+
return
|
|
103
|
+
text = Text()
|
|
104
|
+
for line in diff_lines:
|
|
105
|
+
line_str = line.rstrip("\n")
|
|
106
|
+
if line_str.startswith("+"):
|
|
107
|
+
text.append(line_str + "\n", style="green")
|
|
108
|
+
elif line_str.startswith("-"):
|
|
109
|
+
text.append(line_str + "\n", style="red")
|
|
110
|
+
elif line_str.startswith("@@"):
|
|
111
|
+
text.append(line_str + "\n", style="cyan")
|
|
112
|
+
else:
|
|
113
|
+
text.append(line_str + "\n")
|
|
114
|
+
console.print(Panel(text, title="diff", border_style="dim"))
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def skill_table(skills: list[dict[str, Any]], has_flavor_fn: Callable[[str], bool] | None = None) -> None:
|
|
118
|
+
if not skills:
|
|
119
|
+
info("No skills cooked yet. Run [bold]skillchef cook <source>[/bold] to get started.")
|
|
120
|
+
return
|
|
121
|
+
table = Table(show_header=True, header_style="bold", border_style="dim")
|
|
122
|
+
table.add_column("Name")
|
|
123
|
+
table.add_column("Source", style="dim")
|
|
124
|
+
table.add_column("Last Sync", style="dim")
|
|
125
|
+
table.add_column("Flavor", justify="center")
|
|
126
|
+
table.add_column("Platforms", style="dim")
|
|
127
|
+
for s in skills:
|
|
128
|
+
flavored = has_flavor_fn(s["name"]) if has_flavor_fn else False
|
|
129
|
+
flavor = "[green]yes[/green]" if flavored else "[dim]no[/dim]"
|
|
130
|
+
table.add_row(
|
|
131
|
+
s["name"],
|
|
132
|
+
_truncate(s.get("remote_url", ""), 40),
|
|
133
|
+
s.get("last_sync", "")[:10],
|
|
134
|
+
flavor,
|
|
135
|
+
", ".join(s.get("platforms", [])),
|
|
136
|
+
)
|
|
137
|
+
console.print(table)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def show_platforms(platforms: dict[str, Any]) -> None:
|
|
141
|
+
table = Table(show_header=False, border_style="dim", padding=(0, 2))
|
|
142
|
+
table.add_column("Platform", style="bold")
|
|
143
|
+
table.add_column("Path", style="dim")
|
|
144
|
+
table.add_column("Status")
|
|
145
|
+
for name, path in platforms.items():
|
|
146
|
+
exists = path.exists()
|
|
147
|
+
status = "[green]found[/green]" if exists else "[dim]will create[/dim]"
|
|
148
|
+
table.add_row(name, str(path), status)
|
|
149
|
+
console.print(table)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def show_detected_keys(keys: list[tuple[str, str]]) -> None:
|
|
153
|
+
if not keys:
|
|
154
|
+
info("No LLM API keys detected in environment")
|
|
155
|
+
return
|
|
156
|
+
for env_var, provider in keys:
|
|
157
|
+
success(f"Detected [bold]{env_var}[/bold] ({provider})")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def show_config_summary(cfg: dict[str, Any]) -> None:
|
|
161
|
+
console.print()
|
|
162
|
+
table = Table(show_header=False, border_style="dim", padding=(0, 2))
|
|
163
|
+
table.add_column("Key", style="bold")
|
|
164
|
+
table.add_column("Value")
|
|
165
|
+
table.add_row("Platforms", ", ".join(cfg.get("platforms", [])))
|
|
166
|
+
table.add_row("Editor", cfg.get("editor", ""))
|
|
167
|
+
table.add_row("AI Model", cfg.get("model", ""))
|
|
168
|
+
table.add_row("AI Key", cfg.get("llm_api_key_env", "") or "(auto)")
|
|
169
|
+
console.print(table)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def spinner(msg: str) -> Any:
|
|
173
|
+
return console.status(f"[dim]{msg}[/dim]", spinner="dots")
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def show_skill_md(text: str, title: str = "SKILL.md") -> None:
|
|
177
|
+
console.print(Syntax(text, "markdown", theme="monokai", line_numbers=False, word_wrap=True))
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _truncate(s: str, n: int) -> str:
|
|
181
|
+
return s if len(s) <= n else s[: n - 1] + "…"
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _can_use_interactive_selector() -> bool:
|
|
185
|
+
return sys.stdin.isatty() and sys.stdout.isatty()
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: skillchef
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: pyenv + git for agent skills. Cook, flavor, and sync skills from any source.
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Requires-Dist: click>=8.3.1
|
|
8
|
+
Requires-Dist: httpx>=0.28.1
|
|
9
|
+
Requires-Dist: litellm>=1.81.11
|
|
10
|
+
Requires-Dist: questionary>=2.1.0
|
|
11
|
+
Requires-Dist: rich>=14.3.2
|
|
12
|
+
Requires-Dist: tomli-w>=1.2.0
|
|
13
|
+
Provides-Extra: test
|
|
14
|
+
Requires-Dist: pytest>=8.3.0; extra == 'test'
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
skillchef/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
2
|
+
skillchef/cli.py,sha256=SV5JoWDizzLENtCz_7c5ChwClWu56100UXpmpsHxAJU,1347
|
|
3
|
+
skillchef/config.py,sha256=QLvK59JD4LOkgYPC_LqAAnOSu_H3SX-hQmISV5qvay0,1221
|
|
4
|
+
skillchef/llm.py,sha256=kghXXSUFBS_hy80jWZOohlnHCsrKBvWwG84HfQ4UUgU,2301
|
|
5
|
+
skillchef/merge.py,sha256=FBz39zj9EiLlZ9TERNNEeu-tsCzDSyFLmpNriLdMq10,1411
|
|
6
|
+
skillchef/remote.py,sha256=LQQuf1_QXPo_eq7x9Hvg6xhIasoZyOBBqoHJbothFDY,3404
|
|
7
|
+
skillchef/store.py,sha256=84aNuV5xVzjkZnwP3XDTcyqif8-q1z1JCL9urlkr080,3792
|
|
8
|
+
skillchef/ui.py,sha256=bs37Wjfjjo6Nn6-qikIwBgoThiT3UQhuyiuJVdTdjE0,5799
|
|
9
|
+
skillchef/commands/__init__.py,sha256=tXh_fYTgRn7dx3D3f4D-GM0RJQI8Rul5WXo_JrQSArw,64
|
|
10
|
+
skillchef/commands/common.py,sha256=nQU7YJvJKxE6JvcCtWBWNR47X_Vu5bQdJK-qGhW6kJk,666
|
|
11
|
+
skillchef/commands/cook_cmd.py,sha256=hQH3XtniS0KDLqu8F5N2E84ZR5cGpGmGZx5aEp3T_tg,1233
|
|
12
|
+
skillchef/commands/flavor_cmd.py,sha256=kPAz6yjvJMJuXrgTlODEkHETUuLlptoDPefaUNQ_owU,875
|
|
13
|
+
skillchef/commands/init_cmd.py,sha256=IcSda7BvZo1WPb3jWtIiCGr03QXl20AYwmaCgcJUlK4,1621
|
|
14
|
+
skillchef/commands/list_cmd.py,sha256=OrnevMhx7-JZ_HoKyvFeT5vO2nYalUZgEX27f9IeP0M,177
|
|
15
|
+
skillchef/commands/remove_cmd.py,sha256=5Rl3el4tKkGfUix_yMwy6JAFCnJ4Yz6YIs8OEk88lcw,426
|
|
16
|
+
skillchef/commands/sync_cmd.py,sha256=8He2EMahOUyZGGR1MsIeU6BmWG17IXWvImzzIxZnW_A,4006
|
|
17
|
+
skillchef-0.2.0.dist-info/METADATA,sha256=lCcamuGS-3ApT3kcCkvoX2APKqJ1EOHUOof482rR6x4,435
|
|
18
|
+
skillchef-0.2.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
19
|
+
skillchef-0.2.0.dist-info/entry_points.txt,sha256=Pn0OUlTS0yfVDC0-odzQndDKcuM0CCl-Yigf5EHUYa4,49
|
|
20
|
+
skillchef-0.2.0.dist-info/RECORD,,
|