claude-code-conductor 0.2.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.
- c3/__init__.py +3 -0
- c3/__main__.py +4 -0
- c3/_template/.claude/CLAUDE.md +182 -0
- c3/_template/.claude/agents/architect.md +50 -0
- c3/_template/.claude/agents/code-reviewer.md +50 -0
- c3/_template/.claude/agents/developer.md +55 -0
- c3/_template/.claude/agents/doc-writer.md +62 -0
- c3/_template/.claude/agents/interviewer.md +46 -0
- c3/_template/.claude/agents/planner.md +59 -0
- c3/_template/.claude/agents/project-setup.md +106 -0
- c3/_template/.claude/agents/security-reviewer.md +51 -0
- c3/_template/.claude/agents/tdd-develop.md +117 -0
- c3/_template/.claude/agents/tester.md +48 -0
- c3/_template/.claude/commands/develop.md +10 -0
- c3/_template/.claude/commands/doc.md +174 -0
- c3/_template/.claude/commands/extract-lib.md +292 -0
- c3/_template/.claude/commands/init-session.md +110 -0
- c3/_template/.claude/commands/mcp.md +322 -0
- c3/_template/.claude/commands/promote-pattern.md +135 -0
- c3/_template/.claude/commands/review.md +9 -0
- c3/_template/.claude/commands/setup.md +206 -0
- c3/_template/.claude/commands/start.md +88 -0
- c3/_template/.claude/docs/parallel-orchestra-manifest.md +108 -0
- c3/_template/.claude/hooks/clear_file_history.py +39 -0
- c3/_template/.claude/hooks/enable_sandbox.py +61 -0
- c3/_template/.claude/hooks/pre_compact.py +82 -0
- c3/_template/.claude/hooks/pre_tool.py +64 -0
- c3/_template/.claude/hooks/statusline.py +170 -0
- c3/_template/.claude/hooks/stop.py +202 -0
- c3/_template/.claude/hooks/validate_skill_change.py +33 -0
- c3/_template/.claude/hooks/worktree_guard.py +53 -0
- c3/_template/.claude/memory/.gitkeep +0 -0
- c3/_template/.claude/rules/code-review-checklist.md +91 -0
- c3/_template/.claude/rules/promoted/index.md +5 -0
- c3/_template/.claude/rules/security-review-checklist.md +84 -0
- c3/_template/.claude/settings.json +136 -0
- c3/_template/.claude/settings.local.json +126 -0
- c3/_template/.claude/skills/dev-workflow.md +484 -0
- c3/_template/.claude/skills/parallel-execution.md +121 -0
- c3/_template/.claude/skills/promoted/index.md +5 -0
- c3/_template/.claude/skills/worktree-tdd-workflow.md +71 -0
- c3/cli.py +63 -0
- c3/cli_doctor.py +135 -0
- c3/cli_init.py +70 -0
- c3/cli_list.py +69 -0
- c3/cli_po.py +102 -0
- c3/cli_update.py +117 -0
- c3/paths.py +64 -0
- c3/po/__init__.py +11 -0
- c3/po/detect.py +44 -0
- c3/po/manifest.py +336 -0
- c3/po/run.py +105 -0
- claude_code_conductor-0.2.0.dist-info/METADATA +362 -0
- claude_code_conductor-0.2.0.dist-info/RECORD +57 -0
- claude_code_conductor-0.2.0.dist-info/WHEEL +4 -0
- claude_code_conductor-0.2.0.dist-info/entry_points.txt +2 -0
- claude_code_conductor-0.2.0.dist-info/licenses/LICENSE +21 -0
c3/cli.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""C3 CLI entry point.
|
|
2
|
+
|
|
3
|
+
Each subcommand registers its parser through ``register(subparsers)`` and
|
|
4
|
+
exposes a ``handle(args) -> int`` function. Keeping each subcommand in its own
|
|
5
|
+
module (``cli_*.py``) keeps the dispatch table small and isolates the
|
|
6
|
+
implementation details.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
from c3 import __version__
|
|
15
|
+
from c3 import cli_doctor, cli_init, cli_list, cli_po, cli_update
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
19
|
+
parser = argparse.ArgumentParser(
|
|
20
|
+
prog="c3",
|
|
21
|
+
description="Claude Code Conductor - multi-agent orchestration framework",
|
|
22
|
+
)
|
|
23
|
+
parser.add_argument(
|
|
24
|
+
"--version",
|
|
25
|
+
action="version",
|
|
26
|
+
version=f"c3 {__version__}",
|
|
27
|
+
)
|
|
28
|
+
sub = parser.add_subparsers(dest="command", metavar="<command>")
|
|
29
|
+
sub.required = True
|
|
30
|
+
|
|
31
|
+
cli_init.register(sub)
|
|
32
|
+
cli_update.register(sub)
|
|
33
|
+
cli_list.register(sub)
|
|
34
|
+
cli_doctor.register(sub)
|
|
35
|
+
cli_po.register(sub)
|
|
36
|
+
|
|
37
|
+
return parser
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def main(argv: list[str] | None = None) -> int:
|
|
41
|
+
_force_utf8_streams()
|
|
42
|
+
parser = build_parser()
|
|
43
|
+
args = parser.parse_args(argv)
|
|
44
|
+
return args.handler(args)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _force_utf8_streams() -> None:
|
|
48
|
+
"""Reconfigure stdout/stderr to UTF-8 so Japanese output renders correctly on Windows.
|
|
49
|
+
|
|
50
|
+
No-op on platforms where stdout is already UTF-8 or where ``reconfigure`` is unavailable.
|
|
51
|
+
"""
|
|
52
|
+
for stream in (sys.stdout, sys.stderr):
|
|
53
|
+
reconfigure = getattr(stream, "reconfigure", None)
|
|
54
|
+
if reconfigure is None:
|
|
55
|
+
continue
|
|
56
|
+
try:
|
|
57
|
+
reconfigure(encoding="utf-8")
|
|
58
|
+
except (ValueError, OSError):
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
if __name__ == "__main__":
|
|
63
|
+
sys.exit(main())
|
c3/cli_doctor.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""``c3 doctor`` - diagnose the C3 installation health."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import shutil
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from c3.paths import claude_root_for
|
|
13
|
+
from c3.po.detect import detect_po
|
|
14
|
+
|
|
15
|
+
_OK = "OK"
|
|
16
|
+
_WARN = "WARN"
|
|
17
|
+
_ERR = "ERR"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def register(subparsers: argparse._SubParsersAction) -> None:
|
|
21
|
+
parser = subparsers.add_parser(
|
|
22
|
+
"doctor",
|
|
23
|
+
help="Run diagnostics on the local C3 setup",
|
|
24
|
+
)
|
|
25
|
+
parser.add_argument(
|
|
26
|
+
"--check",
|
|
27
|
+
choices=["all", "po-only"],
|
|
28
|
+
default="all",
|
|
29
|
+
help="Limit checks to a subset (default: all)",
|
|
30
|
+
)
|
|
31
|
+
parser.add_argument(
|
|
32
|
+
"--quiet",
|
|
33
|
+
action="store_true",
|
|
34
|
+
help="Print only failures and warnings",
|
|
35
|
+
)
|
|
36
|
+
parser.set_defaults(handler=handle)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def handle(args: argparse.Namespace) -> int:
|
|
40
|
+
color = _supports_color()
|
|
41
|
+
findings: list[tuple[str, str, str]] = [] # (status, label, detail)
|
|
42
|
+
|
|
43
|
+
if args.check == "po-only":
|
|
44
|
+
findings.append(_check_po())
|
|
45
|
+
else:
|
|
46
|
+
findings.append(_check_claude_dir())
|
|
47
|
+
findings.append(_check_settings_json())
|
|
48
|
+
findings.append(_check_claude_binary())
|
|
49
|
+
findings.append(_check_po())
|
|
50
|
+
|
|
51
|
+
exit_code = 0
|
|
52
|
+
for status, label, detail in findings:
|
|
53
|
+
if args.quiet and status == _OK:
|
|
54
|
+
continue
|
|
55
|
+
print(_format(status, label, detail, color=color))
|
|
56
|
+
if status == _ERR:
|
|
57
|
+
exit_code = 1
|
|
58
|
+
|
|
59
|
+
return exit_code
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _check_claude_dir() -> tuple[str, str, str]:
|
|
63
|
+
root = claude_root_for(Path.cwd())
|
|
64
|
+
if root is None:
|
|
65
|
+
return (
|
|
66
|
+
_WARN,
|
|
67
|
+
".claude/ directory",
|
|
68
|
+
"not found from cwd; run `c3 init` to scaffold one",
|
|
69
|
+
)
|
|
70
|
+
return _OK, ".claude/ directory", str(root / ".claude")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _check_settings_json() -> tuple[str, str, str]:
|
|
74
|
+
root = claude_root_for(Path.cwd())
|
|
75
|
+
if root is None:
|
|
76
|
+
return _WARN, "settings.json", "skipped (.claude/ not found)"
|
|
77
|
+
settings = root / ".claude" / "settings.json"
|
|
78
|
+
if not settings.is_file():
|
|
79
|
+
return _WARN, "settings.json", f"missing at {settings}"
|
|
80
|
+
try:
|
|
81
|
+
json.loads(settings.read_text(encoding="utf-8"))
|
|
82
|
+
except json.JSONDecodeError as exc:
|
|
83
|
+
return _ERR, "settings.json", f"invalid JSON: {exc}"
|
|
84
|
+
return _OK, "settings.json", str(settings)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _check_claude_binary() -> tuple[str, str, str]:
|
|
88
|
+
path = shutil.which("claude")
|
|
89
|
+
if path is None:
|
|
90
|
+
return (
|
|
91
|
+
_WARN,
|
|
92
|
+
"claude binary",
|
|
93
|
+
"not on PATH; install Claude Code CLI before running parallel-orchestra",
|
|
94
|
+
)
|
|
95
|
+
return _OK, "claude binary", path
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _check_po() -> tuple[str, str, str]:
|
|
99
|
+
available, version, cli_path = detect_po()
|
|
100
|
+
if available:
|
|
101
|
+
ver = version or "unknown version"
|
|
102
|
+
return _OK, "parallel-orchestra", f"{ver} at {cli_path}"
|
|
103
|
+
return (
|
|
104
|
+
_WARN,
|
|
105
|
+
"parallel-orchestra",
|
|
106
|
+
(
|
|
107
|
+
"not installed (optional). 並列実行を使うには "
|
|
108
|
+
"`pip install parallel-orchestra` を実行してください。"
|
|
109
|
+
"詳細: https://pypi.org/project/parallel-orchestra/"
|
|
110
|
+
),
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _supports_color() -> bool:
|
|
115
|
+
if os.environ.get("NO_COLOR"):
|
|
116
|
+
return False
|
|
117
|
+
if not sys.stdout.isatty():
|
|
118
|
+
return False
|
|
119
|
+
return True
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _format(status: str, label: str, detail: str, *, color: bool) -> str:
|
|
123
|
+
icon = {
|
|
124
|
+
_OK: "[OK]",
|
|
125
|
+
_WARN: "[WARN]",
|
|
126
|
+
_ERR: "[ERR]",
|
|
127
|
+
}[status]
|
|
128
|
+
if color:
|
|
129
|
+
ansi = {
|
|
130
|
+
_OK: "\033[32m",
|
|
131
|
+
_WARN: "\033[33m",
|
|
132
|
+
_ERR: "\033[31m",
|
|
133
|
+
}[status]
|
|
134
|
+
icon = f"{ansi}{icon}\033[0m"
|
|
135
|
+
return f" {icon} {label}: {detail}"
|
c3/cli_init.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""``c3 init`` - scaffold ``.claude/`` into the current project."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import shutil
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from c3.paths import templates_dir
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def register(subparsers: argparse._SubParsersAction) -> None:
|
|
14
|
+
parser = subparsers.add_parser(
|
|
15
|
+
"init",
|
|
16
|
+
help="Scaffold a fresh .claude/ directory into the current project",
|
|
17
|
+
description=(
|
|
18
|
+
"Copy the bundled C3 .claude/ template into the current working "
|
|
19
|
+
"directory. Refuses to overwrite an existing .claude/ unless "
|
|
20
|
+
"--force is given."
|
|
21
|
+
),
|
|
22
|
+
)
|
|
23
|
+
parser.add_argument(
|
|
24
|
+
"--force",
|
|
25
|
+
action="store_true",
|
|
26
|
+
help="Overwrite an existing .claude/ directory without confirmation",
|
|
27
|
+
)
|
|
28
|
+
parser.add_argument(
|
|
29
|
+
"--target",
|
|
30
|
+
type=Path,
|
|
31
|
+
default=None,
|
|
32
|
+
help="Destination directory (defaults to the current working directory)",
|
|
33
|
+
)
|
|
34
|
+
parser.set_defaults(handler=handle)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def handle(args: argparse.Namespace) -> int:
|
|
38
|
+
target_root: Path = (args.target or Path.cwd()).resolve()
|
|
39
|
+
dest = target_root / ".claude"
|
|
40
|
+
|
|
41
|
+
if dest.exists() and not args.force:
|
|
42
|
+
print(
|
|
43
|
+
f"refusing to overwrite existing directory: {dest}\n"
|
|
44
|
+
"Pass --force to overwrite or run `c3 update` for a diff-aware merge.",
|
|
45
|
+
file=sys.stderr,
|
|
46
|
+
)
|
|
47
|
+
return 1
|
|
48
|
+
|
|
49
|
+
template = templates_dir()
|
|
50
|
+
if dest.exists() and args.force:
|
|
51
|
+
shutil.rmtree(dest)
|
|
52
|
+
|
|
53
|
+
target_root.mkdir(parents=True, exist_ok=True)
|
|
54
|
+
copied = _copytree(template, dest)
|
|
55
|
+
print(f"initialized {dest} ({copied} files copied)")
|
|
56
|
+
return 0
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _copytree(src: Path, dst: Path) -> int:
|
|
60
|
+
"""Copy src -> dst recursively; return number of regular files written."""
|
|
61
|
+
dst.mkdir(parents=True, exist_ok=True)
|
|
62
|
+
count = 0
|
|
63
|
+
for entry in src.iterdir():
|
|
64
|
+
target = dst / entry.name
|
|
65
|
+
if entry.is_dir():
|
|
66
|
+
count += _copytree(entry, target)
|
|
67
|
+
elif entry.is_file():
|
|
68
|
+
shutil.copy2(entry, target)
|
|
69
|
+
count += 1
|
|
70
|
+
return count
|
c3/cli_list.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""``c3 list-agents`` / ``list-skills`` / ``list-commands`` - inspect installed assets."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import re
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from c3.paths import claude_root_for
|
|
11
|
+
|
|
12
|
+
_FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n", re.DOTALL)
|
|
13
|
+
_DESCRIPTION_RE = re.compile(r"^description:\s*(.+?)\s*$", re.MULTILINE)
|
|
14
|
+
_H1_RE = re.compile(r"^#\s+(.+?)\s*$", re.MULTILINE)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def register(subparsers: argparse._SubParsersAction) -> None:
|
|
18
|
+
for kind in ("agents", "skills", "commands"):
|
|
19
|
+
parser = subparsers.add_parser(
|
|
20
|
+
f"list-{kind}",
|
|
21
|
+
help=f"List installed {kind} in the project's .claude/{kind}/",
|
|
22
|
+
)
|
|
23
|
+
parser.add_argument(
|
|
24
|
+
"--target",
|
|
25
|
+
type=Path,
|
|
26
|
+
default=None,
|
|
27
|
+
help="Project root (defaults to walking up from cwd to find .claude/)",
|
|
28
|
+
)
|
|
29
|
+
parser.set_defaults(handler=handle, kind=kind)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def handle(args: argparse.Namespace) -> int:
|
|
33
|
+
start = (args.target or Path.cwd()).resolve()
|
|
34
|
+
root = claude_root_for(start)
|
|
35
|
+
if root is None:
|
|
36
|
+
print(
|
|
37
|
+
f"no .claude/ directory found at or above {start}",
|
|
38
|
+
file=sys.stderr,
|
|
39
|
+
)
|
|
40
|
+
return 1
|
|
41
|
+
|
|
42
|
+
target_dir = root / ".claude" / args.kind
|
|
43
|
+
if not target_dir.is_dir():
|
|
44
|
+
print(f"no .claude/{args.kind}/ directory at {root}", file=sys.stderr)
|
|
45
|
+
return 1
|
|
46
|
+
|
|
47
|
+
files = sorted(p for p in target_dir.glob("*.md") if p.is_file())
|
|
48
|
+
if not files:
|
|
49
|
+
print(f"(no {args.kind} found)")
|
|
50
|
+
return 0
|
|
51
|
+
|
|
52
|
+
width = max(len(p.stem) for p in files)
|
|
53
|
+
for path in files:
|
|
54
|
+
summary = _summary(path)
|
|
55
|
+
print(f" {path.stem.ljust(width)} {summary}")
|
|
56
|
+
return 0
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _summary(path: Path) -> str:
|
|
60
|
+
text = path.read_text(encoding="utf-8")
|
|
61
|
+
fm_match = _FRONTMATTER_RE.match(text)
|
|
62
|
+
if fm_match:
|
|
63
|
+
desc_match = _DESCRIPTION_RE.search(fm_match.group(1))
|
|
64
|
+
if desc_match:
|
|
65
|
+
return desc_match.group(1).strip("\"'")
|
|
66
|
+
h1_match = _H1_RE.search(text)
|
|
67
|
+
if h1_match:
|
|
68
|
+
return h1_match.group(1)
|
|
69
|
+
return ""
|
c3/cli_po.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""``c3 po`` - thin wrapper around the parallel-orchestra CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from c3.paths import claude_root_for
|
|
10
|
+
from c3.po.detect import detect_po
|
|
11
|
+
from c3.po.manifest import validate_manifest
|
|
12
|
+
from c3.po.run import run_manifest
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
_NOT_INSTALLED_MSG = (
|
|
16
|
+
"parallel-orchestra is not installed. "
|
|
17
|
+
"並列実行を使うには `pip install parallel-orchestra` を実行してください。"
|
|
18
|
+
"詳細: https://pypi.org/project/parallel-orchestra/"
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def register(subparsers: argparse._SubParsersAction) -> None:
|
|
23
|
+
parser = subparsers.add_parser(
|
|
24
|
+
"po",
|
|
25
|
+
help="Run a plan-report as a parallel-orchestra manifest",
|
|
26
|
+
)
|
|
27
|
+
inner = parser.add_subparsers(dest="po_command", metavar="<subcommand>")
|
|
28
|
+
inner.required = True
|
|
29
|
+
|
|
30
|
+
dry = inner.add_parser(
|
|
31
|
+
"dry-run",
|
|
32
|
+
help="Validate the manifest without executing tasks",
|
|
33
|
+
)
|
|
34
|
+
dry.add_argument("manifest", type=Path)
|
|
35
|
+
dry.set_defaults(handler=_handle_dry_run)
|
|
36
|
+
|
|
37
|
+
run = inner.add_parser(
|
|
38
|
+
"run",
|
|
39
|
+
help="Execute the manifest",
|
|
40
|
+
)
|
|
41
|
+
run.add_argument("manifest", type=Path)
|
|
42
|
+
run.add_argument("--max-workers", type=int, default=None)
|
|
43
|
+
run.add_argument("--report", type=Path, default=None)
|
|
44
|
+
run.add_argument("--quiet", action="store_true")
|
|
45
|
+
run.add_argument("--claude-exe", default=None)
|
|
46
|
+
run.set_defaults(handler=_handle_run)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _ensure_po_available() -> int:
|
|
50
|
+
available, _, _ = detect_po()
|
|
51
|
+
if not available:
|
|
52
|
+
print(_NOT_INSTALLED_MSG, file=sys.stderr)
|
|
53
|
+
return 1
|
|
54
|
+
return 0
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _preflight(manifest: Path) -> int:
|
|
58
|
+
if not manifest.is_file():
|
|
59
|
+
print(f"manifest not found: {manifest}", file=sys.stderr)
|
|
60
|
+
return 2
|
|
61
|
+
root = claude_root_for(manifest.parent) or claude_root_for(Path.cwd())
|
|
62
|
+
if root is None:
|
|
63
|
+
print("could not locate .claude/ directory for agent lookup", file=sys.stderr)
|
|
64
|
+
return 2
|
|
65
|
+
errors = validate_manifest(manifest, root)
|
|
66
|
+
if errors:
|
|
67
|
+
for err in errors:
|
|
68
|
+
print(err, file=sys.stderr)
|
|
69
|
+
return 2
|
|
70
|
+
return 0
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _handle_dry_run(args: argparse.Namespace) -> int:
|
|
74
|
+
rc = _preflight(args.manifest)
|
|
75
|
+
if rc != 0:
|
|
76
|
+
return rc
|
|
77
|
+
if (rc := _ensure_po_available()) != 0:
|
|
78
|
+
return rc
|
|
79
|
+
result = run_manifest(args.manifest, dry_run=True)
|
|
80
|
+
if result.status == "not_installed":
|
|
81
|
+
print(_NOT_INSTALLED_MSG, file=sys.stderr)
|
|
82
|
+
return 1
|
|
83
|
+
return result.exit_code if result.exit_code >= 0 else 1
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _handle_run(args: argparse.Namespace) -> int:
|
|
87
|
+
rc = _preflight(args.manifest)
|
|
88
|
+
if rc != 0:
|
|
89
|
+
return rc
|
|
90
|
+
if (rc := _ensure_po_available()) != 0:
|
|
91
|
+
return rc
|
|
92
|
+
result = run_manifest(
|
|
93
|
+
args.manifest,
|
|
94
|
+
max_workers=args.max_workers,
|
|
95
|
+
report=args.report,
|
|
96
|
+
quiet=args.quiet,
|
|
97
|
+
claude_exe=args.claude_exe,
|
|
98
|
+
)
|
|
99
|
+
if result.status == "not_installed":
|
|
100
|
+
print(_NOT_INSTALLED_MSG, file=sys.stderr)
|
|
101
|
+
return 1
|
|
102
|
+
return result.exit_code if result.exit_code >= 0 else 1
|
c3/cli_update.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""``c3 update`` - bring the project's ``.claude/`` up to date with the package template."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import filecmp
|
|
7
|
+
import shutil
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from c3.paths import templates_dir
|
|
12
|
+
|
|
13
|
+
# Files that the user is expected to edit locally; never overwrite them.
|
|
14
|
+
# These match the .gitignore entries used in the C3 source repo.
|
|
15
|
+
_LOCAL_FILES: tuple[str, ...] = (
|
|
16
|
+
"docs/decisions.md",
|
|
17
|
+
"docs/taxonomy.md",
|
|
18
|
+
"docs/game-studios-research.md",
|
|
19
|
+
"memory/patterns.json",
|
|
20
|
+
"memory/agent-audit.log",
|
|
21
|
+
)
|
|
22
|
+
_LOCAL_DIRS: tuple[str, ...] = (
|
|
23
|
+
"memory/sessions",
|
|
24
|
+
"reports",
|
|
25
|
+
"tmp",
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def register(subparsers: argparse._SubParsersAction) -> None:
|
|
30
|
+
parser = subparsers.add_parser(
|
|
31
|
+
"update",
|
|
32
|
+
help="Refresh .claude/ from the bundled template (skips local files)",
|
|
33
|
+
description=(
|
|
34
|
+
"Compare the project's .claude/ to the bundled template and "
|
|
35
|
+
"overwrite framework files that differ. User-managed files "
|
|
36
|
+
"(reports/, memory/sessions/, docs/decisions.md, etc.) are "
|
|
37
|
+
"always skipped."
|
|
38
|
+
),
|
|
39
|
+
)
|
|
40
|
+
parser.add_argument(
|
|
41
|
+
"--dry-run",
|
|
42
|
+
action="store_true",
|
|
43
|
+
help="Show what would change without writing anything",
|
|
44
|
+
)
|
|
45
|
+
parser.add_argument(
|
|
46
|
+
"--target",
|
|
47
|
+
type=Path,
|
|
48
|
+
default=None,
|
|
49
|
+
help="Destination directory (defaults to the current working directory)",
|
|
50
|
+
)
|
|
51
|
+
parser.set_defaults(handler=handle)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def handle(args: argparse.Namespace) -> int:
|
|
55
|
+
target_root: Path = (args.target or Path.cwd()).resolve()
|
|
56
|
+
dest = target_root / ".claude"
|
|
57
|
+
if not dest.is_dir():
|
|
58
|
+
print(
|
|
59
|
+
f"no .claude/ directory found in {target_root}. "
|
|
60
|
+
"Run `c3 init` first.",
|
|
61
|
+
file=sys.stderr,
|
|
62
|
+
)
|
|
63
|
+
return 1
|
|
64
|
+
|
|
65
|
+
template = templates_dir()
|
|
66
|
+
actions = list(_walk_diff(template, dest))
|
|
67
|
+
if not actions:
|
|
68
|
+
print("up to date")
|
|
69
|
+
return 0
|
|
70
|
+
|
|
71
|
+
if args.dry_run:
|
|
72
|
+
print(f"{len(actions)} file(s) would change:")
|
|
73
|
+
for action, path in actions:
|
|
74
|
+
print(f" {action}: {path.relative_to(dest)}")
|
|
75
|
+
return 0
|
|
76
|
+
|
|
77
|
+
for action, abs_path in actions:
|
|
78
|
+
rel = abs_path.relative_to(dest)
|
|
79
|
+
src = template / rel
|
|
80
|
+
if action == "add" or action == "update":
|
|
81
|
+
abs_path.parent.mkdir(parents=True, exist_ok=True)
|
|
82
|
+
shutil.copy2(src, abs_path)
|
|
83
|
+
print(f" {action}: {rel}")
|
|
84
|
+
|
|
85
|
+
print(f"{len(actions)} file(s) updated")
|
|
86
|
+
return 0
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _walk_diff(template: Path, dest: Path):
|
|
90
|
+
"""Yield (action, absolute_dest_path) tuples for files that differ.
|
|
91
|
+
|
|
92
|
+
Only ``add`` and ``update`` are emitted; we never delete files in dest.
|
|
93
|
+
"""
|
|
94
|
+
for src_file in _iter_files(template):
|
|
95
|
+
rel = src_file.relative_to(template)
|
|
96
|
+
if _is_local(rel):
|
|
97
|
+
continue
|
|
98
|
+
target = dest / rel
|
|
99
|
+
if not target.exists():
|
|
100
|
+
yield "add", target
|
|
101
|
+
elif not filecmp.cmp(src_file, target, shallow=False):
|
|
102
|
+
yield "update", target
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _iter_files(root: Path):
|
|
106
|
+
for entry in root.iterdir():
|
|
107
|
+
if entry.is_dir():
|
|
108
|
+
yield from _iter_files(entry)
|
|
109
|
+
elif entry.is_file():
|
|
110
|
+
yield entry
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _is_local(rel: Path) -> bool:
|
|
114
|
+
rel_posix = rel.as_posix()
|
|
115
|
+
if rel_posix in _LOCAL_FILES:
|
|
116
|
+
return True
|
|
117
|
+
return any(rel_posix == d or rel_posix.startswith(d + "/") for d in _LOCAL_DIRS)
|
c3/paths.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Path resolution helpers for C3.
|
|
2
|
+
|
|
3
|
+
Two responsibilities:
|
|
4
|
+
- locate the user project's ``.claude/`` directory by walking upward from a cwd
|
|
5
|
+
- locate the bundled ``.claude/`` template (works for both regular installs and
|
|
6
|
+
editable installs)
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from importlib.resources import files as _resource_files
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def claude_root_for(start: Path | str) -> Path | None:
|
|
16
|
+
"""Walk up from ``start`` and return the nearest directory containing ``.claude/``.
|
|
17
|
+
|
|
18
|
+
Returns ``None`` if no ``.claude/`` is found before the filesystem root.
|
|
19
|
+
"""
|
|
20
|
+
here = Path(start).resolve()
|
|
21
|
+
candidates = [here, *here.parents]
|
|
22
|
+
for candidate in candidates:
|
|
23
|
+
if (candidate / ".claude").is_dir():
|
|
24
|
+
return candidate
|
|
25
|
+
return None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def templates_dir() -> Path:
|
|
29
|
+
"""Return the path to the bundled ``.claude/`` template.
|
|
30
|
+
|
|
31
|
+
Resolution order:
|
|
32
|
+
|
|
33
|
+
1. Dev source: walk up from this file looking for a sibling ``.claude/``
|
|
34
|
+
next to a ``pyproject.toml``. This makes editable installs (``pip install
|
|
35
|
+
-e .``) reflect live edits to the source ``.claude/`` without rebuilding.
|
|
36
|
+
In a wheel-installed environment the ``.py`` files live under
|
|
37
|
+
``site-packages/`` and this lookup naturally returns no match.
|
|
38
|
+
2. Installed location: ``importlib.resources.files("c3") / "_template" / ".claude"``.
|
|
39
|
+
This is the path produced by hatchling's build hook + ``force-include``
|
|
40
|
+
during ``pip install``.
|
|
41
|
+
|
|
42
|
+
Raises ``FileNotFoundError`` if neither location exists. This usually means
|
|
43
|
+
the package was built without the template (manually copied source tree) -
|
|
44
|
+
reinstall via pip to fix.
|
|
45
|
+
"""
|
|
46
|
+
here = Path(__file__).resolve()
|
|
47
|
+
for parent in here.parents:
|
|
48
|
+
candidate = parent / ".claude"
|
|
49
|
+
if candidate.is_dir() and (parent / "pyproject.toml").is_file():
|
|
50
|
+
return candidate
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
bundled = _resource_files("c3").joinpath("_template", ".claude")
|
|
54
|
+
bundled_path = Path(str(bundled))
|
|
55
|
+
if bundled_path.is_dir():
|
|
56
|
+
return bundled_path
|
|
57
|
+
except (ModuleNotFoundError, FileNotFoundError, AttributeError):
|
|
58
|
+
pass
|
|
59
|
+
|
|
60
|
+
raise FileNotFoundError(
|
|
61
|
+
"Could not locate the bundled .claude/ template. "
|
|
62
|
+
"Reinstall claude-code-conductor with "
|
|
63
|
+
"`pip install --force-reinstall claude-code-conductor`."
|
|
64
|
+
)
|
c3/po/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""parallel-orchestra (PO) integration helpers.
|
|
2
|
+
|
|
3
|
+
This package only invokes PO via its CLI (subprocess). It deliberately does
|
|
4
|
+
not import ``parallel_orchestra`` so that C3 stays loosely coupled and is
|
|
5
|
+
unaffected by PO's internal API changes.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from c3.po.detect import detect_po
|
|
9
|
+
from c3.po.run import RunResult, run_manifest
|
|
10
|
+
|
|
11
|
+
__all__ = ["detect_po", "run_manifest", "RunResult"]
|
c3/po/detect.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Runtime detection of the parallel-orchestra (PO) installation.
|
|
2
|
+
|
|
3
|
+
The authoritative signal is ``shutil.which`` because PO is invoked as a
|
|
4
|
+
subprocess. ``importlib.metadata`` provides the version for diagnostics but
|
|
5
|
+
its absence does not flip availability to False (e.g. pipx-installed PO can
|
|
6
|
+
expose the binary while hiding the metadata from the caller's interpreter).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import shutil
|
|
12
|
+
import sys
|
|
13
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def detect_po() -> tuple[bool, str | None, str | None]:
|
|
17
|
+
"""Return ``(is_available, version, cli_path)``.
|
|
18
|
+
|
|
19
|
+
``is_available`` is ``True`` iff ``parallel-orchestra`` is on PATH.
|
|
20
|
+
``version`` is the package version reported by ``importlib.metadata``,
|
|
21
|
+
or ``None`` if the metadata is not queryable from this interpreter.
|
|
22
|
+
``cli_path`` is the absolute path returned by ``shutil.which`` (or ``None``).
|
|
23
|
+
|
|
24
|
+
Never raises.
|
|
25
|
+
"""
|
|
26
|
+
cli_path = shutil.which("parallel-orchestra")
|
|
27
|
+
try:
|
|
28
|
+
ver = version("parallel-orchestra")
|
|
29
|
+
except PackageNotFoundError:
|
|
30
|
+
ver = None
|
|
31
|
+
return cli_path is not None, ver, cli_path
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def main() -> int:
|
|
35
|
+
"""CLI helper: print key=value lines and exit 0/1 based on availability."""
|
|
36
|
+
available, ver, cli_path = detect_po()
|
|
37
|
+
print(f"available={'true' if available else 'false'}")
|
|
38
|
+
print(f"version={ver if ver is not None else 'None'}")
|
|
39
|
+
print(f"cli_path={cli_path if cli_path is not None else 'None'}")
|
|
40
|
+
return 0 if available else 1
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
if __name__ == "__main__":
|
|
44
|
+
sys.exit(main())
|