@jahanxu/trellis 0.5.9 → 0.6.1

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.
Files changed (107) hide show
  1. package/README.md +74 -130
  2. package/dist/cli/index.js +1 -0
  3. package/dist/cli/index.js.map +1 -1
  4. package/dist/commands/init.d.ts +1 -0
  5. package/dist/commands/init.d.ts.map +1 -1
  6. package/dist/commands/init.js +30 -2
  7. package/dist/commands/init.js.map +1 -1
  8. package/dist/commands/update.d.ts.map +1 -1
  9. package/dist/commands/update.js +11 -39
  10. package/dist/commands/update.js.map +1 -1
  11. package/dist/configurators/index.d.ts.map +1 -1
  12. package/dist/configurators/index.js +15 -3
  13. package/dist/configurators/index.js.map +1 -1
  14. package/dist/configurators/kilo.d.ts +1 -1
  15. package/dist/configurators/kilo.d.ts.map +1 -1
  16. package/dist/configurators/kilo.js +2 -1
  17. package/dist/configurators/kilo.js.map +1 -1
  18. package/dist/configurators/qoder.d.ts +8 -0
  19. package/dist/configurators/qoder.d.ts.map +1 -0
  20. package/dist/configurators/qoder.js +52 -0
  21. package/dist/configurators/qoder.js.map +1 -0
  22. package/dist/configurators/workflow.d.ts.map +1 -1
  23. package/dist/configurators/workflow.js +3 -1
  24. package/dist/configurators/workflow.js.map +1 -1
  25. package/dist/migrations/manifests/0.3.2.json +9 -0
  26. package/dist/migrations/manifests/0.3.3.json +9 -0
  27. package/dist/migrations/manifests/0.3.4.json +21 -0
  28. package/dist/migrations/manifests/0.3.5.json +9 -0
  29. package/dist/templates/claude/commands/trellis/record-session.md +12 -16
  30. package/dist/templates/codex/skills/record-session/SKILL.md +13 -17
  31. package/dist/templates/cursor/commands/trellis-record-session.md +12 -16
  32. package/dist/templates/extract.d.ts +7 -0
  33. package/dist/templates/extract.d.ts.map +1 -1
  34. package/dist/templates/extract.js +13 -0
  35. package/dist/templates/extract.js.map +1 -1
  36. package/dist/templates/gemini/commands/trellis/record-session.toml +12 -16
  37. package/dist/templates/iflow/commands/trellis/record-session.md +12 -16
  38. package/dist/templates/iflow/hooks/session-start.py +1 -0
  39. package/dist/templates/kilo/commands/trellis/record-session.md +12 -16
  40. package/dist/templates/kilo/index.d.ts +3 -3
  41. package/dist/templates/kilo/index.d.ts.map +1 -1
  42. package/dist/templates/kilo/index.js +7 -7
  43. package/dist/templates/kilo/index.js.map +1 -1
  44. package/dist/templates/kilo/workflows/before-backend-dev.md +13 -0
  45. package/dist/templates/kilo/workflows/before-frontend-dev.md +13 -0
  46. package/dist/templates/kilo/workflows/brainstorm.md +474 -0
  47. package/dist/templates/kilo/workflows/break-loop.md +125 -0
  48. package/dist/templates/kilo/workflows/check-backend.md +13 -0
  49. package/dist/templates/kilo/workflows/check-cross-layer.md +153 -0
  50. package/dist/templates/kilo/workflows/check-frontend.md +13 -0
  51. package/dist/templates/kilo/workflows/create-command.md +152 -0
  52. package/dist/templates/kilo/workflows/finish-work.md +129 -0
  53. package/dist/templates/kilo/workflows/integrate-skill.md +219 -0
  54. package/dist/templates/kilo/workflows/onboard.md +358 -0
  55. package/dist/templates/kilo/workflows/parallel.md +194 -0
  56. package/dist/templates/kilo/workflows/record-session.md +58 -0
  57. package/dist/templates/kilo/workflows/start.md +321 -0
  58. package/dist/templates/kilo/workflows/update-spec.md +285 -0
  59. package/dist/templates/kiro/skills/record-session/SKILL.md +13 -17
  60. package/dist/templates/opencode/commands/trellis/record-session.md +12 -16
  61. package/dist/templates/qoder/index.d.ts +18 -0
  62. package/dist/templates/qoder/index.d.ts.map +1 -0
  63. package/dist/templates/qoder/index.js +40 -0
  64. package/dist/templates/qoder/index.js.map +1 -0
  65. package/dist/templates/qoder/skills/before-backend-dev/SKILL.md +18 -0
  66. package/dist/templates/qoder/skills/before-frontend-dev/SKILL.md +18 -0
  67. package/dist/templates/qoder/skills/brainstorm/SKILL.md +479 -0
  68. package/dist/templates/qoder/skills/break-loop/SKILL.md +130 -0
  69. package/dist/templates/qoder/skills/check-backend/SKILL.md +18 -0
  70. package/dist/templates/qoder/skills/check-cross-layer/SKILL.md +158 -0
  71. package/dist/templates/qoder/skills/check-frontend/SKILL.md +18 -0
  72. package/dist/templates/qoder/skills/create-command/SKILL.md +101 -0
  73. package/dist/templates/qoder/skills/finish-work/SKILL.md +134 -0
  74. package/dist/templates/qoder/skills/integrate-skill/SKILL.md +221 -0
  75. package/dist/templates/qoder/skills/onboard/SKILL.md +363 -0
  76. package/dist/templates/qoder/skills/record-session/SKILL.md +63 -0
  77. package/dist/templates/qoder/skills/start/SKILL.md +326 -0
  78. package/dist/templates/qoder/skills/update-spec/SKILL.md +290 -0
  79. package/dist/templates/trellis/config.yaml +15 -0
  80. package/dist/templates/trellis/index.d.ts +3 -0
  81. package/dist/templates/trellis/index.d.ts.map +1 -1
  82. package/dist/templates/trellis/index.js +4 -0
  83. package/dist/templates/trellis/index.js.map +1 -1
  84. package/dist/templates/trellis/scripts/add_session.py +52 -21
  85. package/dist/templates/trellis/scripts/common/__init__.py +3 -1
  86. package/dist/templates/trellis/scripts/common/cli_adapter.py +125 -20
  87. package/dist/templates/trellis/scripts/common/config.py +52 -0
  88. package/dist/templates/trellis/scripts/common/git_context.py +121 -11
  89. package/dist/templates/trellis/scripts/multi_agent/plan.py +4 -1
  90. package/dist/templates/trellis/scripts/multi_agent/start.py +5 -1
  91. package/dist/templates/trellis/scripts/task.py +26 -0
  92. package/dist/types/ai-tools.d.ts +3 -3
  93. package/dist/types/ai-tools.d.ts.map +1 -1
  94. package/dist/types/ai-tools.js +8 -0
  95. package/dist/types/ai-tools.js.map +1 -1
  96. package/dist/utils/proxy.d.ts +25 -0
  97. package/dist/utils/proxy.d.ts.map +1 -0
  98. package/dist/utils/proxy.js +60 -0
  99. package/dist/utils/proxy.js.map +1 -0
  100. package/dist/utils/template-fetcher.d.ts +11 -2
  101. package/dist/utils/template-fetcher.d.ts.map +1 -1
  102. package/dist/utils/template-fetcher.js +92 -19
  103. package/dist/utils/template-fetcher.js.map +1 -1
  104. package/dist/utils/template-hash.d.ts.map +1 -1
  105. package/dist/utils/template-hash.js +1 -0
  106. package/dist/utils/template-hash.js.map +1 -1
  107. package/package.json +10 -9
