anthropic-skills/skills/vscode/scripts/vscode_skill.py

801 lines
32 KiB
Python

#!/usr/bin/env python3
"""Cross-platform gateway for the VS Code agent skill.
Routing rules:
- On Windows: forwards directly to scripts/vscode-cli.ps1 (the PowerShell
driver that has passed 11/11 safe tests). All flags and areas are passed
through unchanged so behavior on Windows is bit-for-bit identical to the
previous skill.
- On macOS / Linux: provides native Python implementations for the
read/write areas that are safe and portable (info, ext list, settings
get/path/set/smoke, python set-interpreter). Operations requiring VS
Code itself (ext install, ssh open, wsl open) still shell out to the
``code`` CLI.
The gateway never modifies PATH, never reads tokens, and never writes
outside the resolved settings file (or the temp scratch directory during
``settings smoke``).
"""
from __future__ import annotations
import argparse
import json
import os
import platform
import re
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
from typing import Any
SKILL_ROOT = Path(__file__).resolve().parents[1]
PS_DRIVER = SKILL_ROOT / "scripts" / "vscode-cli.ps1"
IS_WINDOWS = platform.system() == "Windows"
# --------------------------------------------------------------------------- #
# Path resolution (cross-platform)
# --------------------------------------------------------------------------- #
def _resolve_config_path() -> Path | None:
env = os.environ.get("VSCODE_SKILL_CONFIG")
if env:
return Path(env)
if IS_WINDOWS:
appdata = os.environ.get("APPDATA")
if appdata:
return Path(appdata) / "vscode-skill" / "config.json"
xdg = os.environ.get("XDG_CONFIG_HOME")
if xdg:
return Path(xdg) / "vscode-skill" / "config.json"
home = Path.home()
return home / ".config" / "vscode-skill" / "config.json"
def _load_config() -> dict[str, Any]:
cfg_path = _resolve_config_path()
if cfg_path and cfg_path.is_file():
try:
return json.loads(cfg_path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
return {}
return {}
def _resolve_cli(product: str) -> str:
cfg = _load_config()
products = cfg.get("products") or {}
entry = products.get(product) or {}
cli_path = entry.get("cliPath")
if cli_path and Path(cli_path).is_file():
return cli_path
if product == "vscode":
env_override = os.environ.get("VSCODE_CODE_CLI")
if env_override and Path(env_override).is_file():
return env_override
for cand in ("code.cmd", "code"):
found = shutil.which(cand)
if found:
return found
if IS_WINDOWS:
local = os.environ.get("LOCALAPPDATA")
if local:
p = Path(local) / "Programs" / "Microsoft VS Code" / "bin" / "code.cmd"
if p.is_file():
return str(p)
if product == "vscode-insiders":
env_override = os.environ.get("VSCODE_INSIDERS_CLI")
if env_override and Path(env_override).is_file():
return env_override
for cand in ("code-insiders.cmd", "code-insiders"):
found = shutil.which(cand)
if found:
return found
raise FileNotFoundError(f"Cannot resolve CLI for product '{product}'. "
f"Set products.{product}.cliPath in your config or add it to PATH.")
def _user_data_dir(product: str) -> Path:
env_override = os.environ.get("VSCODE_USER_DATA")
if env_override:
return Path(env_override)
cfg = _load_config()
entry = (cfg.get("products") or {}).get(product) or {}
custom = entry.get("userDataPath")
if custom:
return Path(custom)
if product == "vscode":
if IS_WINDOWS:
return Path(os.environ["APPDATA"]) / "Code" / "User"
if platform.system() == "Darwin":
return Path.home() / "Library" / "Application Support" / "Code" / "User"
xdg = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
return Path(xdg) / "Code" / "User"
if product == "vscode-insiders":
if IS_WINDOWS:
return Path(os.environ["APPDATA"]) / "Code - Insiders" / "User"
if platform.system() == "Darwin":
return Path.home() / "Library" / "Application Support" / "Code - Insiders" / "User"
xdg = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
return Path(xdg) / "Code - Insiders" / "User"
raise FileNotFoundError(f"No userDataPath known for product '{product}'.")
def _settings_path(scope: str, project: str | None) -> Path:
if scope == "project":
if not project:
raise ValueError("Project scope requires --ProjectPath.")
return Path(project) / ".vscode" / "settings.json"
if scope == "user":
return _user_data_dir(_resolve_default_product()) / "settings.json"
raise ValueError(f"Unknown scope '{scope}'.")
def _resolve_default_product() -> str:
env = os.environ.get("VSCODE_SKILL_PRODUCT")
if env:
return env
cfg = _load_config()
p = cfg.get("product")
if p:
return str(p)
return "vscode"
# --------------------------------------------------------------------------- #
# Forwarding to PowerShell on Windows
# --------------------------------------------------------------------------- #
def _forward_powershell(argv: list[str]) -> int:
if not PS_DRIVER.is_file():
print(f"error: PowerShell driver not found at {PS_DRIVER}", file=sys.stderr)
return 2
args = ["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", str(PS_DRIVER), *argv]
return subprocess.call(args)
# --------------------------------------------------------------------------- #
# Native macOS / Linux implementations
# --------------------------------------------------------------------------- #
def _read_settings(path: Path) -> dict[str, Any]:
if not path.is_file():
return {}
raw = path.read_text(encoding="utf-8")
if not raw.strip():
return {}
try:
return json.loads(raw)
except json.JSONDecodeError:
print(f"warning: {path} is not valid JSON; treating as empty.", file=sys.stderr)
return {}
def _write_settings(path: Path, data: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(data, indent=4, ensure_ascii=False) + "\n", encoding="utf-8")
def _coerce(value: str) -> Any:
s = value.strip()
if s.lower() in ("true", "false"):
return s.lower() == "true"
if s.lstrip("-").isdigit():
return int(s)
try:
return float(s)
except ValueError:
pass
if (s.startswith("{") and s.endswith("}")) or (s.startswith("[") and s.endswith("]")):
try:
return json.loads(s)
except json.JSONDecodeError:
pass
return value
SETTINGS_FLAG_MAP = {
"--FontSize": ("editor.fontSize", int),
"--LineHeight": ("editor.lineHeight", float),
"--FontFamily": ("editor.fontFamily", None),
"--ColorTheme": ("workbench.colorTheme", None),
"--IconTheme": ("workbench.iconTheme", None),
"--ProductIconTheme": ("workbench.productIconTheme", None),
"--WordWrap": ("editor.wordWrap", None),
"--TabSize": ("editor.tabSize", int),
"--FormatOnSave": ("editor.formatOnSave", _coerce),
"--AutoSave": ("files.autoSave", None),
"--Minimap": ("editor.minimap.enabled", _coerce),
}
def cmd_info(_args: argparse.Namespace) -> int:
product = _resolve_default_product()
cli = _resolve_cli(product)
user = _user_data_dir(product)
print(f"== {product} ==")
print(f" CliPath : {cli}")
print(f" UserSettings : {user / 'settings.json'}")
print(f" Extensions : {user.parent.parent / 'extensions'}")
print(f" ConfigFile : {_resolve_config_path()}")
print(f"== Version ==")
subprocess.call([cli, "--version"])
return 0
def cmd_ext_list(_args: argparse.Namespace) -> int:
product = _resolve_default_product()
cli = _resolve_cli(product)
return subprocess.call([cli, "--list-extensions", "--show-versions"])
def cmd_settings_path(args: argparse.Namespace) -> int:
path = _settings_path(args.scope or "user", args.ProjectPath)
print(json.dumps({
"Scope": args.scope or "user",
"Path": str(path),
"Exists": path.is_file(),
}, indent=2))
return 0
def cmd_settings_get(args: argparse.Namespace) -> int:
path = _settings_path(args.scope or "user", args.ProjectPath)
settings = _read_settings(path)
keys = [
"editor.fontFamily", "editor.fontSize", "editor.lineHeight",
"workbench.colorTheme", "workbench.iconTheme",
"workbench.productIconTheme", "editor.wordWrap", "editor.tabSize",
"editor.formatOnSave", "files.autoSave", "editor.minimap.enabled",
]
print(f"== Settings file ({path}) ==")
print(json.dumps({k: settings.get(k) for k in keys}, indent=2, ensure_ascii=False))
return 0
def cmd_settings_set(args: argparse.Namespace) -> int:
if not args.apply:
print("error: settings set is a write operation. Pass --Apply.", file=sys.stderr)
return 2
scope = args.scope or "user"
path = _settings_path(scope, args.ProjectPath)
settings = _read_settings(path)
flag_dict = vars(args)
for flag, (key, coerce) in SETTINGS_FLAG_MAP.items():
val = flag_dict.get(flag.lstrip("-").replace("-", "_").lower())
if val is not None and val != "":
settings[key] = coerce(val) if coerce else val
if args.SettingKey:
settings[args.SettingKey] = _coerce(args.SettingValue or "")
_write_settings(path, settings)
print(json.dumps({"Path": str(path), "Written": True}, indent=2))
return 0
def cmd_settings_smoke(args: argparse.Namespace) -> int:
scope = "user"
path = _settings_path(scope, None)
existed = path.is_file()
original = path.read_text(encoding="utf-8") if existed else None
try:
settings = _read_settings(path)
settings.update({
"editor.fontFamily": "Consolas, 'Courier New', monospace",
"editor.fontSize": 15,
"editor.lineHeight": 22,
"workbench.colorTheme": "Default Dark Modern",
"editor.wordWrap": "on",
"editor.tabSize": 2,
"editor.formatOnSave": False,
"files.autoSave": "off",
"editor.minimap.enabled": False,
})
_write_settings(path, settings)
verify = _read_settings(path)
ok = (
verify["editor.fontFamily"] == "Consolas, 'Courier New', monospace"
and verify["editor.fontSize"] == 15
and verify["editor.lineHeight"] == 22
and verify["workbench.colorTheme"] == "Default Dark Modern"
and verify["editor.wordWrap"] == "on"
and verify["editor.tabSize"] == 2
and verify["editor.formatOnSave"] is False
and verify["files.autoSave"] == "off"
and verify["editor.minimap.enabled"] is False
)
if not ok:
print("error: settings smoke verification failed.", file=sys.stderr)
return 1
print(json.dumps({"Path": str(path), "TemporaryWriteVerified": True}, indent=2))
finally:
if existed and original is not None:
path.write_text(original, encoding="utf-8")
elif path.is_file():
path.unlink()
print(json.dumps({"Path": str(path), "RestoredOriginal": True}, indent=2))
return 0
def cmd_python_set_interpreter(args: argparse.Namespace) -> int:
project = Path(args.ProjectPath).resolve()
venv = project / args.VenvName
candidates = [
venv / "bin" / "python",
venv / "Scripts" / "python.exe",
]
py = next((c for c in candidates if c.is_file()), None)
if py is None:
print(f"error: python interpreter not found; tried: {[str(c) for c in candidates]}", file=sys.stderr)
return 2
settings_path = project / ".vscode" / "settings.json"
settings = _read_settings(settings_path)
settings["python.defaultInterpreterPath"] = str(py)
_write_settings(settings_path, settings)
print(json.dumps({"Settings": str(settings_path), "Interpreter": str(py)}, indent=2))
return 0
# --------------------------------------------------------------------------- #
# Templates (tasks.json / launch.json)
# --------------------------------------------------------------------------- #
TEMPLATES_DIR = SKILL_ROOT / "references" / "templates"
def _list_template_ids(kind: str) -> list[str]:
d = TEMPLATES_DIR / kind
if not d.is_dir():
return []
return sorted(p.stem for p in d.glob("*.json"))
def _load_template(kind: str, template_id: str) -> dict[str, Any]:
path = TEMPLATES_DIR / kind / f"{template_id}.json"
if not path.is_file():
raise FileNotFoundError(f"Template '{template_id}' not found under {TEMPLATES_DIR / kind}. "
f"Available: {_list_template_ids(kind)}")
return json.loads(path.read_text(encoding="utf-8"))
def cmd_tasks_list(_args: argparse.Namespace) -> int:
print(json.dumps({"templates": _list_template_ids("tasks")}, indent=2))
return 0
def cmd_tasks_init(args: argparse.Namespace) -> int:
project = Path(args.ProjectPath).resolve()
target = project / ".vscode" / "tasks.json"
if target.is_file() and not args.Force:
print(f"error: {target} already exists. Pass --Force to overwrite.", file=sys.stderr)
return 2
data = _load_template("tasks", args.Template)
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(json.dumps(data, indent=4, ensure_ascii=False) + "\n", encoding="utf-8")
print(json.dumps({"Path": str(target), "Template": args.Template, "Written": True}, indent=2))
return 0
def cmd_launch_list(_args: argparse.Namespace) -> int:
print(json.dumps({"templates": _list_template_ids("launch")}, indent=2))
return 0
def cmd_launch_init(args: argparse.Namespace) -> int:
project = Path(args.ProjectPath).resolve()
target = project / ".vscode" / "launch.json"
if target.is_file() and not args.Force:
print(f"error: {target} already exists. Pass --Force to overwrite.", file=sys.stderr)
return 2
data = _load_template("launch", args.Template)
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(json.dumps(data, indent=4, ensure_ascii=False) + "\n", encoding="utf-8")
print(json.dumps({"Path": str(target), "Template": args.Template, "Written": True}, indent=2))
return 0
# --------------------------------------------------------------------------- #
# Extension packs
# --------------------------------------------------------------------------- #
EXTENSION_PACKS_FILE = SKILL_ROOT / "references" / "extension-packs.md"
def _parse_extension_packs() -> dict[str, list[dict[str, str]]]:
r"""Parse references/extension-packs.md into {stack_name: [{id, purpose}]}.
The parser is intentionally simple: it looks for `### `<stack>` headers
followed by tables whose first column is the extension ID and second is purpose.
"""
if not EXTENSION_PACKS_FILE.is_file():
return {}
text = EXTENSION_PACKS_FILE.read_text(encoding="utf-8")
packs: dict[str, list[dict[str, str]]] = {}
current_stack: str | None = None
for line in text.splitlines():
m = re.match(r"^### `([a-z0-9-]+)`", line)
if m:
current_stack = m.group(1)
packs[current_stack] = []
continue
if current_stack is None:
continue
if not line.startswith("|"):
continue
cells = [c.strip().strip("`") for c in line.strip().strip("|").split("|")]
if len(cells) < 2:
continue
if cells[0] in ("ID", "---", "---"):
continue
if re.match(r"^[a-zA-Z0-9_-]+\.[a-zA-Z0-9_.-]+$", cells[0]):
packs[current_stack].append({"id": cells[0], "purpose": cells[1]})
return packs
def cmd_extensions_recommend(args: argparse.Namespace) -> int:
packs = _parse_extension_packs()
if args.Stack not in packs:
print(f"error: unknown stack '{args.Stack}'. Available: {sorted(packs.keys())}", file=sys.stderr)
return 2
print(json.dumps({"stack": args.Stack, "extensions": packs[args.Stack]}, indent=2, ensure_ascii=False))
return 0
def cmd_extensions_install(args: argparse.Namespace) -> int:
packs = _parse_extension_packs()
if args.Stack not in packs:
print(f"error: unknown stack '{args.Stack}'. Available: {sorted(packs.keys())}", file=sys.stderr)
return 2
cli = _resolve_cli(_resolve_default_product())
failures: list[dict[str, str]] = []
for entry in packs[args.Stack]:
ext_id = entry["id"]
print(f"installing {ext_id}...", file=sys.stderr)
rc = subprocess.call([cli, "--install-extension", ext_id])
if rc != 0:
failures.append({"id": ext_id, "purpose": entry["purpose"], "exitCode": str(rc)})
summary = {
"stack": args.Stack,
"total": len(packs[args.Stack]),
"failures": failures,
}
print(json.dumps(summary, indent=2, ensure_ascii=False))
return 1 if failures else 0
def cmd_extensions_pin(args: argparse.Namespace) -> int:
packs = _parse_extension_packs()
if args.Stack not in packs:
print(f"error: unknown stack '{args.Stack}'. Available: {sorted(packs.keys())}", file=sys.stderr)
return 2
project = Path(args.ProjectPath).resolve()
target = project / ".vscode" / "extensions.json"
existing = _read_settings(target) if target.is_file() else {}
recommendations = existing.get("recommendations") or []
for entry in packs[args.Stack]:
if entry["id"] not in recommendations:
recommendations.append(entry["id"])
existing["recommendations"] = recommendations
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(json.dumps(existing, indent=4, ensure_ascii=False) + "\n", encoding="utf-8")
print(json.dumps({"Path": str(target), "Stack": args.Stack, "Recommendations": recommendations}, indent=2, ensure_ascii=False))
return 0
def cmd_extensions_stacks(_args: argparse.Namespace) -> int:
packs = _parse_extension_packs()
print(json.dumps({"stacks": sorted(packs.keys())}, indent=2))
return 0
# --------------------------------------------------------------------------- #
# Settings Sync (read-only first)
# --------------------------------------------------------------------------- #
def _sync_storage_paths() -> list[Path]:
"""Locate VS Code's settings sync storage locations for inspection only."""
candidates: list[Path] = []
if IS_WINDOWS:
app = os.environ.get("APPDATA")
if app:
candidates.append(Path(app) / "Code" / "User" / "sync")
candidates.append(Path(app) / "Code - Insiders" / "User" / "sync")
elif platform.system() == "Darwin":
candidates.append(Path.home() / "Library" / "Application Support" / "Code" / "User" / "sync")
candidates.append(Path.home() / "Library" / "Application Support" / "Code - Insiders" / "User" / "sync")
else:
xdg = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
candidates.append(Path(xdg) / "Code" / "User" / "sync")
candidates.append(Path(xdg) / "Code - Insiders" / "User" / "sync")
return [p for p in candidates if p.exists()]
def cmd_settings_sync_status(_args: argparse.Namespace) -> int:
"""Report whether settings sync storage exists on this machine.
Settings sync is a per-account feature; this command does not read tokens
or any sync payload, only the on-disk presence of the sync folder.
"""
paths = _sync_storage_paths()
print(json.dumps({
"syncStorageFound": bool(paths),
"locations": [str(p) for p in paths],
"note": "Settings sync is controlled via the VS Code UI (Accounts → Backup and Sync Settings). "
"The skill does not enable or disable sync programmatically to avoid accidental data leaks.",
}, indent=2))
return 0
# --------------------------------------------------------------------------- #
# Multi-root workspace
# --------------------------------------------------------------------------- #
def _find_workspace_file(project: Path) -> Path | None:
candidates = list(project.glob("*.code-workspace"))
return candidates[0] if candidates else None
def cmd_workspace_info(args: argparse.Namespace) -> int:
if not args.ProjectPath:
print("error: workspace info requires --ProjectPath.", file=sys.stderr)
return 2
project = Path(args.ProjectPath).resolve()
workspace_file = _find_workspace_file(project)
if not workspace_file:
print(json.dumps({
"ProjectPath": str(project),
"IsMultiRoot": False,
"note": "No .code-workspace file found. This is a single-root workspace; use 'settings set --Scope project'.",
}, indent=2))
return 0
data = json.loads(workspace_file.read_text(encoding="utf-8"))
folders = data.get("folders") or []
summary = {
"ProjectPath": str(project),
"IsMultiRoot": len(folders) > 1,
"WorkspaceFile": str(workspace_file),
"Folders": [f.get("path") for f in folders],
"FolderSettings": [
{"path": f.get("path"), "settings": f.get("settings", {})}
for f in folders if f.get("settings")
],
}
print(json.dumps(summary, indent=2, ensure_ascii=False))
return 0
def cmd_workspace_folder_settings_get(args: argparse.Namespace) -> int:
if not args.ProjectPath or not args.Folder:
print("error: workspace folder-settings-get requires --ProjectPath and --Folder.", file=sys.stderr)
return 2
project = Path(args.ProjectPath).resolve()
workspace_file = _find_workspace_file(project)
if not workspace_file:
print("error: not a multi-root workspace (no .code-workspace file).", file=sys.stderr)
return 2
data = json.loads(workspace_file.read_text(encoding="utf-8"))
folder_entry = next((f for f in data.get("folders", []) if f.get("path") == args.Folder), None)
if folder_entry is None:
print(f"error: folder '{args.Folder}' not found in workspace file.", file=sys.stderr)
return 2
folder_settings = folder_entry.get("settings") or {}
if args.SettingKey:
print(json.dumps({"key": args.SettingKey, "value": folder_settings.get(args.SettingKey)}, indent=2, ensure_ascii=False))
else:
print(json.dumps({"folder": args.Folder, "settings": folder_settings}, indent=2, ensure_ascii=False))
return 0
def cmd_workspace_folder_settings_set(args: argparse.Namespace) -> int:
if not args.ProjectPath or not args.Folder or not args.SettingKey:
print("error: workspace folder-settings-set requires --ProjectPath, --Folder, --SettingKey.", file=sys.stderr)
return 2
if not args.Apply:
print("error: workspace folder-settings-set is a write operation. Pass --Apply.", file=sys.stderr)
return 2
project = Path(args.ProjectPath).resolve()
workspace_file = _find_workspace_file(project)
if not workspace_file:
print("error: not a multi-root workspace (no .code-workspace file).", file=sys.stderr)
return 2
data = json.loads(workspace_file.read_text(encoding="utf-8"))
folders = data.get("folders") or []
target = next((f for f in folders if f.get("path") == args.Folder), None)
if target is None:
print(f"error: folder '{args.Folder}' not found in workspace file.", file=sys.stderr)
return 2
target.setdefault("settings", {})[args.SettingKey] = _coerce(args.SettingValue or "")
data["folders"] = folders
workspace_file.write_text(json.dumps(data, indent=4, ensure_ascii=False) + "\n", encoding="utf-8")
print(json.dumps({"WorkspaceFile": str(workspace_file), "Folder": args.Folder, "Key": args.SettingKey, "Value": target["settings"][args.SettingKey]}, indent=2, ensure_ascii=False))
return 0
# --------------------------------------------------------------------------- #
# Profiles (read-only)
# --------------------------------------------------------------------------- #
def _profiles_dir() -> Path | None:
"""Return the profiles directory of the resolved product, or None if absent."""
user_data = _user_data_dir(_resolve_default_product())
return user_data / "profiles"
def cmd_profile_list(_args: argparse.Namespace) -> int:
profiles_dir = _profiles_dir()
if profiles_dir is None or not profiles_dir.is_dir():
print(json.dumps({"profiles": [], "note": "No profiles directory found; no profiles defined."}, indent=2))
return 0
entries: list[dict[str, Any]] = []
for child in sorted(profiles_dir.iterdir()):
if not child.is_dir():
continue
settings_path = child / "settings.json"
entry: dict[str, Any] = {
"name": child.name,
"settingsFileExists": settings_path.is_file(),
}
if settings_path.is_file():
try:
data = json.loads(settings_path.read_text(encoding="utf-8"))
entry["settingCount"] = len(data)
entry["sampleKeys"] = sorted(list(data.keys()))[:5]
except json.JSONDecodeError:
entry["parseError"] = "settings.json is not valid JSON"
entries.append(entry)
print(json.dumps({"profilesDir": str(profiles_dir), "profiles": entries}, indent=2, ensure_ascii=False))
return 0
def cmd_profile_show(args: argparse.Namespace) -> int:
if not args.Profile:
print("error: profile show requires --Profile.", file=sys.stderr)
return 2
profiles_dir = _profiles_dir()
if profiles_dir is None:
print("error: profiles directory does not exist for this product.", file=sys.stderr)
return 2
profile_dir = profiles_dir / args.Profile
settings_path = profile_dir / "settings.json"
if not settings_path.is_file():
print(f"error: profile '{args.Profile}' not found or has no settings.json.", file=sys.stderr)
return 2
print(json.dumps({
"profile": args.Profile,
"settingsFile": str(settings_path),
"settings": json.loads(settings_path.read_text(encoding="utf-8")),
}, indent=2, ensure_ascii=False))
return 0
# --------------------------------------------------------------------------- #
# Argument parsing
# --------------------------------------------------------------------------- #
# --------------------------------------------------------------------------- #
# Argument parsing
# --------------------------------------------------------------------------- #
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(prog="vscode_skill", description="VS Code skill cross-platform gateway.")
sub = p.add_subparsers(dest="area", required=True)
sub.add_parser("info", help="Show product, CLI path, key locations.")
ext = sub.add_parser("ext", help="Manage extensions.")
ext.add_argument("action", choices=["list"], help="Currently only 'list' is supported on macOS/Linux.")
settings = sub.add_parser("settings", help="Read or write settings.")
settings.add_argument("action", choices=["path", "get", "set", "smoke"])
settings.add_argument("--Scope", choices=["user", "project"], default="user")
settings.add_argument("--ProjectPath")
settings.add_argument("--Apply", action="store_true")
settings.add_argument("--FontSize", type=str)
settings.add_argument("--LineHeight", type=str)
settings.add_argument("--FontFamily", type=str)
settings.add_argument("--ColorTheme", type=str)
settings.add_argument("--IconTheme", type=str)
settings.add_argument("--ProductIconTheme", type=str)
settings.add_argument("--WordWrap", type=str)
settings.add_argument("--TabSize", type=str)
settings.add_argument("--FormatOnSave", type=str)
settings.add_argument("--AutoSave", type=str)
settings.add_argument("--Minimap", type=str)
settings.add_argument("--SettingKey", type=str)
settings.add_argument("--SettingValue", type=str)
py = sub.add_parser("python", help="Python interpreter helpers.")
py.add_argument("action", choices=["set-interpreter"])
py.add_argument("--ProjectPath", required=True)
py.add_argument("--VenvName", required=True)
tasks = sub.add_parser("tasks", help="Manage .vscode/tasks.json from templates.")
tasks.add_argument("action", choices=["list", "init"])
tasks.add_argument("--Template")
tasks.add_argument("--ProjectPath")
tasks.add_argument("--Force", action="store_true")
launch = sub.add_parser("launch", help="Manage .vscode/launch.json from templates.")
launch.add_argument("action", choices=["list", "init"])
launch.add_argument("--Template")
launch.add_argument("--ProjectPath")
launch.add_argument("--Force", action="store_true")
ext2 = sub.add_parser("extensions", help="Manage recommended extension stacks.")
ext2.add_argument("action", choices=["stacks", "recommend", "install", "pin"])
ext2.add_argument("--Stack")
ext2.add_argument("--ProjectPath")
sync = sub.add_parser("settings-sync", help="Inspect VS Code settings sync state.")
sync.add_argument("action", choices=["status"])
workspace = sub.add_parser("workspace", help="Inspect and edit multi-root workspace settings.")
workspace.add_argument("action", choices=["info", "folder-settings-set", "folder-settings-get"])
workspace.add_argument("--ProjectPath")
workspace.add_argument("--Folder", help="Multi-root folder name to scope to")
workspace.add_argument("--SettingKey")
workspace.add_argument("--SettingValue")
workspace.add_argument("--Apply", action="store_true")
profile = sub.add_parser("profile", help="Inspect VS Code profiles (read-only).")
profile.add_argument("action", choices=["list", "show"])
profile.add_argument("--Profile")
return p
# --------------------------------------------------------------------------- #
# Native dispatch table
# --------------------------------------------------------------------------- #
NATIVE_DISPATCH = {
("info",): cmd_info,
("ext", "list"): cmd_ext_list,
("settings", "path"): cmd_settings_path,
("settings", "get"): cmd_settings_get,
("settings", "set"): cmd_settings_set,
("settings", "smoke"): cmd_settings_smoke,
("python", "set-interpreter"): cmd_python_set_interpreter,
("tasks", "list"): cmd_tasks_list,
("tasks", "init"): cmd_tasks_init,
("launch", "list"): cmd_launch_list,
("launch", "init"): cmd_launch_init,
("extensions", "stacks"): cmd_extensions_stacks,
("extensions", "recommend"): cmd_extensions_recommend,
("extensions", "install"): cmd_extensions_install,
("extensions", "pin"): cmd_extensions_pin,
("settings-sync", "status"): cmd_settings_sync_status,
("workspace", "info"): cmd_workspace_info,
("workspace", "folder-settings-get"): cmd_workspace_folder_settings_get,
("workspace", "folder-settings-set"): cmd_workspace_folder_settings_set,
("profile", "list"): cmd_profile_list,
("profile", "show"): cmd_profile_show,
}
def main(argv: list[str]) -> int:
# On Windows: always forward to the PowerShell driver (zero behavior drift).
if IS_WINDOWS:
return _forward_powershell(argv)
# On macOS / Linux: parse and dispatch natively.
parser = build_parser()
args = parser.parse_args(argv)
handler = NATIVE_DISPATCH.get((args.area, getattr(args, "action", None)))
if handler is None:
print(f"error: native implementation for '{args.area} {getattr(args, 'action', '')}' is not yet available on this platform.", file=sys.stderr)
print("hint : run this command on Windows, or open an issue at https://github.com/Duanzhoutao/vscode-skill/issues", file=sys.stderr)
return 2
return handler(args)
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))