holoctl 0.5.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.
- holoctl/__init__.py +5 -0
- holoctl/__main__.py +56 -0
- holoctl/cli/__init__.py +0 -0
- holoctl/cli/_console.py +4 -0
- holoctl/cli/agent.py +101 -0
- holoctl/cli/board.py +153 -0
- holoctl/cli/compile_.py +39 -0
- holoctl/cli/doctor.py +126 -0
- holoctl/cli/init_.py +108 -0
- holoctl/cli/overview.py +165 -0
- holoctl/cli/repo.py +116 -0
- holoctl/cli/serve.py +20 -0
- holoctl/cli/sync_.py +81 -0
- holoctl/lib/__init__.py +0 -0
- holoctl/lib/board.py +341 -0
- holoctl/lib/compiler/__init__.py +26 -0
- holoctl/lib/compiler/claude.py +76 -0
- holoctl/lib/compiler/copilot.py +24 -0
- holoctl/lib/compiler/cursor.py +38 -0
- holoctl/lib/compiler/devin.py +51 -0
- holoctl/lib/compiler/generic.py +51 -0
- holoctl/lib/compiler/template.py +21 -0
- holoctl/lib/compiler/windsurf.py +22 -0
- holoctl/lib/config.py +102 -0
- holoctl/lib/discover.py +94 -0
- holoctl/lib/filetree.py +107 -0
- holoctl/lib/git.py +41 -0
- holoctl/lib/markdown.py +72 -0
- holoctl/lib/templates.py +690 -0
- holoctl/server/__init__.py +0 -0
- holoctl/server/app.py +830 -0
- holoctl/server/static/holoctl-ui.js +216 -0
- holoctl/server/static/holoctl.css +1334 -0
- holoctl/templates/commands/holoctl-claude.md +88 -0
- holoctl/templates/commands/holoctl-copilot.prompt.md +32 -0
- holoctl/templates/commands/holoctl-cursor.md +36 -0
- holoctl/templates/commands/holoctl-devin.md +32 -0
- holoctl/templates/commands/holoctl-windsurf.md +31 -0
- holoctl-0.5.0.dist-info/METADATA +297 -0
- holoctl-0.5.0.dist-info/RECORD +44 -0
- holoctl-0.5.0.dist-info/WHEEL +5 -0
- holoctl-0.5.0.dist-info/entry_points.txt +3 -0
- holoctl-0.5.0.dist-info/licenses/LICENSE +21 -0
- holoctl-0.5.0.dist-info/top_level.txt +1 -0
holoctl/__init__.py
ADDED
holoctl/__main__.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
if sys.platform == "win32":
|
|
5
|
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
6
|
+
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from .cli.init_ import app as _init_app, init_cmd
|
|
11
|
+
from .cli.board import app as _board_app
|
|
12
|
+
from .cli.compile_ import app as _compile_app, compile_cmd
|
|
13
|
+
from .cli.sync_ import app as _sync_app, sync_cmd
|
|
14
|
+
from .cli.doctor import app as _doctor_app, doctor_cmd
|
|
15
|
+
from .cli.agent import app as _agent_app
|
|
16
|
+
from .cli.repo import app as _repo_app
|
|
17
|
+
from .cli.serve import app as _serve_app, serve_cmd
|
|
18
|
+
from .cli.overview import app as _overview_app, overview_cmd
|
|
19
|
+
from . import __version__
|
|
20
|
+
|
|
21
|
+
app = typer.Typer(
|
|
22
|
+
name="holoctl",
|
|
23
|
+
help="Universal project operating system for AI coding assistants",
|
|
24
|
+
no_args_is_help=True,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
# Sub-group commands
|
|
28
|
+
app.add_typer(_board_app, name="board", help="Manage the project board")
|
|
29
|
+
app.add_typer(_agent_app, name="agent", help="Manage agent definitions")
|
|
30
|
+
app.add_typer(_repo_app, name="repo", help="Manage repos within a project")
|
|
31
|
+
|
|
32
|
+
# Direct commands
|
|
33
|
+
app.command("init")(init_cmd)
|
|
34
|
+
app.command("compile")(compile_cmd)
|
|
35
|
+
app.command("sync")(sync_cmd)
|
|
36
|
+
app.command("doctor")(doctor_cmd)
|
|
37
|
+
app.command("serve")(serve_cmd)
|
|
38
|
+
app.command("overview")(overview_cmd)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _version_callback(value: bool):
|
|
42
|
+
if value:
|
|
43
|
+
print(__version__)
|
|
44
|
+
raise typer.Exit()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@app.callback(invoke_without_command=True)
|
|
48
|
+
def main(
|
|
49
|
+
version: bool = typer.Option(None, "--version", "-v", callback=_version_callback, is_eager=True),
|
|
50
|
+
ctx: typer.Context = typer.Context,
|
|
51
|
+
):
|
|
52
|
+
pass
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
if __name__ == "__main__":
|
|
56
|
+
app()
|
holoctl/cli/__init__.py
ADDED
|
File without changes
|
holoctl/cli/_console.py
ADDED
holoctl/cli/agent.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from ._console import console
|
|
7
|
+
|
|
8
|
+
from ..lib.config import find_project_root
|
|
9
|
+
from ..lib.markdown import parse_frontmatter
|
|
10
|
+
|
|
11
|
+
app = typer.Typer(help="Manage agent definitions")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _require_root() -> Path:
|
|
15
|
+
root = find_project_root()
|
|
16
|
+
if not root:
|
|
17
|
+
console.print("[red]No .holoctl/ found. Run `holoctl init` first.[/red]")
|
|
18
|
+
raise typer.Exit(1)
|
|
19
|
+
return root
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@app.command("list")
|
|
23
|
+
def agent_list():
|
|
24
|
+
"""List configured agents."""
|
|
25
|
+
root = _require_root()
|
|
26
|
+
agents_dir = root / ".holoctl" / "agents"
|
|
27
|
+
if not agents_dir.exists():
|
|
28
|
+
console.print("[dim]No agents configured.[/dim]")
|
|
29
|
+
return
|
|
30
|
+
|
|
31
|
+
for f in sorted(agents_dir.glob("*.md")):
|
|
32
|
+
data, _ = parse_frontmatter(f.read_text(encoding="utf-8"))
|
|
33
|
+
model = data.get("model", "standard")
|
|
34
|
+
trigger = data.get("trigger", "ticket")
|
|
35
|
+
model_color = "magenta" if model == "reasoning" else "dim" if model == "fast" else "cyan"
|
|
36
|
+
name = data.get("name", f.stem)
|
|
37
|
+
console.print(
|
|
38
|
+
f" [bold]{name:<16}[/bold] [{model_color}]{model:<10}[/{model_color}] "
|
|
39
|
+
f"[dim]{trigger:<16}[/dim] {data.get('description', '')}"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@app.command("add")
|
|
44
|
+
def agent_add(
|
|
45
|
+
name: str = typer.Argument(..., help="Agent name"),
|
|
46
|
+
from_template: Optional[str] = typer.Option(None, "--from", help="Base on an existing agent"),
|
|
47
|
+
):
|
|
48
|
+
"""Create a new agent definition."""
|
|
49
|
+
root = _require_root()
|
|
50
|
+
agents_dir = root / ".holoctl" / "agents"
|
|
51
|
+
target_path = agents_dir / f"{name}.md"
|
|
52
|
+
|
|
53
|
+
if target_path.exists():
|
|
54
|
+
console.print(f"[yellow]Agent {name} already exists.[/yellow]")
|
|
55
|
+
raise typer.Exit(1)
|
|
56
|
+
|
|
57
|
+
if from_template:
|
|
58
|
+
source = agents_dir / f"{from_template}.md"
|
|
59
|
+
if not source.exists():
|
|
60
|
+
console.print(f"[red]Template agent {from_template} not found.[/red]")
|
|
61
|
+
raise typer.Exit(1)
|
|
62
|
+
import re
|
|
63
|
+
content = source.read_text(encoding="utf-8")
|
|
64
|
+
content = re.sub(r"^(name:\s*).*$", rf"\g<1>{name}", content, flags=re.MULTILINE)
|
|
65
|
+
target_path.write_text(content, encoding="utf-8")
|
|
66
|
+
else:
|
|
67
|
+
target_path.write_text(
|
|
68
|
+
f"""---
|
|
69
|
+
name: {name}
|
|
70
|
+
description: "(describe what this agent does)"
|
|
71
|
+
model: standard
|
|
72
|
+
tools: [read, search, edit, write, shell]
|
|
73
|
+
trigger: ticket
|
|
74
|
+
---
|
|
75
|
+
|
|
76
|
+
# Identity
|
|
77
|
+
|
|
78
|
+
You are the **{name}** agent. (Define identity and purpose)
|
|
79
|
+
|
|
80
|
+
# Guard Rail
|
|
81
|
+
|
|
82
|
+
(Define when this agent should refuse to work)
|
|
83
|
+
|
|
84
|
+
# Scope
|
|
85
|
+
|
|
86
|
+
(Define what this agent does and does NOT do)
|
|
87
|
+
|
|
88
|
+
# Work Order
|
|
89
|
+
|
|
90
|
+
1. (Step-by-step work process)
|
|
91
|
+
|
|
92
|
+
# Report Format
|
|
93
|
+
|
|
94
|
+
- **Done**: bullets with file:line references.
|
|
95
|
+
- **Definition of Done**: each Goal item marked `[x]` or `[ ]`.
|
|
96
|
+
- **Suggested next step**: 1 line.
|
|
97
|
+
""",
|
|
98
|
+
encoding="utf-8",
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
console.print(f"[green]Created agent: .holoctl/agents/{name}.md[/green]")
|
holoctl/cli/board.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import json
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
from ._console import console
|
|
8
|
+
|
|
9
|
+
from ..lib.config import find_project_root, load_config
|
|
10
|
+
from ..lib.board import Board
|
|
11
|
+
|
|
12
|
+
app = typer.Typer(help="Manage the project board (tickets, statuses, sprints)")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _get_board() -> tuple[Board, dict, Path]:
|
|
16
|
+
root = find_project_root()
|
|
17
|
+
if not root:
|
|
18
|
+
console.print("[red]No .holoctl/ found. Run `holoctl init` first.[/red]")
|
|
19
|
+
raise typer.Exit(1)
|
|
20
|
+
config = load_config(root)
|
|
21
|
+
return Board(root, config), config, root
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@app.command("stat")
|
|
25
|
+
def stat_cmd():
|
|
26
|
+
"""Show ticket counts by status."""
|
|
27
|
+
board, _, _ = _get_board()
|
|
28
|
+
print(json.dumps(board.stat(), indent=2))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@app.command("get")
|
|
32
|
+
def get_cmd(ticket_id: str = typer.Argument(..., help="Ticket ID")):
|
|
33
|
+
"""Get a single ticket by ID."""
|
|
34
|
+
board, _, _ = _get_board()
|
|
35
|
+
ticket = board.get(ticket_id)
|
|
36
|
+
if not ticket:
|
|
37
|
+
console.print(f"[red]Ticket {ticket_id} not found[/red]")
|
|
38
|
+
raise typer.Exit(1)
|
|
39
|
+
print(json.dumps(ticket, indent=2))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@app.command("ls")
|
|
43
|
+
def ls_cmd(
|
|
44
|
+
priority: Optional[str] = typer.Argument(None, help="Filter by priority (p0, p1, p2, p3)"),
|
|
45
|
+
sprint: Optional[str] = typer.Option(None, "--sprint"),
|
|
46
|
+
status: Optional[str] = typer.Option(None, "--status"),
|
|
47
|
+
agent: Optional[str] = typer.Option(None, "--agent"),
|
|
48
|
+
tag: Optional[str] = typer.Option(None, "--tag"),
|
|
49
|
+
project: Optional[str] = typer.Option(None, "--project", help="Filter by project (subdir name discovered in workspace)"),
|
|
50
|
+
):
|
|
51
|
+
"""List tickets with optional filters."""
|
|
52
|
+
board, _, _ = _get_board()
|
|
53
|
+
filters: dict = {}
|
|
54
|
+
if sprint:
|
|
55
|
+
filters["sprint"] = sprint
|
|
56
|
+
if status:
|
|
57
|
+
filters["status"] = status
|
|
58
|
+
if agent:
|
|
59
|
+
filters["agent"] = agent
|
|
60
|
+
if tag:
|
|
61
|
+
filters["tag"] = tag
|
|
62
|
+
if project:
|
|
63
|
+
filters["project"] = project
|
|
64
|
+
if priority and priority.startswith("p") and len(priority) == 2:
|
|
65
|
+
filters["priority"] = priority
|
|
66
|
+
|
|
67
|
+
tickets = board.ls(filters)
|
|
68
|
+
if not tickets:
|
|
69
|
+
console.print("[dim]No tickets match the filters.[/dim]")
|
|
70
|
+
return
|
|
71
|
+
|
|
72
|
+
for t in tickets:
|
|
73
|
+
dep = f" [dim][dep: {', '.join(t['depends'])}][/dim]" if t.get("depends") else ""
|
|
74
|
+
agents = ", ".join(t["agent"]) if t.get("agent") else "—"
|
|
75
|
+
agents_str = f"[green]{agents}[/green]"
|
|
76
|
+
console.print(
|
|
77
|
+
f"[bold]{t['id']}[/bold] {_priority_color(t['priority'])} "
|
|
78
|
+
f"{_status_color(t['status'])} {(t.get('sprint') or '—'):<12} "
|
|
79
|
+
f"{agents_str:<20} {t['title'][:50]}{dep}"
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@app.command("move")
|
|
84
|
+
def move_cmd(
|
|
85
|
+
ticket_id: str = typer.Argument(...),
|
|
86
|
+
status: str = typer.Argument(...),
|
|
87
|
+
):
|
|
88
|
+
"""Move a ticket to a new status."""
|
|
89
|
+
board, _, _ = _get_board()
|
|
90
|
+
try:
|
|
91
|
+
result = board.move(ticket_id, status)
|
|
92
|
+
console.print(f"{result['id']}: {result['from']} → [bold]{result['to']}[/bold]")
|
|
93
|
+
except (ValueError, KeyError) as e:
|
|
94
|
+
console.print(f"[red]{e}[/red]")
|
|
95
|
+
raise typer.Exit(1)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@app.command("set")
|
|
99
|
+
def set_cmd(
|
|
100
|
+
ticket_id: str = typer.Argument(...),
|
|
101
|
+
field: str = typer.Argument(...),
|
|
102
|
+
value: list[str] = typer.Argument(...),
|
|
103
|
+
):
|
|
104
|
+
"""Set a field on a ticket."""
|
|
105
|
+
board, _, _ = _get_board()
|
|
106
|
+
try:
|
|
107
|
+
result = board.set(ticket_id, field, " ".join(value))
|
|
108
|
+
console.print(f"{result['id']}.{result['field']} = {json.dumps(result['value'])}")
|
|
109
|
+
except (KeyError, ValueError) as e:
|
|
110
|
+
msg = str(e).strip("'")
|
|
111
|
+
console.print(f"[red]{msg}[/red]")
|
|
112
|
+
raise typer.Exit(1)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@app.command("add")
|
|
116
|
+
def add_cmd(ticket_json: str = typer.Argument(..., help="JSON ticket data")):
|
|
117
|
+
"""Create a new ticket from JSON."""
|
|
118
|
+
board, _, _ = _get_board()
|
|
119
|
+
try:
|
|
120
|
+
patch = json.loads(ticket_json)
|
|
121
|
+
ticket = board.add(patch)
|
|
122
|
+
console.print(f"[green]Created {ticket['id']}: {ticket['title']}[/green]")
|
|
123
|
+
print(json.dumps(ticket, indent=2))
|
|
124
|
+
except (json.JSONDecodeError, Exception) as e:
|
|
125
|
+
console.print(f"[red]{e}[/red]")
|
|
126
|
+
raise typer.Exit(1)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@app.command("next-id")
|
|
130
|
+
def next_id_cmd():
|
|
131
|
+
"""Show the next available ticket ID."""
|
|
132
|
+
board, _, _ = _get_board()
|
|
133
|
+
print(board.next_id())
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@app.command("rebuild-index")
|
|
137
|
+
def rebuild_index_cmd():
|
|
138
|
+
"""Rebuild index.json from ticket .md files."""
|
|
139
|
+
board, _, _ = _get_board()
|
|
140
|
+
result = board.rebuild_index()
|
|
141
|
+
console.print(f"[green]Rebuilt index: {result['ticketCount']} tickets, nextId: {result['nextId']}[/green]")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _priority_color(p: str) -> str:
|
|
145
|
+
colors = {"p0": "red", "p1": "yellow", "p2": "blue", "p3": "dim"}
|
|
146
|
+
color = colors.get(p, "white")
|
|
147
|
+
return f"[{color}]{p:<2}[/{color}]"
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _status_color(s: str) -> str:
|
|
151
|
+
colors = {"backlog": "dim", "doing": "cyan", "review": "yellow", "done": "green", "cancelled": "strike"}
|
|
152
|
+
color = colors.get(s, "white")
|
|
153
|
+
return f"[{color}]{s:<10}[/{color}]"
|
holoctl/cli/compile_.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
import typer
|
|
5
|
+
from ._console import console
|
|
6
|
+
|
|
7
|
+
from ..lib.config import find_project_root, load_config
|
|
8
|
+
from ..lib.compiler import compile_project
|
|
9
|
+
|
|
10
|
+
app = typer.Typer()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@app.command("compile")
|
|
14
|
+
def compile_cmd(
|
|
15
|
+
target: Optional[str] = typer.Option(None, "--target", help="Target (claude, cursor, windsurf, copilot, generic)"),
|
|
16
|
+
dry_run: bool = typer.Option(False, "--dry-run", help="Preview without writing files"),
|
|
17
|
+
):
|
|
18
|
+
"""Compile .holoctl/ to tool-specific files."""
|
|
19
|
+
root = find_project_root()
|
|
20
|
+
if not root:
|
|
21
|
+
console.print("[red]No .holoctl/ found. Run `holoctl init` first.[/red]")
|
|
22
|
+
raise typer.Exit(1)
|
|
23
|
+
|
|
24
|
+
config = load_config(root)
|
|
25
|
+
targets = [target] if target else config.get("targets", ["claude"])
|
|
26
|
+
|
|
27
|
+
for t in targets:
|
|
28
|
+
try:
|
|
29
|
+
result = compile_project(root, config, t, dry_run=dry_run)
|
|
30
|
+
if dry_run:
|
|
31
|
+
console.print(f"[dim][dry-run] {t}:[/dim]")
|
|
32
|
+
for f in result["files"]:
|
|
33
|
+
console.print(f" [dim]would write[/dim] {f}")
|
|
34
|
+
else:
|
|
35
|
+
console.print(f"[green]✓ {t}[/green] [dim]({len(result['files'])} files)[/dim]")
|
|
36
|
+
for f in result["files"]:
|
|
37
|
+
console.print(f" [dim]→[/dim] {f}")
|
|
38
|
+
except ValueError as e:
|
|
39
|
+
console.print(f"[red]✗ {t}: {e}[/red]")
|
holoctl/cli/doctor.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import json
|
|
3
|
+
|
|
4
|
+
import typer
|
|
5
|
+
from ._console import console
|
|
6
|
+
|
|
7
|
+
from ..lib.config import find_project_root, load_config
|
|
8
|
+
|
|
9
|
+
app = typer.Typer()
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
_TARGET_OUTPUTS = {
|
|
13
|
+
"claude": ["CLAUDE.md", ".claude/commands"],
|
|
14
|
+
"cursor": [".cursor/commands", ".cursor/rules/holoctl.md"],
|
|
15
|
+
"windsurf": [".windsurfrules"],
|
|
16
|
+
"copilot": [".github/copilot-instructions.md"],
|
|
17
|
+
"devin": ["AGENTS.md", ".devin/skills"],
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@app.command("doctor")
|
|
22
|
+
def doctor_cmd():
|
|
23
|
+
"""Check project health."""
|
|
24
|
+
root = find_project_root()
|
|
25
|
+
if not root:
|
|
26
|
+
console.print("[red]No .holoctl/ found. Run `holoctl init` first.[/red]")
|
|
27
|
+
raise typer.Exit(1)
|
|
28
|
+
|
|
29
|
+
console.print("\n [bold]holoctl doctor[/bold]\n")
|
|
30
|
+
issues = 0
|
|
31
|
+
config = None
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
config = load_config(root)
|
|
35
|
+
_check("Config", ".holoctl/config.json is valid", True)
|
|
36
|
+
except Exception as e:
|
|
37
|
+
_check("Config", f".holoctl/config.json: {e}", False)
|
|
38
|
+
issues += 1
|
|
39
|
+
|
|
40
|
+
# Index <-> .md sync
|
|
41
|
+
index_path = root / ".holoctl" / "board" / "index.json"
|
|
42
|
+
tickets_dir = root / ".holoctl" / "board" / "tickets"
|
|
43
|
+
if index_path.exists():
|
|
44
|
+
try:
|
|
45
|
+
data = json.loads(index_path.read_text(encoding="utf-8"))
|
|
46
|
+
indexed = {t["id"] for t in data.get("tickets", [])}
|
|
47
|
+
on_disk = set()
|
|
48
|
+
if tickets_dir.exists():
|
|
49
|
+
from ..lib.markdown import parse_frontmatter
|
|
50
|
+
for f in tickets_dir.glob("*.md"):
|
|
51
|
+
if f.name.startswith("_"):
|
|
52
|
+
continue
|
|
53
|
+
fm, _ = parse_frontmatter(f.read_text(encoding="utf-8"))
|
|
54
|
+
if fm.get("id"):
|
|
55
|
+
on_disk.add(fm["id"])
|
|
56
|
+
drift = indexed.symmetric_difference(on_disk)
|
|
57
|
+
if drift:
|
|
58
|
+
_check("Board", f"index.json out of sync ({len(drift)} drift) — run `holoctl board rebuild-index`", False)
|
|
59
|
+
issues += 1
|
|
60
|
+
else:
|
|
61
|
+
_check("Board", f"index.json valid ({len(indexed)} tickets, in sync)", True)
|
|
62
|
+
except Exception as e:
|
|
63
|
+
_check("Board", f"index.json parse error: {e}", False)
|
|
64
|
+
issues += 1
|
|
65
|
+
else:
|
|
66
|
+
_check("Board", "index.json exists", False)
|
|
67
|
+
issues += 1
|
|
68
|
+
|
|
69
|
+
# Agents
|
|
70
|
+
agents_dir = root / ".holoctl" / "agents"
|
|
71
|
+
if agents_dir.exists():
|
|
72
|
+
agent_count = len(list(agents_dir.glob("*.md")))
|
|
73
|
+
ok = agent_count > 0
|
|
74
|
+
_check("Agents", f"{agent_count} agent(s) defined", ok)
|
|
75
|
+
if not ok:
|
|
76
|
+
issues += 1
|
|
77
|
+
else:
|
|
78
|
+
_check("Agents", "agents/ directory exists", False)
|
|
79
|
+
issues += 1
|
|
80
|
+
|
|
81
|
+
# Commands
|
|
82
|
+
commands_dir = root / ".holoctl" / "commands"
|
|
83
|
+
if commands_dir.exists():
|
|
84
|
+
cmd_count = len(list(commands_dir.glob("*.md")))
|
|
85
|
+
ok = cmd_count > 0
|
|
86
|
+
_check("Commands", f"{cmd_count} command(s) defined", ok)
|
|
87
|
+
if not ok:
|
|
88
|
+
issues += 1
|
|
89
|
+
else:
|
|
90
|
+
_check("Commands", "commands/ directory exists", False)
|
|
91
|
+
issues += 1
|
|
92
|
+
|
|
93
|
+
# Instructions
|
|
94
|
+
ok = (root / ".holoctl" / "instructions.md").exists()
|
|
95
|
+
_check("Instructions", "instructions.md exists", ok)
|
|
96
|
+
if not ok:
|
|
97
|
+
issues += 1
|
|
98
|
+
|
|
99
|
+
# Context
|
|
100
|
+
ok = (root / ".holoctl" / "context").exists()
|
|
101
|
+
_check("Context", "context/ directory exists", ok)
|
|
102
|
+
if not ok:
|
|
103
|
+
issues += 1
|
|
104
|
+
|
|
105
|
+
# Compile targets
|
|
106
|
+
if config:
|
|
107
|
+
targets = config.get("targets", [])
|
|
108
|
+
for tgt in targets:
|
|
109
|
+
outputs = _TARGET_OUTPUTS.get(tgt, [])
|
|
110
|
+
missing = [o for o in outputs if not (root / o).exists()]
|
|
111
|
+
if missing:
|
|
112
|
+
_check("Compile", f"target '{tgt}' missing: {', '.join(missing)} — run `holoctl compile`", False)
|
|
113
|
+
issues += 1
|
|
114
|
+
else:
|
|
115
|
+
_check("Compile", f"target '{tgt}' compiled", True)
|
|
116
|
+
|
|
117
|
+
console.print("")
|
|
118
|
+
if issues == 0:
|
|
119
|
+
console.print("[green] All checks passed. Project is healthy.[/green]\n")
|
|
120
|
+
else:
|
|
121
|
+
console.print(f"[yellow] {issues} issue(s) found.[/yellow]\n")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _check(category: str, message: str, ok: bool) -> None:
|
|
125
|
+
icon = "[green]✓[/green]" if ok else "[red]✗[/red]"
|
|
126
|
+
console.print(f" {icon} [dim]{category:<14}[/dim] {message}")
|
holoctl/cli/init_.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import json
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from ._console import console
|
|
9
|
+
|
|
10
|
+
from ..lib.config import get_defaults, save_config
|
|
11
|
+
from ..lib.templates import get_templates
|
|
12
|
+
|
|
13
|
+
app = typer.Typer()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@app.command("init")
|
|
17
|
+
def init_cmd(
|
|
18
|
+
name: Optional[str] = typer.Option(None, "--name", help="Project name"),
|
|
19
|
+
prefix: Optional[str] = typer.Option(None, "--prefix", help="Ticket ID prefix (e.g. MP)"),
|
|
20
|
+
targets: Optional[str] = typer.Option(None, "--targets", help="Compile targets (claude,cursor,windsurf,copilot)"),
|
|
21
|
+
skip_compile: bool = typer.Option(False, "--skip-compile", help="Skip auto-compile after init"),
|
|
22
|
+
):
|
|
23
|
+
"""Initialize .holoctl/ in the current directory."""
|
|
24
|
+
cwd = Path.cwd()
|
|
25
|
+
holoctl_dir = cwd / ".holoctl"
|
|
26
|
+
|
|
27
|
+
if (holoctl_dir / "config.json").exists():
|
|
28
|
+
console.print("[yellow].holoctl/ already exists in this directory.[/yellow]")
|
|
29
|
+
raise typer.Exit(1)
|
|
30
|
+
|
|
31
|
+
project_name = name or cwd.name
|
|
32
|
+
project_prefix = prefix or _derive_prefix(project_name)
|
|
33
|
+
target_list = [t.strip() for t in targets.split(",")] if targets else ["claude"]
|
|
34
|
+
|
|
35
|
+
config = get_defaults()
|
|
36
|
+
config["project"]["name"] = project_name
|
|
37
|
+
config["project"]["prefix"] = project_prefix
|
|
38
|
+
config["targets"] = target_list
|
|
39
|
+
|
|
40
|
+
console.print(f"\n [bold]holoctl init[/bold]\n")
|
|
41
|
+
console.print(f" Project: [green]{project_name}[/green]")
|
|
42
|
+
console.print(f" Prefix: [green]{project_prefix}[/green] (tickets: {project_prefix}-001, {project_prefix}-002, ...)")
|
|
43
|
+
console.print(f" Targets: [green]{', '.join(target_list)}[/green]")
|
|
44
|
+
console.print("")
|
|
45
|
+
|
|
46
|
+
dirs = [
|
|
47
|
+
".holoctl",
|
|
48
|
+
".holoctl/board",
|
|
49
|
+
".holoctl/board/tickets",
|
|
50
|
+
".holoctl/agents",
|
|
51
|
+
".holoctl/commands",
|
|
52
|
+
".holoctl/context",
|
|
53
|
+
".holoctl/context/decisions",
|
|
54
|
+
".holoctl/context/documents",
|
|
55
|
+
]
|
|
56
|
+
for d in dirs:
|
|
57
|
+
(cwd / d).mkdir(parents=True, exist_ok=True)
|
|
58
|
+
|
|
59
|
+
save_config(cwd, config)
|
|
60
|
+
|
|
61
|
+
templates = get_templates(config)
|
|
62
|
+
for rel_path, content in templates.items():
|
|
63
|
+
full_path = cwd / rel_path
|
|
64
|
+
full_path.parent.mkdir(parents=True, exist_ok=True)
|
|
65
|
+
full_path.write_text(content, encoding="utf-8")
|
|
66
|
+
|
|
67
|
+
index_data = {
|
|
68
|
+
"meta": {"version": 1, "updated": _today(), "nextId": 1, "counts": {}},
|
|
69
|
+
"tickets": [],
|
|
70
|
+
}
|
|
71
|
+
(cwd / ".holoctl" / "board" / "index.json").write_text(
|
|
72
|
+
json.dumps(index_data, indent="\t") + "\n", encoding="utf-8"
|
|
73
|
+
)
|
|
74
|
+
(cwd / ".holoctl" / "activity.jsonl").write_text("", encoding="utf-8")
|
|
75
|
+
|
|
76
|
+
console.print(f" [green]✓ .holoctl/ initialized successfully.[/green]\n")
|
|
77
|
+
|
|
78
|
+
if not skip_compile:
|
|
79
|
+
from ..lib.compiler import compile_project
|
|
80
|
+
for tgt in target_list:
|
|
81
|
+
try:
|
|
82
|
+
result = compile_project(cwd, config, tgt, dry_run=False)
|
|
83
|
+
count = len(result.get("files", []))
|
|
84
|
+
console.print(f" [green]✓ compiled[/green] [bold]{tgt}[/bold] [dim]({count} files)[/dim]")
|
|
85
|
+
except Exception as e:
|
|
86
|
+
console.print(f" [red]✗ compile {tgt}:[/red] {e}")
|
|
87
|
+
|
|
88
|
+
console.print("")
|
|
89
|
+
console.print(" Next steps:")
|
|
90
|
+
console.print(f" [dim]$[/dim] holoctl board add '{{\"title\":\"My first ticket\",\"agent\":\"developer\"}}'")
|
|
91
|
+
console.print(f" [dim]$[/dim] holoctl serve")
|
|
92
|
+
console.print("")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _today() -> str:
|
|
96
|
+
from datetime import date
|
|
97
|
+
return date.today().isoformat()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _derive_prefix(name: str) -> str:
|
|
101
|
+
cleaned = re.sub(r"[^a-zA-Z0-9]", "", name)
|
|
102
|
+
if len(cleaned) <= 4:
|
|
103
|
+
return cleaned.upper()
|
|
104
|
+
words = re.split(r"[\s_-]+", name)
|
|
105
|
+
words = [w for w in words if w]
|
|
106
|
+
if len(words) >= 2:
|
|
107
|
+
return "".join(w[0] for w in words).upper()[:4]
|
|
108
|
+
return cleaned[:3].upper()
|