galangal-orchestrate 0.2.11__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.

Potentially problematic release.


This version of galangal-orchestrate might be problematic. Click here for more details.

Files changed (49) hide show
  1. galangal/__init__.py +8 -0
  2. galangal/__main__.py +6 -0
  3. galangal/ai/__init__.py +6 -0
  4. galangal/ai/base.py +55 -0
  5. galangal/ai/claude.py +278 -0
  6. galangal/ai/gemini.py +38 -0
  7. galangal/cli.py +296 -0
  8. galangal/commands/__init__.py +42 -0
  9. galangal/commands/approve.py +187 -0
  10. galangal/commands/complete.py +268 -0
  11. galangal/commands/init.py +173 -0
  12. galangal/commands/list.py +20 -0
  13. galangal/commands/pause.py +40 -0
  14. galangal/commands/prompts.py +98 -0
  15. galangal/commands/reset.py +43 -0
  16. galangal/commands/resume.py +29 -0
  17. galangal/commands/skip.py +216 -0
  18. galangal/commands/start.py +144 -0
  19. galangal/commands/status.py +62 -0
  20. galangal/commands/switch.py +28 -0
  21. galangal/config/__init__.py +13 -0
  22. galangal/config/defaults.py +133 -0
  23. galangal/config/loader.py +113 -0
  24. galangal/config/schema.py +155 -0
  25. galangal/core/__init__.py +18 -0
  26. galangal/core/artifacts.py +66 -0
  27. galangal/core/state.py +248 -0
  28. galangal/core/tasks.py +170 -0
  29. galangal/core/workflow.py +835 -0
  30. galangal/prompts/__init__.py +5 -0
  31. galangal/prompts/builder.py +166 -0
  32. galangal/prompts/defaults/design.md +54 -0
  33. galangal/prompts/defaults/dev.md +39 -0
  34. galangal/prompts/defaults/docs.md +46 -0
  35. galangal/prompts/defaults/pm.md +75 -0
  36. galangal/prompts/defaults/qa.md +49 -0
  37. galangal/prompts/defaults/review.md +65 -0
  38. galangal/prompts/defaults/security.md +68 -0
  39. galangal/prompts/defaults/test.md +59 -0
  40. galangal/ui/__init__.py +5 -0
  41. galangal/ui/console.py +123 -0
  42. galangal/ui/tui.py +1065 -0
  43. galangal/validation/__init__.py +5 -0
  44. galangal/validation/runner.py +395 -0
  45. galangal_orchestrate-0.2.11.dist-info/METADATA +278 -0
  46. galangal_orchestrate-0.2.11.dist-info/RECORD +49 -0
  47. galangal_orchestrate-0.2.11.dist-info/WHEEL +4 -0
  48. galangal_orchestrate-0.2.11.dist-info/entry_points.txt +2 -0
  49. galangal_orchestrate-0.2.11.dist-info/licenses/LICENSE +674 -0