@@ -10,18 +10,8 @@ Provides:
10
10
 
11
11
  from __future__ import annotations
12
12
 
13
- import sys
14
-
15
- # IMPORTANT: Force stdout to use UTF-8 on Windows
16
- # This fixes UnicodeEncodeError when outputting non-ASCII characters
17
- if sys.platform == "win32":
18
- import io as _io
19
- if hasattr(sys.stdout, "reconfigure"):
20
- sys.stdout.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr]
21
- elif hasattr(sys.stdout, "detach"):
22
- sys.stdout = _io.TextIOWrapper(sys.stdout.detach(), encoding="utf-8", errors="replace") # type: ignore[union-attr]
23
-
24
13
  import json
14
+ import sys
25
15
  import subprocess
26
16
  from pathlib import Path
27
17
 
@@ -345,6 +335,117 @@ def get_context_text(repo_root: Path | None = None) -> str:
345
335
  return "\n".join(lines)
346
336
 
347
337
 
338
+ def get_context_text_record(repo_root: Path | None = None) -> str:
339
+ """Get context as formatted text for record-session mode.
340
+
341
+ Focused output: MY ACTIVE TASKS first (with [!!!] emphasis),
342
+ then GIT STATUS, RECENT COMMITS, CURRENT TASK.
343
+
344
+ Args:
345
+ repo_root: Repository root path. Defaults to auto-detected.
346
+
347
+ Returns:
348
+ Formatted text output for record-session.
349
+ """
350
+ if repo_root is None:
351
+ repo_root = get_repo_root()
352
+
353
+ lines: list[str] = []
354
+ lines.append("========================================")
355
+ lines.append("SESSION CONTEXT (RECORD MODE)")
356
+ lines.append("========================================")
357
+ lines.append("")
358
+
359
+ developer = get_developer(repo_root)
360
+ if not developer:
361
+ lines.append(
362
+ f"ERROR: Not initialized. Run: python3 ./{DIR_WORKFLOW}/{DIR_SCRIPTS}/init_developer.py <name>"
363
+ )
364
+ return "\n".join(lines)
365
+
366
+ # MY ACTIVE TASKS — first and prominent
367
+ lines.append(f"## [!!!] MY ACTIVE TASKS (Assigned to {developer})")
368
+ lines.append("[!] Review whether any should be archived before recording this session.")
369
+ lines.append("")
370
+
371
+ tasks_dir = get_tasks_dir(repo_root)
372
+ my_task_count = 0
373
+
374
+ if tasks_dir.is_dir():
375
+ for d in sorted(tasks_dir.iterdir()):
376
+ if d.is_dir() and d.name != "archive":
377
+ t_json = d / FILE_TASK_JSON
378
+ if t_json.is_file():
379
+ data = _read_json_file(t_json)
380
+ if data:
381
+ assignee = data.get("assignee", "")
382
+ status = data.get("status", "planning")
383
+
384
+ if assignee == developer:
385
+ title = data.get("title") or data.get("name") or "unknown"
386
+ priority = data.get("priority", "P2")
387
+ lines.append(f"- [{priority}] {title} ({status}) — {d.name}")
388
+ my_task_count += 1
389
+
390
+ if my_task_count == 0:
391
+ lines.append("(no active tasks assigned to you)")
392
+ lines.append("")
393
+
394
+ # GIT STATUS
395
+ lines.append("## GIT STATUS")
396
+ _, branch_out, _ = _run_git_command(["branch", "--show-current"], cwd=repo_root)
397
+ branch = branch_out.strip() or "unknown"
398
+ lines.append(f"Branch: {branch}")
399
+
400
+ _, status_out, _ = _run_git_command(["status", "--porcelain"], cwd=repo_root)
401
+ status_lines = [line for line in status_out.splitlines() if line.strip()]
402
+ status_count = len(status_lines)
403
+
404
+ if status_count == 0:
405
+ lines.append("Working directory: Clean")
406
+ else:
407
+ lines.append(f"Working directory: {status_count} uncommitted change(s)")
408
+ lines.append("")
409
+ lines.append("Changes:")
410
+ _, short_out, _ = _run_git_command(["status", "--short"], cwd=repo_root)
411
+ for line in short_out.splitlines()[:10]:
412
+ lines.append(line)
413
+ lines.append("")
414
+
415
+ # RECENT COMMITS
416
+ lines.append("## RECENT COMMITS")
417
+ _, log_out, _ = _run_git_command(["log", "--oneline", "-5"], cwd=repo_root)
418
+ if log_out.strip():
419
+ for line in log_out.splitlines():
420
+ lines.append(line)
421
+ else:
422
+ lines.append("(no commits)")
423
+ lines.append("")
424
+
425
+ # CURRENT TASK
426
+ lines.append("## CURRENT TASK")
427
+ current_task = get_current_task(repo_root)
428
+ if current_task:
429
+ current_task_dir = repo_root / current_task
430
+ task_json_path = current_task_dir / FILE_TASK_JSON
431
+ lines.append(f"Path: {current_task}")
432
+
433
+ if task_json_path.is_file():
434
+ data = _read_json_file(task_json_path)
435
+ if data:
436
+ t_name = data.get("name") or data.get("id") or "unknown"
437
+ t_status = data.get("status", "unknown")
438
+ lines.append(f"Name: {t_name}")
439
+ lines.append(f"Status: {t_status}")
440
+ else:
441
+ lines.append("(none)")
442
+ lines.append("")
443
+
444
+ lines.append("========================================")
445
+
446
+ return "\n".join(lines)
447
+
448
+
348
449
  def output_text(repo_root: Path | None = None) -> None:
