codex-rule-maker 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.
- codex_builder/__init__.py +6 -0
- codex_builder/builder.py +91 -0
- codex_builder/cli.py +96 -0
- codex_builder/constants.py +32 -0
- codex_builder/models.py +110 -0
- codex_builder/profiles.py +437 -0
- codex_builder/prompt.py +230 -0
- codex_builder/template_renderer.py +702 -0
- codex_builder/validator.py +67 -0
- codex_rule_maker-0.1.0.dist-info/METADATA +287 -0
- codex_rule_maker-0.1.0.dist-info/RECORD +14 -0
- codex_rule_maker-0.1.0.dist-info/WHEEL +5 -0
- codex_rule_maker-0.1.0.dist-info/entry_points.txt +2 -0
- codex_rule_maker-0.1.0.dist-info/top_level.txt +1 -0
codex_builder/builder.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Filesystem builder for generated .codex folders."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional, Union
|
|
9
|
+
|
|
10
|
+
from codex_builder.constants import CODEX_DIR_NAME
|
|
11
|
+
from codex_builder.models import BuildResult, ProjectConfig
|
|
12
|
+
from codex_builder.template_renderer import TemplateRenderer
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ExistingCodexError(FileExistsError):
|
|
16
|
+
"""Raised when .codex already exists and force is not enabled."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class CodexBuildError(RuntimeError):
|
|
20
|
+
"""Raised when the builder cannot write the generated files."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class CodexBuilder:
|
|
24
|
+
"""Create a .codex directory from a ProjectConfig."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, renderer: Optional[TemplateRenderer] = None) -> None:
|
|
27
|
+
self._renderer = renderer or TemplateRenderer()
|
|
28
|
+
|
|
29
|
+
def build(
|
|
30
|
+
self,
|
|
31
|
+
config: ProjectConfig,
|
|
32
|
+
*,
|
|
33
|
+
target_dir: Optional[Union[Path, str]] = None,
|
|
34
|
+
force: bool = False,
|
|
35
|
+
backup_existing: bool = True,
|
|
36
|
+
) -> BuildResult:
|
|
37
|
+
root = Path(target_dir or Path.cwd()).resolve()
|
|
38
|
+
codex_dir = root / CODEX_DIR_NAME
|
|
39
|
+
|
|
40
|
+
if codex_dir.exists() and not force:
|
|
41
|
+
raise ExistingCodexError(f"{codex_dir} already exists. Re-run with --force to replace it.")
|
|
42
|
+
|
|
43
|
+
rendered_files = self._renderer.render(config)
|
|
44
|
+
|
|
45
|
+
backup_dir: Optional[Path] = None
|
|
46
|
+
if codex_dir.exists():
|
|
47
|
+
backup_dir = self._replace_existing_codex(codex_dir, backup_existing=backup_existing)
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
written_files: list[Path] = []
|
|
51
|
+
for relative_path, content in rendered_files.items():
|
|
52
|
+
destination = root / relative_path
|
|
53
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
54
|
+
destination.write_text(content.rstrip() + "\n", encoding="utf-8")
|
|
55
|
+
written_files.append(destination)
|
|
56
|
+
except OSError as exc:
|
|
57
|
+
raise CodexBuildError(f"failed to write .codex files: {exc}") from exc
|
|
58
|
+
|
|
59
|
+
return BuildResult(
|
|
60
|
+
codex_dir=codex_dir,
|
|
61
|
+
written_files=tuple(written_files),
|
|
62
|
+
backup_dir=backup_dir,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
def _replace_existing_codex(self, codex_dir: Path, *, backup_existing: bool) -> Optional[Path]:
|
|
66
|
+
if backup_existing:
|
|
67
|
+
backup_dir = self._next_backup_path(codex_dir)
|
|
68
|
+
try:
|
|
69
|
+
codex_dir.rename(backup_dir)
|
|
70
|
+
except OSError as exc:
|
|
71
|
+
raise CodexBuildError(f"failed to backup existing .codex folder: {exc}") from exc
|
|
72
|
+
return backup_dir
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
if codex_dir.is_dir():
|
|
76
|
+
shutil.rmtree(codex_dir)
|
|
77
|
+
else:
|
|
78
|
+
codex_dir.unlink()
|
|
79
|
+
except OSError as exc:
|
|
80
|
+
raise CodexBuildError(f"failed to remove existing .codex path: {exc}") from exc
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
def _next_backup_path(self, codex_dir: Path) -> Path:
|
|
84
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
85
|
+
base_backup = codex_dir.with_name(f"{CODEX_DIR_NAME}_backup_{timestamp}")
|
|
86
|
+
candidate = base_backup
|
|
87
|
+
index = 1
|
|
88
|
+
while candidate.exists():
|
|
89
|
+
candidate = codex_dir.with_name(f"{base_backup.name}.{index}")
|
|
90
|
+
index += 1
|
|
91
|
+
return candidate
|
codex_builder/cli.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Command line interface for codex-init."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
from codex_builder.builder import CodexBuilder, CodexBuildError, ExistingCodexError
|
|
11
|
+
from codex_builder.models import ConfigError
|
|
12
|
+
from codex_builder.prompt import PromptAbort, PromptSession
|
|
13
|
+
from codex_builder.profiles import UnknownProfileError, supported_profile_names
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
17
|
+
parser = argparse.ArgumentParser(
|
|
18
|
+
prog="codex-init",
|
|
19
|
+
description="Generate a .codex folder with AI developer rules and project reference documents.",
|
|
20
|
+
)
|
|
21
|
+
parser.add_argument("--name", help="Project name. Defaults to the current directory name.")
|
|
22
|
+
parser.add_argument("--description", help="Short project description.")
|
|
23
|
+
parser.add_argument(
|
|
24
|
+
"--stack",
|
|
25
|
+
help=f"Comma-separated stack profiles. Supported: {', '.join(supported_profile_names())}.",
|
|
26
|
+
)
|
|
27
|
+
parser.add_argument("--db", "--database", dest="database", help="Database name, for example mysql or postgres.")
|
|
28
|
+
parser.add_argument("--auth", help="Whether authentication is used: yes/no.")
|
|
29
|
+
parser.add_argument("--external-api", dest="external_api", help="Whether external API integrations are used: yes/no.")
|
|
30
|
+
parser.add_argument("--docs", dest="docs_level", help="Documentation strictness: light, standard, strict.")
|
|
31
|
+
parser.add_argument("--language", help="Document language: ko or en.")
|
|
32
|
+
parser.add_argument("--interactive", action="store_true", help="Prompt for missing values and always show final confirmation.")
|
|
33
|
+
parser.add_argument("--force", action="store_true", help="Replace an existing .codex folder. Default force behavior creates a backup.")
|
|
34
|
+
parser.add_argument(
|
|
35
|
+
"--overwrite",
|
|
36
|
+
action="store_true",
|
|
37
|
+
help="With --force, delete the existing .codex instead of backing it up.",
|
|
38
|
+
)
|
|
39
|
+
parser.add_argument(
|
|
40
|
+
"--target-dir",
|
|
41
|
+
type=Path,
|
|
42
|
+
help="Directory where .codex will be created. Defaults to the current working directory.",
|
|
43
|
+
)
|
|
44
|
+
return parser
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def main(argv: Optional[list[str]] = None) -> int:
|
|
48
|
+
raw_argv = list(sys.argv[1:] if argv is None else argv)
|
|
49
|
+
parser = build_parser()
|
|
50
|
+
args = parser.parse_args(raw_argv)
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
prompt_for_missing = _should_prompt_for_missing(args, raw_argv)
|
|
54
|
+
request = PromptSession().resolve_request(
|
|
55
|
+
args,
|
|
56
|
+
prompt_for_missing=prompt_for_missing,
|
|
57
|
+
confirm_before_build=prompt_for_missing or (sys.stdin.isatty() and sys.stdout.isatty()),
|
|
58
|
+
)
|
|
59
|
+
result = CodexBuilder().build(
|
|
60
|
+
request.config,
|
|
61
|
+
target_dir=request.target_dir,
|
|
62
|
+
force=request.force,
|
|
63
|
+
backup_existing=request.backup_existing,
|
|
64
|
+
)
|
|
65
|
+
except PromptAbort as exc:
|
|
66
|
+
print(f"codex-init: {exc}")
|
|
67
|
+
return 0
|
|
68
|
+
except (ConfigError, UnknownProfileError, ExistingCodexError, CodexBuildError) as exc:
|
|
69
|
+
print(f"codex-init: error: {exc}", file=sys.stderr)
|
|
70
|
+
return 1
|
|
71
|
+
|
|
72
|
+
print(f"Generated: {result.codex_dir}")
|
|
73
|
+
if result.backup_dir is not None:
|
|
74
|
+
print(f"Backup: {result.backup_dir}")
|
|
75
|
+
print(f"Files written: {len(result.written_files)}")
|
|
76
|
+
return 0
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _should_prompt_for_missing(args: argparse.Namespace, raw_argv: list[str]) -> bool:
|
|
80
|
+
if args.interactive:
|
|
81
|
+
return True
|
|
82
|
+
if not raw_argv:
|
|
83
|
+
return True
|
|
84
|
+
|
|
85
|
+
required_option_values = (
|
|
86
|
+
args.name,
|
|
87
|
+
args.stack,
|
|
88
|
+
args.auth,
|
|
89
|
+
args.external_api,
|
|
90
|
+
args.docs_level,
|
|
91
|
+
)
|
|
92
|
+
return any(value is None for value in required_option_values)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
if __name__ == "__main__":
|
|
96
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Shared constants for generated .codex structures."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
CODEX_DIR_NAME = ".codex"
|
|
6
|
+
AI_RULE_DIR_NAME = "AI_RULE_DEVELOPER"
|
|
7
|
+
REF_DOCS_DIR_NAME = "REF_DOCS"
|
|
8
|
+
START_PROMPT_FILE_NAME = "codex_start_prompt.txt"
|
|
9
|
+
|
|
10
|
+
RULE_FILE_NAMES = (
|
|
11
|
+
"GLOBAL_RULES.md",
|
|
12
|
+
"ARCHITECTURE_RULES.md",
|
|
13
|
+
"CODE_STYLE_RULES.md",
|
|
14
|
+
"API_DESIGN_RULES.md",
|
|
15
|
+
"DOCUMENT_RULE.md",
|
|
16
|
+
"TEST_RULES.md",
|
|
17
|
+
"FRAMEWORK_RULES.md",
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
REF_DOC_FILE_NAMES = (
|
|
21
|
+
"PROJECT_OVERVIEW.md",
|
|
22
|
+
"FEATURE_SPEC.md",
|
|
23
|
+
"API_SPEC.md",
|
|
24
|
+
"DB_SPEC.md",
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
DEFAULT_LANGUAGE = "ko"
|
|
28
|
+
DEFAULT_DOCS_LEVEL = "standard"
|
|
29
|
+
DEFAULT_STACK = ("fastapi",)
|
|
30
|
+
|
|
31
|
+
SUPPORTED_LANGUAGES = ("ko", "en")
|
|
32
|
+
SUPPORTED_DOCS_LEVELS = ("light", "standard", "strict")
|
codex_builder/models.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Data models and value parsing helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Iterable, Optional, Union
|
|
8
|
+
|
|
9
|
+
from codex_builder.constants import (
|
|
10
|
+
DEFAULT_DOCS_LEVEL,
|
|
11
|
+
DEFAULT_LANGUAGE,
|
|
12
|
+
DEFAULT_STACK,
|
|
13
|
+
SUPPORTED_DOCS_LEVELS,
|
|
14
|
+
SUPPORTED_LANGUAGES,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ConfigError(ValueError):
|
|
19
|
+
"""Raised when a CLI or builder configuration is invalid."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class ProjectConfig:
|
|
24
|
+
"""Normalized user inputs used to render the .codex document set."""
|
|
25
|
+
|
|
26
|
+
project_name: str
|
|
27
|
+
description: str = ""
|
|
28
|
+
stack: tuple[str, ...] = DEFAULT_STACK
|
|
29
|
+
database: Optional[str] = None
|
|
30
|
+
auth_enabled: bool = False
|
|
31
|
+
external_api_enabled: bool = False
|
|
32
|
+
docs_level: str = DEFAULT_DOCS_LEVEL
|
|
33
|
+
language: str = DEFAULT_LANGUAGE
|
|
34
|
+
|
|
35
|
+
def __post_init__(self) -> None:
|
|
36
|
+
project_name = self.project_name.strip()
|
|
37
|
+
if not project_name:
|
|
38
|
+
raise ConfigError("project name is required")
|
|
39
|
+
|
|
40
|
+
normalized_stack = normalize_stack(self.stack)
|
|
41
|
+
if not normalized_stack:
|
|
42
|
+
normalized_stack = DEFAULT_STACK
|
|
43
|
+
|
|
44
|
+
docs_level = self.docs_level.strip().lower()
|
|
45
|
+
if docs_level not in SUPPORTED_DOCS_LEVELS:
|
|
46
|
+
allowed = ", ".join(SUPPORTED_DOCS_LEVELS)
|
|
47
|
+
raise ConfigError(f"docs level must be one of: {allowed}")
|
|
48
|
+
|
|
49
|
+
language = self.language.strip().lower()
|
|
50
|
+
if language not in SUPPORTED_LANGUAGES:
|
|
51
|
+
allowed = ", ".join(SUPPORTED_LANGUAGES)
|
|
52
|
+
raise ConfigError(f"language must be one of: {allowed}")
|
|
53
|
+
|
|
54
|
+
database = self.database.strip().lower() if self.database else None
|
|
55
|
+
|
|
56
|
+
object.__setattr__(self, "project_name", project_name)
|
|
57
|
+
object.__setattr__(self, "description", self.description.strip())
|
|
58
|
+
object.__setattr__(self, "stack", normalized_stack)
|
|
59
|
+
object.__setattr__(self, "database", database)
|
|
60
|
+
object.__setattr__(self, "docs_level", docs_level)
|
|
61
|
+
object.__setattr__(self, "language", language)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True)
|
|
65
|
+
class BuildResult:
|
|
66
|
+
"""Result returned after generating a .codex folder."""
|
|
67
|
+
|
|
68
|
+
codex_dir: Path
|
|
69
|
+
written_files: tuple[Path, ...]
|
|
70
|
+
backup_dir: Optional[Path] = None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def normalize_stack(raw_stack: Optional[Union[str, Iterable[str]]]) -> tuple[str, ...]:
|
|
74
|
+
"""Normalize comma-separated or iterable stack values."""
|
|
75
|
+
|
|
76
|
+
if raw_stack is None:
|
|
77
|
+
return DEFAULT_STACK
|
|
78
|
+
|
|
79
|
+
if isinstance(raw_stack, str):
|
|
80
|
+
items = raw_stack.split(",")
|
|
81
|
+
else:
|
|
82
|
+
items = []
|
|
83
|
+
for value in raw_stack:
|
|
84
|
+
items.extend(str(value).split(","))
|
|
85
|
+
|
|
86
|
+
normalized: list[str] = []
|
|
87
|
+
for item in items:
|
|
88
|
+
value = item.strip().lower()
|
|
89
|
+
if value and value not in normalized:
|
|
90
|
+
normalized.append(value)
|
|
91
|
+
|
|
92
|
+
return tuple(normalized)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def parse_yes_no(value: Optional[Union[str, bool]], *, default: bool = False) -> bool:
|
|
96
|
+
"""Parse common yes/no CLI values into a bool."""
|
|
97
|
+
|
|
98
|
+
if value is None:
|
|
99
|
+
return default
|
|
100
|
+
|
|
101
|
+
if isinstance(value, bool):
|
|
102
|
+
return value
|
|
103
|
+
|
|
104
|
+
normalized = value.strip().lower()
|
|
105
|
+
if normalized in {"y", "yes", "true", "1", "on"}:
|
|
106
|
+
return True
|
|
107
|
+
if normalized in {"n", "no", "false", "0", "off"}:
|
|
108
|
+
return False
|
|
109
|
+
|
|
110
|
+
raise ConfigError(f"expected yes/no value, got: {value}")
|