forge-dev 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.
forge_core/cli.py ADDED
@@ -0,0 +1,552 @@
1
+ """Forge CLI — AI-Native Development Workflow Engine.
2
+
3
+ Global CLI commands for managing Forge projects.
4
+ Installed as `forge` command via pip.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ import click
14
+ from rich.console import Console
15
+ from rich.panel import Panel
16
+ from rich.table import Table
17
+
18
+ console = Console()
19
+
20
+
21
+ @click.group()
22
+ @click.version_option(package_name="forge-dev")
23
+ def main() -> None:
24
+ """Forge — AI-Native Development Workflow Engine.
25
+
26
+ Transform requirements into production-ready, audited, observable code.
27
+ """
28
+ pass
29
+
30
+
31
+ @main.command()
32
+ @click.option("--name", "-n", help="Project name (default: directory name)")
33
+ @click.option("--backend", "-b", help="Backend framework override")
34
+ @click.option("--no-interactive", is_flag=True, help="Skip interactive questions")
35
+ def init(name: str | None, backend: str | None, no_interactive: bool) -> None:
36
+ """Initialize Forge in the current directory.
37
+
38
+ Detects the project state and adapts:
39
+ - Empty folder → guided setup conversation
40
+ - Has docs → analyzes documents, generates brief
41
+ - Has code → detects stack, offers assessment
42
+ - Has .forge/ → offers to continue or re-evaluate
43
+ """
44
+ from forge_core.detector import detect_project
45
+ from forge_core.phases.context import (
46
+ get_questions_for_empty_project,
47
+ resolve_context,
48
+ save_context,
49
+ )
50
+ from forge_core.registry import ensure_registry, load_user_config
51
+
52
+ # Ensure global registry exists
53
+ ensure_registry()
54
+ user_config = load_user_config()
55
+
56
+ # Detect what's in this directory
57
+ project_path = Path.cwd()
58
+ detection = detect_project(project_path)
59
+
60
+ console.print()
61
+ console.print(Panel(
62
+ f"[bold]Forge Init[/bold]\n{detection.summary()}",
63
+ title="🔥 Forge",
64
+ border_style="bright_red",
65
+ ))
66
+ console.print()
67
+
68
+ if detection.has_forge:
69
+ console.print("[yellow]This project already has Forge initialized.[/yellow]")
70
+ console.print("Options:")
71
+ console.print(" forge status — see current state")
72
+ console.print(" forge assess — re-evaluate against latest standards")
73
+ console.print(" forge audit — run audit agents")
74
+ return
75
+
76
+ if detection.is_empty:
77
+ console.print("[cyan]Empty directory detected. Let's set up your project.[/cyan]")
78
+ console.print()
79
+
80
+ if no_interactive:
81
+ # Use defaults for everything
82
+ overrides = {}
83
+ if name:
84
+ overrides["name"] = name
85
+ if backend:
86
+ overrides["backend"] = backend
87
+ context = resolve_context(detection, user_config, project_name=name, overrides=overrides)
88
+ else:
89
+ # Interactive setup
90
+ questions = get_questions_for_empty_project(user_config)
91
+ overrides = {}
92
+
93
+ for q in questions:
94
+ if q["type"] == "text":
95
+ answer = click.prompt(f" {q['question']}")
96
+ overrides[q["id"]] = answer
97
+ elif q["type"] == "choice":
98
+ console.print(f" {q['question']}")
99
+ for i, opt in enumerate(q["options"], 1):
100
+ default_marker = " [default]" if opt == q.get("default") else ""
101
+ console.print(f" {i}. {opt}{default_marker}")
102
+ idx = click.prompt(" Choice", default="1", type=int)
103
+ overrides[q["id"]] = q["options"][min(idx - 1, len(q["options"]) - 1)]
104
+ elif q["type"] == "boolean":
105
+ answer = click.confirm(f" {q['question']}", default=q.get("default", True))
106
+ overrides[q["id"]] = answer
107
+ elif q["type"] == "multi_choice":
108
+ console.print(f" {q['question']}")
109
+ for i, opt in enumerate(q["options"], 1):
110
+ console.print(f" {i}. {opt}")
111
+ selected = click.prompt(" Select (comma-separated numbers)", default="")
112
+ if selected:
113
+ indices = [int(x.strip()) - 1 for x in selected.split(",")]
114
+ overrides[q["id"]] = [q["options"][i] for i in indices if 0 <= i < len(q["options"])]
115
+ else:
116
+ overrides[q["id"]] = q.get("default", [])
117
+
118
+ # Map question IDs to context fields
119
+ context_overrides = {}
120
+ if "mission" in overrides:
121
+ context_overrides["description"] = overrides["mission"]
122
+ if "type" in overrides:
123
+ context_overrides["type"] = overrides["type"]
124
+ if "regulatory" in overrides:
125
+ context_overrides["regulatory"] = overrides["regulatory"]
126
+ if "backend" in overrides:
127
+ context_overrides["backend"] = overrides["backend"]
128
+ elif backend:
129
+ context_overrides["backend"] = backend
130
+ if "ai_enabled" in overrides:
131
+ context_overrides["ai"] = {"enabled": overrides["ai_enabled"]}
132
+
133
+ context = resolve_context(
134
+ detection, user_config,
135
+ project_name=name,
136
+ overrides=context_overrides,
137
+ )
138
+
139
+ elif detection.has_existing_code:
140
+ console.print("[cyan]Existing code detected. Analyzing stack...[/cyan]")
141
+ context = resolve_context(detection, user_config, project_name=name)
142
+ console.print(f"[green]Detected: {detection.detected_stack}[/green]")
143
+
144
+ else: # has_docs
145
+ console.print("[cyan]Documents found. Analyzing...[/cyan]")
146
+ console.print(f" Found {len(detection.doc_files)} document(s):")
147
+ for doc in detection.doc_files[:10]:
148
+ console.print(f" 📄 {doc.name}")
149
+ console.print()
150
+ console.print("[dim]Run `forge intake` to process these documents into a Forge Brief.[/dim]")
151
+ context = resolve_context(detection, user_config, project_name=name)
152
+
153
+ # Save context
154
+ context_path = save_context(context, project_path)
155
+
156
+ # Create journal
157
+ journal_path = project_path / ".forge" / "journal.md"
158
+ if not journal_path.exists():
159
+ journal_path.write_text(
160
+ f"# {context.name} — Forge Journal\n\n"
161
+ "Record project-specific learnings, workarounds, and nuances here.\n"
162
+ "Forge agents read this file to understand project-specific context.\n\n"
163
+ "## Entries\n\n"
164
+ )
165
+
166
+ # Record in global history
167
+ from forge_core.registry import record_project
168
+ record_project(
169
+ context.name,
170
+ str(project_path),
171
+ {"type": context.type, "backend": context.backend, "cloud": context.cloud},
172
+ )
173
+
174
+ console.print()
175
+ console.print(Panel(
176
+ f"[green]✓ Forge initialized for [bold]{context.name}[/bold][/green]\n\n"
177
+ f"Context: {context_path}\n"
178
+ f"Journal: {journal_path}\n\n"
179
+ f"Stack: {context.backend.value} + {context.frontend.value}\n"
180
+ f"Cloud: {context.cloud.value}\n"
181
+ f"Auth: {context.auth.value}\n\n"
182
+ "Next steps:\n"
183
+ " forge intake <file> — process a requirement document\n"
184
+ " forge plan — generate implementation plan\n"
185
+ " forge audit — run audit agents",
186
+ title="🔥 Ready",
187
+ border_style="green",
188
+ ))
189
+
190
+
191
+ @main.command()
192
+ @click.argument("file", required=False, type=click.Path(exists=True))
193
+ @click.option("--text", "-t", help="Inline requirement text")
194
+ def intake(file: str | None, text: str | None) -> None:
195
+ """Process a requirement into a Forge Brief.
196
+
197
+ Accepts a file (markdown, text, PDF) or inline text.
198
+ Produces a normalized brief in .forge/brief.yaml.
199
+ """
200
+ from forge_core.phases.intake import (
201
+ build_intake_prompt,
202
+ classify_requirement,
203
+ save_brief,
204
+ )
205
+
206
+ project_path = Path.cwd()
207
+ forge_dir = project_path / ".forge"
208
+
209
+ if not forge_dir.exists():
210
+ console.print("[red]No Forge project found. Run `forge init` first.[/red]")
211
+ sys.exit(1)
212
+
213
+ # Get the requirement content
214
+ if file:
215
+ content = Path(file).read_text(errors="ignore")
216
+ console.print(f"[cyan]Reading requirement from: {file}[/cyan]")
217
+ elif text:
218
+ content = text
219
+ else:
220
+ console.print("[yellow]No file or text provided.[/yellow]")
221
+ console.print("Usage:")
222
+ console.print(" forge intake requirements.md")
223
+ console.print(" forge intake --text 'Build a user dashboard with...'")
224
+ return
225
+
226
+ # Classify and build prompt
227
+ req_type = classify_requirement(content)
228
+ console.print(f"[dim]Classified as: {req_type.value}[/dim]")
229
+
230
+ prompt = build_intake_prompt(content, req_type)
231
+
232
+ console.print()
233
+ console.print(Panel(
234
+ "[bold]Intake Analysis Prompt Generated[/bold]\n\n"
235
+ "The intake prompt has been generated for LLM analysis.\n"
236
+ "In a full Forge setup, this would be sent to Claude/GPT\n"
237
+ "to produce the brief automatically.\n\n"
238
+ f"Requirement type: {req_type.value}\n"
239
+ f"Content length: {len(content)} chars\n\n"
240
+ "For now, the prompt is saved to .forge/intake_prompt.md\n"
241
+ "You can send it to an LLM manually or via Claude Code.",
242
+ title="📋 Intake",
243
+ border_style="cyan",
244
+ ))
245
+
246
+ # Save the prompt for LLM processing
247
+ prompt_path = forge_dir / "intake_prompt.md"
248
+ prompt_path.write_text(prompt)
249
+ console.print(f"\n[dim]Prompt saved to: {prompt_path}[/dim]")
250
+
251
+
252
+ @main.command()
253
+ def status() -> None:
254
+ """Show current Forge project status."""
255
+ from forge_core.phases.context import load_context
256
+ from forge_core.phases.intake import load_brief
257
+ from forge_core.registry import get_forge_version
258
+
259
+ project_path = Path.cwd()
260
+ forge_dir = project_path / ".forge"
261
+
262
+ if not forge_dir.exists():
263
+ console.print("[red]No Forge project found. Run `forge init` first.[/red]")
264
+ return
265
+
266
+ context = load_context(project_path)
267
+ brief = load_brief(project_path)
268
+ version = get_forge_version()
269
+
270
+ table = Table(title=f"🔥 Forge Status — {context.name if context else 'Unknown'}")
271
+ table.add_column("Component", style="cyan")
272
+ table.add_column("Status", style="green")
273
+
274
+ table.add_row("Forge Version", version)
275
+ table.add_row("Context", "✓" if context else "✗ Run `forge init`")
276
+ table.add_row("Brief", "✓" if brief else "— Run `forge intake`")
277
+ table.add_row("Plan", "✓" if (forge_dir / "plan.yaml").exists() else "— Run `forge plan`")
278
+ table.add_row("Journal", "✓" if (forge_dir / "journal.md").exists() else "—")
279
+
280
+ if context:
281
+ table.add_row("", "")
282
+ table.add_row("Stack", f"{context.backend.value} + {context.frontend.value}")
283
+ table.add_row("Cloud", context.cloud.value)
284
+ table.add_row("Auth", context.auth.value)
285
+ table.add_row("AI", "Enabled" if context.ai.enabled else "Disabled")
286
+ table.add_row("Type", context.type.value)
287
+ if context.regulatory:
288
+ table.add_row("Regulatory", ", ".join(r.value for r in context.regulatory))
289
+
290
+ console.print()
291
+ console.print(table)
292
+
293
+
294
+ @main.command()
295
+ @click.argument("entry", required=False)
296
+ def journal(entry: str | None) -> None:
297
+ """Add an entry to the project journal.
298
+
299
+ Records learnings, workarounds, and nuances specific to this project.
300
+ Forge agents read the journal for project-specific context.
301
+ """
302
+ from datetime import datetime, timezone
303
+
304
+ project_path = Path.cwd()
305
+ journal_path = project_path / ".forge" / "journal.md"
306
+
307
+ if not journal_path.exists():
308
+ console.print("[red]No Forge project found. Run `forge init` first.[/red]")
309
+ return
310
+
311
+ if entry is None:
312
+ entry = click.prompt("Journal entry")
313
+
314
+ timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M")
315
+ with open(journal_path, "a") as f:
316
+ f.write(f"\n### {timestamp}\n{entry}\n")
317
+
318
+ console.print(f"[green]✓ Journal entry added[/green]")
319
+
320
+
321
+ @main.command()
322
+ @click.option("--name", "-n", required=True, help="Standard name")
323
+ @click.option("--area", "-a", required=True, help="Area this standard governs")
324
+ @click.option("--description", "-d", required=True, help="What the standard requires")
325
+ def standards(name: str, area: str, description: str) -> None:
326
+ """Add or view standards.
327
+
328
+ When adding a new standard, runs the coherence checker first.
329
+ """
330
+ import yaml as _yaml
331
+
332
+ from forge_core.phases.coherence import check_new_standard
333
+ from forge_core.registry import load_standards, version_workflow
334
+
335
+ existing = load_standards()
336
+
337
+ new_standard = {
338
+ "name": name,
339
+ "area": area,
340
+ "description": description,
341
+ }
342
+
343
+ # Run coherence check
344
+ report = check_new_standard(new_standard, existing)
345
+ console.print()
346
+ console.print(report.summary())
347
+
348
+ if not report.passed:
349
+ console.print("\n[red]Cannot add standard — resolve errors first.[/red]")
350
+ return
351
+
352
+ if report.issues:
353
+ if not click.confirm("\nProceed despite warnings?"):
354
+ return
355
+
356
+ # Save the standard
357
+ from forge_core.registry import USER_DIR
358
+ standards_dir = USER_DIR / "standards"
359
+ standards_dir.mkdir(parents=True, exist_ok=True)
360
+
361
+ std_path = standards_dir / f"{name.lower().replace(' ', '-')}.yaml"
362
+ with open(std_path, "w") as f:
363
+ _yaml.dump(new_standard, f, default_flow_style=False)
364
+
365
+ # Version the workflow change
366
+ version_id = version_workflow(f"Added standard: {name}")
367
+ console.print(f"\n[green]✓ Standard '{name}' added[/green]")
368
+ console.print(f"[dim]Workflow versioned: {version_id}[/dim]")
369
+
370
+
371
+ @main.command()
372
+ def upgrade() -> None:
373
+ """Upgrade Forge core from upstream.
374
+
375
+ Updates ~/.forge/core/ while preserving ~/.forge/user/.
376
+ Project .forge/ directories are never touched.
377
+ """
378
+ console.print("[yellow]Forge upgrade would pull latest from GitHub.[/yellow]")
379
+ console.print("[dim]Not yet implemented — will use git pull + migration check.[/dim]")
380
+ console.print()
381
+ console.print("The upgrade process will:")
382
+ console.print(" 1. Pull latest core/ from GitHub")
383
+ console.print(" 2. Run migration check for breaking changes")
384
+ console.print(" 3. Run coherence check against your user/ customizations")
385
+ console.print(" 4. Show impact report before applying")
386
+ console.print(" 5. Never touch project .forge/ directories")
387
+
388
+
389
+ @main.command()
390
+ def assess() -> None:
391
+ """Assess an existing project against current Forge standards.
392
+
393
+ Produces a maturity report with opportunities for improvement.
394
+ """
395
+ from forge_core.detector import detect_project
396
+ from forge_core.registry import load_standards
397
+
398
+ project_path = Path.cwd()
399
+ detection = detect_project(project_path)
400
+
401
+ if detection.is_empty:
402
+ console.print("[red]Nothing to assess — directory is empty.[/red]")
403
+ return
404
+
405
+ standards_list = load_standards()
406
+
407
+ console.print(Panel(
408
+ f"[bold]Maturity Assessment[/bold]\n\n"
409
+ f"Project: {project_path.name}\n"
410
+ f"Code files: {len(detection.code_files)}\n"
411
+ f"Detected stack: {detection.detected_stack}\n"
412
+ f"Standards to check: {len(standards_list)}\n\n"
413
+ "[dim]Full assessment requires LLM analysis.\n"
414
+ "The assessment prompt will be generated for processing.[/dim]",
415
+ title="📊 Assess",
416
+ border_style="yellow",
417
+ ))
418
+
419
+
420
+ @main.command()
421
+ def mcps() -> None:
422
+ """View and manage the MCP registry."""
423
+ from forge_core.registry import load_mcps
424
+
425
+ mcp_list = load_mcps()
426
+
427
+ if not mcp_list:
428
+ console.print("[dim]No MCPs configured. Edit ~/.forge/user/mcps.yaml[/dim]")
429
+ return
430
+
431
+ table = Table(title="MCP Registry")
432
+ table.add_column("Name", style="cyan")
433
+ table.add_column("Description")
434
+ table.add_column("Auto-suggest", style="green")
435
+ table.add_column("Transport")
436
+
437
+ for mcp in mcp_list:
438
+ table.add_row(
439
+ mcp.name,
440
+ mcp.description,
441
+ "✓" if mcp.auto_suggest else "—",
442
+ mcp.transport,
443
+ )
444
+
445
+ console.print()
446
+ console.print(table)
447
+
448
+
449
+ @main.command()
450
+ @click.option(
451
+ "--format", "-f",
452
+ type=click.Choice(["claude", "cursor", "copilot", "generic", "all"]),
453
+ default="claude",
454
+ help="Editor format to generate",
455
+ )
456
+ def sync(format: str) -> None:
457
+ """Generate editor instruction files from Forge governance.
458
+
459
+ Translates ALL Forge knowledge (standards, patterns, journal,
460
+ context) into a CLAUDE.md, .cursorrules, or equivalent file
461
+ that the AI editor reads and follows.
462
+
463
+ This is the core value of Forge — it bridges governance to the editor.
464
+ """
465
+ from forge_core.editor_bridge import write_editor_file
466
+ from forge_core.phases.context import load_context
467
+
468
+ project_path = Path.cwd()
469
+ context = load_context(project_path)
470
+
471
+ if not context:
472
+ console.print("[red]No Forge project found. Run `forge init` first.[/red]")
473
+ return
474
+
475
+ if format == "all":
476
+ formats = ["claude", "cursor", "copilot", "generic"]
477
+ else:
478
+ formats = [format]
479
+
480
+ for fmt in formats:
481
+ path = write_editor_file(project_path, context, fmt)
482
+ console.print(f"[green]✓ Generated: {path}[/green]")
483
+
484
+ console.print()
485
+ console.print("[dim]The editor will read this file automatically.[/dim]")
486
+ console.print("[dim]Re-run `forge sync` after changing standards or context.[/dim]")
487
+
488
+
489
+ @main.command()
490
+ @click.argument("files", nargs=-1, type=click.Path(exists=True))
491
+ @click.option("--full", is_flag=True, help="Audit entire project")
492
+ def audit(files: tuple[str, ...], full: bool) -> None:
493
+ """Run audit agents against code.
494
+
495
+ Generates an audit prompt with all standards, then produces
496
+ a structured report of findings with fixes.
497
+
498
+ Examples:
499
+ forge audit src/api/claims.py — audit specific file
500
+ forge audit --full — audit entire project
501
+ """
502
+ from forge_core.auditor import build_audit_prompt, build_file_audit_prompt
503
+ from forge_core.phases.context import load_context
504
+ from forge_core.registry import load_standards
505
+
506
+ project_path = Path.cwd()
507
+ context = load_context(project_path)
508
+
509
+ if not context:
510
+ console.print("[red]No Forge project found. Run `forge init` first.[/red]")
511
+ return
512
+
513
+ standards = load_standards()
514
+
515
+ if files:
516
+ # Audit specific files
517
+ for file_path in files:
518
+ content = Path(file_path).read_text(errors="ignore")
519
+ prompt = build_file_audit_prompt(content, file_path, context, standards)
520
+
521
+ # Save prompt for LLM processing
522
+ prompt_path = project_path / ".forge" / "audit" / f"audit_prompt_{Path(file_path).stem}.md"
523
+ prompt_path.parent.mkdir(parents=True, exist_ok=True)
524
+ prompt_path.write_text(prompt)
525
+
526
+ console.print(f"[cyan]Audit prompt generated for: {file_path}[/cyan]")
527
+ console.print(f"[dim]Saved to: {prompt_path}[/dim]")
528
+ else:
529
+ # Full project audit
530
+ target_files = None
531
+ if not full:
532
+ console.print("[yellow]Specify files or use --full for entire project.[/yellow]")
533
+ console.print(" forge audit src/api/claims.py")
534
+ console.print(" forge audit --full")
535
+ return
536
+
537
+ prompt = build_audit_prompt(project_path, context, standards)
538
+ prompt_path = project_path / ".forge" / "audit" / "full_audit_prompt.md"
539
+ prompt_path.parent.mkdir(parents=True, exist_ok=True)
540
+ prompt_path.write_text(prompt)
541
+
542
+ console.print("[cyan]Full project audit prompt generated.[/cyan]")
543
+ console.print(f"[dim]Saved to: {prompt_path}[/dim]")
544
+ console.print()
545
+ console.print(
546
+ "[dim]Send this prompt to Claude Code or your AI editor "
547
+ "to perform the audit.[/dim]"
548
+ )
549
+
550
+
551
+ if __name__ == "__main__":
552
+ main()