@@ -0,0 +1,42 @@
1
+ """CLI commands."""
2
+
3
+ from galangal.commands.init import cmd_init
4
+ from galangal.commands.start import cmd_start
5
+ from galangal.commands.resume import cmd_resume
6
+ from galangal.commands.status import cmd_status
7
+ from galangal.commands.list import cmd_list
8
+ from galangal.commands.switch import cmd_switch
9
+ from galangal.commands.pause import cmd_pause
10
+ from galangal.commands.approve import cmd_approve, cmd_approve_design
11
+ from galangal.commands.skip import (
12
+ cmd_skip_design,
13
+ cmd_skip_security,
14
+ cmd_skip_migration,
15
+ cmd_skip_contract,
16
+ cmd_skip_benchmark,
17
+ cmd_skip_to,
18
+ )
19
+ from galangal.commands.reset import cmd_reset
20
+ from galangal.commands.complete import cmd_complete
21
+ from galangal.commands.prompts import cmd_prompts
22
+
23
+ __all__ = [
24
+ "cmd_init",
25
+ "cmd_start",
26
+ "cmd_resume",
27
+ "cmd_status",
28
+ "cmd_list",
29
+ "cmd_switch",
30
+ "cmd_pause",
31
+ "cmd_approve",
32
+ "cmd_approve_design",
33
+ "cmd_skip_design",
34
+ "cmd_skip_security",
35
+ "cmd_skip_migration",
36
+ "cmd_skip_contract",
37
+ "cmd_skip_benchmark",
38
+ "cmd_skip_to",
39
+ "cmd_reset",
40
+ "cmd_complete",
41
+ "cmd_prompts",
42
+ ]
@@ -0,0 +1,187 @@
1
+ """
2
+ galangal approve - Record approval for plans and designs.
3
+ """
4
+
5
+ import argparse
6
+ from datetime import datetime, timezone
7
+
8
+ from rich.prompt import Prompt
9
+
10
+ from galangal.core.state import Stage, load_state, save_state
11
+ from galangal.core.tasks import get_active_task
12
+ from galangal.core.artifacts import artifact_exists, read_artifact, write_artifact
13
+ from galangal.core.workflow import run_workflow
14
+ from galangal.ui.console import console, print_error, print_success, print_info
15
+
16
+
17
+ def prompt_plan_approval(task_name: str, state) -> str:
18
+ """
19
+ Interactive approval prompt for PLAN.md.
20
+ Returns: 'approved', 'rejected', or 'quit'
21
+ """
22
+ console.print("\n" + "=" * 60)
23
+ console.print("[bold yellow]⏸️ APPROVAL REQUIRED[/bold yellow]")
24
+ console.print("=" * 60)
25
+ console.print(f"\nTask: {task_name}")
26
+ console.print(f"Stage: PM → [yellow]APPROVAL GATE[/yellow] → DESIGN")
27
+
28
+ # Show PLAN.md content
29
+ plan = read_artifact("PLAN.md", task_name)
30
+ if plan:
31
+ console.print("\n[bold]PLAN.md Preview:[/bold]")
32
+ console.print("[dim]" + "-" * 40 + "[/dim]")
33
+ # Show first 1500 chars
34
+ preview = plan[:1500] + ("..." if len(plan) > 1500 else "")
35
+ console.print(preview)
36
+ console.print("[dim]" + "-" * 40 + "[/dim]")
37
+
38
+ console.print("\n[bold]Options:[/bold]")
39
+ console.print(" [green]y[/green] - Approve plan and continue to DESIGN")
40
+ console.print(" [red]n[/red] - Reject and restart PM stage")
41
+ console.print(" [yellow]q[/yellow] - Quit/pause (resume later)")
42
+
43
+ while True:
44
+ choice = Prompt.ask("Your choice", default="y").strip().lower()
45
+
46
+ if choice in ["y", "yes", "approve"]:
47
+ approver = Prompt.ask("Approver name", default="")
48
+
49
+ approval_content = f"""# Plan Approval
50
+
51
+ - **Status:** Approved
52
+ - **Approved By:** {approver or "Not specified"}
53
+ - **Date:** {datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")}
54
+ """
55
+ write_artifact("APPROVAL.md", approval_content, task_name)
56
+ print_success("Plan approved!")
57
+ return "approved"
58
+
59
+ elif choice in ["n", "no", "reject"]:
60
+ reason = Prompt.ask("Rejection reason", default="Needs revision")
61
+ state.stage = Stage.PM
62
+ state.attempt = 1
63
+ state.last_failure = f"Plan rejected: {reason}"
64
+ save_state(state)
65
+ print_info("Plan rejected. Restarting PM stage.")
66
+ return "rejected"
67
+
68
+ elif choice in ["q", "quit", "pause"]:
69
+ return "quit"
70
+
71
+ else:
72
+ print_error("Invalid choice. Enter y/n/q")
73
+
74
+
75
+ def prompt_design_approval(task_name: str, state) -> str:
76
+ """
77
+ Interactive approval prompt for DESIGN.md.
78
+ Returns: 'approved', 'rejected', or 'quit'
79
+ """
80
+ console.print("\n" + "=" * 60)
81
+ console.print("[bold yellow]⏸️ DESIGN REVIEW REQUIRED[/bold yellow]")
82
+ console.print("=" * 60)
83
+ console.print(f"\nTask: {task_name}")
84
+ console.print(f"Stage: DESIGN → [yellow]REVIEW GATE[/yellow] → DEV")
85
+
86
+ # Show DESIGN.md content
87
+ design = read_artifact("DESIGN.md", task_name)
88
+ if design:
89
+ console.print("\n[bold]DESIGN.md Preview:[/bold]")
90
+ console.print("[dim]" + "-" * 40 + "[/dim]")
91
+ preview = design[:2000] + ("..." if len(design) > 2000 else "")
92
+ console.print(preview)
93
+ console.print("[dim]" + "-" * 40 + "[/dim]")
94
+
95
+ console.print("\n[bold]Options:[/bold]")
96
+ console.print(" [green]y[/green] - Approve design and continue to DEV")
97
+ console.print(" [red]n[/red] - Reject and restart DESIGN stage")
98
+ console.print(" [yellow]q[/yellow] - Quit/pause (resume later)")
99
+
100
+ while True:
101
+ choice = Prompt.ask("Your choice", default="y").strip().lower()
102
+
103
+ if choice in ["y", "yes", "approve"]:
104
+ approver = Prompt.ask("Reviewer name", default="")
105
+
106
+ review_content = f"""# Design Review
107
+
108
+ **Status:** Approved
109
+ **Date:** {datetime.now(timezone.utc).isoformat()}
110
+ **Reviewer:** {approver or "Not specified"}
111
+
112
+ ## Review Notes
113
+
114
+ Design reviewed and approved for implementation.
115
+ """
116
+ write_artifact("DESIGN_REVIEW.md", review_content, task_name)
117
+ print_success("Design approved!")
118
+ return "approved"
119
+
120
+ elif choice in ["n", "no", "reject"]:
121
+ reason = Prompt.ask("Rejection reason", default="Needs revision")
122
+ state.stage = Stage.DESIGN
123
+ state.attempt = 1
124
+ state.last_failure = f"Design rejected: {reason}"
125
+ save_state(state)
126
+ print_info("Design rejected. Restarting DESIGN stage.")
127
+ return "rejected"
128
+
129
+ elif choice in ["q", "quit", "pause"]:
130
+ return "quit"
131
+
132
+ else:
133
+ print_error("Invalid choice. Enter y/n/q")
134
+
135
+
136
+ def cmd_approve(args: argparse.Namespace) -> int:
137
+ """Record human approval for active task (fallback command)."""
138
+ active = get_active_task()
139
+ if not active:
140
+ print_error("No active task.")
141
+ return 1
142
+
143
+ state = load_state(active)
144
+ if state is None:
145
+ print_error(f"Could not load state for '{active}'.")
146
+ return 1
147
+
148
+ if state.stage != Stage.DESIGN or artifact_exists("APPROVAL.md", active):
149
+ print_error("Approval not needed at this stage.")
150
+ return 1
151
+
152
+ result = prompt_plan_approval(active, state)
153
+ if result == "approved":
154
+ run_workflow(state)
155
+ return 0 if result == "approved" else 1
156
+
157
+
158
+ def cmd_approve_design(args: argparse.Namespace) -> int:
159
+ """Record design review approval for active task (fallback command)."""
160
+ active = get_active_task()
161
+ if not active:
162
+ print_error("No active task.")
163
+ return 1
164
+
165
+ state = load_state(active)
166
+ if state is None:
167
+ print_error(f"Could not load state for '{active}'.")
168
+ return 1
169
+
170
+ if state.stage != Stage.DEV:
171
+ print_error(
172
+ f"Design approval is for DEV stage. Current stage: {state.stage.value}"
173
+ )
174
+ return 1
175
+
176
+ if artifact_exists("DESIGN_REVIEW.md", active):
177
+ print_error("DESIGN_REVIEW.md already exists.")
178
+ return 1
179
+
180
+ if not artifact_exists("DESIGN.md", active):
181
+ print_error("DESIGN.md not found. Design stage may not have completed.")
182
+ return 1
183
+
184
+ result = prompt_design_approval(active, state)
185
+ if result == "approved":
186
+ run_workflow(state)
187
+ return 0 if result == "approved" else 1
@@ -0,0 +1,268 @@
1
+ """
2
+ galangal complete - Complete a task, commit, and create PR.
3
+ """
4
+
5
+ import argparse
6
+ import shutil
7
+ import tempfile
8
+ from datetime import datetime
9
+ from pathlib import Path
10
+
11
+ from rich.prompt import Prompt
12
+
13
+ from galangal.config.loader import get_project_root, get_config, get_done_dir
14
+ from galangal.core.state import Stage, load_state, get_task_dir
15
+ from galangal.core.tasks import get_active_task, clear_active_task, get_current_branch
16
+ from galangal.core.artifacts import read_artifact, run_command
17
+ from galangal.ai.claude import ClaudeBackend
18
+ from galangal.ui.console import console, print_error, print_info, print_success, print_warning
19
+
20
+
21
+ def generate_pr_title(task_name: str, description: str, task_type: str) -> str:
22
+ """Generate a concise PR title using AI."""
23
+ backend = ClaudeBackend()
24
+
25
+ prompt = f"""Generate a concise pull request title for this task.
26
+
27
+ Task: {task_name}
28
+ Type: {task_type}
29
+ Description: {description[:500]}
30
+
31
+ Requirements:
32
+ 1. Max 72 characters
33
+ 2. Start with type prefix based on task type:
34
+ - Feature → "feat: ..."
35
+ - Bug Fix → "fix: ..."
36
+ - Refactor → "refactor: ..."
37
+ - Chore → "chore: ..."
38
+ - Docs → "docs: ..."
39
+ - Hotfix → "fix: ..."
40
+ 3. Be specific about what changed
41
+ 4. Use imperative mood ("Add feature" not "Added feature")
42
+ 5. No period at end
43
+
44
+ Output ONLY the title, nothing else."""
45
+
46
+ title = backend.generate_text(prompt, timeout=30)
47
+ if title:
48
+ title = title.split("\n")[0].strip()
49
+ return title[:72] if len(title) > 72 else title
50
+
51
+ # Fallback
52
+ return description[:72] if len(description) > 72 else description
53
+
54
+
55
+ def generate_commit_summary(task_name: str, description: str) -> str:
56
+ """Generate a commit message summary using AI."""
57
+ backend = ClaudeBackend()
58
+
59
+ spec = read_artifact("SPEC.md", task_name) or ""
60
+ plan = read_artifact("PLAN.md", task_name) or ""
61
+
62
+ code, diff_stat, _ = run_command(["git", "diff", "--stat", "main...HEAD"])
63
+ code, changed_files, _ = run_command(["git", "diff", "--name-only", "main...HEAD"])
64
+
65
+ prompt = f"""Generate a concise git commit message for this task. Follow conventional commit format.
66
+
67
+ Task: {task_name}
68
+ Description: {description}
69
+
70
+ Specification summary:
71
+ {spec[:1000] if spec else "(none)"}
72
+
73
+ Implementation plan summary:
74
+ {plan[:800] if plan else "(none)"}
75
+
76
+ Files changed:
77
+ {changed_files[:1000] if changed_files else "(none)"}
78
+
79
+ Requirements:
80
+ 1. First line: type(scope): brief description (max 72 chars)
81
+ - Types: feat, fix, refactor, chore, docs, test, style, perf
82
+ 2. Blank line
83
+ 3. Body: 2-4 bullet points summarizing key changes
84
+ 4. Do NOT include any co-authored-by or generated-by lines
85
+
86
+ Output ONLY the commit message, nothing else."""
87
+
88
+ summary = backend.generate_text(prompt, timeout=60)
89
+ if summary:
90
+ return summary.strip()
91
+
92
+ return f"{description[:72]}"
93
+
94
+
95
+ def create_pull_request(task_name: str, description: str, task_type: str) -> tuple[bool, str]:
96
+ """Create a pull request for the task branch."""
97
+ config = get_config()
98
+ branch_name = config.branch_pattern.format(task_name=task_name)
99
+ base_branch = config.pr.base_branch
100
+
101
+ code, current_branch, _ = run_command(["git", "branch", "--show-current"])
102
+ current_branch = current_branch.strip()
103
+
104
+ if current_branch != branch_name:
105
+ code, _, err = run_command(["git", "checkout", branch_name])
106
+ if code != 0:
107
+ return False, f"Could not switch to branch {branch_name}: {err}"
108
+
109
+ code, out, err = run_command(["git", "push", "-u", "origin", branch_name])
110
+ if code != 0:
111
+ if "Everything up-to-date" not in out and "Everything up-to-date" not in err:
112
+ return False, f"Failed to push branch: {err or out}"
113
+
114
+ spec_content = read_artifact("SPEC.md", task_name) or description
115
+
116
+ console.print("[dim]Generating PR title...[/dim]")
117
+ pr_title = generate_pr_title(task_name, description, task_type)
118
+
119
+ # Build PR body
120
+ pr_body = f"""## Summary
121
+ {spec_content[:1500] if len(spec_content) > 1500 else spec_content}
122
+
123
+ ---
124
+ """
125
+ # Add codex review if configured
126
+ if config.pr.codex_review:
127
+ pr_body += "@codex review\n"
128
+
129
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".md", delete=False) as f:
130
+ f.write(pr_body)
131
+ body_file = f.name
132
+
133
+ try:
134
+ code, out, err = run_command(
135
+ [
136
+ "gh",
137
+ "pr",
138
+ "create",
139
+ "--title",
140
+ pr_title,
141
+ "--body-file",
142
+ body_file,
143
+ "--base",
144
+ base_branch,
145
+ ]
146
+ )
147
+ finally:
148
+ Path(body_file).unlink(missing_ok=True)
149
+
150
+ if code != 0:
151
+ combined_output = (out + err).lower()
152
+ if "already exists" in combined_output:
153
+ return True, "PR already exists"
154
+ if "pull request create failed" in combined_output:
155
+ code2, pr_url, _ = run_command(
156
+ ["gh", "pr", "view", "--json", "url", "-q", ".url"]
157
+ )
158
+ if code2 == 0 and pr_url.strip():
159
+ return True, pr_url.strip()
160
+ return False, f"Failed to create PR: {err or out}"
161
+
162
+ return True, out.strip()
163
+
164
+
165
+ def commit_changes(task_name: str, description: str) -> tuple[bool, str]:
166
+ """Commit all changes for a task."""
167
+ code, status_out, _ = run_command(["git", "status", "--porcelain"])
168
+ if code != 0:
169
+ return False, "Failed to check git status"
170
+
171
+ if not status_out.strip():
172
+ return True, "No changes to commit"
173
+
174
+ changes = [line for line in status_out.strip().split("\n") if line.strip()]
175
+ change_count = len(changes)
176
+
177
+ console.print(f"[dim]Committing {change_count} changed files...[/dim]")
178
+
179
+ code, _, err = run_command(["git", "add", "-A"])
180
+ if code != 0:
181
+ return False, f"Failed to stage changes: {err}"
182
+
183
+ console.print("[dim]Generating commit summary...[/dim]")
184
+ summary = generate_commit_summary(task_name, description)
185
+
186
+ commit_msg = f"""{summary}
187
+
188
+ Task: {task_name}
189
+ Changes: {change_count} files"""
190
+
191
+ code, out, err = run_command(["git", "commit", "-m", commit_msg])
192
+ if code != 0:
193
+ return False, f"Failed to commit: {err or out}"
194
+
195
+ return True, f"Committed {change_count} files"
196
+
197
+
198
+ def finalize_task(task_name: str, state, force: bool = False) -> bool:
199
+ """Finalize a completed task: move to done/, commit, create PR."""
200
+ config = get_config()
201
+ project_root = get_project_root()
202
+ done_dir = get_done_dir()
203
+
204
+ # 1. Move to done/
205
+ task_dir = get_task_dir(task_name)
206
+ done_dir.mkdir(parents=True, exist_ok=True)
207
+ dest = done_dir / task_name
208
+
209
+ if dest.exists():
210
+ dest = done_dir / f"{task_name}-{datetime.now().strftime('%Y%m%d%H%M%S')}"
211
+
212
+ console.print(f"[dim]Moving task to {dest.relative_to(project_root)}/...[/dim]")
213
+ shutil.move(str(task_dir), str(dest))
214
+ clear_active_task()
215
+
216
+ # 2. Commit changes
217
+ console.print("[dim]Committing changes...[/dim]")
218
+ success, msg = commit_changes(task_name, state.task_description)
219
+ if success:
220
+ print_success(msg)
221
+ else:
222
+ print_warning(msg)
223
+ if not force:
224
+ confirm = Prompt.ask("Continue anyway? [y/N]", default="n").strip().lower()
225
+ if confirm != "y":
226
+ shutil.move(str(dest), str(task_dir))
227
+ from galangal.core.tasks import set_active_task
228
+ set_active_task(task_name)
229
+ print_info("Aborted. Task restored to original location.")
230
+ return False
231
+
232
+ # 3. Create PR
233
+ console.print("[dim]Creating pull request...[/dim]")
234
+ success, msg = create_pull_request(task_name, state.task_description, state.task_type.display_name())
235
+ if success:
236
+ console.print(f"[green]PR:[/green] {msg}")
237
+ else:
238
+ print_warning(f"Could not create PR: {msg}")
239
+ console.print("You may need to create the PR manually.")
240
+
241
+ print_success(f"Task '{task_name}' completed and moved to {config.tasks_dir}/done/")
242
+
243
+ # 4. Switch back to main
244
+ run_command(["git", "checkout", config.pr.base_branch])
245
+ console.print(f"[dim]Switched back to {config.pr.base_branch} branch[/dim]")
246
+
247
+ return True
248
+
249
+
250
+ def cmd_complete(args: argparse.Namespace) -> int:
251
+ """Move completed task to done/, commit, create PR."""
252
+ active = get_active_task()
253
+ if not active:
254
+ print_error("No active task.")
255
+ return 1
256
+
257
+ state = load_state(active)
258
+ if state is None:
259
+ print_error(f"Could not load state for '{active}'.")
260
+ return 1
261
+
262
+ if state.stage != Stage.COMPLETE:
263
+ print_error(f"Task is at stage {state.stage.value}, not COMPLETE.")
264
+ console.print("Run 'resume' to continue the workflow.")
265
+ return 1
266
+
267
+ success = finalize_task(active, state, force=args.force)
268
+ return 0 if success else 1
@@ -0,0 +1,173 @@
1
+ """
2
+ galangal init - Initialize galangal in a project.
3
+ """
4
+
5
+ import argparse
6
+ from pathlib import Path
7
+
8
+ from rich.prompt import Prompt, Confirm
9
+
10
+ from galangal.config.defaults import generate_default_config
11
+ from galangal.config.loader import find_project_root
12
+ from galangal.ui.console import console, print_success, print_error, print_info
13
+
14
+
15
+ def detect_stacks(project_root: Path) -> list[dict[str, str]]:
16
+ """Detect technology stacks in the project."""
17
+ stacks = []
18
+
19
+ # Python detection
20
+ if (project_root / "pyproject.toml").exists() or (project_root / "setup.py").exists():
21
+ framework = None
22
+ # Check for framework
23
+ if (project_root / "requirements.txt").exists():
24
+ reqs = (project_root / "requirements.txt").read_text().lower()
25
+ if "fastapi" in reqs:
26
+ framework = "fastapi"
27
+ elif "django" in reqs:
28
+ framework = "django"
29
+ elif "flask" in reqs:
30
+ framework = "flask"
31
+ stacks.append({"language": "python", "framework": framework, "root": None})
32
+
33
+ # Check subdirectories for separate stacks
34
+ for subdir in ["backend", "api", "server"]:
35
+ subpath = project_root / subdir
36
+ if subpath.exists():
37
+ if (subpath / "pyproject.toml").exists() or (subpath / "requirements.txt").exists():
38
+ framework = None
39
+ if (subpath / "requirements.txt").exists():
40
+ reqs = (subpath / "requirements.txt").read_text().lower()
41
+ if "fastapi" in reqs:
42
+ framework = "fastapi"
43
+ elif "django" in reqs:
44
+ framework = "django"
45
+ # Only add if not already detected at root
46
+ if not any(s.get("root") is None and s["language"] == "python" for s in stacks):
47
+ stacks.append({"language": "python", "framework": framework, "root": f"{subdir}/"})
48
+
49
+ # TypeScript/JavaScript detection
50
+ if (project_root / "package.json").exists():
51
+ pkg = (project_root / "package.json").read_text().lower()
52
+ framework = None
53
+ if "vite" in pkg:
54
+ framework = "vite"
55
+ elif "next" in pkg:
56
+ framework = "next"
57
+ elif "react" in pkg:
58
+ framework = "react"
59
+ elif "vue" in pkg:
60
+ framework = "vue"
61
+ elif "angular" in pkg:
62
+ framework = "angular"
63
+ stacks.append({"language": "typescript", "framework": framework, "root": None})
64
+
65
+ # Check subdirectories for frontend
66
+ for subdir in ["frontend", "admin", "web", "client", "app"]:
67
+ subpath = project_root / subdir
68
+ if subpath.exists() and (subpath / "package.json").exists():
69
+ pkg = (subpath / "package.json").read_text().lower()
70
+ framework = None
71
+ if "vite" in pkg:
72
+ framework = "vite"
73
+ elif "next" in pkg:
74
+ framework = "next"
75
+ elif "react" in pkg:
76
+ framework = "react"
77
+ # Only add if not already detected at root
78
+ if not any(s.get("root") is None and s["language"] == "typescript" for s in stacks):
79
+ stacks.append({"language": "typescript", "framework": framework, "root": f"{subdir}/"})
80
+
81
+ # PHP detection
82
+ if (project_root / "composer.json").exists():
83
+ composer = (project_root / "composer.json").read_text().lower()
84
+ framework = None
85
+ if "symfony" in composer:
86
+ framework = "symfony"
87
+ elif "laravel" in composer:
88
+ framework = "laravel"
89
+ stacks.append({"language": "php", "framework": framework, "root": None})
90
+
91
+ # Go detection
92
+ if (project_root / "go.mod").exists():
93
+ stacks.append({"language": "go", "framework": None, "root": None})
94
+
95
+ # Rust detection
96
+ if (project_root / "Cargo.toml").exists():
97
+ stacks.append({"language": "rust", "framework": None, "root": None})
98
+
99
+ return stacks
100
+
101
+
102
+ def cmd_init(args: argparse.Namespace) -> int:
103
+ """Initialize galangal in the current project."""
104
+ console.print("\n[bold cyan]╔══════════════════════════════════════════════════════════════╗[/bold cyan]")
105
+ console.print("[bold cyan]║[/bold cyan] [bold]Galangal Orchestrate[/bold] [bold cyan]║[/bold cyan]")
106
+ console.print("[bold cyan]║[/bold cyan] AI-Driven Development Workflow [bold cyan]║[/bold cyan]")
107
+ console.print("[bold cyan]╚══════════════════════════════════════════════════════════════╝[/bold cyan]\n")
108
+
109
+ project_root = find_project_root()
110
+ galangal_dir = project_root / ".galangal"
111
+
112
+ if galangal_dir.exists():
113
+ print_info(f"Galangal already initialized in {project_root}")
114
+ if not Confirm.ask("Reinitialize?", default=False):
115
+ return 0
116
+
117
+ console.print(f"[dim]Project root: {project_root}[/dim]")
118
+ console.print("\n[bold]Scanning project structure...[/bold]\n")
119
+
120
+ # Detect stacks
121
+ stacks = detect_stacks(project_root)
122
+
123
+ if stacks:
124
+ console.print("[bold]Detected stacks:[/bold]")
125
+ for i, stack in enumerate(stacks, 1):
126
+ framework = f"/{stack['framework']}" if stack.get("framework") else ""
127
+ root = f" ({stack['root']})" if stack.get("root") else ""
128
+ console.print(f" [{i}] {stack['language'].title()}{framework}{root}")
129
+
130
+ if not Confirm.ask("\nIs this correct?", default=True):
131
+ console.print("[dim]You can edit .galangal/config.yaml after initialization.[/dim]")
132
+ else:
133
+ console.print("[yellow]No stacks detected. You can configure them in .galangal/config.yaml[/yellow]")
134
+ stacks = [{"language": "python", "framework": None, "root": None}]
135
+
136
+ # Get project name
137
+ default_name = project_root.name
138
+ project_name = Prompt.ask("Project name", default=default_name)
139
+
140
+ # Create .galangal directory
141
+ galangal_dir.mkdir(exist_ok=True)
142
+ (galangal_dir / "prompts").mkdir(exist_ok=True)
143
+
144
+ # Generate config
145
+ config_content = generate_default_config(
146
+ project_name=project_name,
147
+ stacks=stacks,
148
+ )
149
+ (galangal_dir / "config.yaml").write_text(config_content)
150
+
151
+ print_success(f"Created .galangal/config.yaml")
152
+ print_success(f"Created .galangal/prompts/ (empty - uses defaults)")
153
+
154
+ # Add to .gitignore
155
+ gitignore = project_root / ".gitignore"
156
+ tasks_entry = "galangal-tasks/"
157
+ if gitignore.exists():
158
+ content = gitignore.read_text()
159
+ if tasks_entry not in content:
160
+ with open(gitignore, "a") as f:
161
+ f.write(f"\n# Galangal task artifacts\n{tasks_entry}\n")
162
+ print_success(f"Added {tasks_entry} to .gitignore")
163
+ else:
164
+ gitignore.write_text(f"# Galangal task artifacts\n{tasks_entry}\n")
165
+ print_success(f"Created .gitignore with {tasks_entry}")
166
+
167
+ console.print("\n[bold green]Initialization complete![/bold green]\n")
168
+ console.print("To customize prompts for your project:")
169
+ console.print(" [cyan]galangal prompts export[/cyan] # Export defaults to .galangal/prompts/")
170
+ console.print("\nNext steps:")
171
+ console.print(" [cyan]galangal start \"Your first task\"[/cyan]")
172
+
173
+ return 0
@@ -0,0 +1,20 @@
1
+ """
2
+ galangal list - List all tasks.
3
+ """
4
+
5
+ import argparse
6
+
7
+ from galangal.core.tasks import get_active_task, list_tasks
8
+ from galangal.ui.console import console, display_task_list
9
+
10
+
11
+ def cmd_list(args: argparse.Namespace) -> int:
12
+ """List all tasks."""
13
+ tasks = list_tasks()
14
+ active = get_active_task()
15
+
16
+ display_task_list(tasks, active)
17
+
18
+ if active:
19
+ console.print("\n[dim]→ = active task[/dim]")
20
+ return 0