devin-devkit 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 @@
1
+ __version__ = "0.1.0"
devin_devkit/cli.py ADDED
@@ -0,0 +1,128 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import sys
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from devin_devkit.installer import DevKitError, apply_plan, build_plan, load_manifest
10
+ from devin_devkit.updater import (
11
+ apply_update_plan,
12
+ build_update_plan,
13
+ fetch_remote_manifest,
14
+ freshness_hint,
15
+ )
16
+
17
+
18
+ def _print_plan(plan: dict[str, Any], as_json: bool) -> None:
19
+ if as_json:
20
+ print(json.dumps(plan, ensure_ascii=False, indent=2))
21
+ return
22
+ print(f"Profile: {plan['profile']} ({plan['platform']}; environment={plan['environment']}; runtime={plan['runtime']})")
23
+ for item in plan["actions"]:
24
+ command = " ".join(item.get("command", []))
25
+ detail = item.get("detail", "")
26
+ suffix = f" — {detail}" if detail else ""
27
+ print(f"- {item['tool']}: {item['action']}{suffix}{': ' + command if command else ''}")
28
+ for error in plan["errors"]:
29
+ print(f"BLOCKED: {error}", file=sys.stderr)
30
+ if not plan["errors"] and any(item["action"] == "install" for item in plan["actions"]):
31
+ print("Dry run only. Re-run with --apply to install.")
32
+
33
+
34
+ def _build_parser() -> argparse.ArgumentParser:
35
+ parser = argparse.ArgumentParser(prog="devin-devkit", description="Install curated Devin tool profiles with isolated uv/npm environments.")
36
+ parser.add_argument("--manifest", type=Path, help=argparse.SUPPRESS)
37
+ sub = parser.add_subparsers(dest="action", required=True)
38
+ sub.add_parser("profiles", help="list available installation profiles")
39
+ sub.add_parser("list", help="list the tools in the manifest")
40
+ install = sub.add_parser("install", help="preview a profile; pass --apply to install")
41
+ install.add_argument("profile")
42
+ install.add_argument("--apply", action="store_true", help="install the selected profile")
43
+ install.add_argument("--json", action="store_true", help="print the plan as JSON")
44
+ install.add_argument(
45
+ "--environment",
46
+ choices=["linux", "personal-windows", "corporate-windows"],
47
+ help="execution environment; defaults to linux on Linux and personal-windows on Windows",
48
+ )
49
+ outdated = sub.add_parser("outdated", help="list installed tools with a newer version in the remote registry")
50
+ outdated.add_argument("--json", action="store_true", help="print the check as JSON")
51
+ update = sub.add_parser("update", help="preview tool updates; pass --apply to install them")
52
+ update.add_argument("--apply", action="store_true", help="reinstall outdated tools")
53
+ update.add_argument("--force", action="store_true", help="reinstall every installed tool")
54
+ update.add_argument("--json", action="store_true", help="print the plan as JSON")
55
+ return parser
56
+
57
+
58
+ def main(argv: list[str] | None = None) -> int:
59
+ parser = _build_parser()
60
+ args = parser.parse_args(argv)
61
+ try:
62
+ manifest = load_manifest(args.manifest)
63
+ if args.action in {"outdated", "update"}:
64
+ remote = fetch_remote_manifest()
65
+ plan = build_update_plan(remote, force=args.action == "update" and args.force)
66
+ if args.json:
67
+ print(json.dumps(plan, ensure_ascii=False, indent=2))
68
+ else:
69
+ print(f"Remote registry v{plan['registry_version']} (generated {plan['generated']})")
70
+ for item in plan["actions"]:
71
+ if item["action"] == "update":
72
+ print(f"- {item['tool']}: {item['from']} -> {item['to']}")
73
+ elif item["action"] == "current":
74
+ print(f"- {item['tool']}: up to date ({item['version']})")
75
+ elif item["action"] == "blocked":
76
+ print(f"- {item['tool']}: BLOCKED — {item['detail']}")
77
+ if args.action == "outdated":
78
+ return 0
79
+ pending = [a for a in plan["actions"] if a["action"] == "update"]
80
+ if not args.apply:
81
+ if pending:
82
+ print("Dry run only. Re-run with --apply to update.")
83
+ return 0
84
+ result = apply_update_plan(plan)
85
+ print(f"Updated: {', '.join(result['updated']) or 'nothing'}")
86
+ for name in result["failed"]:
87
+ print(f"FAILED: {name}", file=sys.stderr)
88
+ return 1 if result["failed"] else 0
89
+ if args.action == "profiles":
90
+ for name, profile in manifest["profiles"].items():
91
+ alias = f" (alias of {profile['alias_of']})" if profile.get("alias_of") else ""
92
+ print(f"{name}: {profile['label']}{alias} — {profile['description']}")
93
+ return 0
94
+ if args.action == "list":
95
+ cat = manifest["catalog"]
96
+ pl = lambda n: "" if n == 1 else "s" # noqa: E731
97
+ print(f"{cat['tool_count']} first-party Devin tools; {cat['distribution_count']} distribution layer{pl(cat['distribution_count'])}; {cat['hub_count']} maintainer hub{pl(cat['hub_count'])}; {cat['related_count']} related project{pl(cat['related_count'])}")
98
+ for tool in manifest["tools"]:
99
+ print(f"{tool['id']}: {tool['package']} {tool['version']} [{tool['manager']}/{tool['source']}/{tool['status']}]")
100
+ hint = freshness_hint(manifest)
101
+ if hint:
102
+ print(hint, file=sys.stderr)
103
+ return 0
104
+
105
+ plan = build_plan(manifest, args.profile, environment=args.environment)
106
+ if not args.apply:
107
+ _print_plan(plan, args.json)
108
+ hint = freshness_hint(manifest)
109
+ if hint:
110
+ print(hint, file=sys.stderr)
111
+ return 2 if plan["errors"] else 0
112
+ if plan["errors"]:
113
+ _print_plan(plan, args.json)
114
+ return 2
115
+ result = apply_plan(plan)
116
+ print(json.dumps(result, ensure_ascii=False, indent=2) if args.json else f"Installed: {', '.join(result['installed']) or 'nothing new'}")
117
+ for item in result["actions"]:
118
+ if item["action"] in {"preexisting", "manual", "unsupported"}:
119
+ detail = item.get("detail", ", ".join(item.get("commands", [])))
120
+ print(f"- {item['tool']}: {item['action']} — {detail}")
121
+ return 0
122
+ except DevKitError as exc:
123
+ print(f"error: {exc}", file=sys.stderr)
124
+ return 2
125
+
126
+
127
+ if __name__ == "__main__":
128
+ raise SystemExit(main())
@@ -0,0 +1,213 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import platform as platform_module
5
+ import re
6
+ import shutil
7
+ import subprocess
8
+ from importlib.resources import files
9
+ from pathlib import Path
10
+ from typing import Any, Callable
11
+
12
+
13
+ class DevKitError(RuntimeError):
14
+ pass
15
+
16
+
17
+ def load_manifest(path: Path | None = None) -> dict[str, Any]:
18
+ source = path if path is not None else files("devin_devkit").joinpath("manifest.json")
19
+ try:
20
+ text = Path(source).read_text(encoding="utf-8") if path else source.read_text(encoding="utf-8")
21
+ return json.loads(text)
22
+ except (OSError, json.JSONDecodeError) as exc:
23
+ raise DevKitError(f"cannot load manifest {source}: {exc}") from exc
24
+
25
+
26
+ def platform_name(system: str | None = None) -> str:
27
+ value = (system or platform_module.system()).lower()
28
+ if value in {"nt", "windows"}:
29
+ return "windows"
30
+ if value in {"posix", "linux"}:
31
+ return "linux"
32
+ if value in {"darwin", "macos"}:
33
+ return "macos"
34
+ return value
35
+
36
+
37
+ _ENVIRONMENT_ALIASES = {
38
+ "linux": "linux",
39
+ "personal-windows": "personal_windows",
40
+ "personal_windows": "personal_windows",
41
+ "windows": "personal_windows",
42
+ "corporate-windows": "corporate_windows",
43
+ "corporate_windows": "corporate_windows",
44
+ "corporate": "corporate_windows",
45
+ }
46
+
47
+
48
+ def normalize_environment(environment: str | None, host: str) -> str:
49
+ if environment is None:
50
+ return "personal_windows" if host == "windows" else host
51
+ normalized = _ENVIRONMENT_ALIASES.get(environment.lower())
52
+ if normalized is None:
53
+ raise ValueError(f"unsupported environment: {environment}")
54
+ expected_host = "windows" if normalized.endswith("_windows") else normalized
55
+ if host != expected_host:
56
+ raise ValueError(f"environment {normalized} cannot run on host platform {host}")
57
+ return normalized
58
+
59
+
60
+ def resolve_profile(manifest: dict[str, Any], name: str) -> tuple[str, dict[str, Any]]:
61
+ profiles = manifest.get("profiles", {})
62
+ if name not in profiles:
63
+ raise DevKitError(f"unknown profile {name!r}; choose from: {', '.join(sorted(profiles))}")
64
+ current = name
65
+ seen: set[str] = set()
66
+ while profiles[current].get("alias_of"):
67
+ if current in seen:
68
+ raise DevKitError(f"profile alias cycle at {current!r}")
69
+ seen.add(current)
70
+ current = profiles[current]["alias_of"]
71
+ if current not in profiles:
72
+ raise DevKitError(f"profile {name!r} points to missing alias target {current!r}")
73
+ return current, profiles[current]
74
+
75
+
76
+ def _node_major(runner: Callable[..., Any], which: Callable[[str], str | None]) -> int | None:
77
+ if which("node") is None:
78
+ return None
79
+ try:
80
+ result = runner(["node", "--version"], capture_output=True, text=True, check=False)
81
+ except OSError:
82
+ return None
83
+ match = re.search(r"v?(\d+)", result.stdout or "")
84
+ return int(match.group(1)) if match else None
85
+
86
+
87
+ def build_plan(
88
+ manifest: dict[str, Any],
89
+ profile_name: str,
90
+ *,
91
+ system: str | None = None,
92
+ environment: str | None = None,
93
+ which: Callable[[str], str | None] = shutil.which,
94
+ runner: Callable[..., Any] = subprocess.run,
95
+ ) -> dict[str, Any]:
96
+ resolved_name, profile = resolve_profile(manifest, profile_name)
97
+ host = platform_name(system)
98
+ if host not in manifest.get("supported_platforms", []):
99
+ planned = ", ".join(manifest.get("planned_platforms", [])) or "none"
100
+ raise DevKitError(f"platform {host!r} is not supported; planned platforms: {planned}")
101
+ try:
102
+ selected_environment = normalize_environment(environment, host)
103
+ except ValueError as exc:
104
+ raise DevKitError(str(exc)) from exc
105
+ if selected_environment not in manifest.get("environments", {}):
106
+ raise DevKitError(f"manifest does not define environment {selected_environment!r}")
107
+
108
+ tool_map = {tool["id"]: tool for tool in manifest.get("tools", [])}
109
+ actions: list[dict[str, Any]] = []
110
+ errors: list[str] = []
111
+ node_major = None
112
+
113
+ for tool_id in profile.get("tools", []):
114
+ tool = tool_map.get(tool_id)
115
+ if tool is None:
116
+ errors.append(f"profile {resolved_name!r} references unknown tool {tool_id!r}")
117
+ continue
118
+ environment_meta = tool.get("environments", {}).get(selected_environment)
119
+ if environment_meta is None:
120
+ errors.append(f"{tool_id} has no {selected_environment} environment metadata")
121
+ actions.append({"tool": tool_id, "action": "unsupported", "detail": f"no metadata for {selected_environment}"})
122
+ continue
123
+ if not environment_meta.get("supported"):
124
+ reason = environment_meta.get("reason", f"not listed for {selected_environment}")
125
+ errors.append(f"{tool_id} is unsupported in {selected_environment}: {reason}")
126
+ actions.append({"tool": tool_id, "action": "unsupported", "detail": reason})
127
+ continue
128
+ if selected_environment == "corporate_windows" and (
129
+ environment_meta.get("external_dependencies") or environment_meta.get("delegation") in {"optional", "supported", "required"}
130
+ ):
131
+ reason = "external runtime or delegation is disabled in corporate Windows"
132
+ errors.append(f"{tool_id} violates the corporate Windows local-only guarantee")
133
+ actions.append({"tool": tool_id, "action": "blocked", "detail": reason})
134
+ continue
135
+ if host not in tool.get("platforms", []):
136
+ actions.append({"tool": tool_id, "action": "unsupported", "detail": f"not listed for {host}"})
137
+ continue
138
+ if tool.get("status") == "manual" or tool.get("manager") == "manual":
139
+ actions.append({"tool": tool_id, "action": "manual", "detail": tool.get("manual_note", "manual setup required")})
140
+ continue
141
+
142
+ manager = tool["manager"]
143
+ commands = tool.get("commands", [])
144
+ present = [command for command in commands if which(command) is not None]
145
+ if present:
146
+ if len(present) == len(commands):
147
+ actions.append({"tool": tool_id, "action": "preexisting", "detail": "commands are on PATH; versions are not verified", "commands": present})
148
+ else:
149
+ missing = [command for command in commands if command not in present]
150
+ errors.append(f"{tool_id} has a partial PATH collision; present={present}, missing={missing}")
151
+ actions.append({"tool": tool_id, "action": "blocked", "detail": "partial command collision"})
152
+ continue
153
+
154
+ manager_binary = "uv" if manager == "uv" else "npm"
155
+ if which(manager_binary) is None:
156
+ errors.append(f"{tool_id} requires {manager_binary} on PATH")
157
+ actions.append({"tool": tool_id, "action": "blocked", "detail": f"missing {manager_binary}"})
158
+ continue
159
+ if tool.get("requires_git") and which("git") is None:
160
+ errors.append(f"{tool_id} has a Git dependency and requires git on PATH")
161
+ actions.append({"tool": tool_id, "action": "blocked", "detail": "missing git"})
162
+ continue
163
+ if manager == "npm":
164
+ if node_major is None:
165
+ node_major = _node_major(runner, which)
166
+ if node_major is None or node_major < 20:
167
+ errors.append(f"{tool_id} requires Node.js >=20")
168
+ actions.append({"tool": tool_id, "action": "blocked", "detail": "Node.js >=20 is required"})
169
+ continue
170
+
171
+ spec = tool.get("install_spec")
172
+ if not spec:
173
+ errors.append(f"{tool_id} has no install specification")
174
+ actions.append({"tool": tool_id, "action": "blocked", "detail": "missing install specification"})
175
+ continue
176
+ command = ["uv", "tool", "install", spec] if manager == "uv" else ["npm", "install", "--global", spec]
177
+ actions.append({"tool": tool_id, "action": "install", "command": command})
178
+
179
+ manual = [
180
+ {"tool": name, "action": "manual", "detail": tool_map.get(name, {}).get("manual_note", "manual setup required")}
181
+ for name in profile.get("manual", [])
182
+ ]
183
+ actions.extend(manual)
184
+ return {
185
+ "profile": profile_name,
186
+ "resolved_profile": resolved_name,
187
+ "platform": host,
188
+ "environment": selected_environment,
189
+ "runtime": manifest["environments"][selected_environment].get("runtime"),
190
+ "dry_run": True,
191
+ "actions": actions,
192
+ "errors": errors,
193
+ }
194
+
195
+
196
+ def apply_plan(plan: dict[str, Any], runner: Callable[..., Any] = subprocess.run) -> dict[str, Any]:
197
+ if plan["errors"]:
198
+ raise DevKitError("preflight failed: " + "; ".join(plan["errors"]))
199
+ completed: list[str] = []
200
+ for action in plan["actions"]:
201
+ if action["action"] != "install":
202
+ continue
203
+ try:
204
+ result = runner(action["command"], check=False)
205
+ except OSError as exc:
206
+ raise DevKitError(f"failed to start {action['tool']}: {exc}") from exc
207
+ if result.returncode:
208
+ raise DevKitError(
209
+ f"install failed for {action['tool']} (exit {result.returncode}); "
210
+ f"completed before failure: {completed}"
211
+ )
212
+ completed.append(action["tool"])
213
+ return {"profile": plan["profile"], "installed": completed, "actions": plan["actions"]}