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.
Files changed (35) hide show
  1. {taken_cli-0.2.0 → taken_cli-0.2.2}/PKG-INFO +3 -2
  2. {taken_cli-0.2.0 → taken_cli-0.2.2}/README.md +2 -1
  3. {taken_cli-0.2.0 → taken_cli-0.2.2}/pyproject.toml +1 -1
  4. {taken_cli-0.2.0 → taken_cli-0.2.2}/pyproject.toml.orig +1 -1
  5. taken_cli-0.2.2/src/taken/commands/mirrors.py +95 -0
  6. {taken_cli-0.2.0 → taken_cli-0.2.2}/src/taken/commands/save.py +9 -1
  7. {taken_cli-0.2.0 → taken_cli-0.2.2}/src/taken/commands/update.py +15 -5
  8. taken_cli-0.2.2/src/taken/commands/use.py +315 -0
  9. {taken_cli-0.2.0 → taken_cli-0.2.2}/src/taken/core/config.py +19 -1
  10. {taken_cli-0.2.0 → taken_cli-0.2.2}/src/taken/core/mirror.py +49 -5
  11. {taken_cli-0.2.0 → taken_cli-0.2.2}/src/taken/core/project.py +18 -6
  12. {taken_cli-0.2.0 → taken_cli-0.2.2}/src/taken/main.py +26 -0
  13. {taken_cli-0.2.0 → taken_cli-0.2.2}/src/taken/models/project.py +4 -0
  14. taken_cli-0.2.0/src/taken/commands/use.py +0 -198
  15. {taken_cli-0.2.0 → taken_cli-0.2.2}/src/taken/__init__.py +0 -0
  16. {taken_cli-0.2.0 → taken_cli-0.2.2}/src/taken/commands/__init__.py +0 -0
  17. {taken_cli-0.2.0 → taken_cli-0.2.2}/src/taken/commands/add.py +0 -0
  18. {taken_cli-0.2.0 → taken_cli-0.2.2}/src/taken/commands/git.py +0 -0
  19. {taken_cli-0.2.0 → taken_cli-0.2.2}/src/taken/commands/init.py +0 -0
  20. {taken_cli-0.2.0 → taken_cli-0.2.2}/src/taken/commands/install.py +0 -0
  21. {taken_cli-0.2.0 → taken_cli-0.2.2}/src/taken/commands/list.py +0 -0
  22. {taken_cli-0.2.0 → taken_cli-0.2.2}/src/taken/commands/remove.py +0 -0
  23. {taken_cli-0.2.0 → taken_cli-0.2.2}/src/taken/core/__init__.py +0 -0
  24. {taken_cli-0.2.0 → taken_cli-0.2.2}/src/taken/core/editor.py +0 -0
  25. {taken_cli-0.2.0 → taken_cli-0.2.2}/src/taken/core/git.py +0 -0
  26. {taken_cli-0.2.0 → taken_cli-0.2.2}/src/taken/core/github.py +0 -0
  27. {taken_cli-0.2.0 → taken_cli-0.2.2}/src/taken/core/hashing.py +0 -0
  28. {taken_cli-0.2.0 → taken_cli-0.2.2}/src/taken/core/paths.py +0 -0
  29. {taken_cli-0.2.0 → taken_cli-0.2.2}/src/taken/core/registry.py +0 -0
  30. {taken_cli-0.2.0 → taken_cli-0.2.2}/src/taken/core/skills.py +0 -0
  31. {taken_cli-0.2.0 → taken_cli-0.2.2}/src/taken/models/__init__.py +0 -0
  32. {taken_cli-0.2.0 → taken_cli-0.2.2}/src/taken/models/config.py +0 -0
  33. {taken_cli-0.2.0 → taken_cli-0.2.2}/src/taken/models/registry.py +0 -0
  34. {taken_cli-0.2.0 → taken_cli-0.2.2}/src/taken/utils/__init__.py +0 -0
  35. {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.0
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 mirrors it into `.claude/skills/` (symlink or copy, your choice). Tracking lives in `~/.taken/projects.yaml` — nothing taken-specific is written into the project.
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 mirrors it into `.claude/skills/` (symlink or copy, your choice). Tracking lives in `~/.taken/projects.yaml` — nothing taken-specific is written into the project.
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
  ---
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "taken-cli"
3
- version = "0.2.0"
3
+ version = "0.2.2"
4
4
  description = "Personal dotfile-style CLI for managing AI agent skills"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "taken-cli"
3
- version = "0.2.0"
3
+ version = "0.2.2"
4
4
  description = "Personal dotfile-style CLI for managing AI agent skills"
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
@@ -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 is_project_config_exists, read_project_config, resolve_layout, write_project_config
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 is_project_config_exists, read_project_config, resolve_layout, write_project_config
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 dst in sync_mirrors(project_root, layout, name):
173
- err_console.print(
174
- f"[yellow]⚠[/yellow] Could not symlink [bold]{dst.relative_to(project_root)}[/bold] — copied instead."
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[Path]:
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
- fell_back: list[Path] = []
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.append(dst)
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
- return fell_back
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
- key: {
59
- "copied_at": entry.copied_at.isoformat(),
60
- "copied_hash": entry.copied_hash,
61
- }
62
- for key, entry in config.skills.items()
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