opensandbox-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.
@@ -0,0 +1,775 @@
1
+ # Copyright 2026 Alibaba Group Holding Ltd.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """Install built-in OpenSandbox AI skills/rules for coding tools."""
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ from dataclasses import asdict
21
+ from pathlib import Path
22
+ from typing import Literal, TypedDict, cast
23
+
24
+ import click
25
+
26
+ from opensandbox_cli.client import ClientContext
27
+ from opensandbox_cli.output import OutputFormatter
28
+ from opensandbox_cli.skill_registry import (
29
+ BUILTIN_SKILLS,
30
+ DEFAULT_SKILL,
31
+ SkillSpec,
32
+ extract_section,
33
+ get_builtin_skill,
34
+ get_builtin_skill_source,
35
+ list_builtin_skills,
36
+ read_skill_markdown,
37
+ render_skill_for_target,
38
+ split_frontmatter,
39
+ )
40
+ from opensandbox_cli.utils import handle_errors, output_option, prepare_output
41
+
42
+
43
+ class CopyScopeConfig(TypedDict):
44
+ strategy: Literal["copy"]
45
+ dest_dir: Path
46
+ preserve_frontmatter: bool
47
+ file_suffix: str | None
48
+ dest_file_template: str | None
49
+
50
+
51
+ class AppendScopeConfig(TypedDict):
52
+ strategy: Literal["append"]
53
+ dest_file: Path
54
+ preserve_frontmatter: bool
55
+
56
+
57
+ TargetScopeConfig = CopyScopeConfig | AppendScopeConfig
58
+
59
+
60
+ class TargetConfig(TypedDict):
61
+ label: str
62
+ scopes: dict[str, TargetScopeConfig]
63
+
64
+
65
+ _TARGETS = cast(dict[str, TargetConfig], {
66
+ "claude": {
67
+ "label": "Claude Code",
68
+ "scopes": {
69
+ "project": {
70
+ "strategy": "copy",
71
+ "dest_dir": Path(".claude") / "skills",
72
+ "preserve_frontmatter": True,
73
+ },
74
+ "global": {
75
+ "strategy": "copy",
76
+ "dest_dir": Path.home() / ".claude" / "skills",
77
+ "preserve_frontmatter": True,
78
+ }
79
+ },
80
+ },
81
+ "cursor": {
82
+ "label": "Cursor",
83
+ "scopes": {
84
+ "project": {
85
+ "strategy": "copy",
86
+ "dest_dir": Path(".cursor") / "rules",
87
+ "preserve_frontmatter": False,
88
+ "file_suffix": ".mdc",
89
+ },
90
+ "global": {
91
+ "strategy": "copy",
92
+ "dest_dir": Path.home() / ".cursor" / "rules",
93
+ "preserve_frontmatter": False,
94
+ "file_suffix": ".mdc",
95
+ }
96
+ },
97
+ },
98
+ "codex": {
99
+ "label": "Codex",
100
+ "scopes": {
101
+ "project": {
102
+ "strategy": "copy",
103
+ "dest_dir": Path(".codex") / "skills",
104
+ "dest_file_template": "{slug}/SKILL.md",
105
+ "preserve_frontmatter": True,
106
+ },
107
+ "global": {
108
+ "strategy": "copy",
109
+ "dest_dir": Path.home() / ".codex" / "skills",
110
+ "dest_file_template": "{slug}/SKILL.md",
111
+ "preserve_frontmatter": True,
112
+ },
113
+ },
114
+ },
115
+ "copilot": {
116
+ "label": "GitHub Copilot",
117
+ "scopes": {
118
+ "project": {
119
+ "strategy": "append",
120
+ "dest_file": Path(".github") / "copilot-instructions.md",
121
+ "preserve_frontmatter": False,
122
+ },
123
+ "global": {
124
+ "strategy": "append",
125
+ "dest_file": Path.home() / ".github" / "copilot-instructions.md",
126
+ "preserve_frontmatter": False,
127
+ }
128
+ },
129
+ },
130
+ "windsurf": {
131
+ "label": "Windsurf",
132
+ "scopes": {
133
+ "project": {
134
+ "strategy": "append",
135
+ "dest_file": Path(".windsurfrules"),
136
+ "preserve_frontmatter": False,
137
+ },
138
+ "global": {
139
+ "strategy": "append",
140
+ "dest_file": Path.home() / ".windsurfrules",
141
+ "preserve_frontmatter": False,
142
+ }
143
+ },
144
+ },
145
+ "cline": {
146
+ "label": "Cline",
147
+ "scopes": {
148
+ "project": {
149
+ "strategy": "append",
150
+ "dest_file": Path(".clinerules"),
151
+ "preserve_frontmatter": False,
152
+ },
153
+ "global": {
154
+ "strategy": "append",
155
+ "dest_file": Path.home() / ".clinerules",
156
+ "preserve_frontmatter": False,
157
+ }
158
+ },
159
+ },
160
+ "opencode": {
161
+ "label": "OpenCode",
162
+ "scopes": {
163
+ "project": {
164
+ "strategy": "copy",
165
+ "dest_dir": Path(".agents") / "skills",
166
+ "dest_file_template": "{slug}/SKILL.md",
167
+ "preserve_frontmatter": True,
168
+ },
169
+ "global": {
170
+ "strategy": "copy",
171
+ "dest_dir": Path.home() / ".agents" / "skills",
172
+ "dest_file_template": "{slug}/SKILL.md",
173
+ "preserve_frontmatter": True,
174
+ },
175
+ },
176
+ },
177
+ })
178
+
179
+ _ALL_TARGET_NAMES = list(_TARGETS.keys())
180
+ _ALL_SKILL_NAMES = list(BUILTIN_SKILLS.keys())
181
+ _ALL_SCOPE_NAMES = ["project", "global"]
182
+ _SKILL_AREAS = {
183
+ "sandbox-lifecycle": "Lifecycle",
184
+ "command-execution": "Execution",
185
+ "file-operations": "Files",
186
+ "network-egress": "Network",
187
+ "sandbox-troubleshooting": "Troubleshooting",
188
+ }
189
+
190
+
191
+ class InstallResult(TypedDict):
192
+ skill: str
193
+ target: str
194
+ target_label: str
195
+ scope: str
196
+ path: str
197
+ status: Literal["installed", "updated", "already_present"]
198
+ requires_restart: bool
199
+
200
+
201
+ class UninstallResult(TypedDict):
202
+ skill: str
203
+ target: str
204
+ target_label: str
205
+ scope: str
206
+ path: str
207
+ status: Literal["removed", "not_installed"]
208
+ requires_restart: bool
209
+
210
+
211
+ def _marker_begin(skill: SkillSpec) -> str:
212
+ return f"<!-- BEGIN {skill.marker_id} -->"
213
+
214
+
215
+ def _marker_end(skill: SkillSpec) -> str:
216
+ return f"<!-- END {skill.marker_id} -->"
217
+
218
+
219
+ def _get_scope_cfg(name: str, scope: str) -> TargetScopeConfig:
220
+ target_cfg = _TARGETS[name]
221
+ scopes = target_cfg["scopes"]
222
+ return scopes[scope]
223
+
224
+
225
+ def _target_layout_summary(name: str, scope: str) -> str:
226
+ cfg = _get_scope_cfg(name, scope)
227
+ if cfg["strategy"] == "append":
228
+ return f"aggregate into one instructions file at {cfg['dest_file']}"
229
+
230
+ dest_dir = cfg["dest_dir"]
231
+ template = cfg.get("dest_file_template")
232
+ if template:
233
+ sample_path = dest_dir / template.format(slug="<skill-name>")
234
+ return f"install one file per skill under {sample_path}"
235
+
236
+ suffix = cfg.get("file_suffix") or ".md"
237
+ sample_path = dest_dir / f"<skill-name>{suffix}"
238
+ return f"install one file per skill under {sample_path}"
239
+
240
+
241
+ def _target_destination(name: str, scope: str, skill: SkillSpec) -> Path:
242
+ cfg = _get_scope_cfg(name, scope)
243
+ if cfg["strategy"] == "copy":
244
+ dest_dir = cfg["dest_dir"]
245
+ template = cfg.get("dest_file_template") or ""
246
+ if template:
247
+ return dest_dir / template.format(slug=skill.slug)
248
+ suffix = cfg.get("file_suffix") or ".md"
249
+ return dest_dir / f"{skill.slug}{suffix}"
250
+ return cfg["dest_file"]
251
+
252
+
253
+ def _render_for_target(name: str, scope: str, skill: SkillSpec) -> str:
254
+ cfg = _get_scope_cfg(name, scope)
255
+ markdown = read_skill_markdown(skill)
256
+ preserve_frontmatter = bool(cfg.get("preserve_frontmatter", False))
257
+ return render_skill_for_target(
258
+ skill,
259
+ markdown,
260
+ preserve_frontmatter=preserve_frontmatter,
261
+ )
262
+
263
+
264
+ def _get_output_formatter() -> OutputFormatter | None:
265
+ ctx = click.get_current_context(silent=True)
266
+ obj = getattr(ctx, "obj", None) if ctx else None
267
+ output = getattr(obj, "output", None) if obj else None
268
+ return output if isinstance(output, OutputFormatter) else None
269
+
270
+
271
+ def _prepare_skills_output(output_format: str | None) -> None:
272
+ ctx = click.get_current_context(silent=True)
273
+ obj = getattr(ctx, "obj", None) if ctx else None
274
+ if isinstance(obj, ClientContext):
275
+ prepare_output(obj, output_format, allowed=("table", "json", "yaml"), fallback="table")
276
+ return
277
+
278
+ existing = getattr(obj, "output", None) if obj is not None else None
279
+ if output_format is None and isinstance(existing, OutputFormatter):
280
+ return
281
+
282
+ fmt = output_format or "table"
283
+ formatter = OutputFormatter(fmt, color=False)
284
+ if obj is not None:
285
+ obj.output = formatter
286
+
287
+
288
+ def _emit_output(
289
+ *,
290
+ table_renderer,
291
+ data: object,
292
+ ) -> None:
293
+ output = _get_output_formatter()
294
+ if output is None or output.fmt == "table":
295
+ table_renderer()
296
+ return
297
+
298
+ if output.fmt == "json":
299
+ click.echo(json.dumps(data, indent=2, default=str))
300
+ return
301
+
302
+ output._print_yaml(data)
303
+
304
+
305
+ def _remove_marked_block(existing: str, skill: SkillSpec) -> str:
306
+ begin = _marker_begin(skill)
307
+ end = _marker_end(skill)
308
+ if begin not in existing or end not in existing:
309
+ return existing
310
+
311
+ start = existing.index(begin)
312
+ finish = existing.index(end) + len(end)
313
+ before = existing[:start].rstrip("\n")
314
+ after = existing[finish:].lstrip("\n")
315
+
316
+ if before and after:
317
+ return before + "\n\n" + after
318
+ return before or after
319
+
320
+
321
+ def _is_installed(name: str, scope: str, skill: SkillSpec) -> bool:
322
+ dest = _target_destination(name, scope, skill)
323
+ if not dest.exists():
324
+ return False
325
+
326
+ cfg = _get_scope_cfg(name, scope)
327
+ if cfg["strategy"] == "copy":
328
+ return True
329
+
330
+ content = dest.read_text(encoding="utf-8")
331
+ return _marker_begin(skill) in content and _marker_end(skill) in content
332
+
333
+
334
+ def _build_marked_block(skill: SkillSpec, content: str) -> str:
335
+ return (
336
+ f"{_marker_begin(skill)}\n"
337
+ f"{content.strip()}\n"
338
+ f"{_marker_end(skill)}\n"
339
+ )
340
+
341
+
342
+ def _install_copy(name: str, scope: str, skill: SkillSpec, content: str) -> tuple[str, Path]:
343
+ dest = _target_destination(name, scope, skill)
344
+ if not dest.exists():
345
+ status = "installed"
346
+ else:
347
+ existing = dest.read_text(encoding="utf-8")
348
+ status = "already_present" if existing == content else "updated"
349
+ dest.parent.mkdir(parents=True, exist_ok=True)
350
+ dest.write_text(content, encoding="utf-8")
351
+ return status, dest
352
+
353
+
354
+ def _install_append(name: str, scope: str, skill: SkillSpec, content: str) -> tuple[str, Path]:
355
+ dest = _target_destination(name, scope, skill)
356
+ dest.parent.mkdir(parents=True, exist_ok=True)
357
+
358
+ existing = dest.read_text(encoding="utf-8") if dest.exists() else ""
359
+ cleaned = _remove_marked_block(existing, skill).rstrip("\n")
360
+ marked_block = _build_marked_block(skill, content)
361
+ new_content = f"{cleaned}\n\n{marked_block}" if cleaned else marked_block
362
+ if not existing:
363
+ status = "installed"
364
+ elif new_content == existing:
365
+ status = "already_present"
366
+ else:
367
+ status = "updated" if _is_installed(name, scope, skill) else "installed"
368
+ dest.write_text(new_content, encoding="utf-8")
369
+ return status, dest
370
+
371
+
372
+ def _install_target(name: str, scope: str, skill: SkillSpec) -> tuple[str, Path]:
373
+ content = _render_for_target(name, scope, skill)
374
+ cfg = _get_scope_cfg(name, scope)
375
+ if cfg["strategy"] == "copy":
376
+ return _install_copy(name, scope, skill, content)
377
+ return _install_append(name, scope, skill, content)
378
+
379
+
380
+ def _uninstall_target(name: str, scope: str, skill: SkillSpec) -> tuple[bool, Path]:
381
+ dest = _target_destination(name, scope, skill)
382
+ if not dest.exists():
383
+ return False, dest
384
+
385
+ cfg = _get_scope_cfg(name, scope)
386
+ if cfg["strategy"] == "copy":
387
+ dest.unlink()
388
+ if dest.parent.exists() and not any(dest.parent.iterdir()):
389
+ dest.parent.rmdir()
390
+ return True, dest
391
+
392
+ existing = dest.read_text(encoding="utf-8")
393
+ cleaned = _remove_marked_block(existing, skill)
394
+ if cleaned == existing:
395
+ return False, dest
396
+ if cleaned.strip():
397
+ dest.write_text(cleaned.rstrip("\n") + "\n", encoding="utf-8")
398
+ else:
399
+ dest.unlink()
400
+ return True, dest
401
+
402
+
403
+ def _resolve_skills(skill_name: str | None, install_all_builtins: bool) -> list[SkillSpec]:
404
+ if install_all_builtins:
405
+ return list_builtin_skills()
406
+ if not skill_name:
407
+ raise click.UsageError("A skill name is required unless --all-builtins is used.")
408
+ return [get_builtin_skill(skill_name)]
409
+
410
+
411
+ def _install_guidance_text() -> str:
412
+ return (
413
+ "Install guidance:\n\n"
414
+ " Discover bundled skills first:\n"
415
+ " osb skills list\n"
416
+ " osb skills show <skill-name>\n\n"
417
+ " Install one skill for one tool:\n"
418
+ " osb skills install <skill-name> --target <tool> --scope <scope>\n\n"
419
+ " Install all bundled skills for one tool:\n"
420
+ " osb skills install --all-builtins --target <tool> --scope <scope>\n\n"
421
+ " Discover skills and targets:\n"
422
+ " osb skills list\n"
423
+ " osb skills show <skill-name>\n\n"
424
+ f" Available skills: {', '.join(_ALL_SKILL_NAMES)}\n"
425
+ f" Available targets: {', '.join(_ALL_TARGET_NAMES)}\n"
426
+ f" Available scopes: {', '.join(_ALL_SCOPE_NAMES)}"
427
+ )
428
+
429
+
430
+ @click.group("skills", invoke_without_command=True)
431
+ @click.pass_context
432
+ def skills_group(ctx: click.Context) -> None:
433
+ """Manage bundled OpenSandbox skills for AI coding tools.
434
+
435
+ Discover with `osb skills list`, inspect with `osb skills show <skill>`,
436
+ then install non-interactively with
437
+ `osb skills install <skill> --target codex --scope project`.
438
+ """
439
+ if ctx.invoked_subcommand is None:
440
+ click.echo(ctx.get_help())
441
+
442
+
443
+ @skills_group.command("install")
444
+ @click.argument(
445
+ "skill_name",
446
+ required=False,
447
+ type=click.Choice(_ALL_SKILL_NAMES, case_sensitive=False),
448
+ )
449
+ @click.option(
450
+ "--all-builtins",
451
+ is_flag=True,
452
+ default=False,
453
+ help="Install all bundled skills instead of a single skill.",
454
+ )
455
+ @click.option(
456
+ "--target",
457
+ "-t",
458
+ type=click.Choice(_ALL_TARGET_NAMES + ["all"], case_sensitive=False),
459
+ default=None,
460
+ help="Target AI tool to install the skill for.",
461
+ )
462
+ @click.option(
463
+ "--scope",
464
+ type=click.Choice(_ALL_SCOPE_NAMES, case_sensitive=False),
465
+ default=None,
466
+ help="Install scope for targets that support multiple locations.",
467
+ )
468
+ @click.option(
469
+ "--force",
470
+ "-f",
471
+ is_flag=True,
472
+ default=False,
473
+ help="Accepted for compatibility. Installs are already non-interactive and idempotent.",
474
+ )
475
+ @output_option("table", "json", "yaml")
476
+ @handle_errors
477
+ def skills_install(
478
+ skill_name: str | None,
479
+ all_builtins: bool,
480
+ target: str | None,
481
+ scope: str | None,
482
+ force: bool,
483
+ output_format: str | None,
484
+ ) -> None:
485
+ """Install one or more bundled OpenSandbox skills.
486
+
487
+ This command is non-interactive and idempotent. Re-running an install will
488
+ report `already_present` or `updated` instead of prompting.
489
+ """
490
+ _prepare_skills_output(output_format)
491
+ if all_builtins and skill_name:
492
+ raise click.UsageError("Pass either a skill name or --all-builtins, not both.")
493
+ if target is None:
494
+ raise click.UsageError(
495
+ "Missing required option '--target'.\n\n" + _install_guidance_text()
496
+ )
497
+ if scope is None:
498
+ raise click.UsageError(
499
+ "Missing required option '--scope'.\n\n" + _install_guidance_text()
500
+ )
501
+ if not all_builtins and skill_name is None:
502
+ raise click.UsageError(
503
+ "A skill name is required unless --all-builtins is used.\n\n" + _install_guidance_text()
504
+ )
505
+ _ = force
506
+
507
+ skills = _resolve_skills(skill_name, all_builtins)
508
+ targets = _ALL_TARGET_NAMES if target == "all" else [target]
509
+ results: list[InstallResult] = []
510
+
511
+ for skill in skills:
512
+ for target_name in targets:
513
+ label = str(_TARGETS[target_name]["label"])
514
+ status, installed_path = _install_target(target_name, scope, skill)
515
+ results.append(
516
+ {
517
+ "skill": skill.slug,
518
+ "target": target_name,
519
+ "target_label": label,
520
+ "scope": scope,
521
+ "path": str(installed_path),
522
+ "status": cast(Literal["installed", "updated", "already_present"], status),
523
+ "requires_restart": True,
524
+ }
525
+ )
526
+
527
+ def _render_table() -> None:
528
+ click.echo("Install plan:\n")
529
+ for target_name in targets:
530
+ label = str(_TARGETS[target_name]["label"])
531
+ click.echo(f" {label} [{scope}]: {_target_layout_summary(target_name, scope)}")
532
+ click.echo()
533
+
534
+ for result in results:
535
+ click.echo(
536
+ f" {result['status']:<15} "
537
+ f"{result['target_label']} [{result['scope']}]: "
538
+ f"{result['skill']} -> {result['path']}"
539
+ )
540
+
541
+ click.echo()
542
+ click.echo("Done! Restart your AI coding tool to pick up the updated skill set.")
543
+
544
+ _emit_output(
545
+ table_renderer=_render_table,
546
+ data={
547
+ "operations": results,
548
+ "requires_restart": True,
549
+ },
550
+ )
551
+
552
+
553
+ @skills_group.command("show")
554
+ @click.argument(
555
+ "skill_name",
556
+ type=click.Choice(_ALL_SKILL_NAMES, case_sensitive=False),
557
+ )
558
+ @output_option("table", "json", "yaml")
559
+ @handle_errors
560
+ def skills_show(skill_name: str, output_format: str | None) -> None:
561
+ """Show details for a bundled skill."""
562
+ _prepare_skills_output(output_format)
563
+ skill = get_builtin_skill(skill_name)
564
+ markdown = read_skill_markdown(skill)
565
+ _, body = split_frontmatter(markdown)
566
+ when_to_use = extract_section(body, "When To Use")
567
+ quick_start = None
568
+ for heading in (
569
+ "Triage Order",
570
+ "Golden Paths",
571
+ "Core Workflow",
572
+ "Command Map",
573
+ "Common Commands",
574
+ "Fast Path",
575
+ "Inspect Current Policy",
576
+ "Preferred Workflow",
577
+ ):
578
+ quick_start = extract_section(body, heading)
579
+ if quick_start:
580
+ break
581
+
582
+ json_shapes = None
583
+ if "```json" in body:
584
+ start = body.find("```json")
585
+ end = body.find("```", start + 7)
586
+ if start != -1 and end != -1:
587
+ json_shapes = body[start + 7 : end].strip()
588
+
589
+ payload = {
590
+ "skill": skill.slug,
591
+ "title": skill.title,
592
+ "area": _SKILL_AREAS.get(skill.slug, "General"),
593
+ "summary": skill.summary,
594
+ "trigger_hint": skill.trigger_hint,
595
+ "when_to_use": when_to_use,
596
+ "quick_start": quick_start,
597
+ "json_shapes": json_shapes,
598
+ "content": markdown.strip(),
599
+ }
600
+
601
+ def _render_table() -> None:
602
+ click.echo(f"Skill: {skill.slug}")
603
+ click.echo(f"Title: {skill.title}")
604
+ click.echo(f"Area: {_SKILL_AREAS.get(skill.slug, 'General')}")
605
+ click.echo(f"Summary: {skill.summary}")
606
+ click.echo(f"Trigger: {skill.trigger_hint}")
607
+ click.echo()
608
+
609
+ if when_to_use:
610
+ click.echo("When To Use:")
611
+ click.echo(when_to_use)
612
+ click.echo()
613
+
614
+ if quick_start:
615
+ click.echo("Quick Start:")
616
+ click.echo(quick_start)
617
+ click.echo()
618
+
619
+ for heading in ("Minimal Closed Loops", "Response Pattern", "Guidance"):
620
+ section = extract_section(body, heading)
621
+ if section:
622
+ click.echo(f"{heading}:")
623
+ click.echo(section)
624
+ click.echo()
625
+
626
+ if json_shapes:
627
+ click.echo("JSON Shapes:")
628
+ click.echo(json_shapes)
629
+ click.echo()
630
+
631
+ click.echo("Full Skill:")
632
+ click.echo(markdown.strip())
633
+
634
+ _emit_output(table_renderer=_render_table, data=payload)
635
+
636
+
637
+ @skills_group.command("list")
638
+ @output_option("table", "json", "yaml")
639
+ @handle_errors
640
+ def skills_list(output_format: str | None) -> None:
641
+ """List bundled skills, supported targets, and install status."""
642
+ _prepare_skills_output(output_format)
643
+ skill_rows = [
644
+ {
645
+ **asdict(skill),
646
+ "area": _SKILL_AREAS.get(skill.slug, "General"),
647
+ "source_path": str(get_builtin_skill_source(skill)),
648
+ }
649
+ for skill in list_builtin_skills()
650
+ ]
651
+ target_rows: list[dict[str, object]] = []
652
+ for target_name, cfg in _TARGETS.items():
653
+ label = str(cfg["label"])
654
+ for scope_name in cfg["scopes"]:
655
+ installed_skills = []
656
+ for skill in list_builtin_skills():
657
+ dest = _target_destination(target_name, scope_name, skill)
658
+ status = "installed" if _is_installed(target_name, scope_name, skill) else "not installed"
659
+ installed_skills.append(
660
+ {
661
+ "skill": skill.slug,
662
+ "status": status,
663
+ "path": str(dest),
664
+ }
665
+ )
666
+ target_rows.append(
667
+ {
668
+ "target": target_name,
669
+ "scope": scope_name,
670
+ "label": label,
671
+ "layout": _target_layout_summary(target_name, scope_name),
672
+ "skills": installed_skills,
673
+ }
674
+ )
675
+
676
+ def _render_table() -> None:
677
+ click.echo("Bundled skills:\n")
678
+ for skill in list_builtin_skills():
679
+ area = _SKILL_AREAS.get(skill.slug, "General")
680
+ click.echo(f" {skill.slug:<24} [{area}] {skill.summary}")
681
+ click.echo(f" {'':<24} Trigger: {skill.trigger_hint}")
682
+
683
+ click.echo("\nSupported targets:\n")
684
+ for target_row in target_rows:
685
+ click.echo(
686
+ f" {target_row['target']:<10} {target_row['scope']:<8} "
687
+ f"{target_row['label']:<18} {target_row['layout']}"
688
+ )
689
+ for skill_row in cast(list[dict[str, str]], target_row["skills"]):
690
+ click.echo(
691
+ f" {'':<10} {'':<8} {'':<18} {skill_row['skill']:<24} "
692
+ f"{skill_row['status']:<13} ({skill_row['path']})"
693
+ )
694
+
695
+ _emit_output(
696
+ table_renderer=_render_table,
697
+ data={
698
+ "skills": skill_rows,
699
+ "targets": target_rows,
700
+ },
701
+ )
702
+
703
+
704
+ @skills_group.command("uninstall")
705
+ @click.argument(
706
+ "skill_name",
707
+ required=False,
708
+ default=DEFAULT_SKILL,
709
+ type=click.Choice(_ALL_SKILL_NAMES, case_sensitive=False),
710
+ )
711
+ @click.option(
712
+ "--target",
713
+ "-t",
714
+ type=click.Choice(_ALL_TARGET_NAMES + ["all"], case_sensitive=False),
715
+ default=None,
716
+ help="Target AI tool to remove the skill from.",
717
+ )
718
+ @click.option(
719
+ "--scope",
720
+ type=click.Choice(_ALL_SCOPE_NAMES, case_sensitive=False),
721
+ default=None,
722
+ help="Install scope to remove from.",
723
+ )
724
+ @output_option("table", "json", "yaml")
725
+ @handle_errors
726
+ def skills_uninstall(
727
+ skill_name: str,
728
+ target: str | None,
729
+ scope: str | None,
730
+ output_format: str | None,
731
+ ) -> None:
732
+ """Remove an installed OpenSandbox skill from one or more AI tools."""
733
+ _prepare_skills_output(output_format)
734
+ if target is None:
735
+ raise click.UsageError(
736
+ "Missing required option '--target'.\n\n" + _install_guidance_text()
737
+ )
738
+ if scope is None:
739
+ raise click.UsageError(
740
+ "Missing required option '--scope'.\n\n" + _install_guidance_text()
741
+ )
742
+ skill = get_builtin_skill(skill_name)
743
+ targets = _ALL_TARGET_NAMES if target == "all" else [target]
744
+ results: list[UninstallResult] = []
745
+
746
+ for target_name in targets:
747
+ label = str(_TARGETS[target_name]["label"])
748
+ removed, dest = _uninstall_target(target_name, scope, skill)
749
+ results.append(
750
+ {
751
+ "skill": skill.slug,
752
+ "target": target_name,
753
+ "target_label": label,
754
+ "scope": scope,
755
+ "path": str(dest),
756
+ "status": "removed" if removed else "not_installed",
757
+ "requires_restart": True,
758
+ }
759
+ )
760
+
761
+ def _render_table() -> None:
762
+ for result in results:
763
+ click.echo(
764
+ f" {result['status']:<15} "
765
+ f"{result['target_label']} [{result['scope']}]: "
766
+ f"{result['skill']} -> {result['path']}"
767
+ )
768
+
769
+ _emit_output(
770
+ table_renderer=_render_table,
771
+ data={
772
+ "operations": results,
773
+ "requires_restart": True,
774
+ },
775
+ )