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.
- opensandbox_cli/__init__.py +20 -0
- opensandbox_cli/__main__.py +20 -0
- opensandbox_cli/client.py +127 -0
- opensandbox_cli/commands/__init__.py +13 -0
- opensandbox_cli/commands/command.py +359 -0
- opensandbox_cli/commands/config_cmd.py +183 -0
- opensandbox_cli/commands/devops.py +123 -0
- opensandbox_cli/commands/egress.py +98 -0
- opensandbox_cli/commands/file.py +442 -0
- opensandbox_cli/commands/sandbox.py +580 -0
- opensandbox_cli/commands/skills.py +775 -0
- opensandbox_cli/config.py +160 -0
- opensandbox_cli/main.py +138 -0
- opensandbox_cli/output.py +363 -0
- opensandbox_cli/py.typed +1 -0
- opensandbox_cli/skill_registry.py +184 -0
- opensandbox_cli/skills/opensandbox-command-execution.md +215 -0
- opensandbox_cli/skills/opensandbox-file-operations.md +244 -0
- opensandbox_cli/skills/opensandbox-network-egress.md +179 -0
- opensandbox_cli/skills/opensandbox-sandbox-lifecycle.md +305 -0
- opensandbox_cli/skills/opensandbox-sandbox-troubleshooting.md +177 -0
- opensandbox_cli/utils.py +212 -0
- opensandbox_cli-0.1.0.dist-info/METADATA +597 -0
- opensandbox_cli-0.1.0.dist-info/RECORD +27 -0
- opensandbox_cli-0.1.0.dist-info/WHEEL +4 -0
- opensandbox_cli-0.1.0.dist-info/entry_points.txt +3 -0
- opensandbox_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -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
|
+
)
|