jup 0.1.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.
- jup/__init__.py +3 -0
- jup/commands.py +340 -0
- jup/config.py +65 -0
- jup/main.py +110 -0
- jup/models.py +63 -0
- jup-0.1.0.dist-info/METADATA +45 -0
- jup-0.1.0.dist-info/RECORD +9 -0
- jup-0.1.0.dist-info/WHEEL +4 -0
- jup-0.1.0.dist-info/entry_points.txt +2 -0
jup/__init__.py
ADDED
jup/commands.py
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
import shutil
|
|
2
|
+
import subprocess
|
|
3
|
+
import tempfile
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import typer
|
|
6
|
+
from rich import print
|
|
7
|
+
|
|
8
|
+
from .main import app, verbose_state
|
|
9
|
+
from .config import (
|
|
10
|
+
get_config,
|
|
11
|
+
get_skills_lock,
|
|
12
|
+
save_skills_lock,
|
|
13
|
+
get_skills_storage_dir,
|
|
14
|
+
get_scope_dir,
|
|
15
|
+
)
|
|
16
|
+
from .models import SkillSource, SyncMode, DEFAULT_AGENTS
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
GH_PREFIX = "gh" # Used to namespace GitHub sources in storage
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def rel_home(p):
|
|
23
|
+
return str(p).replace(str(Path.home()), "~")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def run_git_clone(repo_url: str, dest_dir: Path, **kwargs):
|
|
27
|
+
str_kwargs_flattened = []
|
|
28
|
+
for k, v in kwargs.items():
|
|
29
|
+
if len(k) == 1:
|
|
30
|
+
str_kwargs_flattened.append(f"-{k}")
|
|
31
|
+
elif len(k) > 1:
|
|
32
|
+
str_kwargs_flattened.append(f"--{k.replace('_', '-')}")
|
|
33
|
+
else:
|
|
34
|
+
continue
|
|
35
|
+
|
|
36
|
+
if isinstance(v, bool):
|
|
37
|
+
continue # Flags don't have a value
|
|
38
|
+
str_kwargs_flattened.append(str(v))
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
subprocess.run(
|
|
42
|
+
["git", "clone", *str_kwargs_flattened, repo_url, str(dest_dir)],
|
|
43
|
+
check=True,
|
|
44
|
+
stdout=subprocess.PIPE,
|
|
45
|
+
stderr=subprocess.PIPE,
|
|
46
|
+
)
|
|
47
|
+
except subprocess.CalledProcessError as e:
|
|
48
|
+
print(f"[red]Failed to clone repository: {e.stderr.decode()}[/red]")
|
|
49
|
+
raise typer.Exit(code=1)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@app.command("add")
|
|
53
|
+
def add_skill(
|
|
54
|
+
repo: str = typer.Argument(..., help="GitHub repository (e.g., obra/superpowers)"),
|
|
55
|
+
category: str = typer.Option(
|
|
56
|
+
"misc", "--category", help="Category for the skill (e.g., productivity/custom)"
|
|
57
|
+
),
|
|
58
|
+
verbose: bool = False,
|
|
59
|
+
):
|
|
60
|
+
"""Install skills from a GitHub repository."""
|
|
61
|
+
verbose_state.verbose = verbose
|
|
62
|
+
if "/" not in repo:
|
|
63
|
+
print("[red]Repository must be in format 'owner/repo'[/red]")
|
|
64
|
+
raise typer.Exit(code=1)
|
|
65
|
+
|
|
66
|
+
owner, repo_name = repo.split("/", 1)
|
|
67
|
+
repo_url = f"https://github.com/{repo}.git"
|
|
68
|
+
|
|
69
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
70
|
+
temp_path = Path(temp_dir)
|
|
71
|
+
print(f"Cloning {repo_url} to {rel_home(temp_path)}...")
|
|
72
|
+
run_git_clone(repo_url, temp_path, depth=1)
|
|
73
|
+
|
|
74
|
+
skills_dir = temp_path / "skills"
|
|
75
|
+
if not skills_dir.exists() or not skills_dir.is_dir():
|
|
76
|
+
print(f"[red]No 'skills/' directory found in {repo}[/red]")
|
|
77
|
+
raise typer.Exit(code=1)
|
|
78
|
+
|
|
79
|
+
# Determine internal storage path
|
|
80
|
+
storage_base = get_skills_storage_dir()
|
|
81
|
+
target_dir = storage_base / category / GH_PREFIX / owner / repo_name
|
|
82
|
+
|
|
83
|
+
# Extract nested skills
|
|
84
|
+
found_skills: list[Path] = []
|
|
85
|
+
for item in skills_dir.iterdir():
|
|
86
|
+
# check if is dir and contains a SKILL.md file
|
|
87
|
+
if item.is_dir() and (item / "SKILL.md").exists():
|
|
88
|
+
found_skills.append(item)
|
|
89
|
+
|
|
90
|
+
if verbose_state.verbose:
|
|
91
|
+
print(f"Found {len(found_skills)} skills at [cyan]{rel_home(target_dir)}[/cyan]:\n\t" + ", ".join(f"[blue]{skill.name}[/blue]" for skill in found_skills))
|
|
92
|
+
if not found_skills:
|
|
93
|
+
print("[red]No skills found inside the 'skills/' directory.[/red]")
|
|
94
|
+
raise typer.Exit(code=1)
|
|
95
|
+
|
|
96
|
+
# Clear existing if any
|
|
97
|
+
if target_dir.exists():
|
|
98
|
+
print(f"Overwriting existing directory at {rel_home(target_dir)}...")
|
|
99
|
+
shutil.rmtree(target_dir)
|
|
100
|
+
|
|
101
|
+
# Copy all skills to internal storage
|
|
102
|
+
for skill in found_skills:
|
|
103
|
+
dest_skill_dir = target_dir / skill.name
|
|
104
|
+
shutil.copytree(skill, dest_skill_dir)
|
|
105
|
+
|
|
106
|
+
if verbose_state.verbose:
|
|
107
|
+
print(f"Copied skills to [cyan]{rel_home(target_dir)}[/cyan]")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# Update Lockfile
|
|
113
|
+
config = get_config()
|
|
114
|
+
lock = get_skills_lock(config)
|
|
115
|
+
lock.sources[repo] = SkillSource(
|
|
116
|
+
repo=repo, category=category, skills=[skill.name for skill in found_skills]
|
|
117
|
+
)
|
|
118
|
+
save_skills_lock(config, lock)
|
|
119
|
+
|
|
120
|
+
print(f"✅ Successfully added {len(found_skills)} skills from {repo} to [green]{rel_home(target_dir)}[/green]")
|
|
121
|
+
|
|
122
|
+
# Trigger sync
|
|
123
|
+
sync_skills(verbose=verbose_state.verbose)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@app.command("remove")
|
|
127
|
+
def remove_skill(
|
|
128
|
+
target: str = typer.Argument(..., help="Skill name or repository (owner/repo)"),
|
|
129
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation"),
|
|
130
|
+
verbose: bool = False,
|
|
131
|
+
):
|
|
132
|
+
"""Remove a skill or all skills from a repository."""
|
|
133
|
+
verbose_state.verbose = verbose
|
|
134
|
+
if not yes:
|
|
135
|
+
typer.confirm(f"Are you sure you want to remove {target}?", abort=True)
|
|
136
|
+
|
|
137
|
+
config = get_config()
|
|
138
|
+
lock = get_skills_lock(config)
|
|
139
|
+
|
|
140
|
+
repo_to_remove = None
|
|
141
|
+
skill_to_remove = None
|
|
142
|
+
|
|
143
|
+
if "/" in target and target in lock.sources:
|
|
144
|
+
repo_to_remove = target
|
|
145
|
+
else:
|
|
146
|
+
# Search for skill name
|
|
147
|
+
for repo, source in lock.sources.items():
|
|
148
|
+
if target in source.skills:
|
|
149
|
+
skill_to_remove = target
|
|
150
|
+
repo_to_remove = repo
|
|
151
|
+
break
|
|
152
|
+
|
|
153
|
+
if not repo_to_remove:
|
|
154
|
+
print(f"[red]Could not find {target} in installed skills.[/red]")
|
|
155
|
+
raise typer.Exit(code=1)
|
|
156
|
+
|
|
157
|
+
source = lock.sources[repo_to_remove]
|
|
158
|
+
|
|
159
|
+
# Remove symlinks/directories for this skill/repo from all targets
|
|
160
|
+
targets = []
|
|
161
|
+
scope_dir = get_scope_dir(config)
|
|
162
|
+
default_skills_dir = scope_dir / "skills"
|
|
163
|
+
targets.append(default_skills_dir)
|
|
164
|
+
for agent_name in config.agents:
|
|
165
|
+
if agent_name in DEFAULT_AGENTS:
|
|
166
|
+
agent = DEFAULT_AGENTS[agent_name]
|
|
167
|
+
loc = (
|
|
168
|
+
agent.local_location
|
|
169
|
+
if config.scope == "local"
|
|
170
|
+
else agent.global_location
|
|
171
|
+
)
|
|
172
|
+
targets.append(Path(loc).expanduser().resolve())
|
|
173
|
+
|
|
174
|
+
removed_skills = []
|
|
175
|
+
if skill_to_remove:
|
|
176
|
+
# Remove only the specific skill
|
|
177
|
+
for t in targets:
|
|
178
|
+
skill_path = t / skill_to_remove
|
|
179
|
+
if skill_path.exists() or skill_path.is_symlink():
|
|
180
|
+
if skill_path.is_symlink() or skill_path.is_file():
|
|
181
|
+
skill_path.unlink()
|
|
182
|
+
elif skill_path.is_dir():
|
|
183
|
+
shutil.rmtree(skill_path)
|
|
184
|
+
if verbose_state.verbose:
|
|
185
|
+
print(f"Removed skill at [red]{rel_home(skill_path)}[/red]")
|
|
186
|
+
source.skills.remove(skill_to_remove)
|
|
187
|
+
removed_skills.append(skill_to_remove)
|
|
188
|
+
print(f"🗑️ Removed skill '[yellow]{skill_to_remove}[/yellow]' from {repo_to_remove}")
|
|
189
|
+
if not source.skills:
|
|
190
|
+
del lock.sources[repo_to_remove]
|
|
191
|
+
print(f"No more skills in [yellow]{repo_to_remove}[/yellow], removed repository reference.")
|
|
192
|
+
else:
|
|
193
|
+
# Remove all skills from this repo
|
|
194
|
+
for skill in list(source.skills):
|
|
195
|
+
for t in targets:
|
|
196
|
+
skill_path = t / skill
|
|
197
|
+
if skill_path.exists() or skill_path.is_symlink():
|
|
198
|
+
if skill_path.is_symlink() or skill_path.is_file():
|
|
199
|
+
skill_path.unlink()
|
|
200
|
+
elif skill_path.is_dir():
|
|
201
|
+
shutil.rmtree(skill_path)
|
|
202
|
+
if verbose_state.verbose:
|
|
203
|
+
print(f"Removed skill at [red]{rel_home(skill_path)}[/red]")
|
|
204
|
+
removed_skills.append(skill)
|
|
205
|
+
del lock.sources[repo_to_remove]
|
|
206
|
+
print(f"🗑️ Removed repository '[yellow]{repo_to_remove}[/yellow]' and all its skills.")
|
|
207
|
+
|
|
208
|
+
save_skills_lock(config, lock)
|
|
209
|
+
print(f"Removed {len(removed_skills)} skills from " + ", ".join([f"[yellow]{rel_home(t)}[/yellow]" for t in targets]))
|
|
210
|
+
sync_skills(verbose=verbose_state.verbose)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
@app.command("sync")
|
|
214
|
+
def sync_skills(verbose: bool = False):
|
|
215
|
+
"""Update all links/copies in default-lib and for other agents."""
|
|
216
|
+
verbose_state.verbose = verbose
|
|
217
|
+
config = get_config()
|
|
218
|
+
lock = get_skills_lock(config)
|
|
219
|
+
scope_dir = get_scope_dir(config)
|
|
220
|
+
|
|
221
|
+
# Target directories
|
|
222
|
+
targets = []
|
|
223
|
+
|
|
224
|
+
# Default scope directory
|
|
225
|
+
default_skills_dir = scope_dir / "skills"
|
|
226
|
+
targets.append(default_skills_dir)
|
|
227
|
+
|
|
228
|
+
# Agent directories
|
|
229
|
+
for agent_name in config.agents:
|
|
230
|
+
if agent_name in DEFAULT_AGENTS:
|
|
231
|
+
agent = DEFAULT_AGENTS[agent_name]
|
|
232
|
+
# Use global/local location based on scope
|
|
233
|
+
loc = (
|
|
234
|
+
agent.local_location
|
|
235
|
+
if config.scope == "local"
|
|
236
|
+
else agent.global_location
|
|
237
|
+
)
|
|
238
|
+
targets.append(Path(loc).expanduser().resolve())
|
|
239
|
+
else:
|
|
240
|
+
print(f"[yellow]Warning: Unknown agent '{agent_name}'. Skipping.[/yellow]")
|
|
241
|
+
|
|
242
|
+
# Ensure target directories exist
|
|
243
|
+
for t in targets:
|
|
244
|
+
t.mkdir(parents=True, exist_ok=True)
|
|
245
|
+
|
|
246
|
+
# We only overwrite skills managed by our lockfile
|
|
247
|
+
# to avoid blowing away user's manual skills.
|
|
248
|
+
|
|
249
|
+
# Process each skill source
|
|
250
|
+
total_links = 0
|
|
251
|
+
|
|
252
|
+
for repo, source in lock.sources.items():
|
|
253
|
+
owner, repo_name = repo.split("/", 1)
|
|
254
|
+
storage_dir = (
|
|
255
|
+
get_skills_storage_dir() / str(source.category) / "gh" / owner / repo_name
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
for skill in source.skills:
|
|
259
|
+
skill_src_dir = storage_dir / skill
|
|
260
|
+
if not skill_src_dir.exists():
|
|
261
|
+
print(f"⚠️ Source dir for '[red]{skill}[/red]' missing: [red]{rel_home(skill_src_dir)}[/red]")
|
|
262
|
+
continue
|
|
263
|
+
|
|
264
|
+
for target_base in targets:
|
|
265
|
+
target_skill_dir = target_base / skill
|
|
266
|
+
|
|
267
|
+
# Clean up existing managed target
|
|
268
|
+
if target_skill_dir.exists() or target_skill_dir.is_symlink():
|
|
269
|
+
if target_skill_dir.is_symlink():
|
|
270
|
+
target_skill_dir.unlink()
|
|
271
|
+
if verbose_state.verbose:
|
|
272
|
+
print(f"Removed old symlink [magenta]{rel_home(target_skill_dir)}[/magenta]")
|
|
273
|
+
elif target_skill_dir.is_dir():
|
|
274
|
+
shutil.rmtree(target_skill_dir)
|
|
275
|
+
if verbose_state.verbose:
|
|
276
|
+
print(f"Removed old directory [magenta]{rel_home(target_skill_dir)}[/magenta]")
|
|
277
|
+
else:
|
|
278
|
+
target_skill_dir.unlink()
|
|
279
|
+
if verbose_state.verbose:
|
|
280
|
+
print(f"Removed old file [magenta]{rel_home(target_skill_dir)}[/magenta]")
|
|
281
|
+
|
|
282
|
+
if config.sync_mode == SyncMode.LINK:
|
|
283
|
+
target_skill_dir.symlink_to(skill_src_dir, target_is_directory=True)
|
|
284
|
+
total_links += 1
|
|
285
|
+
if verbose_state.verbose:
|
|
286
|
+
print(f"🔗 Linked [cyan]{rel_home(target_skill_dir)}[/cyan] -> [cyan]{rel_home(skill_src_dir)}[/cyan]")
|
|
287
|
+
else:
|
|
288
|
+
shutil.copytree(skill_src_dir, target_skill_dir)
|
|
289
|
+
if verbose_state.verbose:
|
|
290
|
+
print(f"📁 Copied [cyan]{rel_home(skill_src_dir)}[/cyan] -> [cyan]{rel_home(target_skill_dir)}[/cyan]")
|
|
291
|
+
|
|
292
|
+
print(f"🔄 Synced {sum(len(s.skills) for s in lock.sources.values())} skills across {len(targets)} locations.")
|
|
293
|
+
if verbose_state.verbose:
|
|
294
|
+
print(f"Added {total_links} symlinks (sync_mode=[cyan]{str(config.sync_mode)}[/cyan])")
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
@app.command("list")
|
|
298
|
+
def list_skills():
|
|
299
|
+
"""List installed skills as a table."""
|
|
300
|
+
from rich.table import Table
|
|
301
|
+
config = get_config()
|
|
302
|
+
lock = get_skills_lock(config)
|
|
303
|
+
|
|
304
|
+
if not lock.sources:
|
|
305
|
+
print("No skills installed.")
|
|
306
|
+
return
|
|
307
|
+
|
|
308
|
+
table = Table(title="Installed Skills")
|
|
309
|
+
table.add_column("Repo", style="cyan", no_wrap=True)
|
|
310
|
+
table.add_column("Skill Name", style="magenta")
|
|
311
|
+
table.add_column("Location", style="green")
|
|
312
|
+
table.add_column("Agents", style="yellow")
|
|
313
|
+
|
|
314
|
+
# Determine all agent directories
|
|
315
|
+
agent_dirs = {}
|
|
316
|
+
for agent_name in config.agents:
|
|
317
|
+
if agent_name in DEFAULT_AGENTS:
|
|
318
|
+
agent = DEFAULT_AGENTS[agent_name]
|
|
319
|
+
loc = agent.local_location if config.scope == "local" else agent.global_location
|
|
320
|
+
agent_dirs[agent_name] = loc
|
|
321
|
+
else:
|
|
322
|
+
agent_dirs[agent_name] = "(unknown)"
|
|
323
|
+
|
|
324
|
+
# Default location
|
|
325
|
+
default_loc = str((get_scope_dir(config) / "skills").expanduser().resolve())
|
|
326
|
+
|
|
327
|
+
for repo, source in lock.sources.items():
|
|
328
|
+
for skill in source.skills:
|
|
329
|
+
# Default location
|
|
330
|
+
locations = [default_loc]
|
|
331
|
+
# Agent locations
|
|
332
|
+
agent_list = []
|
|
333
|
+
for agent_name, loc in agent_dirs.items():
|
|
334
|
+
agent_list.append(agent_name)
|
|
335
|
+
locations.append(str(Path(loc).expanduser().resolve()))
|
|
336
|
+
# Only show unique locations
|
|
337
|
+
locations_str = "\n".join(sorted(set(locations)))
|
|
338
|
+
agents_str = ", ".join(agent_list) if agent_list else "none"
|
|
339
|
+
table.add_row(repo, skill, locations_str, agents_str)
|
|
340
|
+
print(table)
|
jup/config.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from .models import DEFAULT_AGENTS, JupConfig, SkillsLock
|
|
3
|
+
|
|
4
|
+
JUP_CONFIG_DIR = Path.home() / ".jup"
|
|
5
|
+
CONFIG_FILE = JUP_CONFIG_DIR / "config.json"
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def get_config() -> JupConfig:
|
|
9
|
+
if not CONFIG_FILE.exists():
|
|
10
|
+
return JupConfig()
|
|
11
|
+
try:
|
|
12
|
+
json_bytes = CONFIG_FILE.read_bytes()
|
|
13
|
+
return JupConfig.model_validate_json(json_bytes)
|
|
14
|
+
except Exception:
|
|
15
|
+
# Default config if corrupted
|
|
16
|
+
return JupConfig()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def save_config(config: JupConfig):
|
|
20
|
+
JUP_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
21
|
+
with open(CONFIG_FILE, "w") as f:
|
|
22
|
+
f.write(config.model_dump_json(indent=4, by_alias=True))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_scope_dir(config: JupConfig, agent_name: str | None = None) -> Path:
|
|
26
|
+
"""
|
|
27
|
+
Return the skills directory for the given config and optional agent.
|
|
28
|
+
"""
|
|
29
|
+
agent_key = agent_name if agent_name and agent_name in DEFAULT_AGENTS else "default"
|
|
30
|
+
agent_config = DEFAULT_AGENTS[agent_key]
|
|
31
|
+
|
|
32
|
+
if config.scope == "local":
|
|
33
|
+
return Path(agent_config.local_location).expanduser().resolve()
|
|
34
|
+
|
|
35
|
+
return Path(agent_config.global_location).expanduser().resolve()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def get_skills_storage_dir() -> Path:
|
|
39
|
+
"""Internal storage for all downloaded skills globally."""
|
|
40
|
+
storage = JUP_CONFIG_DIR / "skills"
|
|
41
|
+
storage.mkdir(parents=True, exist_ok=True)
|
|
42
|
+
return storage
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def get_lockfile_path(config: JupConfig) -> Path:
|
|
46
|
+
scope_dir = get_scope_dir(config)
|
|
47
|
+
scope_dir.mkdir(parents=True, exist_ok=True)
|
|
48
|
+
return scope_dir / "skills.lock"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def get_skills_lock(config: JupConfig) -> SkillsLock:
|
|
52
|
+
lock_file = get_lockfile_path(config)
|
|
53
|
+
if not lock_file.exists():
|
|
54
|
+
return SkillsLock()
|
|
55
|
+
try:
|
|
56
|
+
json_bytes = lock_file.read_bytes()
|
|
57
|
+
return SkillsLock.model_validate_json(json_bytes)
|
|
58
|
+
except Exception:
|
|
59
|
+
return SkillsLock()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def save_skills_lock(config: JupConfig, lock: SkillsLock):
|
|
63
|
+
lock_file = get_lockfile_path(config)
|
|
64
|
+
with open(lock_file, "w") as f:
|
|
65
|
+
f.write(lock.model_dump_json(indent=4))
|
jup/main.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
from rich import print
|
|
5
|
+
from .config import get_config, save_config
|
|
6
|
+
from .models import SyncMode, ScopeType, JupConfig
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class VerboseState:
|
|
11
|
+
verbose: bool = False
|
|
12
|
+
|
|
13
|
+
verbose_state = VerboseState()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
app = typer.Typer(help="jup - Agent Skills Manager", no_args_is_help=True, context_settings={"allow_extra_args": True, "ignore_unknown_options": True})
|
|
18
|
+
config_app = typer.Typer(help="Manage configuration settings", no_args_is_help=True)
|
|
19
|
+
app.add_typer(config_app, name="config")
|
|
20
|
+
|
|
21
|
+
# Show all config values
|
|
22
|
+
@config_app.command("show", short_help="Show all config values")
|
|
23
|
+
def config_show():
|
|
24
|
+
"""Display all configuration values."""
|
|
25
|
+
config = get_config()
|
|
26
|
+
from rich.table import Table
|
|
27
|
+
table = Table(title="Current Configuration")
|
|
28
|
+
table.add_column("Key", style="cyan", no_wrap=True)
|
|
29
|
+
table.add_column("Value", style="magenta")
|
|
30
|
+
for key in JupConfig.model_fields:
|
|
31
|
+
value = getattr(config, key)
|
|
32
|
+
if isinstance(value, list):
|
|
33
|
+
value_str = ", ".join(value) if value else "none"
|
|
34
|
+
elif hasattr(value, "value"):
|
|
35
|
+
value_str = value.value
|
|
36
|
+
else:
|
|
37
|
+
value_str = str(value)
|
|
38
|
+
table.add_row(key, value_str)
|
|
39
|
+
print(table)
|
|
40
|
+
|
|
41
|
+
@config_app.command("get", short_help="Get a config value", no_args_is_help=True)
|
|
42
|
+
def config_get(key: str = typer.Argument(..., help="Config key to get (scope, agents, sync-mode)")):
|
|
43
|
+
config = get_config()
|
|
44
|
+
# Normalize key
|
|
45
|
+
normalize_key_map = {"sync-mode": "sync_mode"}
|
|
46
|
+
norm_key = normalize_key_map.get(key, key)
|
|
47
|
+
|
|
48
|
+
if norm_key in JupConfig.model_fields:
|
|
49
|
+
value = getattr(config, norm_key)
|
|
50
|
+
if isinstance(value, list):
|
|
51
|
+
print(",".join(value) if value else "none")
|
|
52
|
+
else:
|
|
53
|
+
print(value.value if isinstance(value, Enum) else value)
|
|
54
|
+
else:
|
|
55
|
+
print(f"[red]Unknown config key: {key}[/red]")
|
|
56
|
+
raise typer.Exit(code=1)
|
|
57
|
+
|
|
58
|
+
@config_app.command("set", short_help="Set a config value", no_args_is_help=True)
|
|
59
|
+
def config_set(
|
|
60
|
+
key: str = typer.Argument(..., help="Config key to set"),
|
|
61
|
+
value: str = typer.Argument(..., help="Value to set")
|
|
62
|
+
):
|
|
63
|
+
config = get_config()
|
|
64
|
+
# Normalize key
|
|
65
|
+
key_map = {"sync-mode": "sync_mode", "sync_mode": "sync_mode"}
|
|
66
|
+
norm_key = key_map.get(key, key)
|
|
67
|
+
if norm_key not in JupConfig.model_fields:
|
|
68
|
+
print(f"[red]Unknown config key: {key}[/red]")
|
|
69
|
+
raise typer.Exit(code=1)
|
|
70
|
+
value = value.strip()
|
|
71
|
+
try:
|
|
72
|
+
if norm_key == "scope":
|
|
73
|
+
config.scope = ScopeType(value)
|
|
74
|
+
elif norm_key == "agents":
|
|
75
|
+
config.agents = [v.strip() for v in value.split(",")] if value.lower() != "none" else []
|
|
76
|
+
elif norm_key == "sync_mode":
|
|
77
|
+
config.sync_mode = SyncMode(value)
|
|
78
|
+
else:
|
|
79
|
+
print(f"[red]Unknown config key: {key}[/red]")
|
|
80
|
+
raise typer.Exit(code=1)
|
|
81
|
+
save_config(config)
|
|
82
|
+
print(f"Set {key} to {value}")
|
|
83
|
+
except ValueError:
|
|
84
|
+
print(f"[red]Invalid value for {key}: {value}[/red]")
|
|
85
|
+
raise typer.Exit(code=1)
|
|
86
|
+
|
|
87
|
+
@config_app.command("unset", short_help="Unset a config value (revert to default)", no_args_is_help=True)
|
|
88
|
+
def config_unset(key: str = typer.Argument(..., help="Config key to unset")):
|
|
89
|
+
config = get_config()
|
|
90
|
+
key_map = {"sync-mode": "sync_mode", "sync_mode": "sync_mode"}
|
|
91
|
+
norm_key = key_map.get(key, key)
|
|
92
|
+
if norm_key == "scope":
|
|
93
|
+
config.scope = ScopeType.GLOBAL
|
|
94
|
+
elif norm_key == "agents":
|
|
95
|
+
config.agents = []
|
|
96
|
+
elif norm_key == "sync_mode":
|
|
97
|
+
config.sync_mode = SyncMode.LINK
|
|
98
|
+
else:
|
|
99
|
+
print(f"[red]Unknown config key: {key}[/red]")
|
|
100
|
+
raise typer.Exit(code=1)
|
|
101
|
+
save_config(config)
|
|
102
|
+
print(f"Unset {key} (reverted to default)")
|
|
103
|
+
|
|
104
|
+
def main():
|
|
105
|
+
# Import commands to register them
|
|
106
|
+
from . import commands # noqa
|
|
107
|
+
app()
|
|
108
|
+
|
|
109
|
+
if __name__ == "__main__":
|
|
110
|
+
main()
|
jup/models.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
from enum import StrEnum
|
|
2
|
+
from typing import List, Dict, Optional
|
|
3
|
+
from pydantic import BaseModel, Field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class SyncMode(StrEnum):
|
|
7
|
+
LINK = "link"
|
|
8
|
+
COPY = "copy"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ScopeType(StrEnum):
|
|
12
|
+
GLOBAL = "global"
|
|
13
|
+
LOCAL = "local"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class JupConfig(BaseModel):
|
|
17
|
+
scope: ScopeType = Field(default=ScopeType.GLOBAL)
|
|
18
|
+
agents: List[str] = Field(default_factory=list)
|
|
19
|
+
sync_mode: SyncMode = Field(default=SyncMode.LINK, alias="sync-mode")
|
|
20
|
+
|
|
21
|
+
model_config = {"populate_by_name": True}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class SkillSource(BaseModel):
|
|
25
|
+
repo: str
|
|
26
|
+
category: Optional[str] = None
|
|
27
|
+
skills: List[str] = Field(default_factory=list)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class SkillsLock(BaseModel):
|
|
31
|
+
version: str = Field(default="0.0.0")
|
|
32
|
+
sources: Dict[str, SkillSource] = Field(default_factory=dict)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class AgentConfig(BaseModel):
|
|
36
|
+
name: str
|
|
37
|
+
global_location: str
|
|
38
|
+
local_location: str
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# Pre-defined registry of agents based on known paths, extensible later.
|
|
42
|
+
DEFAULT_AGENTS: Dict[str, AgentConfig] = {
|
|
43
|
+
"gemini": AgentConfig(
|
|
44
|
+
name="gemini",
|
|
45
|
+
global_location="~/.gemini/skills",
|
|
46
|
+
local_location="./.gemini/skills",
|
|
47
|
+
),
|
|
48
|
+
"copilot": AgentConfig(
|
|
49
|
+
name="copilot",
|
|
50
|
+
global_location="~/.copilot/skills",
|
|
51
|
+
local_location="./.copilot/skills",
|
|
52
|
+
),
|
|
53
|
+
"claude": AgentConfig(
|
|
54
|
+
name="claude",
|
|
55
|
+
global_location="~/.claude/skills",
|
|
56
|
+
local_location="./.claude/skills",
|
|
57
|
+
),
|
|
58
|
+
"default": AgentConfig(
|
|
59
|
+
name="default",
|
|
60
|
+
global_location="~/.agents/skills",
|
|
61
|
+
local_location="./.agents/skills",
|
|
62
|
+
),
|
|
63
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: jup
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: jup - Agent Skills Manager
|
|
5
|
+
Project-URL: Homepage, https://github.com/andrader/jup
|
|
6
|
+
Project-URL: Issues, https://github.com/andrader/jup/issues
|
|
7
|
+
Project-URL: Repository, https://github.com/andrader/jup
|
|
8
|
+
Requires-Python: >=3.12
|
|
9
|
+
Requires-Dist: pydantic>=2.10.6
|
|
10
|
+
Requires-Dist: rich>=13.9.4
|
|
11
|
+
Requires-Dist: typer>=0.15.1
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# jup: Agent Skills Manager
|
|
15
|
+
|
|
16
|
+
`jup` is a cli tool for managing agent skills.
|
|
17
|
+
|
|
18
|
+
## Installation
|
|
19
|
+
|
|
20
|
+
You can run `jup` directly or install it as a tool using `uv`:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
# Run without installing
|
|
24
|
+
uvx git+https://github.com/andrader/jup --help
|
|
25
|
+
|
|
26
|
+
# Install as a global tool
|
|
27
|
+
uv tool install git+https://github.com/andrader/jup
|
|
28
|
+
jup --help
|
|
29
|
+
|
|
30
|
+
# Or with pip
|
|
31
|
+
pip install git+https://github.com/andrader/jup
|
|
32
|
+
jup --help
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Getting Started
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
# Example command (to be implemented)
|
|
39
|
+
jup --help
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
## Contributing
|
|
44
|
+
|
|
45
|
+
Contributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for details on development setup, guidelines, and publishing.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
jup/__init__.py,sha256=9EVxmFiWsAoqWJ6br1bc3BxlA71JyOQP28fUHhX2k7E,43
|
|
2
|
+
jup/commands.py,sha256=Z94RR5WAB9E2ZdePp0QEX909_nt3rEVqP7s975v7EjA,12709
|
|
3
|
+
jup/config.py,sha256=rNwjdHcIzGnqmROCPgCJuIl9ldgxVpNcky2d47ex2fw,2021
|
|
4
|
+
jup/main.py,sha256=GmFmRdn3e4W4wgItR5j_t9OImuUJXMrX5UwpMGAk4pE,3876
|
|
5
|
+
jup/models.py,sha256=4yc7s1XAQSezISnv_ILK6GlxSIItGl44gLiCjlblgDs,1563
|
|
6
|
+
jup-0.1.0.dist-info/METADATA,sha256=4imqfd1TKwFLOgfzSVxvt3SOFTOZvbuHDfDv3291FAQ,1052
|
|
7
|
+
jup-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
8
|
+
jup-0.1.0.dist-info/entry_points.txt,sha256=alcsB8e4wmMSFM6qTvs-74-eZp9vAsTgSWIalFCimMo,37
|
|
9
|
+
jup-0.1.0.dist-info/RECORD,,
|