349
450
  """Output context in text format.
350
451
 
@@ -370,11 +471,20 @@ def main() -> None:
370
471
  action="store_true",
371
472
  help="Output context in JSON format",
372
473
  )
474
+ parser.add_argument(
475
+ "--mode",
476
+ "-m",
477
+ choices=["default", "record"],
478
+ default="default",
479
+ help="Output mode: default (full context) or record (for record-session)",
480
+ )
373
481
 
374
482
  args = parser.parse_args()
375
483
 
376
484
  if args.json:
377
485
  output_json()
486
+ elif args.mode == "record":
487
+ print(get_context_text_record())
378
488
  else:
379
489
  output_text()
380
490
 
@@ -77,7 +77,7 @@ def main() -> int:
77
77
  parser.add_argument("--requirement", "-r", required=True, help="Requirement description")
78
78
  parser.add_argument(
79
79
  "--platform", "-p",
80
- choices=["claude", "cursor", "iflow", "opencode"],
80
+ choices=["claude", "cursor", "iflow", "opencode", "qoder"],
81
81
  default=DEFAULT_PLATFORM,
82
82
  help="Platform to use (default: claude)"
83
83
  )
@@ -173,6 +173,9 @@ def main() -> int:
173
173
  env["http_proxy"] = http_proxy
174
174
  env["all_proxy"] = all_proxy
175
175
 
176
+ # Clear nested-session detection so the new CLI process can start
177
+ env.pop("CLAUDECODE", None)
178
+
176
179
  # Set non-interactive env var based on platform
177
180
  env.update(adapter.get_non_interactive_env())
178
181
 
@@ -124,7 +124,7 @@ def main() -> int:
124
124
  parser.add_argument("task_dir", help="Task directory path")
125
125
  parser.add_argument(
126
126
  "--platform", "-p",
127
- choices=["claude", "cursor", "iflow", "opencode"],
127
+ choices=["claude", "cursor", "iflow", "opencode", "qoder"],
128
128
  default=DEFAULT_PLATFORM,
129
129
  help="Platform to use (default: claude)"
130
130
  )
@@ -362,6 +362,10 @@ def main() -> int:
362
362
  env["http_proxy"] = http_proxy
363
363
  env["all_proxy"] = all_proxy
364
364
 
365
+ # Clear nested-session detection so the new CLI process can start
366
+ # (when this script runs inside a Claude Code session, CLAUDECODE=1 is inherited)
367
+ env.pop("CLAUDECODE", None)
368
+
365
369
  # Set non-interactive env var based on platform
366
370
  env.update(adapter.get_non_interactive_env())
367
371
 
@@ -659,6 +659,10 @@ def cmd_archive(args: argparse.Namespace) -> int:
659
659
  year_month = archive_dest.parent.name
660
660
  print(colored(f"Archived: {dir_name} -> archive/{year_month}/", Colors.GREEN), file=sys.stderr)
661
661
 
662
+ # Auto-commit unless --no-commit
663
+ if not getattr(args, "no_commit", False):
664
+ _auto_commit_archive(dir_name, repo_root)
665
+
662
666
  # Return the archive path
663
667
  print(f"{DIR_WORKFLOW}/{DIR_TASKS}/{DIR_ARCHIVE}/{year_month}/{dir_name}")
664
668
  return 0
@@ -666,6 +670,27 @@ def cmd_archive(args: argparse.Namespace) -> int:
666
670
  return 1
667
671
 
668
672
 
673
+ def _auto_commit_archive(task_name: str, repo_root: Path) -> None:
674
+ """Stage .trellis/tasks/ changes and commit after archive."""
675
+ tasks_rel = f"{DIR_WORKFLOW}/{DIR_TASKS}"
676
+ _run_git_command(["add", "-A", tasks_rel], cwd=repo_root)
677
+
678
+ # Check if there are staged changes
679
+ rc, _, _ = _run_git_command(
680
+ ["diff", "--cached", "--quiet", "--", tasks_rel], cwd=repo_root
681
+ )
682
+ if rc == 0:
683
+ print("[OK] No task changes to commit.", file=sys.stderr)
684
+ return
685
+
686
+ commit_msg = f"chore(task): archive {task_name}"
687
+ rc, _, err = _run_git_command(["commit", "-m", commit_msg], cwd=repo_root)
688
+ if rc == 0:
689
+ print(f"[OK] Auto-committed: {commit_msg}", file=sys.stderr)
690
+ else:
691
+ print(f"[WARN] Auto-commit failed: {err.strip()}", file=sys.stderr)
692
+
693
+
669
694
  # =============================================================================
670
695
  # Command: list
671
696
  # =============================================================================
@@ -1005,6 +1030,7 @@ def main() -> int:
1005
1030
  # archive
1006
1031
  p_archive = subparsers.add_parser("archive", help="Archive task")
1007
1032
  p_archive.add_argument("name", help="Task name")
1033
+ p_archive.add_argument("--no-commit", action="store_true", help="Skip auto git commit after archive")
1008
1034
 
1009
1035
  # list
1010
1036
  p_list = subparsers.add_parser("list", help="List tasks")
@@ -6,16 +6,16 @@
6
6
  /**
7
7
  * Supported AI coding tools
8
8
  */
9
- export type AITool = "claude-code" | "cursor" | "opencode" | "iflow" | "codex" | "kilo" | "kiro" | "gemini" | "antigravity";
9
+ export type AITool = "claude-code" | "cursor" | "opencode" | "iflow" | "codex" | "kilo" | "kiro" | "gemini" | "antigravity" | "qoder";
10
10
  /**
11
11
  * Template directory categories
12
12
  */
13
- export type TemplateDir = "common" | "claude" | "cursor" | "opencode" | "iflow" | "codex" | "kilo" | "kiro" | "gemini" | "antigravity";
13
+ export type TemplateDir = "common" | "claude" | "cursor" | "opencode" | "iflow" | "codex" | "kilo" | "kiro" | "gemini" | "antigravity" | "qoder";
14
14
  /**
15
15
  * CLI flag names for platform selection (e.g., --claude, --cursor, --kilo, --kiro, --gemini, --antigravity)
16
16
  * Must match keys in InitOptions (src/commands/init.ts)
17
17
  */
18
- export type CliFlag = "claude" | "cursor" | "opencode" | "iflow" | "codex" | "kilo" | "kiro" | "gemini" | "antigravity";
18
+ export type CliFlag = "claude" | "cursor" | "opencode" | "iflow" | "codex" | "kilo" | "kiro" | "gemini" | "antigravity" | "qoder";
19
19
  /**
20
20
  * Configuration for an AI tool
21
21
  */
@@ -1 +1 @@
1
- {"version":3,"file":"ai-tools.d.ts","sourceRoot":"","sources":["../../src/types/ai-tools.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH;;GAEG;AACH,MAAM,MAAM,MAAM,GACd,aAAa,GACb,QAAQ,GACR,UAAU,GACV,OAAO,GACP,OAAO,GACP,MAAM,GACN,MAAM,GACN,QAAQ,GACR,aAAa,CAAC;AAElB;;GAEG;AACH,MAAM,MAAM,WAAW,GACnB,QAAQ,GACR,QAAQ,GACR,QAAQ,GACR,UAAU,GACV,OAAO,GACP,OAAO,GACP,MAAM,GACN,MAAM,GACN,QAAQ,GACR,aAAa,CAAC;AAElB;;;GAGG;AACH,MAAM,MAAM,OAAO,GACf,QAAQ,GACR,QAAQ,GACR,UAAU,GACV,OAAO,GACP,OAAO,GACP,MAAM,GACN,MAAM,GACN,QAAQ,GACR,aAAa,CAAC;AAElB;;GAEG;AACH,MAAM,WAAW,YAAY;IAC3B,+BAA+B;IAC/B,IAAI,EAAE,MAAM,CAAC;IACb,kDAAkD;IAClD,YAAY,EAAE,WAAW,EAAE,CAAC;IAC5B,kEAAkE;IAClE,SAAS,EAAE,MAAM,CAAC;IAClB,qEAAqE;IACrE,OAAO,EAAE,OAAO,CAAC;IACjB,yEAAyE;IACzE,cAAc,EAAE,OAAO,CAAC;IACxB,+EAA+E;IAC/E,cAAc,EAAE,OAAO,CAAC;CACzB;AAED;;;;;;;;;;GAUG;AACH,eAAO,MAAM,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,YAAY,CAyEjD,CAAC;AAEF;;GAEG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,YAAY,CAExD;AAED;;GAEG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,WAAW,EAAE,CAE3D"}
1
+ {"version":3,"file":"ai-tools.d.ts","sourceRoot":"","sources":["../../src/types/ai-tools.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH;;GAEG;AACH,MAAM,MAAM,MAAM,GACd,aAAa,GACb,QAAQ,GACR,UAAU,GACV,OAAO,GACP,OAAO,GACP,MAAM,GACN,MAAM,GACN,QAAQ,GACR,aAAa,GACb,OAAO,CAAC;AAEZ;;GAEG;AACH,MAAM,MAAM,WAAW,GACnB,QAAQ,GACR,QAAQ,GACR,QAAQ,GACR,UAAU,GACV,OAAO,GACP,OAAO,GACP,MAAM,GACN,MAAM,GACN,QAAQ,GACR,aAAa,GACb,OAAO,CAAC;AAEZ;;;GAGG;AACH,MAAM,MAAM,OAAO,GACf,QAAQ,GACR,QAAQ,GACR,UAAU,GACV,OAAO,GACP,OAAO,GACP,MAAM,GACN,MAAM,GACN,QAAQ,GACR,aAAa,GACb,OAAO,CAAC;AAEZ;;GAEG;AACH,MAAM,WAAW,YAAY;IAC3B,+BAA+B;IAC/B,IAAI,EAAE,MAAM,CAAC;IACb,kDAAkD;IAClD,YAAY,EAAE,WAAW,EAAE,CAAC;IAC5B,kEAAkE;IAClE,SAAS,EAAE,MAAM,CAAC;IAClB,qEAAqE;IACrE,OAAO,EAAE,OAAO,CAAC;IACjB,yEAAyE;IACzE,cAAc,EAAE,OAAO,CAAC;IACxB,+EAA+E;IAC/E,cAAc,EAAE,OAAO,CAAC;CACzB;AAED;;;;;;;;;;GAUG;AACH,eAAO,MAAM,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,YAAY,CAiFjD,CAAC;AAEF;;GAEG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,YAAY,CAExD;AAED;;GAEG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,WAAW,EAAE,CAE3D"}
@@ -87,6 +87,14 @@ export const AI_TOOLS = {
87
87
  defaultChecked: false,
88
88
  hasPythonHooks: false,
89
89
  },
90
+ qoder: {
91
+ name: "Qoder",
92
+ templateDirs: ["common", "qoder"],
93
+ configDir: ".qoder",
94
+ cliFlag: "qoder",
95
+ defaultChecked: false,
96
+ hasPythonHooks: false,
97
+ },
90
98
  };
91
99
  /**
92
100
  * Get the configuration for a specific AI tool
@@ -1 +1 @@
1
- {"version":3,"file":"ai-tools.js","sourceRoot":"","sources":["../../src/types/ai-tools.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAgEH;;;;;;;;;;GAUG;AACH,MAAM,CAAC,MAAM,QAAQ,GAAiC;IACpD,aAAa,EAAE;QACb,IAAI,EAAE,aAAa;QACnB,YAAY,EAAE,CAAC,QAAQ,EAAE,QAAQ,CAAC;QAClC,SAAS,EAAE,SAAS;QACpB,OAAO,EAAE,QAAQ;QACjB,cAAc,EAAE,IAAI;QACpB,cAAc,EAAE,IAAI;KACrB;IACD,MAAM,EAAE;QACN,IAAI,EAAE,QAAQ;QACd,YAAY,EAAE,CAAC,QAAQ,EAAE,QAAQ,CAAC;QAClC,SAAS,EAAE,SAAS;QACpB,OAAO,EAAE,QAAQ;QACjB,cAAc,EAAE,IAAI;QACpB,cAAc,EAAE,KAAK;KACtB;IACD,QAAQ,EAAE;QACR,IAAI,EAAE,UAAU;QAChB,YAAY,EAAE,CAAC,QAAQ,EAAE,UAAU,CAAC;QACpC,SAAS,EAAE,WAAW;QACtB,OAAO,EAAE,UAAU;QACnB,cAAc,EAAE,KAAK;QACrB,cAAc,EAAE,KAAK;KACtB;IACD,KAAK,EAAE;QACL,IAAI,EAAE,WAAW;QACjB,YAAY,EAAE,CAAC,QAAQ,EAAE,OAAO,CAAC;QACjC,SAAS,EAAE,QAAQ;QACnB,OAAO,EAAE,OAAO;QAChB,cAAc,EAAE,KAAK;QACrB,cAAc,EAAE,IAAI;KACrB;IACD,KAAK,EAAE;QACL,IAAI,EAAE,OAAO;QACb,YAAY,EAAE,CAAC,QAAQ,EAAE,OAAO,CAAC;QACjC,SAAS,EAAE,gBAAgB;QAC3B,OAAO,EAAE,OAAO;QAChB,cAAc,EAAE,KAAK;QACrB,cAAc,EAAE,KAAK;KACtB;IACD,IAAI,EAAE;QACJ,IAAI,EAAE,UAAU;QAChB,YAAY,EAAE,CAAC,QAAQ,EAAE,MAAM,CAAC;QAChC,SAAS,EAAE,WAAW;QACtB,OAAO,EAAE,MAAM;QACf,cAAc,EAAE,KAAK;QACrB,cAAc,EAAE,KAAK;KACtB;IACD,IAAI,EAAE;QACJ,IAAI,EAAE,WAAW;QACjB,YAAY,EAAE,CAAC,QAAQ,EAAE,MAAM,CAAC;QAChC,SAAS,EAAE,cAAc;QACzB,OAAO,EAAE,MAAM;QACf,cAAc,EAAE,KAAK;QACrB,cAAc,EAAE,KAAK;KACtB;IACD,MAAM,EAAE;QACN,IAAI,EAAE,YAAY;QAClB,YAAY,EAAE,CAAC,QAAQ,EAAE,QAAQ,CAAC;QAClC,SAAS,EAAE,SAAS;QACpB,OAAO,EAAE,QAAQ;QACjB,cAAc,EAAE,KAAK;QACrB,cAAc,EAAE,KAAK;KACtB;IACD,WAAW,EAAE;QACX,IAAI,EAAE,aAAa;QACnB,YAAY,EAAE,CAAC,QAAQ,EAAE,aAAa,CAAC;QACvC,SAAS,EAAE,kBAAkB;QAC7B,OAAO,EAAE,aAAa;QACtB,cAAc,EAAE,KAAK;QACrB,cAAc,EAAE,KAAK;KACtB;CACF,CAAC;AAEF;;GAEG;AACH,MAAM,UAAU,aAAa,CAAC,IAAY;IACxC,OAAO,QAAQ,CAAC,IAAI,CAAC,CAAC;AACxB,CAAC;AAED;;GAEG;AACH,MAAM,UAAU,eAAe,CAAC,IAAY;IAC1C,OAAO,QAAQ,CAAC,IAAI,CAAC,CAAC,YAAY,CAAC;AACrC,CAAC"}
1
+ {"version":3,"file":"ai-tools.js","sourceRoot":"","sources":["../../src/types/ai-tools.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAmEH;;;;;;;;;;GAUG;AACH,MAAM,CAAC,MAAM,QAAQ,GAAiC;IACpD,aAAa,EAAE;QACb,IAAI,EAAE,aAAa;QACnB,YAAY,EAAE,CAAC,QAAQ,EAAE,QAAQ,CAAC;QAClC,SAAS,EAAE,SAAS;QACpB,OAAO,EAAE,QAAQ;QACjB,cAAc,EAAE,IAAI;QACpB,cAAc,EAAE,IAAI;KACrB;IACD,MAAM,EAAE;QACN,IAAI,EAAE,QAAQ;QACd,YAAY,EAAE,CAAC,QAAQ,EAAE,QAAQ,CAAC;QAClC,SAAS,EAAE,SAAS;QACpB,OAAO,EAAE,QAAQ;QACjB,cAAc,EAAE,IAAI;QACpB,cAAc,EAAE,KAAK;KACtB;IACD,QAAQ,EAAE;QACR,IAAI,EAAE,UAAU;QAChB,YAAY,EAAE,CAAC,QAAQ,EAAE,UAAU,CAAC;QACpC,SAAS,EAAE,WAAW;QACtB,OAAO,EAAE,UAAU;QACnB,cAAc,EAAE,KAAK;QACrB,cAAc,EAAE,KAAK;KACtB;IACD,KAAK,EAAE;QACL,IAAI,EAAE,WAAW;QACjB,YAAY,EAAE,CAAC,QAAQ,EAAE,OAAO,CAAC;QACjC,SAAS,EAAE,QAAQ;QACnB,OAAO,EAAE,OAAO;QAChB,cAAc,EAAE,KAAK;QACrB,cAAc,EAAE,IAAI;KACrB;IACD,KAAK,EAAE;QACL,IAAI,EAAE,OAAO;QACb,YAAY,EAAE,CAAC,QAAQ,EAAE,OAAO,CAAC;QACjC,SAAS,EAAE,gBAAgB;QAC3B,OAAO,EAAE,OAAO;QAChB,cAAc,EAAE,KAAK;QACrB,cAAc,EAAE,KAAK;KACtB;IACD,IAAI,EAAE;QACJ,IAAI,EAAE,UAAU;QAChB,YAAY,EAAE,CAAC,QAAQ,EAAE,MAAM,CAAC;QAChC,SAAS,EAAE,WAAW;QACtB,OAAO,EAAE,MAAM;QACf,cAAc,EAAE,KAAK;QACrB,cAAc,EAAE,KAAK;KACtB;IACD,IAAI,EAAE;QACJ,IAAI,EAAE,WAAW;QACjB,YAAY,EAAE,CAAC,QAAQ,EAAE,MAAM,CAAC;QAChC,SAAS,EAAE,cAAc;QACzB,OAAO,EAAE,MAAM;QACf,cAAc,EAAE,KAAK;QACrB,cAAc,EAAE,KAAK;KACtB;IACD,MAAM,EAAE;QACN,IAAI,EAAE,YAAY;QAClB,YAAY,EAAE,CAAC,QAAQ,EAAE,QAAQ,CAAC;QAClC,SAAS,EAAE,SAAS;QACpB,OAAO,EAAE,QAAQ;QACjB,cAAc,EAAE,KAAK;QACrB,cAAc,EAAE,KAAK;KACtB;IACD,WAAW,EAAE;QACX,IAAI,EAAE,aAAa;QACnB,YAAY,EAAE,CAAC,QAAQ,EAAE,aAAa,CAAC;QACvC,SAAS,EAAE,kBAAkB;QAC7B,OAAO,EAAE,aAAa;QACtB,cAAc,EAAE,KAAK;QACrB,cAAc,EAAE,KAAK;KACtB;IACD,KAAK,EAAE;QACL,IAAI,EAAE,OAAO;QACb,YAAY,EAAE,CAAC,QAAQ,EAAE,OAAO,CAAC;QACjC,SAAS,EAAE,QAAQ;QACnB,OAAO,EAAE,OAAO;QAChB,cAAc,EAAE,KAAK;QACrB,cAAc,EAAE,KAAK;KACtB;CACF,CAAC;AAEF;;GAEG;AACH,MAAM,UAAU,aAAa,CAAC,IAAY;IACxC,OAAO,QAAQ,CAAC,IAAI,CAAC,CAAC;AACxB,CAAC;AAED;;GAEG;AACH,MAAM,UAAU,eAAe,CAAC,IAAY;IAC1C,OAAO,QAAQ,CAAC,IAAI,CAAC,CAAC,YAAY,CAAC;AACrC,CAAC"}
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Proxy detection and configuration for network requests.
3
+ *
4
+ * Detects HTTP_PROXY / HTTPS_PROXY / ALL_PROXY environment variables
5
+ * and configures undici's global dispatcher so that all fetch() calls
6
+ * (including those made internally by giget) go through the proxy.
7
+ */
8
+ /**
9
+ * Mask credentials in a proxy URL for safe logging.
10
+ *
11
+ * Replaces username and password with "***" so that credentials
12
+ * are never printed to the console or written to log files.
13
+ */
14
+ export declare function maskProxyUrl(url: string): string;
15
+ /**
16
+ * Set up a global proxy dispatcher if proxy environment variables are present.
17
+ *
18
+ * Uses undici's ProxyAgent and setGlobalDispatcher so that all fetch() calls
19
+ * go through the proxy. The try/catch handles malformed proxy URLs (e.g.
20
+ * socks5://, missing protocol) that would cause `new ProxyAgent()` to throw.
21
+ *
22
+ * @returns The proxy URL string if a proxy was configured, or null otherwise.
23
+ */
24
+ export declare function setupProxy(): string | null;
25
+ //# sourceMappingURL=proxy.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"proxy.d.ts","sourceRoot":"","sources":["../../src/utils/proxy.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAIH;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAWhD;AAED;;;;;;;;GAQG;AACH,wBAAgB,UAAU,IAAI,MAAM,GAAG,IAAI,CAyB1C"}
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Proxy detection and configuration for network requests.
3
+ *
4
+ * Detects HTTP_PROXY / HTTPS_PROXY / ALL_PROXY environment variables
5
+ * and configures undici's global dispatcher so that all fetch() calls
6
+ * (including those made internally by giget) go through the proxy.
7
+ */
8
+ import { ProxyAgent, setGlobalDispatcher } from "undici";
9
+ /**
10
+ * Mask credentials in a proxy URL for safe logging.
11
+ *
12
+ * Replaces username and password with "***" so that credentials
13
+ * are never printed to the console or written to log files.
14
+ */
15
+ export function maskProxyUrl(url) {
16
+ try {
17
+ const parsed = new URL(url);
18
+ if (parsed.username || parsed.password) {
19
+ parsed.username = "***";
20
+ parsed.password = "***";
21
+ }
22
+ return parsed.toString();
23
+ }
24
+ catch {
25
+ return "***";
26
+ }
27
+ }
28
+ /**
29
+ * Set up a global proxy dispatcher if proxy environment variables are present.
30
+ *
31
+ * Uses undici's ProxyAgent and setGlobalDispatcher so that all fetch() calls
32
+ * go through the proxy. The try/catch handles malformed proxy URLs (e.g.
33
+ * socks5://, missing protocol) that would cause `new ProxyAgent()` to throw.
34
+ *
35
+ * @returns The proxy URL string if a proxy was configured, or null otherwise.
36
+ */
37
+ export function setupProxy() {
38
+ const candidates = [
39
+ process.env.HTTPS_PROXY,
40
+ process.env.https_proxy,
41
+ process.env.HTTP_PROXY,
42
+ process.env.http_proxy,
43
+ process.env.ALL_PROXY,
44
+ process.env.all_proxy,
45
+ ];
46
+ const proxyUrl = candidates.find((v) => v != null && v !== "");
47
+ if (!proxyUrl) {
48
+ return null;
49
+ }
50
+ try {
51
+ const agent = new ProxyAgent(proxyUrl);
52
+ setGlobalDispatcher(agent);
53
+ return proxyUrl;
54
+ }
55
+ catch {
56
+ console.warn("Warning: Could not configure proxy. The proxy URL may be malformed.");
57
+ return null;
58
+ }
59
+ }
60
+ //# sourceMappingURL=proxy.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"proxy.js","sourceRoot":"","sources":["../../src/utils/proxy.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,EAAE,UAAU,EAAE,mBAAmB,EAAE,MAAM,QAAQ,CAAC;AAEzD;;;;;GAKG;AACH,MAAM,UAAU,YAAY,CAAC,GAAW;IACtC,IAAI,CAAC;QACH,MAAM,MAAM,GAAG,IAAI,GAAG,CAAC,GAAG,CAAC,CAAC;QAC5B,IAAI,MAAM,CAAC,QAAQ,IAAI,MAAM,CAAC,QAAQ,EAAE,CAAC;YACvC,MAAM,CAAC,QAAQ,GAAG,KAAK,CAAC;YACxB,MAAM,CAAC,QAAQ,GAAG,KAAK,CAAC;QAC1B,CAAC;QACD,OAAO,MAAM,CAAC,QAAQ,EAAE,CAAC;IAC3B,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,KAAK,CAAC;IACf,CAAC;AACH,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,UAAU,UAAU;IACxB,MAAM,UAAU,GAAG;QACjB,OAAO,CAAC,GAAG,CAAC,WAAW;QACvB,OAAO,CAAC,GAAG,CAAC,WAAW;QACvB,OAAO,CAAC,GAAG,CAAC,UAAU;QACtB,OAAO,CAAC,GAAG,CAAC,UAAU;QACtB,OAAO,CAAC,GAAG,CAAC,SAAS;QACrB,OAAO,CAAC,GAAG,CAAC,SAAS;KACtB,CAAC;IACF,MAAM,QAAQ,GAAG,UAAU,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,IAAI,IAAI,IAAI,CAAC,KAAK,EAAE,CAAC,CAAC;IAE/D,IAAI,CAAC,QAAQ,EAAE,CAAC;QACd,OAAO,IAAI,CAAC;IACd,CAAC;IAED,IAAI,CAAC;QACH,MAAM,KAAK,GAAG,IAAI,UAAU,CAAC,QAAQ,CAAC,CAAC;QACvC,mBAAmB,CAAC,KAAK,CAAC,CAAC;QAC3B,OAAO,QAAQ,CAAC;IAClB,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,CAAC,IAAI,CACV,qEAAqE,CACtE,CAAC;QACF,OAAO,IAAI,CAAC;IACd,CAAC;AACH,CAAC"}
@@ -4,6 +4,14 @@
4
4
  * Fetches spec templates from the official docs repository:
5
5
  * https://github.com/mindfold-ai/docs/tree/main/marketplace
6
6
  */
7
+ export declare const TEMPLATE_INDEX_URL = "https://raw.githubusercontent.com/mindfold-ai/docs/main/marketplace/index.json";
8
+ /** Timeout constants for network operations */
9
+ export declare const TIMEOUTS: {
10
+ /** Timeout for fetching the template index (ms) */
11
+ readonly INDEX_FETCH_MS: 5000;
12
+ /** Timeout for downloading a template via giget (ms) */
13
+ readonly DOWNLOAD_MS: 30000;
14
+ };
7
15
  export interface SpecTemplate {
8
16
  id: string;
9
17
  type: string;
@@ -15,7 +23,7 @@ export interface SpecTemplate {
15
23
  export type TemplateStrategy = "skip" | "overwrite" | "append";
16
24
  /**
17
25
  * Fetch available templates from the remote index
18
- * Returns empty array on network error (allows fallback to blank)
26
+ * Returns empty array on network error or timeout (allows fallback to blank)
19
27
  */
20
28
  export declare function fetchTemplateIndex(): Promise<SpecTemplate[]>;
21
29
  /**
@@ -41,9 +49,10 @@ export declare function downloadWithStrategy(templatePath: string, destDir: stri
41
49
  * @param cwd - Current working directory
42
50
  * @param templateId - Template ID from the index
43
51
  * @param strategy - How to handle existing directory
52
+ * @param template - Optional pre-fetched SpecTemplate to avoid double-fetch
44
53
  * @returns Object with success status and message
45
54
  */
46
- export declare function downloadTemplateById(cwd: string, templateId: string, strategy: TemplateStrategy): Promise<{
55
+ export declare function downloadTemplateById(cwd: string, templateId: string, strategy: TemplateStrategy, template?: SpecTemplate): Promise<{
47
56
  success: boolean;
48
57
  message: string;
49
58
  skipped?: boolean;
@@ -1 +1 @@
1
- {"version":3,"file":"template-fetcher.d.ts","sourceRoot":"","sources":["../../src/utils/template-fetcher.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AA4BH,MAAM,WAAW,YAAY;IAC3B,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,MAAM,EAAE,CAAC;CACjB;AAOD,MAAM,MAAM,gBAAgB,GAAG,MAAM,GAAG,WAAW,GAAG,QAAQ,CAAC;AAM/D;;;GAGG;AACH,wBAAsB,kBAAkB,IAAI,OAAO,CAAC,YAAY,EAAE,CAAC,CAYlE;AAED;;GAEG;AACH,wBAAsB,YAAY,CAChC,UAAU,EAAE,MAAM,GACjB,OAAO,CAAC,YAAY,GAAG,IAAI,CAAC,CAG9B;AAMD;;GAEG;AACH,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,GAAG,MAAM,CAGxE;AAED;;;;;;;GAOG;AACH,wBAAsB,oBAAoB,CACxC,YAAY,EAAE,MAAM,EACpB,OAAO,EAAE,MAAM,EACf,QAAQ,EAAE,gBAAgB,GACzB,OAAO,CAAC,OAAO,CAAC,CAmClB;AA2BD;;;;;;;GAOG;AACH,wBAAsB,oBAAoB,CACxC,GAAG,EAAE,MAAM,EACX,UAAU,EAAE,MAAM,EAClB,QAAQ,EAAE,gBAAgB,GACzB,OAAO,CAAC;IAAE,OAAO,EAAE,OAAO,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE,OAAO,CAAA;CAAE,CAAC,CA6CnE"}
1
+ {"version":3,"file":"template-fetcher.d.ts","sourceRoot":"","sources":["../../src/utils/template-fetcher.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAWH,eAAO,MAAM,kBAAkB,mFACmD,CAAC;AAYnF,+CAA+C;AAC/C,eAAO,MAAM,QAAQ;IACnB,mDAAmD;;IAEnD,wDAAwD;;CAEhD,CAAC;AAMX,MAAM,WAAW,YAAY;IAC3B,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,MAAM,EAAE,CAAC;CACjB;AAOD,MAAM,MAAM,gBAAgB,GAAG,MAAM,GAAG,WAAW,GAAG,QAAQ,CAAC;AAgC/D;;;GAGG;AACH,wBAAsB,kBAAkB,IAAI,OAAO,CAAC,YAAY,EAAE,CAAC,CAclE;AAED;;GAEG;AACH,wBAAsB,YAAY,CAChC,UAAU,EAAE,MAAM,GACjB,OAAO,CAAC,YAAY,GAAG,IAAI,CAAC,CAG9B;AAMD;;GAEG;AACH,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,GAAG,MAAM,CAGxE;AAED;;;;;;;GAOG;AACH,wBAAsB,oBAAoB,CACxC,YAAY,EAAE,MAAM,EACpB,OAAO,EAAE,MAAM,EACf,QAAQ,EAAE,gBAAgB,GACzB,OAAO,CAAC,OAAO,CAAC,CAuElB;AA2BD;;;;;;;;GAQG;AACH,wBAAsB,oBAAoB,CACxC,GAAG,EAAE,MAAM,EACX,UAAU,EAAE,MAAM,EAClB,QAAQ,EAAE,gBAAgB,EAC1B,QAAQ,CAAC,EAAE,YAAY,GACtB,OAAO,CAAC;IAAE,OAAO,EAAE,OAAO,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE,OAAO,CAAA;CAAE,CAAC,CAgEnE"}
@@ -11,7 +11,7 @@ import { downloadTemplate } from "giget";
11
11
  // =============================================================================
12
12
  // Constants
13
13
  // =============================================================================
14
- const TEMPLATE_INDEX_URL = "https://raw.githubusercontent.com/mindfold-ai/docs/main/marketplace/index.json";
14
+ export const TEMPLATE_INDEX_URL = "https://raw.githubusercontent.com/mindfold-ai/docs/main/marketplace/index.json";
15
15
  const TEMPLATE_REPO = "gh:mindfold-ai/docs";
16
16
  /** Map template type to installation path */
17
17
  const INSTALL_PATHS = {
@@ -20,16 +20,42 @@ const INSTALL_PATHS = {
20
20
  command: ".claude/commands",
21
21
  full: ".", // Entire project root
22
22
  };
23
+ /** Timeout constants for network operations */
24
+ export const TIMEOUTS = {
25
+ /** Timeout for fetching the template index (ms) */
26
+ INDEX_FETCH_MS: 5_000,
27
+ /** Timeout for downloading a template via giget (ms) */
28
+ DOWNLOAD_MS: 30_000,
29
+ };
30
+ // =============================================================================
31
+ // Helpers
32
+ // =============================================================================
33
+ /**
34
+ * Race a promise against a timeout.
35
+ * giget does not support AbortSignal, so we use Promise.race instead.
36
+ * The timer is cleaned up on success to avoid keeping the process alive.
37
+ */
38
+ function withTimeout(promise, ms, label) {
39
+ let timer;
40
+ const timeoutPromise = new Promise((_, reject) => {
41
+ timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms / 1000}s`)), ms);
42
+ });
43
+ return Promise.race([promise, timeoutPromise]).finally(() => {
44
+ clearTimeout(timer);
45
+ });
46
+ }
23
47
  // =============================================================================
24
48
  // Fetch Template Index
25
49
  // =============================================================================
26
50
  /**
27
51
  * Fetch available templates from the remote index
28
- * Returns empty array on network error (allows fallback to blank)
52
+ * Returns empty array on network error or timeout (allows fallback to blank)
29
53
  */
30
54
  export async function fetchTemplateIndex() {
31
55
  try {
32
- const res = await fetch(TEMPLATE_INDEX_URL);
56
+ const res = await fetch(TEMPLATE_INDEX_URL, {
57
+ signal: AbortSignal.timeout(TIMEOUTS.INDEX_FETCH_MS),
58
+ });
33
59
  if (!res.ok) {
34
60
  throw new Error(`HTTP ${res.status}`);
35
61
  }
@@ -37,7 +63,7 @@ export async function fetchTemplateIndex() {
37
63
  return index.templates;
38
64
  }
39
65
  catch {
40
- // Network error - return empty array, caller will fallback to blank
66
+ // Network error or timeout - return empty array, caller will fallback to blank
41
67
  return [];
42
68
  }
43
69
  }
@@ -80,12 +106,27 @@ export async function downloadWithStrategy(templatePath, destDir, strategy) {
80
106
  if (strategy === "append" && exists) {
81
107
  const tempDir = path.join(os.tmpdir(), `trellis-template-${Date.now()}`);
82
108
  try {
83
- await downloadTemplate(`${TEMPLATE_REPO}/${templatePath}`, {
109
+ await withTimeout(downloadTemplate(`${TEMPLATE_REPO}/${templatePath}`, {
84
110
  dir: tempDir,
85
111
  preferOffline: true,
86
- });
112
+ }), TIMEOUTS.DOWNLOAD_MS, "Template download");
87
113
  await copyMissing(tempDir, destDir);
88
114
  }
115
+ catch (error) {
116
+ // Clean up partially written files on timeout.
117
+ // Note: giget does not support AbortSignal, so the background download may
118
+ // still be running. Removing the directory causes it to fail with ENOENT,
119
+ // which settles the orphaned promise harmlessly.
120
+ if (error instanceof Error && error.message.includes("timed out")) {
121
+ try {
122
+ fs.rmSync(tempDir, { recursive: true, force: true });
123
+ }
124
+ catch {
125
+ // Best-effort cleanup
126
+ }
127
+ }
128
+ throw error;
129
+ }
89
130
  finally {
90
131
  // Clean up temp directory
91
132
  await fs.promises.rm(tempDir, { recursive: true, force: true });
@@ -93,10 +134,27 @@ export async function downloadWithStrategy(templatePath, destDir, strategy) {
93
134
  return true;
94
135
  }
95
136
  // Default: Direct download (for new directory or after overwrite)
96
- await downloadTemplate(`${TEMPLATE_REPO}/${templatePath}`, {
97
- dir: destDir,
98
- preferOffline: true,
99
- });
137
+ try {
138
+ await withTimeout(downloadTemplate(`${TEMPLATE_REPO}/${templatePath}`, {
139
+ dir: destDir,
140
+ preferOffline: true,
141
+ }), TIMEOUTS.DOWNLOAD_MS, "Template download");
142
+ }
143
+ catch (error) {
144
+ // Clean up partially written files on timeout.
145
+ // Note: giget does not support AbortSignal, so the background download may
146
+ // still be running. Removing the directory causes it to fail with ENOENT,
147
+ // which settles the orphaned promise harmlessly.
148
+ if (error instanceof Error && error.message.includes("timed out")) {
149
+ try {
150
+ fs.rmSync(destDir, { recursive: true, force: true });
151
+ }
152
+ catch {
153
+ // Best-effort cleanup
154
+ }
155
+ }
156
+ throw error;
157
+ }
100
158
  return true;
101
159
  }
102
160
  /**
@@ -127,26 +185,27 @@ async function copyMissing(src, dest) {
127
185
  * @param cwd - Current working directory
128
186
  * @param templateId - Template ID from the index
129
187
  * @param strategy - How to handle existing directory
188
+ * @param template - Optional pre-fetched SpecTemplate to avoid double-fetch
130
189
  * @returns Object with success status and message
131
190
  */
132
- export async function downloadTemplateById(cwd, templateId, strategy) {
133
- // Find template in index
134
- const template = await findTemplate(templateId);
135
- if (!template) {
191
+ export async function downloadTemplateById(cwd, templateId, strategy, template) {
192
+ // Use pre-fetched template or find from index
193
+ const resolved = template ?? (await findTemplate(templateId));
194
+ if (!resolved) {
136
195
  return {
137
196
  success: false,
138
197
  message: `Template "${templateId}" not found`,
139
198
  };
140
199
  }
141
200
  // Only support spec type in MVP
142
- if (template.type !== "spec") {
201
+ if (resolved.type !== "spec") {
143
202
  return {
144
203
  success: false,
145
- message: `Template type "${template.type}" is not supported yet (only "spec" is supported)`,
204
+ message: `Template type "${resolved.type}" is not supported yet (only "spec" is supported)`,
146
205
  };
147
206
  }
148
207
  // Get destination path
149
- const destDir = getInstallPath(cwd, template.type);
208
+ const destDir = getInstallPath(cwd, resolved.type);
150
209
  // Check if directory exists for skip strategy
151
210
  if (strategy === "skip" && fs.existsSync(destDir)) {
152
211
  return {
@@ -157,7 +216,7 @@ export async function downloadTemplateById(cwd, templateId, strategy) {
157
216
  }
158
217
  // Download template
159
218
  try {
160
- await downloadWithStrategy(template.path, destDir, strategy);
219
+ await downloadWithStrategy(resolved.path, destDir, strategy);
161
220
  return {
162
221
  success: true,
163
222
  message: `Downloaded template "${templateId}" to ${destDir}`,
@@ -165,9 +224,23 @@ export async function downloadTemplateById(cwd, templateId, strategy) {
165
224
  }
166
225
  catch (error) {
167
226
  const errorMessage = error instanceof Error ? error.message : "Unknown error";
227
+ // Classify errors for user-friendly messages
228
+ if (errorMessage.includes("timed out")) {
229
+ return {
230
+ success: false,
231
+ message: "Download timed out. Check your network connection and try again.",
232
+ };
233
+ }
234
+ if (errorMessage.includes("Failed to download") ||
235
+ errorMessage.includes("Failed to fetch")) {
236
+ return {
237
+ success: false,
238
+ message: "Could not reach template server. Check your network connection.",
239
+ };
240
+ }
168
241
  return {
169
242
  success: false,
170
- message: `Failed to download template: ${errorMessage}`,
243
+ message: `Download failed: ${errorMessage}`,
171
244
  };
172
245
  }
173
246
  }