taken-cli 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.
- taken/__init__.py +0 -0
- taken/commands/__init__.py +0 -0
- taken/commands/add.py +214 -0
- taken/commands/git.py +10 -0
- taken/commands/init.py +171 -0
- taken/commands/install.py +227 -0
- taken/commands/list.py +57 -0
- taken/commands/remove.py +128 -0
- taken/commands/save.py +169 -0
- taken/commands/update.py +296 -0
- taken/commands/use.py +153 -0
- taken/core/__init__.py +0 -0
- taken/core/config.py +75 -0
- taken/core/editor.py +14 -0
- taken/core/git.py +85 -0
- taken/core/github.py +158 -0
- taken/core/hashing.py +21 -0
- taken/core/paths.py +4 -0
- taken/core/project.py +68 -0
- taken/core/registry.py +111 -0
- taken/core/skills.py +116 -0
- taken/main.py +36 -0
- taken/models/__init__.py +0 -0
- taken/models/config.py +41 -0
- taken/models/project.py +14 -0
- taken/models/registry.py +137 -0
- taken/utils/__init__.py +0 -0
- taken/utils/console.py +8 -0
- taken_cli-0.1.0.dist-info/METADATA +127 -0
- taken_cli-0.1.0.dist-info/RECORD +32 -0
- taken_cli-0.1.0.dist-info/WHEEL +4 -0
- taken_cli-0.1.0.dist-info/entry_points.txt +3 -0
taken/__init__.py
ADDED
|
File without changes
|
|
File without changes
|
taken/commands/add.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from rich.panel import Panel
|
|
7
|
+
|
|
8
|
+
from taken.core import paths
|
|
9
|
+
from taken.core.config import is_config_exists, read_config
|
|
10
|
+
from taken.core.editor import open_in_editor
|
|
11
|
+
from taken.core.git import auto_commit_and_push
|
|
12
|
+
from taken.core.registry import read_registry, write_registry
|
|
13
|
+
from taken.core.skills import (
|
|
14
|
+
adopt_skill,
|
|
15
|
+
is_path_argument,
|
|
16
|
+
lookup_lock_entry,
|
|
17
|
+
scaffold_skill,
|
|
18
|
+
)
|
|
19
|
+
from taken.models.config import TakenConfig
|
|
20
|
+
from taken.models.registry import RegistryEntry, SkillSource
|
|
21
|
+
from taken.utils.console import console, err_console
|
|
22
|
+
|
|
23
|
+
_VALID_NAME = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_-]*$")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def add(
|
|
27
|
+
skill_or_path: str = typer.Argument(..., help="Skill name to create, or path to existing skill folder to adopt"),
|
|
28
|
+
) -> None:
|
|
29
|
+
"""Add a new personal skill or adopt an existing skill into taken management."""
|
|
30
|
+
if not is_config_exists(paths.TAKEN_HOME):
|
|
31
|
+
err_console.print(
|
|
32
|
+
Panel(
|
|
33
|
+
"Taken is not initialized. Run [bold]taken init[/bold] to get started.",
|
|
34
|
+
title="[red]Not Initialized[/red]",
|
|
35
|
+
border_style="red",
|
|
36
|
+
)
|
|
37
|
+
)
|
|
38
|
+
raise typer.Exit(code=1)
|
|
39
|
+
|
|
40
|
+
config = read_config(paths.TAKEN_HOME)
|
|
41
|
+
|
|
42
|
+
if is_path_argument(skill_or_path):
|
|
43
|
+
_adopt_mode(skill_or_path, config)
|
|
44
|
+
else:
|
|
45
|
+
_create_mode(skill_or_path, config)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _create_mode(name: str, config: TakenConfig) -> None:
|
|
49
|
+
if "/" in name:
|
|
50
|
+
err_console.print(
|
|
51
|
+
Panel(
|
|
52
|
+
f"Skill name must not contain a namespace. Got: [bold]{name}[/bold]\n"
|
|
53
|
+
f"Your namespace [bold]{config.username}[/bold] is added automatically.\n"
|
|
54
|
+
"Example: [bold]taken add my-skill[/bold]",
|
|
55
|
+
title="[red]Invalid Skill Name[/red]",
|
|
56
|
+
border_style="red",
|
|
57
|
+
)
|
|
58
|
+
)
|
|
59
|
+
raise typer.Exit(code=1)
|
|
60
|
+
|
|
61
|
+
if not _VALID_NAME.match(name):
|
|
62
|
+
err_console.print(
|
|
63
|
+
Panel(
|
|
64
|
+
f"[bold]{name}[/bold] is not a valid skill name.\n"
|
|
65
|
+
"Use lowercase letters, numbers, hyphens, and underscores only.\n"
|
|
66
|
+
"Must start with a letter or number.",
|
|
67
|
+
title="[red]Invalid Skill Name[/red]",
|
|
68
|
+
border_style="red",
|
|
69
|
+
)
|
|
70
|
+
)
|
|
71
|
+
raise typer.Exit(code=1)
|
|
72
|
+
|
|
73
|
+
registry = read_registry(paths.TAKEN_HOME)
|
|
74
|
+
full_name = f"{config.username}/{name}"
|
|
75
|
+
|
|
76
|
+
if registry.exists(full_name):
|
|
77
|
+
err_console.print(
|
|
78
|
+
Panel(
|
|
79
|
+
f"Skill [bold]{full_name}[/bold] is already in your registry.\nUse [bold]taken list[/bold] to see it.",
|
|
80
|
+
title="[red]Already Exists[/red]",
|
|
81
|
+
border_style="red",
|
|
82
|
+
)
|
|
83
|
+
)
|
|
84
|
+
raise typer.Exit(code=1)
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
skill_md = scaffold_skill(config.username, name, paths.TAKEN_HOME)
|
|
88
|
+
except FileExistsError:
|
|
89
|
+
err_console.print(
|
|
90
|
+
Panel(
|
|
91
|
+
f"Skill directory already exists on disk but is not in the registry.\n"
|
|
92
|
+
f"Path: [dim]{paths.TAKEN_HOME}/skills/{config.username}/{name}[/dim]\n"
|
|
93
|
+
"Run [bold]taken doctor[/bold] to repair registry drift.",
|
|
94
|
+
title="[red]Directory Conflict[/red]",
|
|
95
|
+
border_style="red",
|
|
96
|
+
)
|
|
97
|
+
)
|
|
98
|
+
raise typer.Exit(code=1) from None
|
|
99
|
+
|
|
100
|
+
now = datetime.now()
|
|
101
|
+
entry = RegistryEntry(
|
|
102
|
+
namespace=config.username,
|
|
103
|
+
name=name,
|
|
104
|
+
source=SkillSource.PERSONAL,
|
|
105
|
+
version="1",
|
|
106
|
+
created_at=now,
|
|
107
|
+
updated_at=now,
|
|
108
|
+
)
|
|
109
|
+
registry.add(entry)
|
|
110
|
+
write_registry(registry, paths.TAKEN_HOME)
|
|
111
|
+
auto_commit_and_push(paths.TAKEN_HOME, f"add: {full_name}")
|
|
112
|
+
|
|
113
|
+
console.print(
|
|
114
|
+
Panel(
|
|
115
|
+
f"[green]✓[/green] Created [bold]{full_name}[/bold]\n"
|
|
116
|
+
f"[green]✓[/green] Registered as [bold]personal[/bold] skill\n\n"
|
|
117
|
+
f"[dim]Opening editor…[/dim]",
|
|
118
|
+
title="[green]Skill Created[/green]",
|
|
119
|
+
border_style="green",
|
|
120
|
+
padding=(1, 2),
|
|
121
|
+
)
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
open_in_editor(skill_md)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _adopt_mode(path_str: str, config: TakenConfig) -> None:
|
|
128
|
+
source_dir = Path(path_str).resolve()
|
|
129
|
+
|
|
130
|
+
if not source_dir.is_dir():
|
|
131
|
+
err_console.print(
|
|
132
|
+
Panel(
|
|
133
|
+
f"[bold]{path_str}[/bold] is not a directory.",
|
|
134
|
+
title="[red]Invalid Path[/red]",
|
|
135
|
+
border_style="red",
|
|
136
|
+
)
|
|
137
|
+
)
|
|
138
|
+
raise typer.Exit(code=1)
|
|
139
|
+
|
|
140
|
+
name = source_dir.name
|
|
141
|
+
lock_entry = lookup_lock_entry(name, Path.cwd())
|
|
142
|
+
|
|
143
|
+
if lock_entry is not None:
|
|
144
|
+
namespace = lock_entry.source.split("/")[0]
|
|
145
|
+
source = SkillSource.NPX
|
|
146
|
+
repo = lock_entry.source
|
|
147
|
+
version = lock_entry.ref
|
|
148
|
+
source_url = lock_entry.source_url
|
|
149
|
+
skill_path = lock_entry.skill_path
|
|
150
|
+
skill_folder_hash = lock_entry.skill_folder_hash
|
|
151
|
+
else:
|
|
152
|
+
namespace = config.username
|
|
153
|
+
source = SkillSource.PERSONAL
|
|
154
|
+
repo = None
|
|
155
|
+
version = None
|
|
156
|
+
source_url = None
|
|
157
|
+
skill_path = None
|
|
158
|
+
skill_folder_hash = None
|
|
159
|
+
|
|
160
|
+
registry = read_registry(paths.TAKEN_HOME)
|
|
161
|
+
full_name = f"{namespace}/{name}"
|
|
162
|
+
|
|
163
|
+
if registry.exists(full_name):
|
|
164
|
+
err_console.print(
|
|
165
|
+
Panel(
|
|
166
|
+
f"Skill [bold]{full_name}[/bold] is already in your registry.\nUse [bold]taken list[/bold] to see it.",
|
|
167
|
+
title="[red]Already Exists[/red]",
|
|
168
|
+
border_style="red",
|
|
169
|
+
)
|
|
170
|
+
)
|
|
171
|
+
raise typer.Exit(code=1)
|
|
172
|
+
|
|
173
|
+
try:
|
|
174
|
+
adopt_skill(source_dir, namespace, name, paths.TAKEN_HOME)
|
|
175
|
+
except FileExistsError:
|
|
176
|
+
err_console.print(
|
|
177
|
+
Panel(
|
|
178
|
+
f"Skill directory already exists at [dim]{paths.TAKEN_HOME}/skills/{namespace}/{name}[/dim]\n"
|
|
179
|
+
"Run [bold]taken doctor[/bold] to repair registry drift.",
|
|
180
|
+
title="[red]Directory Conflict[/red]",
|
|
181
|
+
border_style="red",
|
|
182
|
+
)
|
|
183
|
+
)
|
|
184
|
+
raise typer.Exit(code=1) from None
|
|
185
|
+
|
|
186
|
+
now = datetime.now()
|
|
187
|
+
entry = RegistryEntry(
|
|
188
|
+
namespace=namespace,
|
|
189
|
+
name=name,
|
|
190
|
+
source=source,
|
|
191
|
+
repo=repo,
|
|
192
|
+
version=version,
|
|
193
|
+
installed_at=now if source == SkillSource.NPX else None,
|
|
194
|
+
created_at=now if source == SkillSource.PERSONAL else None,
|
|
195
|
+
updated_at=now,
|
|
196
|
+
source_url=source_url,
|
|
197
|
+
skill_path=skill_path,
|
|
198
|
+
skill_folder_hash=skill_folder_hash,
|
|
199
|
+
)
|
|
200
|
+
registry.add(entry)
|
|
201
|
+
write_registry(registry, paths.TAKEN_HOME)
|
|
202
|
+
auto_commit_and_push(paths.TAKEN_HOME, f"add: {full_name}")
|
|
203
|
+
|
|
204
|
+
source_detail = f" ({repo})" if repo else ""
|
|
205
|
+
console.print(
|
|
206
|
+
Panel(
|
|
207
|
+
f"[green]✓[/green] Adopted [bold]{name}[/bold] → [bold]{full_name}[/bold]\n"
|
|
208
|
+
f"[green]✓[/green] Source: [bold]{source.value}[/bold]{source_detail}\n\n"
|
|
209
|
+
f"[dim]Copied to ~/.taken/skills/{namespace}/{name}/[/dim]",
|
|
210
|
+
title="[green]Skill Adopted[/green]",
|
|
211
|
+
border_style="green",
|
|
212
|
+
padding=(1, 2),
|
|
213
|
+
)
|
|
214
|
+
)
|
taken/commands/git.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
|
|
3
|
+
from taken.core import paths
|
|
4
|
+
from taken.core.git import run_passthrough
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def git(ctx: typer.Context) -> None:
|
|
8
|
+
"""Run a git command in the taken home directory (~/.taken/)."""
|
|
9
|
+
returncode = run_passthrough(paths.TAKEN_HOME, ctx.args)
|
|
10
|
+
raise SystemExit(returncode)
|
taken/commands/init.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import shutil
|
|
2
|
+
import subprocess
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from rich.panel import Panel
|
|
7
|
+
from rich.prompt import Confirm, Prompt
|
|
8
|
+
|
|
9
|
+
from taken.core import paths
|
|
10
|
+
from taken.core.config import is_config_exists, write_config
|
|
11
|
+
from taken.core.git import init_repo
|
|
12
|
+
from taken.core.registry import write_registry
|
|
13
|
+
from taken.models.config import TakenConfig
|
|
14
|
+
from taken.models.registry import Registry
|
|
15
|
+
from taken.utils.console import console, err_console
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _resolve_username() -> str:
|
|
19
|
+
"""
|
|
20
|
+
Prompt the user to choose their namespace username via sequential prompts.
|
|
21
|
+
Options: system username, git config name, or manual override.
|
|
22
|
+
"""
|
|
23
|
+
# Gather candidates
|
|
24
|
+
import getpass
|
|
25
|
+
|
|
26
|
+
whoami = getpass.getuser()
|
|
27
|
+
|
|
28
|
+
git_name: str | None = None
|
|
29
|
+
try:
|
|
30
|
+
result = subprocess.run(
|
|
31
|
+
["git", "config", "user.name"],
|
|
32
|
+
capture_output=True,
|
|
33
|
+
text=True,
|
|
34
|
+
timeout=3,
|
|
35
|
+
)
|
|
36
|
+
git_name = result.stdout.strip() or None
|
|
37
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
38
|
+
git_name = None
|
|
39
|
+
|
|
40
|
+
# Present options
|
|
41
|
+
console.print("\n[bold]Choose your skill namespace username:[/bold]")
|
|
42
|
+
console.print(f" [cyan]1[/cyan] System username → [green]{whoami}[/green]")
|
|
43
|
+
|
|
44
|
+
if git_name:
|
|
45
|
+
console.print(f" [cyan]2[/cyan] Git config name → [green]{git_name}[/green]")
|
|
46
|
+
console.print(" [cyan]3[/cyan] Enter manually")
|
|
47
|
+
else:
|
|
48
|
+
console.print(" [cyan]2[/cyan] Enter manually [dim](git config name not found)[/dim]")
|
|
49
|
+
|
|
50
|
+
choice = Prompt.ask(
|
|
51
|
+
"\nPick an option",
|
|
52
|
+
choices=["1", "2", "3"] if git_name else ["1", "2"],
|
|
53
|
+
default="1",
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
if choice == "1":
|
|
57
|
+
return whoami
|
|
58
|
+
elif choice == "2" and git_name:
|
|
59
|
+
return git_name
|
|
60
|
+
else:
|
|
61
|
+
# Manual override
|
|
62
|
+
username = Prompt.ask("Enter your username").strip()
|
|
63
|
+
if not username:
|
|
64
|
+
err_console.print(
|
|
65
|
+
Panel(
|
|
66
|
+
"Username cannot be empty.",
|
|
67
|
+
title="[red]Error[/red]",
|
|
68
|
+
border_style="red",
|
|
69
|
+
)
|
|
70
|
+
)
|
|
71
|
+
raise typer.Exit(code=1)
|
|
72
|
+
return username
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _handle_existing_init() -> bool:
|
|
76
|
+
"""
|
|
77
|
+
Handle the case where ~/.taken/ already exists.
|
|
78
|
+
Prompts user to choose between resetting config only or full wipe.
|
|
79
|
+
Returns True if we should proceed, False if user aborted.
|
|
80
|
+
"""
|
|
81
|
+
err_console.print(
|
|
82
|
+
Panel(
|
|
83
|
+
"[yellow]Taken is already initialized at[/yellow] [bold]~/.taken/[/bold]\n\n"
|
|
84
|
+
"Reinitializing will overwrite your config. Choose carefully.",
|
|
85
|
+
title="[yellow]Already Initialized[/yellow]",
|
|
86
|
+
border_style="yellow",
|
|
87
|
+
)
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
proceed = Confirm.ask("Do you want to reinitialize?", default=False)
|
|
91
|
+
if not proceed:
|
|
92
|
+
console.print("[dim]Aborted. Nothing was changed.[/dim]")
|
|
93
|
+
return False
|
|
94
|
+
|
|
95
|
+
# Give user two options
|
|
96
|
+
console.print("\n[bold]What would you like to reset?[/bold]")
|
|
97
|
+
console.print(" [cyan]1[/cyan] Reset config only [dim](keeps your skills and registry intact)[/dim]")
|
|
98
|
+
console.print(" [cyan]2[/cyan] Full wipe [dim](deletes everything — skills, registry, config)[/dim]")
|
|
99
|
+
|
|
100
|
+
choice = Prompt.ask("\nPick an option", choices=["1", "2"], default="1")
|
|
101
|
+
|
|
102
|
+
if choice == "2":
|
|
103
|
+
confirm_wipe = Confirm.ask(
|
|
104
|
+
"[red]This will delete ALL your skills and registry. Are you sure?[/red]",
|
|
105
|
+
default=False,
|
|
106
|
+
)
|
|
107
|
+
if not confirm_wipe:
|
|
108
|
+
console.print("[dim]Aborted. Nothing was changed.[/dim]")
|
|
109
|
+
return False
|
|
110
|
+
|
|
111
|
+
# Full wipe — delete ~/.taken/ entirely
|
|
112
|
+
shutil.rmtree(paths.TAKEN_HOME)
|
|
113
|
+
console.print("[dim]Wiped ~/.taken/ — starting fresh.[/dim]")
|
|
114
|
+
|
|
115
|
+
# Reset config only — just let init proceed and overwrite config.yaml
|
|
116
|
+
return True
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def init() -> None:
|
|
120
|
+
"""
|
|
121
|
+
Initialize Taken — sets up ~/.taken/ with config.yaml and registry.yaml.
|
|
122
|
+
"""
|
|
123
|
+
console.print(
|
|
124
|
+
Panel(
|
|
125
|
+
"[bold white]Welcome to Taken[/bold white]\n[dim]A very particular set of skills, managed.[/dim]",
|
|
126
|
+
border_style="bright_blue",
|
|
127
|
+
padding=(1, 4),
|
|
128
|
+
)
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
# Handle existing init
|
|
132
|
+
if is_config_exists(paths.TAKEN_HOME):
|
|
133
|
+
should_proceed = _handle_existing_init()
|
|
134
|
+
if not should_proceed:
|
|
135
|
+
raise typer.Exit(code=0)
|
|
136
|
+
|
|
137
|
+
# Resolve username
|
|
138
|
+
username = _resolve_username()
|
|
139
|
+
|
|
140
|
+
# Create directory structure
|
|
141
|
+
skills_dir = paths.TAKEN_HOME / "skills" / username
|
|
142
|
+
skills_dir.mkdir(parents=True, exist_ok=True)
|
|
143
|
+
|
|
144
|
+
# Write config
|
|
145
|
+
config = TakenConfig(
|
|
146
|
+
username=username,
|
|
147
|
+
taken_home=paths.TAKEN_HOME,
|
|
148
|
+
initialized_at=datetime.now(),
|
|
149
|
+
)
|
|
150
|
+
write_config(config)
|
|
151
|
+
|
|
152
|
+
# Write empty registry (only if it doesn't exist — preserve existing on config-only reset)
|
|
153
|
+
from taken.core.registry import is_registry_exists
|
|
154
|
+
|
|
155
|
+
if not is_registry_exists(paths.TAKEN_HOME):
|
|
156
|
+
write_registry(Registry(), paths.TAKEN_HOME)
|
|
157
|
+
|
|
158
|
+
# Initialize git repo and make initial commit
|
|
159
|
+
init_repo(paths.TAKEN_HOME)
|
|
160
|
+
|
|
161
|
+
# Success
|
|
162
|
+
console.print(
|
|
163
|
+
Panel(
|
|
164
|
+
f"[green]✓[/green] Initialized at [bold]~/.taken/[/bold]\n"
|
|
165
|
+
f"[green]✓[/green] Username set to [bold]{username}[/bold]\n\n"
|
|
166
|
+
f"[dim]Your personal skills namespace: [bold]{username}/<skill-name>[/bold][/dim]",
|
|
167
|
+
title="[green]Taken Initialized[/green]",
|
|
168
|
+
border_style="green",
|
|
169
|
+
padding=(1, 2),
|
|
170
|
+
)
|
|
171
|
+
)
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import shutil
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
|
|
4
|
+
import typer
|
|
5
|
+
from InquirerPy import inquirer
|
|
6
|
+
from InquirerPy.enum import INQUIRERPY_EMPTY_CIRCLE_SEQUENCE, INQUIRERPY_FILL_CIRCLE_SEQUENCE
|
|
7
|
+
from rich.panel import Panel
|
|
8
|
+
|
|
9
|
+
from taken.core import paths
|
|
10
|
+
from taken.core.config import is_config_exists
|
|
11
|
+
from taken.core.git import auto_commit_and_push
|
|
12
|
+
from taken.core.github import (
|
|
13
|
+
GitHubSkill,
|
|
14
|
+
discover_skills,
|
|
15
|
+
download_skill,
|
|
16
|
+
get_commit_sha,
|
|
17
|
+
get_default_branch,
|
|
18
|
+
normalize_source,
|
|
19
|
+
parse_source,
|
|
20
|
+
)
|
|
21
|
+
from taken.core.registry import read_registry, write_registry
|
|
22
|
+
from taken.models.registry import RegistryEntry, SkillSource, VersionPin
|
|
23
|
+
from taken.utils.console import console, err_console
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _select_skills(skills: list[GitHubSkill]) -> list[GitHubSkill]:
|
|
27
|
+
if len(skills) == 1:
|
|
28
|
+
return skills
|
|
29
|
+
|
|
30
|
+
choices = [{"name": s.name, "value": s} for s in skills]
|
|
31
|
+
selected: list[GitHubSkill] = inquirer.fuzzy( # type: ignore[attr-defined]
|
|
32
|
+
message="Select skills to install:",
|
|
33
|
+
choices=choices,
|
|
34
|
+
multiselect=True,
|
|
35
|
+
marker=INQUIRERPY_FILL_CIRCLE_SEQUENCE,
|
|
36
|
+
marker_pl=INQUIRERPY_EMPTY_CIRCLE_SEQUENCE,
|
|
37
|
+
instruction="(type to filter space/tab to select enter to confirm)",
|
|
38
|
+
keybindings={"toggle": [{"key": "space"}, {"key": "tab"}]},
|
|
39
|
+
validate=lambda x: len(x) > 0,
|
|
40
|
+
invalid_message="Select at least one skill.",
|
|
41
|
+
).execute()
|
|
42
|
+
return selected
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _filter_skills(
|
|
46
|
+
skills: list[GitHubSkill],
|
|
47
|
+
skill_filter: list[str],
|
|
48
|
+
owner: str,
|
|
49
|
+
repo: str,
|
|
50
|
+
) -> list[GitHubSkill]:
|
|
51
|
+
"""Apply name filter; raises typer.Exit(1) if no skills match."""
|
|
52
|
+
if not skill_filter:
|
|
53
|
+
return skills
|
|
54
|
+
filtered = [s for s in skills if s.name in skill_filter]
|
|
55
|
+
if not filtered:
|
|
56
|
+
available = ", ".join(s.name for s in skills)
|
|
57
|
+
err_console.print(
|
|
58
|
+
Panel(
|
|
59
|
+
f"No skill named [bold]{', '.join(skill_filter)}[/bold] found in [bold]{owner}/{repo}[/bold].\n"
|
|
60
|
+
f"Available: [dim]{available}[/dim]",
|
|
61
|
+
title="[red]Skill Not Found[/red]",
|
|
62
|
+
border_style="red",
|
|
63
|
+
)
|
|
64
|
+
)
|
|
65
|
+
raise typer.Exit(code=1)
|
|
66
|
+
return filtered
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _print_install_results(
|
|
70
|
+
installed: list[str],
|
|
71
|
+
skipped: list[str],
|
|
72
|
+
owner: str,
|
|
73
|
+
repo: str,
|
|
74
|
+
sha: str,
|
|
75
|
+
pin: bool,
|
|
76
|
+
) -> None:
|
|
77
|
+
if installed:
|
|
78
|
+
lines = "\n".join(f"[green]✓[/green] [bold]{n}[/bold]" for n in installed)
|
|
79
|
+
lines += f"\n\n[dim]{owner}/{repo} @ {sha[:8]}[/dim]"
|
|
80
|
+
if pin:
|
|
81
|
+
lines += "\n[dim]Pinned to commit[/dim]"
|
|
82
|
+
console.print(Panel(lines, title="[green]Skills Installed[/green]", border_style="green", padding=(1, 2)))
|
|
83
|
+
if skipped:
|
|
84
|
+
console.print(f"[dim]Already in registry (skipped): {', '.join(skipped)}[/dim]")
|
|
85
|
+
if not installed and not skipped:
|
|
86
|
+
console.print("[dim]Nothing installed.[/dim]")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _fetch_github_skills(owner: str, repo: str, ref: str) -> tuple[str, list[GitHubSkill]]:
|
|
90
|
+
"""Resolve ref to a commit SHA and discover skills in the repo."""
|
|
91
|
+
resolved_ref = ref if ref else get_default_branch(owner, repo)
|
|
92
|
+
sha = get_commit_sha(owner, repo, resolved_ref)
|
|
93
|
+
skills = discover_skills(owner, repo, sha)
|
|
94
|
+
return sha, skills
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _install_skills(
|
|
98
|
+
selected: list[GitHubSkill],
|
|
99
|
+
owner: str,
|
|
100
|
+
repo: str,
|
|
101
|
+
sha: str,
|
|
102
|
+
pin: bool,
|
|
103
|
+
) -> tuple[list[str], list[str]]:
|
|
104
|
+
"""Download and register each selected skill; return (installed, skipped)."""
|
|
105
|
+
registry = read_registry(paths.TAKEN_HOME)
|
|
106
|
+
installed: list[str] = []
|
|
107
|
+
skipped: list[str] = []
|
|
108
|
+
|
|
109
|
+
for gh_skill in selected:
|
|
110
|
+
full_name = f"{owner}/{gh_skill.name}"
|
|
111
|
+
dest = paths.TAKEN_HOME / "skills" / owner / gh_skill.name
|
|
112
|
+
|
|
113
|
+
if registry.exists(full_name) or dest.exists():
|
|
114
|
+
skipped.append(full_name)
|
|
115
|
+
continue
|
|
116
|
+
|
|
117
|
+
try:
|
|
118
|
+
with console.status(f"[dim]Downloading {full_name}…[/dim]"):
|
|
119
|
+
download_skill(owner, repo, gh_skill.skill_path, sha, dest)
|
|
120
|
+
except Exception as e:
|
|
121
|
+
err_console.print(Panel(str(e), title=f"[red]Failed: {full_name}[/red]", border_style="red"))
|
|
122
|
+
if dest.exists():
|
|
123
|
+
shutil.rmtree(dest)
|
|
124
|
+
continue
|
|
125
|
+
|
|
126
|
+
now = datetime.now()
|
|
127
|
+
entry = RegistryEntry(
|
|
128
|
+
namespace=owner,
|
|
129
|
+
name=gh_skill.name,
|
|
130
|
+
source=SkillSource.TAKEN,
|
|
131
|
+
repo=f"{owner}/{repo}",
|
|
132
|
+
version=sha,
|
|
133
|
+
pin=VersionPin.PINNED if pin else VersionPin.FLOATING,
|
|
134
|
+
installed_at=now,
|
|
135
|
+
updated_at=now,
|
|
136
|
+
source_url=f"https://github.com/{owner}/{repo}",
|
|
137
|
+
skill_path=gh_skill.skill_path or None,
|
|
138
|
+
skill_folder_hash=gh_skill.skill_folder_hash or None,
|
|
139
|
+
)
|
|
140
|
+
registry.add(entry)
|
|
141
|
+
installed.append(full_name)
|
|
142
|
+
|
|
143
|
+
if installed:
|
|
144
|
+
write_registry(registry, paths.TAKEN_HOME)
|
|
145
|
+
|
|
146
|
+
return installed, skipped
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def install(
|
|
150
|
+
source: str = typer.Argument(
|
|
151
|
+
...,
|
|
152
|
+
metavar="source",
|
|
153
|
+
help="GitHub repo, URL, or npx skills add command. E.g. vercel-labs/agent-skills",
|
|
154
|
+
),
|
|
155
|
+
skill: list[str] = typer.Option( # noqa: B008
|
|
156
|
+
[],
|
|
157
|
+
"--skill",
|
|
158
|
+
"-s",
|
|
159
|
+
help="Skill name(s) to install (repeatable). Alternative to owner/repo/skill path form.",
|
|
160
|
+
),
|
|
161
|
+
ref: str = typer.Option(
|
|
162
|
+
"", "--ref", metavar="REF", help="Branch, tag, or commit SHA (default: repo default branch)."
|
|
163
|
+
),
|
|
164
|
+
pin: bool = typer.Option(False, "--pin", help="Pin to the exact commit SHA."),
|
|
165
|
+
) -> None:
|
|
166
|
+
"""Install one or more skills from a GitHub repository."""
|
|
167
|
+
if not is_config_exists(paths.TAKEN_HOME):
|
|
168
|
+
err_console.print(
|
|
169
|
+
Panel(
|
|
170
|
+
"Taken is not initialized. Run [bold]taken init[/bold] to get started.",
|
|
171
|
+
title="[red]Not Initialized[/red]",
|
|
172
|
+
border_style="red",
|
|
173
|
+
)
|
|
174
|
+
)
|
|
175
|
+
raise typer.Exit(code=1)
|
|
176
|
+
|
|
177
|
+
try:
|
|
178
|
+
normalized = normalize_source(source)
|
|
179
|
+
owner, repo, path_filter = parse_source(normalized)
|
|
180
|
+
except ValueError as e:
|
|
181
|
+
err_console.print(Panel(str(e), title="[red]Invalid Source[/red]", border_style="red"))
|
|
182
|
+
raise typer.Exit(code=1) from None
|
|
183
|
+
|
|
184
|
+
# Merge skill selection: --skill flag wins; path filter is fallback; empty = all
|
|
185
|
+
skill_filter: list[str] = list(skill) or ([path_filter] if path_filter else [])
|
|
186
|
+
|
|
187
|
+
try:
|
|
188
|
+
with console.status("[dim]Contacting GitHub…[/dim]"):
|
|
189
|
+
sha, skills = _fetch_github_skills(owner, repo, ref)
|
|
190
|
+
except FileNotFoundError:
|
|
191
|
+
err_console.print(
|
|
192
|
+
Panel(
|
|
193
|
+
f"Repository [bold]{owner}/{repo}[/bold] not found on GitHub.\nCheck the name and try again.",
|
|
194
|
+
title="[red]Not Found[/red]",
|
|
195
|
+
border_style="red",
|
|
196
|
+
)
|
|
197
|
+
)
|
|
198
|
+
raise typer.Exit(code=1) from None
|
|
199
|
+
except PermissionError as e:
|
|
200
|
+
err_console.print(
|
|
201
|
+
Panel(
|
|
202
|
+
f"{e}\n\nTip: set [bold]GITHUB_TOKEN[/bold] in your environment for higher rate limits.",
|
|
203
|
+
title="[red]GitHub API Error[/red]",
|
|
204
|
+
border_style="red",
|
|
205
|
+
)
|
|
206
|
+
)
|
|
207
|
+
raise typer.Exit(code=1) from e
|
|
208
|
+
except Exception as e:
|
|
209
|
+
err_console.print(Panel(str(e), title="[red]Error[/red]", border_style="red"))
|
|
210
|
+
raise typer.Exit(code=1) from e
|
|
211
|
+
|
|
212
|
+
if not skills:
|
|
213
|
+
err_console.print(
|
|
214
|
+
Panel(
|
|
215
|
+
f"No skills (SKILL.md files) found in [bold]{owner}/{repo}[/bold].",
|
|
216
|
+
title="[red]No Skills Found[/red]",
|
|
217
|
+
border_style="red",
|
|
218
|
+
)
|
|
219
|
+
)
|
|
220
|
+
raise typer.Exit(code=1)
|
|
221
|
+
|
|
222
|
+
skills = _filter_skills(skills, skill_filter, owner, repo)
|
|
223
|
+
selected = skills if skill_filter else _select_skills(skills)
|
|
224
|
+
installed, skipped = _install_skills(selected, owner, repo, sha, pin)
|
|
225
|
+
if installed:
|
|
226
|
+
auto_commit_and_push(paths.TAKEN_HOME, f"install: {' '.join(installed)}")
|
|
227
|
+
_print_install_results(installed, skipped, owner, repo, sha, pin)
|
taken/commands/list.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
from rich.box import SIMPLE
|
|
3
|
+
from rich.panel import Panel
|
|
4
|
+
from rich.table import Table
|
|
5
|
+
|
|
6
|
+
from taken.core import paths
|
|
7
|
+
from taken.core.config import is_config_exists
|
|
8
|
+
from taken.core.registry import read_registry
|
|
9
|
+
from taken.models.registry import SkillSource
|
|
10
|
+
from taken.utils.console import console, err_console
|
|
11
|
+
|
|
12
|
+
_SOURCE_STYLE: dict[SkillSource, str] = {
|
|
13
|
+
SkillSource.PERSONAL: "green",
|
|
14
|
+
SkillSource.NPX: "blue",
|
|
15
|
+
SkillSource.TAKEN: "magenta",
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def list() -> None:
|
|
20
|
+
"""List all skills in the registry."""
|
|
21
|
+
if not is_config_exists(paths.TAKEN_HOME):
|
|
22
|
+
err_console.print(
|
|
23
|
+
Panel(
|
|
24
|
+
"Taken is not initialized. Run [bold]taken init[/bold] to get started.",
|
|
25
|
+
title="[red]Not Initialized[/red]",
|
|
26
|
+
border_style="red",
|
|
27
|
+
)
|
|
28
|
+
)
|
|
29
|
+
raise typer.Exit(code=1)
|
|
30
|
+
|
|
31
|
+
registry = read_registry(paths.TAKEN_HOME)
|
|
32
|
+
|
|
33
|
+
if not registry.skills:
|
|
34
|
+
console.print("[yellow]No skills registered yet. Use [bold]taken add[/bold] to create one.[/yellow]")
|
|
35
|
+
return
|
|
36
|
+
|
|
37
|
+
table = Table(show_header=True, header_style="bold", box=SIMPLE, padding=(0, 1))
|
|
38
|
+
table.add_column("Skill", style="bold cyan", no_wrap=True)
|
|
39
|
+
table.add_column("Source")
|
|
40
|
+
table.add_column("Version")
|
|
41
|
+
table.add_column("Date")
|
|
42
|
+
|
|
43
|
+
for entry in sorted(registry.skills.values(), key=lambda e: e.full_name):
|
|
44
|
+
style = _SOURCE_STYLE.get(entry.source, "")
|
|
45
|
+
version = entry.version[:8] if entry.version else "—"
|
|
46
|
+
date = entry.created_at or entry.installed_at
|
|
47
|
+
date_str = date.strftime("%Y-%m-%d") if date else "—"
|
|
48
|
+
table.add_row(
|
|
49
|
+
entry.full_name,
|
|
50
|
+
f"[{style}]{entry.source.value}[/{style}]",
|
|
51
|
+
version,
|
|
52
|
+
date_str,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
console.print(table)
|
|
56
|
+
count = len(registry.skills)
|
|
57
|
+
console.print(f"[dim]{count} skill{'s' if count != 1 else ''}[/dim]")
|