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 ADDED
@@ -0,0 +1,3 @@
1
+ from .main import main
2
+
3
+ __all__ = ["main"]
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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ jup = jup.main:app