taken-cli 0.2.0__tar.gz → 0.2.2__tar.gz
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_cli-0.2.0 → taken_cli-0.2.2}/PKG-INFO +3 -2
- {taken_cli-0.2.0 → taken_cli-0.2.2}/README.md +2 -1
- {taken_cli-0.2.0 → taken_cli-0.2.2}/pyproject.toml +1 -1
- {taken_cli-0.2.0 → taken_cli-0.2.2}/pyproject.toml.orig +1 -1
- taken_cli-0.2.2/src/taken/commands/mirrors.py +95 -0
- {taken_cli-0.2.0 → taken_cli-0.2.2}/src/taken/commands/save.py +9 -1
- {taken_cli-0.2.0 → taken_cli-0.2.2}/src/taken/commands/update.py +15 -5
- taken_cli-0.2.2/src/taken/commands/use.py +315 -0
- {taken_cli-0.2.0 → taken_cli-0.2.2}/src/taken/core/config.py +19 -1
- {taken_cli-0.2.0 → taken_cli-0.2.2}/src/taken/core/mirror.py +49 -5
- {taken_cli-0.2.0 → taken_cli-0.2.2}/src/taken/core/project.py +18 -6
- {taken_cli-0.2.0 → taken_cli-0.2.2}/src/taken/main.py +26 -0
- {taken_cli-0.2.0 → taken_cli-0.2.2}/src/taken/models/project.py +4 -0
- taken_cli-0.2.0/src/taken/commands/use.py +0 -198
- {taken_cli-0.2.0 → taken_cli-0.2.2}/src/taken/__init__.py +0 -0
- {taken_cli-0.2.0 → taken_cli-0.2.2}/src/taken/commands/__init__.py +0 -0
- {taken_cli-0.2.0 → taken_cli-0.2.2}/src/taken/commands/add.py +0 -0
- {taken_cli-0.2.0 → taken_cli-0.2.2}/src/taken/commands/git.py +0 -0
- {taken_cli-0.2.0 → taken_cli-0.2.2}/src/taken/commands/init.py +0 -0
- {taken_cli-0.2.0 → taken_cli-0.2.2}/src/taken/commands/install.py +0 -0
- {taken_cli-0.2.0 → taken_cli-0.2.2}/src/taken/commands/list.py +0 -0
- {taken_cli-0.2.0 → taken_cli-0.2.2}/src/taken/commands/remove.py +0 -0
- {taken_cli-0.2.0 → taken_cli-0.2.2}/src/taken/core/__init__.py +0 -0
- {taken_cli-0.2.0 → taken_cli-0.2.2}/src/taken/core/editor.py +0 -0
- {taken_cli-0.2.0 → taken_cli-0.2.2}/src/taken/core/git.py +0 -0
- {taken_cli-0.2.0 → taken_cli-0.2.2}/src/taken/core/github.py +0 -0
- {taken_cli-0.2.0 → taken_cli-0.2.2}/src/taken/core/hashing.py +0 -0
- {taken_cli-0.2.0 → taken_cli-0.2.2}/src/taken/core/paths.py +0 -0
- {taken_cli-0.2.0 → taken_cli-0.2.2}/src/taken/core/registry.py +0 -0
- {taken_cli-0.2.0 → taken_cli-0.2.2}/src/taken/core/skills.py +0 -0
- {taken_cli-0.2.0 → taken_cli-0.2.2}/src/taken/models/__init__.py +0 -0
- {taken_cli-0.2.0 → taken_cli-0.2.2}/src/taken/models/config.py +0 -0
- {taken_cli-0.2.0 → taken_cli-0.2.2}/src/taken/models/registry.py +0 -0
- {taken_cli-0.2.0 → taken_cli-0.2.2}/src/taken/utils/__init__.py +0 -0
- {taken_cli-0.2.0 → taken_cli-0.2.2}/src/taken/utils/console.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: taken-cli
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: Personal dotfile-style CLI for managing AI agent skills
|
|
5
5
|
Author: Pradyoth
|
|
6
6
|
Author-email: Pradyoth <pradyoth.dev@gmail.com>
|
|
@@ -85,7 +85,7 @@ Interactive fuzzy picker when multiple skills are found. Key flags:
|
|
|
85
85
|
```bash
|
|
86
86
|
taken use
|
|
87
87
|
```
|
|
88
|
-
Fuzzy-picks from your registry, copies the skill into `.agents/skills/` in the current project, and
|
|
88
|
+
Fuzzy-picks from your registry, copies the skill into `.agents/skills/` in the current project, and asks which agent folders (`.claude/skills/`, `.cursor/skills/`, ...) to symlink or copy it into, then shows every link it made. Tracking lives in `~/.taken/projects.yaml` — nothing taken-specific is written into the project.
|
|
89
89
|
|
|
90
90
|
**6. Push edits back to registry**
|
|
91
91
|
```bash
|
|
@@ -114,6 +114,7 @@ Re-copies the latest registry version into the project. Warns before overwriting
|
|
|
114
114
|
| `taken update [namespace/skill]` | Re-copy latest registry version into project |
|
|
115
115
|
| `taken list` | Show all skills in the registry |
|
|
116
116
|
| `taken remove <namespace/skill>` | Remove a skill from registry |
|
|
117
|
+
| `taken mirrors list\|add\|remove` | Manage the agent folders `taken use` links into |
|
|
117
118
|
| `taken git <args>` | Run any git command inside `~/.taken/` |
|
|
118
119
|
|
|
119
120
|
---
|
|
@@ -65,7 +65,7 @@ Interactive fuzzy picker when multiple skills are found. Key flags:
|
|
|
65
65
|
```bash
|
|
66
66
|
taken use
|
|
67
67
|
```
|
|
68
|
-
Fuzzy-picks from your registry, copies the skill into `.agents/skills/` in the current project, and
|
|
68
|
+
Fuzzy-picks from your registry, copies the skill into `.agents/skills/` in the current project, and asks which agent folders (`.claude/skills/`, `.cursor/skills/`, ...) to symlink or copy it into, then shows every link it made. Tracking lives in `~/.taken/projects.yaml` — nothing taken-specific is written into the project.
|
|
69
69
|
|
|
70
70
|
**6. Push edits back to registry**
|
|
71
71
|
```bash
|
|
@@ -94,6 +94,7 @@ Re-copies the latest registry version into the project. Warns before overwriting
|
|
|
94
94
|
| `taken update [namespace/skill]` | Re-copy latest registry version into project |
|
|
95
95
|
| `taken list` | Show all skills in the registry |
|
|
96
96
|
| `taken remove <namespace/skill>` | Remove a skill from registry |
|
|
97
|
+
| `taken mirrors list\|add\|remove` | Manage the agent folders `taken use` links into |
|
|
97
98
|
| `taken git <args>` | Run any git command inside `~/.taken/` |
|
|
98
99
|
|
|
99
100
|
---
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
from rich.panel import Panel
|
|
3
|
+
|
|
4
|
+
from taken.core import paths
|
|
5
|
+
from taken.core.config import is_config_exists, normalize_mirror_dir, read_config, write_config
|
|
6
|
+
from taken.core.git import auto_commit_and_push
|
|
7
|
+
from taken.models.config import TakenConfig
|
|
8
|
+
from taken.utils.console import console, err_console
|
|
9
|
+
|
|
10
|
+
mirrors_app = typer.Typer(
|
|
11
|
+
help="Manage the agent dirs (e.g. .claude/skills) that `taken use` can link skills into.",
|
|
12
|
+
no_args_is_help=True,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _load_config() -> TakenConfig:
|
|
17
|
+
if not is_config_exists(paths.TAKEN_HOME):
|
|
18
|
+
err_console.print(
|
|
19
|
+
Panel(
|
|
20
|
+
"Taken is not initialized. Run [bold]taken init[/bold] to get started.",
|
|
21
|
+
title="[red]Not Initialized[/red]",
|
|
22
|
+
border_style="red",
|
|
23
|
+
)
|
|
24
|
+
)
|
|
25
|
+
raise typer.Exit(code=1)
|
|
26
|
+
return read_config(paths.TAKEN_HOME)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _normalize_or_exit(raw: str, config: TakenConfig) -> str:
|
|
30
|
+
try:
|
|
31
|
+
return normalize_mirror_dir(raw, config.skills.main_dir)
|
|
32
|
+
except ValueError as e:
|
|
33
|
+
err_console.print(Panel(str(e), title="[red]Invalid Mirror Dir[/red]", border_style="red"))
|
|
34
|
+
raise typer.Exit(code=1) from e
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@mirrors_app.command("list")
|
|
38
|
+
def list_mirrors() -> None:
|
|
39
|
+
"""Show the main skills dir, mirror mode, and the mirror dirs offered by `taken use`."""
|
|
40
|
+
config = _load_config()
|
|
41
|
+
skills = config.skills
|
|
42
|
+
console.print(f"[bold]Main dir:[/bold] {skills.main_dir}")
|
|
43
|
+
console.print(f"[bold]Mirror mode:[/bold] {skills.mirror_mode.value}")
|
|
44
|
+
if not skills.mirror_dirs:
|
|
45
|
+
console.print("[bold]Mirror dirs:[/bold] [dim]none — add one with[/dim] [bold]taken mirrors add <dir>[/bold]")
|
|
46
|
+
return
|
|
47
|
+
console.print("[bold]Mirror dirs:[/bold]")
|
|
48
|
+
for d in skills.mirror_dirs:
|
|
49
|
+
console.print(f" • {d}")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@mirrors_app.command("add")
|
|
53
|
+
def add_mirror(
|
|
54
|
+
directory: str = typer.Argument(..., help="Dir relative to the project root, e.g. .cursor/skills"),
|
|
55
|
+
) -> None:
|
|
56
|
+
"""Add a mirror dir that `taken use` will offer."""
|
|
57
|
+
config = _load_config()
|
|
58
|
+
normalized = _normalize_or_exit(directory, config)
|
|
59
|
+
|
|
60
|
+
if normalized in config.skills.mirror_dirs:
|
|
61
|
+
console.print(f"[dim]{normalized} is already in your mirror list.[/dim]")
|
|
62
|
+
return
|
|
63
|
+
|
|
64
|
+
config.skills.mirror_dirs.append(normalized)
|
|
65
|
+
write_config(config)
|
|
66
|
+
auto_commit_and_push(paths.TAKEN_HOME, f"mirrors: add {normalized}")
|
|
67
|
+
console.print(f"[green]✓[/green] Added [bold]{normalized}[/bold] — `taken use` will offer it from now on.")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@mirrors_app.command("remove")
|
|
71
|
+
def remove_mirror(
|
|
72
|
+
directory: str = typer.Argument(..., help="Mirror dir to stop offering, e.g. .cursor/skills"),
|
|
73
|
+
) -> None:
|
|
74
|
+
"""Stop offering a mirror dir. Existing links in projects are left in place."""
|
|
75
|
+
config = _load_config()
|
|
76
|
+
normalized = _normalize_or_exit(directory, config)
|
|
77
|
+
|
|
78
|
+
if normalized not in config.skills.mirror_dirs:
|
|
79
|
+
err_console.print(
|
|
80
|
+
Panel(
|
|
81
|
+
f"[bold]{normalized}[/bold] is not in your mirror list.\n"
|
|
82
|
+
"Run [bold]taken mirrors list[/bold] to see it.",
|
|
83
|
+
title="[red]Not Found[/red]",
|
|
84
|
+
border_style="red",
|
|
85
|
+
)
|
|
86
|
+
)
|
|
87
|
+
raise typer.Exit(code=1)
|
|
88
|
+
|
|
89
|
+
config.skills.mirror_dirs.remove(normalized)
|
|
90
|
+
write_config(config)
|
|
91
|
+
auto_commit_and_push(paths.TAKEN_HOME, f"mirrors: remove {normalized}")
|
|
92
|
+
console.print(
|
|
93
|
+
f"[green]✓[/green] Removed [bold]{normalized}[/bold].\n"
|
|
94
|
+
"[dim]Existing links in projects stay; re-run `taken use` in a project to drop them.[/dim]"
|
|
95
|
+
)
|
|
@@ -16,7 +16,13 @@ from taken.core.config import is_config_exists, read_config
|
|
|
16
16
|
from taken.core.git import auto_commit_and_push
|
|
17
17
|
from taken.core.hashing import compute_skill_hash
|
|
18
18
|
from taken.core.mirror import edited_copies, main_skill_dir, remove_path, sync_mirrors
|
|
19
|
-
from taken.core.project import
|
|
19
|
+
from taken.core.project import (
|
|
20
|
+
is_project_config_exists,
|
|
21
|
+
read_project_config,
|
|
22
|
+
resolve_layout,
|
|
23
|
+
skill_layout,
|
|
24
|
+
write_project_config,
|
|
25
|
+
)
|
|
20
26
|
from taken.core.registry import read_registry, write_registry
|
|
21
27
|
from taken.models.project import ProjectConfig, ProjectSkillEntry, SkillLayout
|
|
22
28
|
from taken.models.registry import Registry
|
|
@@ -93,6 +99,7 @@ def _process_skill(
|
|
|
93
99
|
registry_skill_dir = paths.TAKEN_HOME / "skills" / namespace / name
|
|
94
100
|
|
|
95
101
|
existing = project_config.skills[full_name]
|
|
102
|
+
layout = skill_layout(layout, existing)
|
|
96
103
|
edited = edited_copies(project_root, layout, name, existing.copied_hash)
|
|
97
104
|
|
|
98
105
|
if not edited:
|
|
@@ -115,6 +122,7 @@ def _process_skill(
|
|
|
115
122
|
project_config.skills[full_name] = ProjectSkillEntry(
|
|
116
123
|
copied_at=existing.copied_at,
|
|
117
124
|
copied_hash=new_hash,
|
|
125
|
+
mirror_dirs=existing.mirror_dirs,
|
|
118
126
|
)
|
|
119
127
|
|
|
120
128
|
registry_entry = registry.get(full_name)
|
|
@@ -18,7 +18,13 @@ from taken.core.git import auto_commit_and_push
|
|
|
18
18
|
from taken.core.github import discover_skills, download_skill, get_commit_sha, get_default_branch
|
|
19
19
|
from taken.core.hashing import compute_skill_hash
|
|
20
20
|
from taken.core.mirror import edited_copies, main_skill_dir, remove_path, sync_mirrors
|
|
21
|
-
from taken.core.project import
|
|
21
|
+
from taken.core.project import (
|
|
22
|
+
is_project_config_exists,
|
|
23
|
+
read_project_config,
|
|
24
|
+
resolve_layout,
|
|
25
|
+
skill_layout,
|
|
26
|
+
write_project_config,
|
|
27
|
+
)
|
|
22
28
|
from taken.core.registry import read_registry, write_registry
|
|
23
29
|
from taken.models.project import ProjectConfig, ProjectSkillEntry, SkillLayout
|
|
24
30
|
from taken.models.registry import Registry, RegistryEntry, SkillSource, VersionPin
|
|
@@ -148,6 +154,7 @@ def _process_skill(
|
|
|
148
154
|
raise typer.Exit(code=1)
|
|
149
155
|
|
|
150
156
|
existing = project_config.skills[full_name]
|
|
157
|
+
layout = skill_layout(layout, existing)
|
|
151
158
|
registry_hash = compute_skill_hash(registry_skill_dir)
|
|
152
159
|
registry_changed = registry_hash != existing.copied_hash
|
|
153
160
|
project_edited = bool(edited_copies(project_root, layout, name, existing.copied_hash))
|
|
@@ -169,15 +176,18 @@ def _process_skill(
|
|
|
169
176
|
project_skill_dir.parent.mkdir(parents=True, exist_ok=True)
|
|
170
177
|
remove_path(project_skill_dir)
|
|
171
178
|
shutil.copytree(registry_skill_dir, project_skill_dir)
|
|
172
|
-
for
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
179
|
+
for result in sync_mirrors(project_root, layout, name):
|
|
180
|
+
if result.fell_back:
|
|
181
|
+
err_console.print(
|
|
182
|
+
f"[yellow]⚠[/yellow] Could not symlink [bold]{result.path.relative_to(project_root)}[/bold] "
|
|
183
|
+
"— copied instead."
|
|
184
|
+
)
|
|
176
185
|
|
|
177
186
|
new_hash = compute_skill_hash(project_skill_dir)
|
|
178
187
|
project_config.skills[full_name] = ProjectSkillEntry(
|
|
179
188
|
copied_at=existing.copied_at,
|
|
180
189
|
copied_hash=new_hash,
|
|
190
|
+
mirror_dirs=existing.mirror_dirs,
|
|
181
191
|
)
|
|
182
192
|
|
|
183
193
|
updated.append(f"[green]✓[/green] [bold]{full_name}[/bold] → {layout.main_dir}/{name}/")
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import shutil
|
|
2
|
+
import sys
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
from InquirerPy import inquirer
|
|
8
|
+
from InquirerPy.base.control import Choice
|
|
9
|
+
from InquirerPy.enum import (
|
|
10
|
+
INQUIRERPY_EMPTY_CIRCLE_SEQUENCE,
|
|
11
|
+
INQUIRERPY_FILL_CIRCLE_SEQUENCE,
|
|
12
|
+
)
|
|
13
|
+
from rich.panel import Panel
|
|
14
|
+
from rich.prompt import Confirm
|
|
15
|
+
|
|
16
|
+
from taken.core import paths
|
|
17
|
+
from taken.core.config import is_config_exists, normalize_mirror_dir, read_config
|
|
18
|
+
from taken.core.hashing import compute_skill_hash
|
|
19
|
+
from taken.core.mirror import (
|
|
20
|
+
MirrorResult,
|
|
21
|
+
edited_copies,
|
|
22
|
+
main_skill_dir,
|
|
23
|
+
remove_path,
|
|
24
|
+
remove_unselected_mirrors,
|
|
25
|
+
sync_mirrors,
|
|
26
|
+
untracked_paths,
|
|
27
|
+
)
|
|
28
|
+
from taken.core.project import read_project_config, resolve_layout, skill_layout, write_project_config
|
|
29
|
+
from taken.core.registry import read_registry
|
|
30
|
+
from taken.models.config import MirrorMode
|
|
31
|
+
from taken.models.project import ProjectConfig, ProjectSkillEntry, SkillLayout
|
|
32
|
+
from taken.models.registry import Registry, RegistryEntry
|
|
33
|
+
from taken.utils.console import console, err_console
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _resolve_selected(
|
|
37
|
+
namespace_skill: str | None,
|
|
38
|
+
registry: Registry,
|
|
39
|
+
) -> list[RegistryEntry]:
|
|
40
|
+
"""Return the list of skills the user chose; raises typer.Exit on invalid input."""
|
|
41
|
+
if namespace_skill is not None:
|
|
42
|
+
entry = registry.get(namespace_skill)
|
|
43
|
+
if entry is None:
|
|
44
|
+
err_console.print(
|
|
45
|
+
Panel(
|
|
46
|
+
f"[bold]{namespace_skill}[/bold] not found in registry.\n"
|
|
47
|
+
"Run [bold]taken list[/bold] to see available skills.",
|
|
48
|
+
title="[red]Skill Not Found[/red]",
|
|
49
|
+
border_style="red",
|
|
50
|
+
)
|
|
51
|
+
)
|
|
52
|
+
raise typer.Exit(code=1)
|
|
53
|
+
return [entry]
|
|
54
|
+
|
|
55
|
+
choices = [
|
|
56
|
+
{"name": f"{e.full_name} [{e.source.value}]", "value": e.full_name}
|
|
57
|
+
for e in sorted(registry.skills.values(), key=lambda e: e.full_name)
|
|
58
|
+
]
|
|
59
|
+
selected_names: list[str] = inquirer.fuzzy( # type: ignore[attr-defined]
|
|
60
|
+
message="Search skills:",
|
|
61
|
+
choices=choices,
|
|
62
|
+
multiselect=True,
|
|
63
|
+
marker=INQUIRERPY_FILL_CIRCLE_SEQUENCE,
|
|
64
|
+
marker_pl=INQUIRERPY_EMPTY_CIRCLE_SEQUENCE,
|
|
65
|
+
instruction="(type to filter space/tab to select enter to confirm)",
|
|
66
|
+
keybindings={"toggle": [{"key": "space"}, {"key": "tab"}]},
|
|
67
|
+
validate=lambda x: len(x) > 0,
|
|
68
|
+
invalid_message="Select at least one skill.",
|
|
69
|
+
).execute()
|
|
70
|
+
|
|
71
|
+
if not selected_names:
|
|
72
|
+
raise typer.Exit(code=0)
|
|
73
|
+
|
|
74
|
+
return [e for name in selected_names if (e := registry.get(name)) is not None]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def use(
|
|
78
|
+
namespace_skill: str | None = typer.Argument(
|
|
79
|
+
None,
|
|
80
|
+
help="Skill to copy into project, e.g. pradyothsp/hello-world. Omit for interactive picker.",
|
|
81
|
+
),
|
|
82
|
+
mode: MirrorMode | None = typer.Option( # noqa: B008
|
|
83
|
+
None,
|
|
84
|
+
"--mode",
|
|
85
|
+
help="How to mirror into extra dirs like .claude/skills (symlink or copy). Remembered for this project.",
|
|
86
|
+
case_sensitive=False,
|
|
87
|
+
),
|
|
88
|
+
mirror: list[str] = typer.Option( # noqa: B008
|
|
89
|
+
[],
|
|
90
|
+
"--mirror",
|
|
91
|
+
"-m",
|
|
92
|
+
help="Mirror dir to link into (repeatable). Skips the prompt. Must be in `taken mirrors list`.",
|
|
93
|
+
),
|
|
94
|
+
no_mirror: bool = typer.Option(
|
|
95
|
+
False,
|
|
96
|
+
"--no-mirror",
|
|
97
|
+
help="Don't mirror into any agent dir. Skips the prompt.",
|
|
98
|
+
),
|
|
99
|
+
) -> None:
|
|
100
|
+
"""Copy skill(s) from ~/.taken/ into the current project's skills dir and mirror them to agent dirs."""
|
|
101
|
+
if not is_config_exists(paths.TAKEN_HOME):
|
|
102
|
+
err_console.print(
|
|
103
|
+
Panel(
|
|
104
|
+
"Taken is not initialized. Run [bold]taken init[/bold] to get started.",
|
|
105
|
+
title="[red]Not Initialized[/red]",
|
|
106
|
+
border_style="red",
|
|
107
|
+
)
|
|
108
|
+
)
|
|
109
|
+
raise typer.Exit(code=1)
|
|
110
|
+
|
|
111
|
+
registry = read_registry(paths.TAKEN_HOME)
|
|
112
|
+
|
|
113
|
+
if not registry.skills:
|
|
114
|
+
err_console.print(
|
|
115
|
+
Panel(
|
|
116
|
+
"No skills in your registry yet. Run [bold]taken add[/bold] to create one.",
|
|
117
|
+
title="[red]Registry Empty[/red]",
|
|
118
|
+
border_style="red",
|
|
119
|
+
)
|
|
120
|
+
)
|
|
121
|
+
raise typer.Exit(code=1)
|
|
122
|
+
|
|
123
|
+
selected = _resolve_selected(namespace_skill, registry)
|
|
124
|
+
|
|
125
|
+
project_root = Path.cwd()
|
|
126
|
+
project_config = read_project_config(project_root)
|
|
127
|
+
if mode is not None:
|
|
128
|
+
project_config.mirror_mode = mode
|
|
129
|
+
layout = resolve_layout(read_config(paths.TAKEN_HOME), project_config)
|
|
130
|
+
|
|
131
|
+
chosen_dirs = _resolve_mirror_dirs(mirror, no_mirror, layout, selected, project_config)
|
|
132
|
+
new_layout = layout.model_copy(update={"mirror_dirs": tuple(chosen_dirs)})
|
|
133
|
+
|
|
134
|
+
now = datetime.now()
|
|
135
|
+
copied: list[str] = []
|
|
136
|
+
skipped: list[str] = []
|
|
137
|
+
|
|
138
|
+
for entry in selected:
|
|
139
|
+
existing = project_config.skills.get(entry.full_name)
|
|
140
|
+
prev_layout = skill_layout(layout, existing)
|
|
141
|
+
if not _confirm_overwrite(entry, project_root, prev_layout, new_layout, existing):
|
|
142
|
+
skipped.append(entry.full_name)
|
|
143
|
+
continue
|
|
144
|
+
|
|
145
|
+
candidate_dirs = list(dict.fromkeys([*layout.mirror_dirs, *prev_layout.mirror_dirs]))
|
|
146
|
+
removed = remove_unselected_mirrors(
|
|
147
|
+
project_root,
|
|
148
|
+
new_layout,
|
|
149
|
+
candidate_dirs,
|
|
150
|
+
entry.name,
|
|
151
|
+
existing.copied_hash if existing else None,
|
|
152
|
+
)
|
|
153
|
+
results = _copy_skill(entry, project_root, new_layout, project_config, now)
|
|
154
|
+
copied.append(_describe_copy(entry, project_root, new_layout, results, removed))
|
|
155
|
+
|
|
156
|
+
if not copied and not skipped:
|
|
157
|
+
return
|
|
158
|
+
|
|
159
|
+
write_project_config(project_config, project_root)
|
|
160
|
+
|
|
161
|
+
if copied:
|
|
162
|
+
lines = "\n\n".join(copied) + "\n\n[dim]Tracked in ~/.taken/projects.yaml[/dim]"
|
|
163
|
+
console.print(
|
|
164
|
+
Panel(
|
|
165
|
+
lines,
|
|
166
|
+
title="[green]Skills Added[/green]",
|
|
167
|
+
border_style="green",
|
|
168
|
+
padding=(1, 2),
|
|
169
|
+
)
|
|
170
|
+
)
|
|
171
|
+
if skipped:
|
|
172
|
+
console.print(f"[dim]Skipped: {', '.join(skipped)}[/dim]")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _is_interactive() -> bool:
|
|
176
|
+
return sys.stdin.isatty() and sys.stdout.isatty()
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _default_mirror_dirs(
|
|
180
|
+
available: list[str],
|
|
181
|
+
selected: list[RegistryEntry],
|
|
182
|
+
project_config: ProjectConfig,
|
|
183
|
+
) -> list[str]:
|
|
184
|
+
"""Pre-tick a single already-used skill's previous choice; otherwise everything."""
|
|
185
|
+
if len(selected) == 1:
|
|
186
|
+
existing = project_config.skills.get(selected[0].full_name)
|
|
187
|
+
if existing is not None and existing.mirror_dirs is not None:
|
|
188
|
+
return [d for d in available if d in existing.mirror_dirs]
|
|
189
|
+
return available
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _resolve_mirror_dirs(
|
|
193
|
+
mirror: list[str],
|
|
194
|
+
no_mirror: bool,
|
|
195
|
+
layout: SkillLayout,
|
|
196
|
+
selected: list[RegistryEntry],
|
|
197
|
+
project_config: ProjectConfig,
|
|
198
|
+
) -> list[str]:
|
|
199
|
+
"""Decide which mirror dirs to use: flags first, then the interactive picker, then defaults."""
|
|
200
|
+
available = list(layout.mirror_dirs)
|
|
201
|
+
|
|
202
|
+
if no_mirror:
|
|
203
|
+
return []
|
|
204
|
+
|
|
205
|
+
if mirror:
|
|
206
|
+
chosen: list[str] = []
|
|
207
|
+
for raw in mirror:
|
|
208
|
+
try:
|
|
209
|
+
normalized = normalize_mirror_dir(raw, layout.main_dir)
|
|
210
|
+
except ValueError as e:
|
|
211
|
+
err_console.print(Panel(str(e), title="[red]Invalid Mirror Dir[/red]", border_style="red"))
|
|
212
|
+
raise typer.Exit(code=1) from e
|
|
213
|
+
if normalized not in available:
|
|
214
|
+
err_console.print(
|
|
215
|
+
Panel(
|
|
216
|
+
f"[bold]{normalized}[/bold] is not in your mirror list.\n"
|
|
217
|
+
f"Run [bold]taken mirrors add {normalized}[/bold] first.",
|
|
218
|
+
title="[red]Unknown Mirror Dir[/red]",
|
|
219
|
+
border_style="red",
|
|
220
|
+
)
|
|
221
|
+
)
|
|
222
|
+
raise typer.Exit(code=1)
|
|
223
|
+
chosen.append(normalized)
|
|
224
|
+
return list(dict.fromkeys(chosen))
|
|
225
|
+
|
|
226
|
+
if not available:
|
|
227
|
+
return []
|
|
228
|
+
|
|
229
|
+
preselected = _default_mirror_dirs(available, selected, project_config)
|
|
230
|
+
if not _is_interactive():
|
|
231
|
+
return preselected
|
|
232
|
+
|
|
233
|
+
verb = "Symlink" if layout.mirror_mode == MirrorMode.SYMLINK else "Copy"
|
|
234
|
+
picked: list[str] = inquirer.checkbox( # type: ignore[attr-defined]
|
|
235
|
+
message=f"{verb} into which folders?",
|
|
236
|
+
choices=[Choice(value=d, name=d, enabled=d in preselected) for d in available],
|
|
237
|
+
instruction="(space to toggle, enter to confirm — none is fine)",
|
|
238
|
+
enabled_symbol=INQUIRERPY_FILL_CIRCLE_SEQUENCE,
|
|
239
|
+
disabled_symbol=INQUIRERPY_EMPTY_CIRCLE_SEQUENCE,
|
|
240
|
+
).execute()
|
|
241
|
+
return picked
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _confirm_overwrite(
|
|
245
|
+
entry: RegistryEntry,
|
|
246
|
+
project_root: Path,
|
|
247
|
+
prev_layout: SkillLayout,
|
|
248
|
+
new_layout: SkillLayout,
|
|
249
|
+
existing: ProjectSkillEntry | None,
|
|
250
|
+
) -> bool:
|
|
251
|
+
"""Ask before clobbering local edits or folders taken did not create. Returns True to proceed."""
|
|
252
|
+
if existing is not None:
|
|
253
|
+
if edited_copies(project_root, prev_layout, entry.name, existing.copied_hash):
|
|
254
|
+
console.print(f"\n[yellow]⚠[/yellow] [bold]{entry.name}[/bold] has local changes in your project.")
|
|
255
|
+
return Confirm.ask(f" Overwrite [bold]{entry.name}[/bold]?", default=False)
|
|
256
|
+
# Only newly chosen dirs can hold something taken didn't create.
|
|
257
|
+
new_dirs = tuple(d for d in new_layout.mirror_dirs if d not in prev_layout.mirror_dirs)
|
|
258
|
+
check_layout = new_layout.model_copy(update={"mirror_dirs": new_dirs})
|
|
259
|
+
else:
|
|
260
|
+
check_layout = new_layout
|
|
261
|
+
|
|
262
|
+
foreign = untracked_paths(project_root, check_layout, entry.name)
|
|
263
|
+
if foreign:
|
|
264
|
+
listed = ", ".join(str(p.relative_to(project_root)) for p in foreign)
|
|
265
|
+
console.print(f"\n[yellow]⚠[/yellow] [bold]{listed}[/bold] already exists and was not created by taken.")
|
|
266
|
+
return Confirm.ask(f" Overwrite with [bold]{entry.full_name}[/bold]?", default=False)
|
|
267
|
+
|
|
268
|
+
return True
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _describe_copy(
|
|
272
|
+
entry: RegistryEntry,
|
|
273
|
+
project_root: Path,
|
|
274
|
+
layout: SkillLayout,
|
|
275
|
+
results: list[MirrorResult],
|
|
276
|
+
removed: list[Path],
|
|
277
|
+
) -> str:
|
|
278
|
+
main_rel = f"{layout.main_dir}/{entry.name}"
|
|
279
|
+
lines = [f"[green]✓[/green] [bold]{entry.full_name}[/bold] → {main_rel}/"]
|
|
280
|
+
for result in results:
|
|
281
|
+
rel = result.path.relative_to(project_root)
|
|
282
|
+
if result.is_link:
|
|
283
|
+
lines.append(f" [cyan]🔗 {rel}[/cyan] → {main_rel}")
|
|
284
|
+
elif result.fell_back:
|
|
285
|
+
lines.append(f" [yellow]📄 {rel}[/yellow] (copied — symlink not permitted)")
|
|
286
|
+
else:
|
|
287
|
+
lines.append(f" [cyan]📄 {rel}[/cyan] (copy)")
|
|
288
|
+
lines.extend(f" [dim]✗ removed {path.relative_to(project_root)}[/dim]" for path in removed)
|
|
289
|
+
if not results:
|
|
290
|
+
lines.append(" [dim]not mirrored to any agent dir[/dim]")
|
|
291
|
+
return "\n".join(lines)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _copy_skill(
|
|
295
|
+
entry: RegistryEntry,
|
|
296
|
+
project_root: Path,
|
|
297
|
+
layout: SkillLayout,
|
|
298
|
+
project_config: ProjectConfig,
|
|
299
|
+
now: datetime,
|
|
300
|
+
) -> list[MirrorResult]:
|
|
301
|
+
"""Copy registry skill into the main dir, then mirror it into the layout's dirs."""
|
|
302
|
+
src = paths.TAKEN_HOME / "skills" / entry.namespace / entry.name
|
|
303
|
+
dst = main_skill_dir(project_root, layout, entry.name)
|
|
304
|
+
|
|
305
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
306
|
+
remove_path(dst)
|
|
307
|
+
shutil.copytree(src, dst)
|
|
308
|
+
results = sync_mirrors(project_root, layout, entry.name)
|
|
309
|
+
|
|
310
|
+
project_config.skills[entry.full_name] = ProjectSkillEntry(
|
|
311
|
+
copied_at=now,
|
|
312
|
+
copied_hash=compute_skill_hash(dst),
|
|
313
|
+
mirror_dirs=list(layout.mirror_dirs),
|
|
314
|
+
)
|
|
315
|
+
return results
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
from datetime import datetime
|
|
2
|
-
from pathlib import Path
|
|
2
|
+
from pathlib import Path, PurePosixPath
|
|
3
3
|
from typing import Any, cast
|
|
4
4
|
|
|
5
5
|
from ruamel.yaml import YAML
|
|
@@ -88,3 +88,21 @@ def read_config(taken_home: Path | None = None) -> TakenConfig:
|
|
|
88
88
|
|
|
89
89
|
def is_config_exists(taken_home: Path) -> bool:
|
|
90
90
|
return get_config_path(taken_home).exists()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def normalize_mirror_dir(raw: str, main_dir: str) -> str:
|
|
94
|
+
"""
|
|
95
|
+
Validate and normalize a mirror dir (e.g. '.cursor/skills/' -> '.cursor/skills').
|
|
96
|
+
|
|
97
|
+
Raises ValueError for absolute paths, parent-dir escapes, or the main skills dir itself.
|
|
98
|
+
"""
|
|
99
|
+
cleaned = raw.strip().replace("\\", "/")
|
|
100
|
+
path = PurePosixPath(cleaned)
|
|
101
|
+
if not cleaned or path.is_absolute() or cleaned.startswith("~"):
|
|
102
|
+
raise ValueError("Mirror dir must be a path relative to the project root, e.g. .cursor/skills")
|
|
103
|
+
if ".." in path.parts:
|
|
104
|
+
raise ValueError("Mirror dir must stay inside the project (no '..').")
|
|
105
|
+
normalized = str(path)
|
|
106
|
+
if normalized in (".", str(PurePosixPath(main_dir))):
|
|
107
|
+
raise ValueError(f"{normalized} is the main skills dir — it can't also be a mirror.")
|
|
108
|
+
return normalized
|
|
@@ -1,12 +1,21 @@
|
|
|
1
1
|
import os
|
|
2
2
|
import shutil
|
|
3
3
|
from pathlib import Path
|
|
4
|
+
from typing import NamedTuple
|
|
4
5
|
|
|
5
6
|
from taken.core.hashing import compute_skill_hash
|
|
6
7
|
from taken.models.config import MirrorMode
|
|
7
8
|
from taken.models.project import SkillLayout
|
|
8
9
|
|
|
9
10
|
|
|
11
|
+
class MirrorResult(NamedTuple):
|
|
12
|
+
"""Outcome of mirroring one skill into one mirror dir."""
|
|
13
|
+
|
|
14
|
+
path: Path
|
|
15
|
+
is_link: bool
|
|
16
|
+
fell_back: bool # wanted a symlink but had to copy
|
|
17
|
+
|
|
18
|
+
|
|
10
19
|
def main_skill_dir(project_root: Path, layout: SkillLayout, name: str) -> Path:
|
|
11
20
|
"""The tracked copy of a skill inside the project (e.g. .agents/skills/<name>)."""
|
|
12
21
|
return project_root / layout.main_dir / name
|
|
@@ -31,29 +40,64 @@ def remove_path(path: Path) -> None:
|
|
|
31
40
|
shutil.rmtree(path)
|
|
32
41
|
|
|
33
42
|
|
|
34
|
-
def sync_mirrors(project_root: Path, layout: SkillLayout, name: str) -> list[
|
|
43
|
+
def sync_mirrors(project_root: Path, layout: SkillLayout, name: str) -> list[MirrorResult]:
|
|
35
44
|
"""
|
|
36
45
|
Recreate every mirror of a skill from the main copy, using the layout's mirror mode.
|
|
37
46
|
|
|
38
47
|
Symlinks are relative, so they keep working when the project is moved or cloned.
|
|
39
48
|
If a symlink cannot be created (e.g. Windows without privileges), falls back to a copy.
|
|
40
|
-
Returns the mirrors that fell back from symlink to copy.
|
|
41
49
|
"""
|
|
42
50
|
main = main_skill_dir(project_root, layout, name)
|
|
43
|
-
|
|
51
|
+
results: list[MirrorResult] = []
|
|
44
52
|
|
|
45
53
|
for dst in mirror_skill_dirs(project_root, layout, name):
|
|
46
54
|
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
47
55
|
remove_path(dst)
|
|
56
|
+
fell_back = False
|
|
48
57
|
if layout.mirror_mode == MirrorMode.SYMLINK:
|
|
49
58
|
try:
|
|
50
59
|
dst.symlink_to(os.path.relpath(main, dst.parent), target_is_directory=True)
|
|
60
|
+
results.append(MirrorResult(path=dst, is_link=True, fell_back=False))
|
|
51
61
|
continue
|
|
52
62
|
except OSError:
|
|
53
|
-
fell_back
|
|
63
|
+
fell_back = True
|
|
54
64
|
shutil.copytree(main, dst)
|
|
65
|
+
results.append(MirrorResult(path=dst, is_link=False, fell_back=fell_back))
|
|
66
|
+
|
|
67
|
+
return results
|
|
68
|
+
|
|
55
69
|
|
|
56
|
-
|
|
70
|
+
def remove_unselected_mirrors(
|
|
71
|
+
project_root: Path,
|
|
72
|
+
layout: SkillLayout,
|
|
73
|
+
candidate_dirs: list[str],
|
|
74
|
+
name: str,
|
|
75
|
+
baseline_hash: str | None,
|
|
76
|
+
) -> list[Path]:
|
|
77
|
+
"""
|
|
78
|
+
Remove this skill from candidate mirror dirs that are not in the layout.
|
|
79
|
+
|
|
80
|
+
Only removes what taken owns: symlinks to the main copy, or copies that still match
|
|
81
|
+
the last-written hash. Anything edited or foreign is left alone. Returns removed paths.
|
|
82
|
+
"""
|
|
83
|
+
main = main_skill_dir(project_root, layout, name)
|
|
84
|
+
removed: list[Path] = []
|
|
85
|
+
for d in candidate_dirs:
|
|
86
|
+
if d in layout.mirror_dirs:
|
|
87
|
+
continue
|
|
88
|
+
path = project_root / d / name
|
|
89
|
+
if path == main:
|
|
90
|
+
continue
|
|
91
|
+
is_ours = is_managed_link(path, main) or (
|
|
92
|
+
baseline_hash is not None
|
|
93
|
+
and path.is_dir()
|
|
94
|
+
and not path.is_symlink()
|
|
95
|
+
and compute_skill_hash(path) == baseline_hash
|
|
96
|
+
)
|
|
97
|
+
if is_ours:
|
|
98
|
+
remove_path(path)
|
|
99
|
+
removed.append(path)
|
|
100
|
+
return removed
|
|
57
101
|
|
|
58
102
|
|
|
59
103
|
def real_copies(project_root: Path, layout: SkillLayout, name: str) -> list[Path]:
|
|
@@ -54,13 +54,17 @@ def _serialize_project(config: ProjectConfig) -> dict[str, Any]:
|
|
|
54
54
|
data: dict[str, Any] = {}
|
|
55
55
|
if config.mirror_mode is not None:
|
|
56
56
|
data["mirror_mode"] = config.mirror_mode.value
|
|
57
|
-
data["skills"] = {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
57
|
+
data["skills"] = {key: _serialize_skill(entry) for key, entry in config.skills.items()}
|
|
58
|
+
return data
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _serialize_skill(entry: ProjectSkillEntry) -> dict[str, Any]:
|
|
62
|
+
data: dict[str, Any] = {
|
|
63
|
+
"copied_at": entry.copied_at.isoformat(),
|
|
64
|
+
"copied_hash": entry.copied_hash,
|
|
63
65
|
}
|
|
66
|
+
if entry.mirror_dirs is not None:
|
|
67
|
+
data["mirror_dirs"] = list(entry.mirror_dirs)
|
|
64
68
|
return data
|
|
65
69
|
|
|
66
70
|
|
|
@@ -73,6 +77,7 @@ def _deserialize_project(data: dict[str, Any]) -> ProjectConfig:
|
|
|
73
77
|
key: ProjectSkillEntry(
|
|
74
78
|
copied_at=datetime.fromisoformat(entry["copied_at"]),
|
|
75
79
|
copied_hash=entry.get("copied_hash", ""),
|
|
80
|
+
mirror_dirs=list(entry["mirror_dirs"]) if entry.get("mirror_dirs") is not None else None,
|
|
76
81
|
)
|
|
77
82
|
for key, entry in raw_skills.items()
|
|
78
83
|
},
|
|
@@ -138,3 +143,10 @@ def resolve_layout(config: TakenConfig, project_config: ProjectConfig) -> SkillL
|
|
|
138
143
|
mirror_dirs=tuple(config.skills.mirror_dirs),
|
|
139
144
|
mirror_mode=project_config.mirror_mode or config.skills.mirror_mode,
|
|
140
145
|
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def skill_layout(layout: SkillLayout, entry: ProjectSkillEntry | None) -> SkillLayout:
|
|
149
|
+
"""Narrow a project layout to the mirror dirs chosen for one skill (if it has a saved choice)."""
|
|
150
|
+
if entry is None or entry.mirror_dirs is None:
|
|
151
|
+
return layout
|
|
152
|
+
return layout.model_copy(update={"mirror_dirs": tuple(entry.mirror_dirs)})
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
from importlib.metadata import version as package_version
|
|
2
|
+
|
|
1
3
|
import typer
|
|
2
4
|
|
|
3
5
|
from taken.commands.add import add
|
|
@@ -5,10 +7,12 @@ from taken.commands.git import git
|
|
|
5
7
|
from taken.commands.init import init
|
|
6
8
|
from taken.commands.install import install
|
|
7
9
|
from taken.commands.list import list as list_cmd
|
|
10
|
+
from taken.commands.mirrors import mirrors_app
|
|
8
11
|
from taken.commands.remove import remove
|
|
9
12
|
from taken.commands.save import save
|
|
10
13
|
from taken.commands.update import update
|
|
11
14
|
from taken.commands.use import use
|
|
15
|
+
from taken.utils.console import console
|
|
12
16
|
|
|
13
17
|
app = typer.Typer(
|
|
14
18
|
name="taken",
|
|
@@ -17,6 +21,27 @@ app = typer.Typer(
|
|
|
17
21
|
rich_markup_mode="rich",
|
|
18
22
|
)
|
|
19
23
|
|
|
24
|
+
|
|
25
|
+
def _version_callback(value: bool) -> None:
|
|
26
|
+
if value:
|
|
27
|
+
console.print(f"taken {package_version('taken-cli')}")
|
|
28
|
+
raise typer.Exit()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@app.callback()
|
|
32
|
+
def _root(
|
|
33
|
+
version: bool = typer.Option(
|
|
34
|
+
False,
|
|
35
|
+
"--version",
|
|
36
|
+
"-V",
|
|
37
|
+
help="Show the installed taken version and exit.",
|
|
38
|
+
callback=_version_callback,
|
|
39
|
+
is_eager=True,
|
|
40
|
+
),
|
|
41
|
+
) -> None:
|
|
42
|
+
"""A very particular set of skills, managed."""
|
|
43
|
+
|
|
44
|
+
|
|
20
45
|
app.command("init")(init)
|
|
21
46
|
app.command("add")(add)
|
|
22
47
|
app.command("install")(install)
|
|
@@ -25,6 +50,7 @@ app.command("use")(use)
|
|
|
25
50
|
app.command("save")(save)
|
|
26
51
|
app.command("update")(update)
|
|
27
52
|
app.command("remove")(remove)
|
|
53
|
+
app.add_typer(mirrors_app, name="mirrors")
|
|
28
54
|
app.command("git", context_settings={"allow_extra_args": True, "ignore_unknown_options": True})(git)
|
|
29
55
|
|
|
30
56
|
|
|
@@ -8,6 +8,10 @@ from taken.models.config import MirrorMode
|
|
|
8
8
|
class ProjectSkillEntry(BaseModel):
|
|
9
9
|
copied_at: datetime
|
|
10
10
|
copied_hash: str
|
|
11
|
+
mirror_dirs: list[str] | None = Field(
|
|
12
|
+
default=None,
|
|
13
|
+
description="Mirror dirs chosen for this skill at `taken use`. None means the global mirror_dirs.",
|
|
14
|
+
)
|
|
11
15
|
|
|
12
16
|
|
|
13
17
|
class ProjectConfig(BaseModel):
|
|
@@ -1,198 +0,0 @@
|
|
|
1
|
-
import shutil
|
|
2
|
-
from datetime import datetime
|
|
3
|
-
from pathlib import Path
|
|
4
|
-
|
|
5
|
-
import typer
|
|
6
|
-
from InquirerPy import inquirer
|
|
7
|
-
from InquirerPy.enum import (
|
|
8
|
-
INQUIRERPY_EMPTY_CIRCLE_SEQUENCE,
|
|
9
|
-
INQUIRERPY_FILL_CIRCLE_SEQUENCE,
|
|
10
|
-
)
|
|
11
|
-
from rich.panel import Panel
|
|
12
|
-
from rich.prompt import Confirm
|
|
13
|
-
|
|
14
|
-
from taken.core import paths
|
|
15
|
-
from taken.core.config import is_config_exists, read_config
|
|
16
|
-
from taken.core.hashing import compute_skill_hash
|
|
17
|
-
from taken.core.mirror import edited_copies, main_skill_dir, remove_path, sync_mirrors, untracked_paths
|
|
18
|
-
from taken.core.project import read_project_config, resolve_layout, write_project_config
|
|
19
|
-
from taken.core.registry import read_registry
|
|
20
|
-
from taken.models.config import MirrorMode
|
|
21
|
-
from taken.models.project import ProjectConfig, ProjectSkillEntry, SkillLayout
|
|
22
|
-
from taken.models.registry import Registry, RegistryEntry
|
|
23
|
-
from taken.utils.console import console, err_console
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
def _resolve_selected(
|
|
27
|
-
namespace_skill: str | None,
|
|
28
|
-
registry: Registry,
|
|
29
|
-
) -> list[RegistryEntry]:
|
|
30
|
-
"""Return the list of skills the user chose; raises typer.Exit on invalid input."""
|
|
31
|
-
if namespace_skill is not None:
|
|
32
|
-
entry = registry.get(namespace_skill)
|
|
33
|
-
if entry is None:
|
|
34
|
-
err_console.print(
|
|
35
|
-
Panel(
|
|
36
|
-
f"[bold]{namespace_skill}[/bold] not found in registry.\n"
|
|
37
|
-
"Run [bold]taken list[/bold] to see available skills.",
|
|
38
|
-
title="[red]Skill Not Found[/red]",
|
|
39
|
-
border_style="red",
|
|
40
|
-
)
|
|
41
|
-
)
|
|
42
|
-
raise typer.Exit(code=1)
|
|
43
|
-
return [entry]
|
|
44
|
-
|
|
45
|
-
choices = [
|
|
46
|
-
{"name": f"{e.full_name} [{e.source.value}]", "value": e.full_name}
|
|
47
|
-
for e in sorted(registry.skills.values(), key=lambda e: e.full_name)
|
|
48
|
-
]
|
|
49
|
-
selected_names: list[str] = inquirer.fuzzy( # type: ignore[attr-defined]
|
|
50
|
-
message="Search skills:",
|
|
51
|
-
choices=choices,
|
|
52
|
-
multiselect=True,
|
|
53
|
-
marker=INQUIRERPY_FILL_CIRCLE_SEQUENCE,
|
|
54
|
-
marker_pl=INQUIRERPY_EMPTY_CIRCLE_SEQUENCE,
|
|
55
|
-
instruction="(type to filter space/tab to select enter to confirm)",
|
|
56
|
-
keybindings={"toggle": [{"key": "space"}, {"key": "tab"}]},
|
|
57
|
-
validate=lambda x: len(x) > 0,
|
|
58
|
-
invalid_message="Select at least one skill.",
|
|
59
|
-
).execute()
|
|
60
|
-
|
|
61
|
-
if not selected_names:
|
|
62
|
-
raise typer.Exit(code=0)
|
|
63
|
-
|
|
64
|
-
return [e for name in selected_names if (e := registry.get(name)) is not None]
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
def use(
|
|
68
|
-
namespace_skill: str | None = typer.Argument(
|
|
69
|
-
None,
|
|
70
|
-
help="Skill to copy into project, e.g. pradyothsp/hello-world. Omit for interactive picker.",
|
|
71
|
-
),
|
|
72
|
-
mode: MirrorMode | None = typer.Option( # noqa: B008
|
|
73
|
-
None,
|
|
74
|
-
"--mode",
|
|
75
|
-
help="How to mirror into extra dirs like .claude/skills (symlink or copy). Remembered for this project.",
|
|
76
|
-
case_sensitive=False,
|
|
77
|
-
),
|
|
78
|
-
) -> None:
|
|
79
|
-
"""Copy skill(s) from ~/.taken/ into the current project's skills dir and mirror them to agent dirs."""
|
|
80
|
-
if not is_config_exists(paths.TAKEN_HOME):
|
|
81
|
-
err_console.print(
|
|
82
|
-
Panel(
|
|
83
|
-
"Taken is not initialized. Run [bold]taken init[/bold] to get started.",
|
|
84
|
-
title="[red]Not Initialized[/red]",
|
|
85
|
-
border_style="red",
|
|
86
|
-
)
|
|
87
|
-
)
|
|
88
|
-
raise typer.Exit(code=1)
|
|
89
|
-
|
|
90
|
-
registry = read_registry(paths.TAKEN_HOME)
|
|
91
|
-
|
|
92
|
-
if not registry.skills:
|
|
93
|
-
err_console.print(
|
|
94
|
-
Panel(
|
|
95
|
-
"No skills in your registry yet. Run [bold]taken add[/bold] to create one.",
|
|
96
|
-
title="[red]Registry Empty[/red]",
|
|
97
|
-
border_style="red",
|
|
98
|
-
)
|
|
99
|
-
)
|
|
100
|
-
raise typer.Exit(code=1)
|
|
101
|
-
|
|
102
|
-
selected = _resolve_selected(namespace_skill, registry)
|
|
103
|
-
|
|
104
|
-
project_root = Path.cwd()
|
|
105
|
-
project_config = read_project_config(project_root)
|
|
106
|
-
if mode is not None:
|
|
107
|
-
project_config.mirror_mode = mode
|
|
108
|
-
layout = resolve_layout(read_config(paths.TAKEN_HOME), project_config)
|
|
109
|
-
|
|
110
|
-
now = datetime.now()
|
|
111
|
-
copied: list[str] = []
|
|
112
|
-
skipped: list[str] = []
|
|
113
|
-
|
|
114
|
-
for entry in selected:
|
|
115
|
-
if not _confirm_overwrite(entry, project_root, layout, project_config):
|
|
116
|
-
skipped.append(entry.full_name)
|
|
117
|
-
continue
|
|
118
|
-
|
|
119
|
-
fell_back = _copy_skill(entry, project_root, layout, project_config, now)
|
|
120
|
-
copied.append(_describe_copy(entry, layout))
|
|
121
|
-
for dst in fell_back:
|
|
122
|
-
err_console.print(
|
|
123
|
-
f"[yellow]⚠[/yellow] Could not symlink [bold]{dst.relative_to(project_root)}[/bold] — copied instead."
|
|
124
|
-
)
|
|
125
|
-
|
|
126
|
-
if not copied and not skipped:
|
|
127
|
-
return
|
|
128
|
-
|
|
129
|
-
write_project_config(project_config, project_root)
|
|
130
|
-
|
|
131
|
-
if copied:
|
|
132
|
-
lines = "\n".join(copied) + "\n\n[dim]Tracked in ~/.taken/projects.yaml[/dim]"
|
|
133
|
-
console.print(
|
|
134
|
-
Panel(
|
|
135
|
-
lines,
|
|
136
|
-
title="[green]Skills Added[/green]",
|
|
137
|
-
border_style="green",
|
|
138
|
-
padding=(1, 2),
|
|
139
|
-
)
|
|
140
|
-
)
|
|
141
|
-
if skipped:
|
|
142
|
-
console.print(f"[dim]Skipped: {', '.join(skipped)}[/dim]")
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
def _confirm_overwrite(
|
|
146
|
-
entry: RegistryEntry,
|
|
147
|
-
project_root: Path,
|
|
148
|
-
layout: SkillLayout,
|
|
149
|
-
project_config: ProjectConfig,
|
|
150
|
-
) -> bool:
|
|
151
|
-
"""Ask before clobbering local edits or folders taken did not create. Returns True to proceed."""
|
|
152
|
-
existing = project_config.skills.get(entry.full_name)
|
|
153
|
-
|
|
154
|
-
if existing is not None:
|
|
155
|
-
if edited_copies(project_root, layout, entry.name, existing.copied_hash):
|
|
156
|
-
console.print(f"\n[yellow]⚠[/yellow] [bold]{entry.name}[/bold] has local changes in your project.")
|
|
157
|
-
return Confirm.ask(f" Overwrite [bold]{entry.name}[/bold]?", default=False)
|
|
158
|
-
return True
|
|
159
|
-
|
|
160
|
-
foreign = untracked_paths(project_root, layout, entry.name)
|
|
161
|
-
if foreign:
|
|
162
|
-
listed = ", ".join(str(p.relative_to(project_root)) for p in foreign)
|
|
163
|
-
console.print(f"\n[yellow]⚠[/yellow] [bold]{listed}[/bold] already exists and was not created by taken.")
|
|
164
|
-
return Confirm.ask(f" Overwrite with [bold]{entry.full_name}[/bold]?", default=False)
|
|
165
|
-
|
|
166
|
-
return True
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
def _describe_copy(entry: RegistryEntry, layout: SkillLayout) -> str:
|
|
170
|
-
line = f"[green]✓[/green] [bold]{entry.full_name}[/bold] → {layout.main_dir}/{entry.name}/"
|
|
171
|
-
if layout.mirror_dirs:
|
|
172
|
-
verb = "linked" if layout.mirror_mode == MirrorMode.SYMLINK else "copied"
|
|
173
|
-
targets = ", ".join(f"{d}/{entry.name}/" for d in layout.mirror_dirs)
|
|
174
|
-
line += f"\n [dim]{verb} to {targets}[/dim]"
|
|
175
|
-
return line
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
def _copy_skill(
|
|
179
|
-
entry: RegistryEntry,
|
|
180
|
-
project_root: Path,
|
|
181
|
-
layout: SkillLayout,
|
|
182
|
-
project_config: ProjectConfig,
|
|
183
|
-
now: datetime,
|
|
184
|
-
) -> list[Path]:
|
|
185
|
-
"""Copy registry skill into the main dir, then mirror it. Returns mirrors that fell back to copy."""
|
|
186
|
-
src = paths.TAKEN_HOME / "skills" / entry.namespace / entry.name
|
|
187
|
-
dst = main_skill_dir(project_root, layout, entry.name)
|
|
188
|
-
|
|
189
|
-
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
190
|
-
remove_path(dst)
|
|
191
|
-
shutil.copytree(src, dst)
|
|
192
|
-
fell_back = sync_mirrors(project_root, layout, entry.name)
|
|
193
|
-
|
|
194
|
-
project_config.skills[entry.full_name] = ProjectSkillEntry(
|
|
195
|
-
copied_at=now,
|
|
196
|
-
copied_hash=compute_skill_hash(dst),
|
|
197
|
-
)
|
|
198
|
-
return fell_back
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|