dev-code 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,5 @@
1
+ Metadata-Version: 2.4
2
+ Name: dev-code
3
+ Version: 0.1.0
4
+ License-File: LICENSE
5
+ Requires-Python: >=3.10
@@ -0,0 +1,8 @@
1
+ dev_code.py,sha256=ft32yZbbyIFy3A3lKaglTAm-WDa40Jfh91qQw8UwSEc,23680
2
+ dev_code_templates/dev-code/.devcontainer/Dockerfile,sha256=drzCJYodMLgaYoEKT3GgQfUfduzqjEh0cf8Pce4tjuQ,1748
3
+ dev_code_templates/dev-code/.devcontainer/devcontainer.json,sha256=IHjjg1v151GsiT_KHNjUfTrp5JpiKH7lEvEsLIX9nTk,604
4
+ dev_code-0.1.0.dist-info/METADATA,sha256=balfl4DEGFufvM-SElHyGN7Kek5Izh6SfQEbVMR5z8E,98
5
+ dev_code-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
6
+ dev_code-0.1.0.dist-info/entry_points.txt,sha256=OzdJScH9Xuti6o-IJ0kK1EntOs9WiYWxv2_jHUN5JmI,43
7
+ dev_code-0.1.0.dist-info/licenses/LICENSE,sha256=Qy3ZbsaYLHGW7pDXkM5xKFicJrZrfjhysMEWJ-rmtIE,1072
8
+ dev_code-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ dev-code = dev_code:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nasser Alansari
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
dev_code.py ADDED
@@ -0,0 +1,691 @@
1
+ #!/usr/bin/env python3
2
+ import argparse
3
+ import json
4
+ import logging
5
+ import os
6
+ import re
7
+ import shutil
8
+ import subprocess
9
+ import sys
10
+ import time
11
+
12
+ logger = logging.getLogger("dev-code")
13
+
14
+ BANNER = (
15
+ " _ _\n"
16
+ " | | | |\n"
17
+ " __| | _____ ________ ___ ___ __| | ___\n"
18
+ " / _` |/ _ \\ \\ / /______/ __/ _ \\ / _` |/ _ \\\n"
19
+ "| (_| | __/\\ V / | (_| (_) | (_| | __/\n"
20
+ " \\__,_|\\___| \\_/ \\___\\___/ \\__,_|\\___|\n"
21
+ " project · editor · container — simplified "
22
+ )
23
+
24
+
25
+ def _configure_logging(verbose: bool) -> None:
26
+ """Configure the module logger. Guard prevents double-registration."""
27
+ if logger.handlers:
28
+ return
29
+ handler = logging.StreamHandler(sys.stderr)
30
+ handler.setFormatter(logging.Formatter("%(levelname)s: %(message)s"))
31
+ logger.addHandler(handler)
32
+ logger.setLevel(logging.DEBUG if verbose else logging.WARNING)
33
+
34
+
35
+ def is_wsl() -> bool:
36
+ """Detect WSL (but avoid Docker containers) by reading /proc/version."""
37
+ if "WSLENV" not in os.environ:
38
+ return False
39
+ try:
40
+ with open("/proc/version", "r") as f:
41
+ content = f.read()
42
+ return "Microsoft" in content or "WSL" in content
43
+ except Exception:
44
+ return False
45
+
46
+
47
+ def wsl_to_windows(path: str) -> str:
48
+ """Convert WSL path to Windows path using wslpath."""
49
+ try:
50
+ return subprocess.check_output(
51
+ ["wslpath", "-w", path],
52
+ text=True
53
+ ).strip()
54
+ except subprocess.CalledProcessError as e:
55
+ raise RuntimeError(f"Failed to convert path with wslpath: {path}") from e
56
+
57
+
58
+ def resolve_template_dir() -> str:
59
+ """Return the user template directory (DEVCODE_TEMPLATE_DIR or XDG default)."""
60
+ override = os.environ.get("DEVCODE_TEMPLATE_DIR")
61
+ if override:
62
+ return override
63
+ xdg = os.environ.get("XDG_DATA_HOME", os.path.join(os.path.expanduser("~"), ".local", "share"))
64
+ return os.path.join(xdg, "dev-code", "templates")
65
+
66
+
67
+ def get_builtin_template_path(name: str) -> str | None:
68
+ """Return absolute path to a bundled template directory, or None if not found."""
69
+ module_dir = os.path.dirname(os.path.abspath(__file__))
70
+ candidate = os.path.join(module_dir, "dev_code_templates", name)
71
+ if os.path.isdir(candidate):
72
+ return candidate
73
+ try:
74
+ import importlib.resources as pkg_resources
75
+ ref = pkg_resources.files("dev_code_templates").joinpath(name)
76
+ if ref.is_dir():
77
+ return str(ref)
78
+ except Exception as e:
79
+ logger.debug("importlib.resources fallback failed for %r: %s", name, e)
80
+ return None
81
+
82
+
83
+ def resolve_template(name: str) -> str:
84
+ """Return absolute path to template's devcontainer.json. Exits on failure."""
85
+ user_path = os.path.join(resolve_template_dir(), name, ".devcontainer", "devcontainer.json")
86
+ if os.path.exists(user_path):
87
+ return user_path
88
+ builtin = get_builtin_template_path(name)
89
+ if builtin:
90
+ path = os.path.join(builtin, ".devcontainer", "devcontainer.json")
91
+ if os.path.exists(path):
92
+ return path
93
+ logger.error("template not found: %s", name)
94
+ sys.exit(1)
95
+
96
+
97
+ def build_devcontainer_uri(host_path: str, config_file: str, container_folder: str) -> str:
98
+ # Handle WSL conversion
99
+ if is_wsl():
100
+ host_path = wsl_to_windows(host_path)
101
+ config_file = wsl_to_windows(config_file)
102
+
103
+ # Build JSON
104
+ data = {
105
+ "hostPath": host_path,
106
+ "configFile": {
107
+ "$mid": 1,
108
+ "path": config_file,
109
+ "scheme": "file"
110
+ }
111
+ }
112
+
113
+ # Compact JSON (important!)
114
+ json_str = json.dumps(data, separators=(",", ":"))
115
+
116
+ # Hex encode
117
+ hex_str = json_str.encode("utf-8").hex()
118
+
119
+ # Build URI
120
+ return f"vscode-remote://dev-container+{hex_str}{container_folder}"
121
+
122
+
123
+
124
+ def parse_devcontainer_json(config_file: str):
125
+ """Parse devcontainer.json. Returns (dict, cli_used: bool).
126
+
127
+ Tries in order: devcontainer CLI, jq, Python json+re fallback.
128
+ """
129
+ # Strategy 1: devcontainer CLI (resolves ${localEnv:VAR} automatically)
130
+ if shutil.which("devcontainer"):
131
+ result = subprocess.run(
132
+ ["devcontainer", "read-configuration", "--config", config_file],
133
+ capture_output=True, text=True
134
+ )
135
+ if result.returncode == 0:
136
+ try:
137
+ data = json.loads(result.stdout)
138
+ # read-configuration wraps output; extract .configuration if present
139
+ return data.get("configuration", data), True
140
+ except json.JSONDecodeError:
141
+ pass
142
+
143
+ # Strategy 2: jq
144
+ if shutil.which("jq"):
145
+ result = subprocess.run(
146
+ ["jq", ".", config_file],
147
+ capture_output=True, text=True
148
+ )
149
+ if result.returncode == 0:
150
+ try:
151
+ return json.loads(result.stdout), False
152
+ except json.JSONDecodeError:
153
+ pass
154
+
155
+ # Strategy 3: Python json + re fallback
156
+ with open(config_file) as f:
157
+ content = f.read()
158
+
159
+ # Strip full-line // comments (re.MULTILINE makes ^ match start of each line)
160
+ content = re.sub(r"^\s*//[^\n]*\n?", "", content, flags=re.MULTILINE)
161
+ # Strip trailing commas before } or ]
162
+ content = re.sub(r",(\s*[}\]])", r"\1", content)
163
+
164
+ try:
165
+ return json.loads(content), False
166
+ except json.JSONDecodeError as e:
167
+ logger.error("failed to parse %s: %s", config_file, e)
168
+ sys.exit(1)
169
+
170
+
171
+ def wait_for_container(config_file: str, project_path: str, timeout: int) -> str:
172
+ """Poll Docker until a devcontainer for project_path starts. Returns container ID."""
173
+ label_value = wsl_to_windows(project_path) if is_wsl() else project_path
174
+ deadline = time.time() + timeout
175
+
176
+ while time.time() < deadline:
177
+ result = subprocess.run(
178
+ [
179
+ "docker", "container", "ls",
180
+ "--filter", f"label=devcontainer.local_folder={label_value}",
181
+ "--filter", f"label=devcontainer.config_file={config_file}",
182
+ "--format", "{{.ID}}",
183
+ ],
184
+ capture_output=True, text=True,
185
+ )
186
+ ids = [line.strip() for line in result.stdout.splitlines() if line.strip()]
187
+ if ids:
188
+ if len(ids) > 1:
189
+ logger.warning("multiple containers matched label; using first (%s)", ids[0])
190
+ return ids[0]
191
+ time.sleep(2)
192
+
193
+ logger.error(
194
+ "timed out waiting for container (label=devcontainer.local_folder=%s); "
195
+ "path format mismatch may be the cause (e.g. in WSL, Windows path vs Linux path)",
196
+ label_value,
197
+ )
198
+ sys.exit(1)
199
+
200
+
201
+ def _substitute_env_vars(s: str):
202
+ """Resolve ${localEnv:VAR} patterns. Returns resolved string, or None if any var unset/empty."""
203
+ for match in re.finditer(r"\$\{localEnv:([^}]+)\}", s):
204
+ var = match.group(1)
205
+ if not os.environ.get(var):
206
+ return None
207
+ return re.sub(r"\$\{localEnv:([^}]+)\}", lambda m: os.environ[m.group(1)], s)
208
+
209
+
210
+ def _docker_run(cmd: list, label: str) -> bool:
211
+ """Run a full docker command list. Logs warning and returns False on failure."""
212
+ result = subprocess.run(cmd, capture_output=True)
213
+ if result.returncode != 0:
214
+ logger.warning("%s failed (exit %d)", label, result.returncode)
215
+ if result.stderr:
216
+ logger.debug("%s stderr: %s", label, result.stderr.decode(errors="replace").strip())
217
+ return False
218
+ return True
219
+
220
+
221
+ def _list_dir_children(source_dir: str) -> list:
222
+ """Return absolute paths of all children in source_dir (includes dot files)."""
223
+ return [os.path.join(source_dir, name) for name in os.listdir(source_dir)]
224
+
225
+
226
+ def _process_entry(container_id: str, entry: dict, cli_used: bool, idx: int, config_dir: str) -> None:
227
+ """Process a single customizations.dev-code.cp copy entry."""
228
+ # Warn on wrong-cased Override key
229
+ for key in entry:
230
+ if key.lower() == "override" and key != "Override":
231
+ logger.warning("entry %d uses '%s' — use 'Override' (capital O); ignoring", idx, key)
232
+
233
+ source = entry.get("source")
234
+ target = entry.get("target")
235
+
236
+ if not source or not target:
237
+ missing = "source" if not source else "target"
238
+ logger.warning("entry %d missing '%s', skipping", idx, missing)
239
+ return
240
+
241
+ # Step 1: Env var substitution + relative path resolution
242
+ if not cli_used:
243
+ resolved = _substitute_env_vars(source)
244
+ if resolved is None:
245
+ logger.warning("entry %d source env var unset or empty, skipping", idx)
246
+ return
247
+ source = resolved
248
+
249
+ dot_expand = source.endswith("/.")
250
+ if dot_expand:
251
+ source = source[:-2]
252
+ if not source:
253
+ source = "/" # source was "/." — strip of 2-char string leaves empty; treat as root
254
+ if not os.path.isabs(source):
255
+ source = os.path.abspath(os.path.join(config_dir, source))
256
+ if dot_expand:
257
+ source = source + "/."
258
+
259
+ # Step 2: Source expansion for dir-contents (source/.)
260
+ if source.endswith("/."):
261
+ if not target.endswith("/"):
262
+ logger.warning(
263
+ "entry %d source ends with '/.' but target '%s' has no trailing '/'; appending '/' to target",
264
+ idx, target,
265
+ )
266
+ target = target + "/"
267
+ actual_dir = source[:-2]
268
+ if not os.path.isdir(actual_dir):
269
+ logger.warning("entry %d source dir not found or not a directory: %s, skipping", idx, actual_dir)
270
+ return
271
+ # Empty dir is a silent no-op
272
+ for child_path in _list_dir_children(actual_dir):
273
+ child_entry = dict(entry)
274
+ child_entry["source"] = child_path
275
+ child_entry["target"] = target
276
+ _process_entry(container_id, child_entry, cli_used=True, idx=idx, config_dir=config_dir)
277
+ return
278
+
279
+ # Step 3: Check source exists
280
+ if not os.path.exists(source):
281
+ logger.warning("entry %d source not found: %s, skipping", idx, source)
282
+ return
283
+
284
+ # Step 4: Classify source type (file vs dir-itself; docker cp handles both natively)
285
+ # source_is_dir = os.path.isdir(source) # not branched on — docker cp handles both
286
+
287
+ # Step 5: Compute effective target (for override check and chown/chmod only)
288
+ if target.endswith("/"):
289
+ effective = target.rstrip("/") + "/" + os.path.basename(source)
290
+ else:
291
+ effective = target
292
+
293
+ # Step 6: Override check (before any side effects)
294
+ override = entry.get("Override", False)
295
+ if not override:
296
+ result = subprocess.run(
297
+ ["docker", "exec", container_id, "test", "-e", effective],
298
+ capture_output=True,
299
+ )
300
+ if result.returncode == 0:
301
+ logger.info("entry %d effective target '%s' exists and Override=false, skipping", idx, effective)
302
+ return
303
+
304
+ # Step 7: Pre-create dirs
305
+ if target.endswith("/"):
306
+ _docker_run(["docker", "exec", container_id, "mkdir", "-p", target], f"entry {idx} mkdir {target}")
307
+ else:
308
+ parent = os.path.dirname(target)
309
+ if parent:
310
+ _docker_run(["docker", "exec", container_id, "mkdir", "-p", parent], f"entry {idx} mkdir {parent}")
311
+
312
+ # Step 8: Copy
313
+ ok = _docker_run(["docker", "cp", source, f"{container_id}:{target}"], f"entry {idx} cp")
314
+ if not ok:
315
+ return
316
+
317
+ # Step 9: chown / chmod (only if copy succeeded; applied to effective)
318
+ owner = entry.get("owner")
319
+ group = entry.get("group")
320
+ if owner and group:
321
+ _docker_run(
322
+ ["docker", "exec", "-u", "root", container_id, "chown", "-R", f"{owner}:{group}", effective],
323
+ f"entry {idx} chown",
324
+ )
325
+
326
+ permissions = entry.get("permissions")
327
+ if permissions:
328
+ _docker_run(
329
+ ["docker", "exec", "-u", "root", container_id, "chmod", "-R", permissions, effective],
330
+ f"entry {idx} chmod",
331
+ )
332
+
333
+
334
+ def run_post_launch(config_file: str, project_path: str, timeout: int) -> None:
335
+ """Parse devcontainer.json and run customizations.dev-code.cp copy entries."""
336
+ data, cli_used = parse_devcontainer_json(config_file)
337
+
338
+ dev_code_section = data.get("customizations", {}).get("dev-code")
339
+ if dev_code_section is None:
340
+ return
341
+ if not isinstance(dev_code_section, dict):
342
+ logger.error("customizations.dev-code must be a dict in %s", config_file)
343
+ sys.exit(1)
344
+
345
+ entries = dev_code_section.get("cp")
346
+ if not entries:
347
+ return
348
+ if not isinstance(entries, list):
349
+ logger.error("customizations.dev-code.cp must be a list in %s", config_file)
350
+ sys.exit(1)
351
+
352
+ container_id = wait_for_container(config_file, project_path, timeout)
353
+
354
+ config_dir = os.path.dirname(os.path.abspath(config_file))
355
+ for idx, entry in enumerate(entries):
356
+ _process_entry(container_id, entry, cli_used, idx, config_dir)
357
+
358
+
359
+ def cmd_open(args) -> None:
360
+ """open subcommand: open a project in VS Code using a devcontainer template."""
361
+ config_file = resolve_template(args.template)
362
+
363
+ project_path = os.path.abspath(args.projectpath)
364
+ if project_path == "/":
365
+ logger.error("projectpath must not resolve to /")
366
+ sys.exit(1)
367
+
368
+ container_folder = args.container_folder or f"/workspaces/{os.path.basename(project_path)}"
369
+ uri = build_devcontainer_uri(project_path, config_file, container_folder)
370
+
371
+ if args.dry_run:
372
+ _cmd_open_dry_run(config_file, project_path, uri)
373
+ return
374
+
375
+ if not shutil.which("code"):
376
+ logger.error("'code' not found on PATH")
377
+ sys.exit(1)
378
+
379
+ subprocess.Popen(["code", "--folder-uri", uri], start_new_session=True)
380
+ run_post_launch(config_file, project_path, args.timeout)
381
+
382
+
383
+ def _cmd_open_dry_run(config_file: str, project_path: str, uri: str) -> None:
384
+ """Print dry-run plan to stdout without executing anything."""
385
+ print(f"Config: {config_file}")
386
+ print(f"URI: {uri}")
387
+
388
+ data, cli_used = parse_devcontainer_json(config_file)
389
+ dev_code_section = data.get("customizations", {}).get("dev-code")
390
+ entries = []
391
+ if isinstance(dev_code_section, dict):
392
+ raw = dev_code_section.get("cp")
393
+ if isinstance(raw, list):
394
+ entries = raw
395
+
396
+ if not entries:
397
+ print("(dry run — no copy entries)")
398
+ return
399
+
400
+ print("Copy plan:")
401
+ config_dir = os.path.dirname(os.path.abspath(config_file))
402
+ for idx, entry in enumerate(entries):
403
+ source = entry.get("source", "")
404
+ target = entry.get("target", "(no target)")
405
+
406
+ # Env var substitution
407
+ if not cli_used:
408
+ unset_vars = [m.group(1) for m in re.finditer(r"\$\{localEnv:([^}]+)\}", source)
409
+ if not os.environ.get(m.group(1))]
410
+ if unset_vars:
411
+ logger.warning("entry %d: env var unset: %s", idx, ", ".join(unset_vars))
412
+ print(f" [{idx}] <unset: {unset_vars[0]}> → {target}")
413
+ continue
414
+ source = re.sub(r"\$\{localEnv:([^}]+)\}", lambda m: os.environ[m.group(1)], source)
415
+
416
+ # Relative path resolution
417
+ dot_expand = source.endswith("/.")
418
+ if dot_expand:
419
+ source = source[:-2] or "/"
420
+ if not os.path.isabs(source):
421
+ source = os.path.abspath(os.path.join(config_dir, source))
422
+ if dot_expand:
423
+ source += "/."
424
+
425
+ annotation = " [missing]" if not os.path.exists(source.rstrip("/.")) else ""
426
+ print(f" [{idx}] {source}{annotation} → {target}")
427
+
428
+ print("(dry run — no operations executed)")
429
+
430
+
431
+ def cmd_new(args) -> None:
432
+ """Create a new template by copying a base template."""
433
+ template_dir = resolve_template_dir()
434
+ dest = os.path.join(template_dir, args.name)
435
+
436
+ # Step 1: fail if name already exists
437
+ if os.path.exists(dest):
438
+ logger.error("template '%s' already exists: %s", args.name, dest)
439
+ sys.exit(1)
440
+
441
+ # Step 2: resolve base (check before creating dirs)
442
+ base_name = args.base or "dev-code"
443
+ base_user = os.path.join(template_dir, base_name)
444
+ if os.path.isdir(base_user):
445
+ base_src = base_user
446
+ else:
447
+ builtin = get_builtin_template_path(base_name)
448
+ if builtin:
449
+ base_src = builtin
450
+ else:
451
+ logger.error("base template not found: %s", base_name)
452
+ sys.exit(1)
453
+
454
+ # Step 3-4: create template dir
455
+ try:
456
+ os.makedirs(template_dir, exist_ok=True)
457
+ except OSError as e:
458
+ logger.error("cannot create template dir %s: %s", template_dir, e)
459
+ sys.exit(1)
460
+
461
+ # Step 5: copy
462
+ shutil.copytree(base_src, dest)
463
+ print(f"Created template '{args.name}' at {dest}")
464
+
465
+ # Step 6: --edit
466
+ if args.edit:
467
+ open_args = argparse.Namespace(
468
+ template=args.name,
469
+ projectpath=dest,
470
+ container_folder=None,
471
+ timeout=300,
472
+ dry_run=False,
473
+ )
474
+ cmd_open(open_args)
475
+
476
+
477
+ def cmd_edit(args) -> None:
478
+ """Open a template directory for editing using the built-in dev-code devcontainer."""
479
+ template_dir = resolve_template_dir()
480
+
481
+ if args.template is None:
482
+ if not os.path.isdir(template_dir):
483
+ logger.error("template dir not found: %s — run 'dev-code init' first", template_dir)
484
+ sys.exit(1)
485
+ project_path = template_dir
486
+ else:
487
+ project_path = os.path.join(template_dir, args.template)
488
+ if not os.path.isdir(project_path):
489
+ logger.error("template not found: %s", args.template)
490
+ sys.exit(1)
491
+
492
+ open_args = argparse.Namespace(
493
+ template="dev-code",
494
+ projectpath=project_path,
495
+ container_folder=None,
496
+ timeout=300,
497
+ dry_run=False,
498
+ )
499
+ cmd_open(open_args)
500
+
501
+
502
+ def cmd_init(args) -> None:
503
+ """Seed the built-in dev-code template into the user template dir."""
504
+ builtin = get_builtin_template_path("dev-code")
505
+ if builtin is None:
506
+ logger.error("built-in template 'dev-code' not found — packaging error")
507
+ sys.exit(1)
508
+
509
+ template_dir = resolve_template_dir()
510
+ dest = os.path.join(template_dir, "dev-code")
511
+
512
+ if os.path.exists(dest):
513
+ print(f"Skipped 'dev-code': already exists at {dest}")
514
+ return
515
+
516
+ try:
517
+ os.makedirs(template_dir, exist_ok=True)
518
+ except OSError as e:
519
+ logger.error("cannot create template dir %s: %s", template_dir, e)
520
+ sys.exit(1)
521
+
522
+ try:
523
+ shutil.copytree(builtin, dest)
524
+ except Exception as e:
525
+ logger.error("copy failed: %s", e)
526
+ sys.exit(1)
527
+
528
+ print(f"Copied built-in 'dev-code' to {dest}")
529
+
530
+
531
+ def cmd_list(args) -> None:
532
+ """List available templates."""
533
+ # Collect built-ins
534
+ builtins = []
535
+ module_dir = os.path.dirname(os.path.abspath(__file__))
536
+ builtin_base = os.path.join(module_dir, "dev_code_templates")
537
+ if os.path.isdir(builtin_base):
538
+ for name in sorted(os.listdir(builtin_base)):
539
+ p = os.path.join(builtin_base, name)
540
+ if os.path.isdir(p):
541
+ builtins.append((name, p))
542
+
543
+ # Collect user templates
544
+ template_dir = resolve_template_dir()
545
+ user = []
546
+ if os.path.isdir(template_dir):
547
+ for name in sorted(os.listdir(template_dir)):
548
+ p = os.path.join(template_dir, name)
549
+ if os.path.isdir(p):
550
+ user.append((name, p))
551
+
552
+ if not args.long:
553
+ for name, _ in builtins:
554
+ print(name)
555
+ for name, _ in user:
556
+ print(name)
557
+ if not user and not builtins:
558
+ print("(no templates — run 'dev-code init' to get started)")
559
+ elif not user:
560
+ print("(no user templates — run 'dev-code init' to get started)")
561
+ return
562
+
563
+ # --long output
564
+ print(f"Template dir: {template_dir}")
565
+ print()
566
+
567
+ all_names = [n for n, _ in builtins] + [n for n, _ in user]
568
+ col_w = max((len(n) for n in all_names), default=8) + 2
569
+
570
+ if builtins:
571
+ print("BUILT-IN")
572
+ for name, path in builtins:
573
+ print(f" {name:<{col_w}}{path}")
574
+ print()
575
+
576
+ if user:
577
+ print("USER")
578
+ for name, path in user:
579
+ print(f" {name:<{col_w}}{path}")
580
+ else:
581
+ print(" (no user templates — run 'dev-code init' to get started)")
582
+
583
+
584
+ def _template_name_from_config(config_path: str) -> str:
585
+ """Extract template name as the directory immediately above .devcontainer/."""
586
+ norm = os.path.normpath(config_path)
587
+ parts = norm.replace("\\", "/").split("/")
588
+ for i, part in enumerate(parts):
589
+ if part == ".devcontainer" and i > 0:
590
+ return parts[i - 1]
591
+ return os.path.basename(os.path.dirname(os.path.dirname(norm)))
592
+
593
+
594
+ def cmd_ps(args) -> None:
595
+ """List running devcontainers."""
596
+ fmt = "{{.ID}}\t{{.Label \"devcontainer.local_folder\"}}\t{{.Label \"devcontainer.config_file\"}}\t{{.Status}}"
597
+ result = subprocess.run(
598
+ ["docker", "container", "ls",
599
+ "--filter", "label=devcontainer.local_folder",
600
+ "--format", fmt],
601
+ capture_output=True, text=True,
602
+ )
603
+ if result.returncode != 0:
604
+ logger.error("docker ps failed — is Docker running?")
605
+ sys.exit(1)
606
+
607
+ rows = [line.split("\t") for line in result.stdout.splitlines() if line.strip()]
608
+ if not rows:
609
+ print("no running devcontainers")
610
+ return
611
+
612
+ home = os.path.expanduser("~")
613
+
614
+ def fmt_path(p):
615
+ return "~" + p[len(home):] if p.startswith(home) else p
616
+
617
+ # Build display rows
618
+ display = []
619
+ for row in rows:
620
+ if len(row) < 4:
621
+ continue
622
+ cid, folder, config, status = row[0], row[1], row[2], row[3]
623
+ template = _template_name_from_config(config) if config else "(unknown)"
624
+ display.append((cid[:12], template, fmt_path(folder), status))
625
+
626
+ # Column widths
627
+ headers = ("CONTAINER ID", "TEMPLATE", "PROJECT PATH", "STATUS")
628
+ widths = [max(len(h), max((len(r[i]) for r in display), default=0)) for i, h in enumerate(headers)]
629
+
630
+ def fmt_row(r):
631
+ return " ".join(f"{v:<{widths[i]}}" for i, v in enumerate(r))
632
+
633
+ print(fmt_row(headers))
634
+ for row in display:
635
+ print(fmt_row(row))
636
+
637
+
638
+ class _BannerFormatter(argparse.RawDescriptionHelpFormatter):
639
+ def format_help(self):
640
+ return BANNER + "\n\n" + super().format_help()
641
+
642
+
643
+ def main():
644
+ parser = argparse.ArgumentParser(
645
+ prog="dev-code",
646
+ formatter_class=_BannerFormatter,
647
+ )
648
+ parser.add_argument("-v", "--verbose", action="store_true")
649
+ subparsers = parser.add_subparsers(dest="subcommand")
650
+
651
+ p_open = subparsers.add_parser("open")
652
+ p_open.add_argument("template")
653
+ p_open.add_argument("projectpath")
654
+ p_open.add_argument("--container-folder")
655
+ p_open.add_argument("--timeout", type=int, default=300)
656
+ p_open.add_argument("--dry-run", action="store_true", dest="dry_run")
657
+
658
+ p_new = subparsers.add_parser("new")
659
+ p_new.add_argument("name")
660
+ p_new.add_argument("base", nargs="?")
661
+ p_new.add_argument("--edit", action="store_true")
662
+
663
+ p_edit = subparsers.add_parser("edit")
664
+ p_edit.add_argument("template", nargs="?")
665
+
666
+ subparsers.add_parser("init")
667
+
668
+ p_list = subparsers.add_parser("list")
669
+ p_list.add_argument("--long", action="store_true")
670
+
671
+ subparsers.add_parser("ps")
672
+
673
+ args = parser.parse_args()
674
+ if args.subcommand is None:
675
+ parser.print_help()
676
+ sys.exit(0)
677
+ _configure_logging(args.verbose)
678
+
679
+ dispatch = {
680
+ "open": cmd_open,
681
+ "new": cmd_new,
682
+ "edit": cmd_edit,
683
+ "init": cmd_init,
684
+ "list": cmd_list,
685
+ "ps": cmd_ps,
686
+ }
687
+ dispatch[args.subcommand](args)
688
+
689
+
690
+ if __name__ == "__main__":
691
+ main()
@@ -0,0 +1,56 @@
1
+ FROM mcr.microsoft.com/devcontainers/base:noble
2
+
3
+ # ===== Env =====
4
+ ARG USERNAME=vscode \
5
+ FZF_VERSION=0.68.0
6
+
7
+ ENV USERNAME=${USERNAME} \
8
+ USER_HOME=/home/${USERNAME} \
9
+ HISTFILE=/commandhistory/.history \
10
+ HISTSIZE=10000 \
11
+ EDITOR=vim \
12
+ VISUAL=vim
13
+ ENV SHELL=/bin/zsh
14
+
15
+ # ===== Install deps + fzf =====
16
+ RUN apt-get update && apt-get install -y --no-install-recommends \
17
+ fzf \
18
+ rsync \
19
+ vim \
20
+ gh \
21
+ && rm -rf /var/lib/apt/lists/* \
22
+ && ARCH="$(uname -m)" \
23
+ && [ "$ARCH" = "x86_64" ] && ARCH=amd64 || ARCH=arm64 \
24
+ && curl -fsSL "https://github.com/junegunn/fzf/releases/download/v${FZF_VERSION}/fzf-${FZF_VERSION}-linux_${ARCH}.tar.gz" \
25
+ | tar -xz -C /usr/local/bin
26
+
27
+ # ===== Setup history =====
28
+ RUN mkdir -p /commandhistory \
29
+ && touch /commandhistory/.history \
30
+ && chown -R ${USERNAME}:${USERNAME} /commandhistory
31
+
32
+ # ===== Install Powerlevel10k =====
33
+ RUN git clone --depth=1 https://github.com/romkatv/powerlevel10k.git \
34
+ ${USER_HOME}/.oh-my-zsh/custom/themes/powerlevel10k \
35
+ && chown -R ${USERNAME}:${USERNAME} ${USER_HOME}/.oh-my-zsh
36
+
37
+ # ===== Setup zsh config =====
38
+
39
+ RUN ZSHRC="${USER_HOME}/.zshrc" \
40
+ && sed -i "s/ZSH_THEME=\".*/ZSH_THEME=\"powerlevel10k\/powerlevel10k\"/g" "$ZSHRC" \
41
+ && cat <<EOF >> "$ZSHRC"
42
+ source <(fzf --zsh)
43
+ POWERLEVEL9K_DISABLE_CONFIGURATION_WIZARD=true
44
+ POWERLEVEL9K_SHORTEN_STRATEGY="truncate_to_last"
45
+ POWERLEVEL9K_LEFT_PROMPT_ELEMENTS=(user dir vcs status)
46
+ POWERLEVEL9K_RIGHT_PROMPT_ELEMENTS=()
47
+ POWERLEVEL9K_STATUS_OK=false
48
+ POWERLEVEL9K_STATUS_CROSS=true
49
+ POWERLEVEL9K_PROMPT_ON_NEWLINE=true
50
+ EOF
51
+
52
+ # ===== Fix USER HOME permissions =====
53
+ RUN chown -R ${USERNAME}:${USERNAME} ${USER_HOME}
54
+
55
+ # ===== Switch user =====
56
+ USER ${USERNAME}
@@ -0,0 +1,20 @@
1
+ // For format details, see https://aka.ms/devcontainer.json. For config options, see the
2
+ // README at: https://github.com/devcontainers/templates/tree/main/src/ubuntu
3
+ {
4
+ "name": "Dev",
5
+ // "image": "mcr.microsoft.com/devcontainers/base:noble",
6
+ "build": {
7
+ "dockerfile": "Dockerfile"
8
+ },
9
+ "runArgs": [
10
+ "--name", "dev-${localWorkspaceFolderBasename}-${devcontainerId}"
11
+ ],
12
+ "features": {
13
+ "ghcr.io/devcontainers-extra/features/uv:1": {},
14
+ },
15
+ "mounts": [
16
+ "source=devcontainer-history-${devcontainerId},target=/commandhistory,type=volume",
17
+ ],
18
+ "containerEnv": {},
19
+ "remoteUser": "vscode"
20
+ }