801 lines
32 KiB
Python
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:])) |