codexmgr 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.
codexmgr/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Project-local Codex setup manager."""
2
+
3
+ __all__ = ["__version__"]
4
+
5
+ __version__ = "0.1.0"
@@ -0,0 +1,80 @@
1
+ """Helpers for preserving manual AGENTS.md content around a managed block."""
2
+
3
+ from pathlib import Path
4
+
5
+ from .errors import CommandError
6
+
7
+ BEGIN_MARKER = "<!-- BEGIN CODEXMGR GENERATED -->"
8
+ END_MARKER = "<!-- END CODEXMGR GENERATED -->"
9
+
10
+
11
+ def write_managed_agents_md(path: Path, generated_markdown: str) -> None:
12
+ """Write generated markdown into the CODEXMGR managed block.
13
+
14
+ Args:
15
+ path: Project AGENTS.md path to create or update.
16
+ generated_markdown: Markdown content to place inside the managed block.
17
+
18
+ Returns:
19
+ None. The file is written with UTF-8 encoding.
20
+ """
21
+ current = path.read_text(encoding="utf-8") if path.exists() else ""
22
+ updated = _replace_block(current, generated_markdown)
23
+ path.write_text(updated, encoding="utf-8")
24
+
25
+
26
+ def _replace_block(current: str, generated_markdown: str) -> str:
27
+ """Replace or append the generated block in AGENTS.md content.
28
+
29
+ Args:
30
+ current: Existing AGENTS.md content.
31
+ generated_markdown: Markdown content for the managed block.
32
+
33
+ Returns:
34
+ Updated AGENTS.md content.
35
+ """
36
+ begin_count = current.count(BEGIN_MARKER)
37
+ end_count = current.count(END_MARKER)
38
+ if begin_count != end_count:
39
+ raise CommandError("Incomplete CODEXMGR generated block in AGENTS.md")
40
+ if begin_count > 1:
41
+ raise CommandError("Multiple CODEXMGR generated blocks in AGENTS.md")
42
+
43
+ block = _format_block(generated_markdown)
44
+ if begin_count == 0:
45
+ return _append_block(current, block)
46
+
47
+ begin_index = current.index(BEGIN_MARKER)
48
+ end_index = current.index(END_MARKER, begin_index)
49
+ after_index = end_index + len(END_MARKER)
50
+ return f"{current[:begin_index]}{block}{current[after_index:]}"
51
+
52
+
53
+ def _append_block(current: str, block: str) -> str:
54
+ """Append a formatted generated block to AGENTS.md content.
55
+
56
+ Args:
57
+ current: Existing AGENTS.md content.
58
+ block: Fully formatted generated block with markers.
59
+
60
+ Returns:
61
+ AGENTS.md content with the block appended.
62
+ """
63
+ if not current:
64
+ return f"{block}\n"
65
+ return f"{current.rstrip('\n')}\n\n{block}\n"
66
+
67
+
68
+ def _format_block(generated_markdown: str) -> str:
69
+ """Format generated markdown with CODEXMGR block markers.
70
+
71
+ Args:
72
+ generated_markdown: Markdown body for the managed block.
73
+
74
+ Returns:
75
+ Block text including begin and end markers.
76
+ """
77
+ body = generated_markdown.rstrip("\n")
78
+ if not body:
79
+ return f"{BEGIN_MARKER}\n{END_MARKER}"
80
+ return f"{BEGIN_MARKER}\n{body}\n{END_MARKER}"
codexmgr/agentsmd.py ADDED
@@ -0,0 +1,96 @@
1
+ """Manage configured AGENTS.md template sources and rendered output."""
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ from .agents_file import write_managed_agents_md
7
+ from .errors import CommandError
8
+ from .paths import agents_md_path, config_path, resolve_template
9
+ from .project_config import (
10
+ agents_md_sources,
11
+ load_required_project_config,
12
+ require_codex_dir,
13
+ set_agents_md_sources,
14
+ )
15
+ from .renderer import render_agents_markdown
16
+ from .toml_io import load_optional_toml_file, load_toml_file, write_toml_file
17
+
18
+
19
+ def add_agentsmd(reference: str, cwd: Path, codexmgr_home: Path) -> str:
20
+ """Add an AGENTS.md template reference to project configuration.
21
+
22
+ Args:
23
+ reference: Named template or TOML path to add.
24
+ cwd: Project directory whose codexmgr.toml should be updated.
25
+ codexmgr_home: codexmgr home used to validate named template references.
26
+
27
+ Returns:
28
+ The reference that was added or already present.
29
+ """
30
+ require_codex_dir(cwd)
31
+ resolve_template(reference, cwd, codexmgr_home)
32
+
33
+ config = load_optional_toml_file(config_path(cwd))
34
+ sources = agents_md_sources(config)
35
+ if reference not in sources:
36
+ sources.append(reference)
37
+ set_agents_md_sources(config, sources)
38
+ write_toml_file(config_path(cwd), config)
39
+ return reference
40
+
41
+
42
+ def remove_agentsmd(source_id: str, cwd: Path) -> str:
43
+ """Remove an AGENTS.md template reference from project configuration.
44
+
45
+ Args:
46
+ source_id: Source identifier or reference to remove.
47
+ cwd: Project directory whose codexmgr.toml should be updated.
48
+
49
+ Returns:
50
+ The removed source identifier.
51
+ """
52
+ config = load_required_project_config(cwd)
53
+ sources = agents_md_sources(config)
54
+ if source_id not in sources:
55
+ raise CommandError(f"Source not found in codexmgr.toml: {source_id}")
56
+
57
+ set_agents_md_sources(config, [source for source in sources if source != source_id])
58
+ write_toml_file(config_path(cwd), config)
59
+ return source_id
60
+
61
+
62
+ def resolve_locked_agents_md(
63
+ config: dict[str, Any],
64
+ cwd: Path,
65
+ codexmgr_home: Path,
66
+ ) -> dict[str, Any]:
67
+ """Resolve configured AGENTS.md sources into lock data.
68
+
69
+ Args:
70
+ config: Parsed .codex/codexmgr.toml content.
71
+ cwd: Project directory used to resolve path sources.
72
+ codexmgr_home: codexmgr home used to resolve named sources.
73
+
74
+ Returns:
75
+ Resolved source data keyed by source identifier.
76
+ """
77
+ locked_sources: dict[str, Any] = {}
78
+ for reference in agents_md_sources(config):
79
+ source_id, template_path = resolve_template(reference, cwd, codexmgr_home)
80
+ if source_id in locked_sources:
81
+ raise CommandError(f"Duplicate AGENTS.md source identifier: {source_id}")
82
+ template_data = load_toml_file(template_path)
83
+ if not template_data:
84
+ raise CommandError(f"Template is empty: {template_path}")
85
+ locked_sources[source_id] = template_data
86
+ return locked_sources
87
+
88
+
89
+ def write_agents_md(cwd: Path, locked_sources: dict[str, Any]) -> None:
90
+ """Write resolved AGENTS.md lock data to the managed block.
91
+
92
+ Args:
93
+ cwd: Project directory whose root AGENTS.md should be updated.
94
+ locked_sources: Resolved source data keyed by source identifier.
95
+ """
96
+ write_managed_agents_md(agents_md_path(cwd), render_agents_markdown(locked_sources))
codexmgr/cli.py ADDED
@@ -0,0 +1,240 @@
1
+ """Command line parsing and dispatch for codexmgr."""
2
+
3
+ import argparse
4
+ import sys
5
+ from pathlib import Path
6
+ from typing import TextIO
7
+
8
+ from .agentsmd import add_agentsmd, remove_agentsmd
9
+ from .codex import run_codex
10
+ from .errors import CommandError
11
+ from .paths import global_codex_dir, global_codexmgr_dir
12
+ from .project import apply_project_config, setup_project
13
+ from .skills import disable_skill, enable_skill
14
+
15
+
16
+ def main(
17
+ argv: list[str] | None = None,
18
+ *,
19
+ cwd: Path | None = None,
20
+ codex_home: Path | None = None,
21
+ codexmgr_home: Path | None = None,
22
+ stdout: TextIO | None = None,
23
+ stderr: TextIO | None = None,
24
+ ) -> int:
25
+ """Run the codexmgr command line interface.
26
+
27
+ Args:
28
+ argv: Optional argument list without the executable name.
29
+ cwd: Optional project directory used instead of the current directory.
30
+ codex_home: Optional Codex home used instead of CODEX_HOME or ~/.codex.
31
+ codexmgr_home: Optional codexmgr home used instead of CODEXMGR_HOME
32
+ or ~/.codexmgr.
33
+ stdout: Optional stream for normal command output.
34
+ stderr: Optional stream for command errors.
35
+
36
+ Returns:
37
+ A process-style exit code where zero means success.
38
+ """
39
+ out = stdout if stdout is not None else sys.stdout
40
+ err = stderr if stderr is not None else sys.stderr
41
+ raw_argv = list(sys.argv[1:] if argv is None else argv)
42
+ args = _parse_args(raw_argv)
43
+ project_dir = cwd if cwd is not None else Path.cwd()
44
+ codex_dir = codex_home if codex_home is not None else global_codex_dir()
45
+ codexmgr_dir = (
46
+ codexmgr_home if codexmgr_home is not None else global_codexmgr_dir()
47
+ )
48
+
49
+ try:
50
+ return _dispatch(args, project_dir, codex_dir, codexmgr_dir, out)
51
+ except CommandError as exc:
52
+ err.write(f"{exc}\n")
53
+ return 1
54
+
55
+
56
+ def entrypoint() -> None:
57
+ """Run the console-script entrypoint.
58
+
59
+ Returns:
60
+ None. The function raises SystemExit with the CLI exit code.
61
+ """
62
+ raise SystemExit(main())
63
+
64
+
65
+ def _parse_args(argv: list[str]) -> argparse.Namespace:
66
+ """Parse raw command line arguments.
67
+
68
+ Args:
69
+ argv: Command line arguments without the executable name.
70
+
71
+ Returns:
72
+ Parsed argparse namespace for dispatch.
73
+ """
74
+ if argv[:1] == ["codex"]:
75
+ return argparse.Namespace(command="codex", codex_args=argv[1:])
76
+ return _build_parser().parse_args(argv)
77
+
78
+
79
+ def _build_parser() -> argparse.ArgumentParser:
80
+ """Build the codexmgr argument parser.
81
+
82
+ Returns:
83
+ Configured top-level argparse parser.
84
+ """
85
+ parser = argparse.ArgumentParser(prog="codexmgr")
86
+ subparsers = parser.add_subparsers(dest="command", required=True)
87
+
88
+ subparsers.add_parser("setup", help="Create a project .codex directory")
89
+ subparsers.add_parser("apply", help="Apply the project Codex configuration")
90
+
91
+ codex = subparsers.add_parser("codex", add_help=False, help="Run codex with project config")
92
+ codex.add_argument("codex_args", nargs=argparse.REMAINDER)
93
+
94
+ agentsmd = subparsers.add_parser("agentsmd", help="Manage AGENTS.md fragments")
95
+ agentsmd_subparsers = agentsmd.add_subparsers(dest="agentsmd_command", required=True)
96
+
97
+ add = agentsmd_subparsers.add_parser("add", help="Add an AGENTS.md template")
98
+ _add_no_sync_argument(add)
99
+ add.add_argument("reference", help="Template name or TOML file path")
100
+
101
+ remove = agentsmd_subparsers.add_parser("remove", help="Remove an AGENTS.md template")
102
+ _add_no_sync_argument(remove)
103
+ remove.add_argument("source_id", help="Template source identifier")
104
+
105
+ skill = subparsers.add_parser("skill", help="Manage project skill configuration")
106
+ skill_subparsers = skill.add_subparsers(dest="skill_command", required=True)
107
+
108
+ enable = skill_subparsers.add_parser("enable", help="Enable a skill")
109
+ _add_no_sync_argument(enable)
110
+ enable.add_argument("skill", help="Skill name or path")
111
+
112
+ disable = skill_subparsers.add_parser("disable", help="Disable a skill")
113
+ _add_no_sync_argument(disable)
114
+ disable.add_argument("skill", help="Skill name or path")
115
+
116
+ return parser
117
+
118
+
119
+ def _add_no_sync_argument(parser: argparse.ArgumentParser) -> None:
120
+ """Add the shared sync opt-out flag to a mutating command.
121
+
122
+ Args:
123
+ parser: Subcommand parser that updates .codex/codexmgr.toml.
124
+
125
+ Returns:
126
+ None. The parser is mutated in place.
127
+ """
128
+ parser.add_argument(
129
+ "--no-sync",
130
+ action="store_true",
131
+ help="Do not run apply after updating codexmgr.toml",
132
+ )
133
+
134
+
135
+ def _dispatch(
136
+ args: argparse.Namespace,
137
+ cwd: Path,
138
+ codex_home: Path,
139
+ codexmgr_home: Path,
140
+ stdout: TextIO,
141
+ ) -> int:
142
+ """Run the parsed command.
143
+
144
+ Args:
145
+ args: Parsed command line namespace.
146
+ cwd: Project directory for project-local operations.
147
+ codex_home: Global Codex home for resolving named skills.
148
+ codexmgr_home: codexmgr home for resolving named AGENTS.md templates.
149
+ stdout: Stream for command output.
150
+
151
+ Returns:
152
+ A process-style exit code where zero means success.
153
+ """
154
+ if args.command == "setup":
155
+ codex_dir = setup_project(cwd)
156
+ stdout.write(f"Created {codex_dir}\n")
157
+ return 0
158
+
159
+ if args.command == "apply":
160
+ apply_project_config(cwd, codex_home, codexmgr_home)
161
+ stdout.write("Applied project Codex configuration\n")
162
+ return 0
163
+
164
+ if args.command == "codex":
165
+ return run_codex(cwd, args.codex_args)
166
+
167
+ if args.command == "agentsmd" and args.agentsmd_command == "add":
168
+ source_id = add_agentsmd(args.reference, cwd, codexmgr_home)
169
+ return _finish_config_change(
170
+ f"Added {source_id}",
171
+ args.no_sync,
172
+ cwd,
173
+ codex_home,
174
+ codexmgr_home,
175
+ stdout,
176
+ )
177
+
178
+ if args.command == "agentsmd" and args.agentsmd_command == "remove":
179
+ source_id = remove_agentsmd(args.source_id, cwd)
180
+ return _finish_config_change(
181
+ f"Removed {source_id}",
182
+ args.no_sync,
183
+ cwd,
184
+ codex_home,
185
+ codexmgr_home,
186
+ stdout,
187
+ )
188
+
189
+ if args.command == "skill" and args.skill_command == "enable":
190
+ skill = enable_skill(args.skill, cwd)
191
+ return _finish_config_change(
192
+ f"Enabled {skill}",
193
+ args.no_sync,
194
+ cwd,
195
+ codex_home,
196
+ codexmgr_home,
197
+ stdout,
198
+ )
199
+
200
+ if args.command == "skill" and args.skill_command == "disable":
201
+ skill = disable_skill(args.skill, cwd)
202
+ return _finish_config_change(
203
+ f"Disabled {skill}",
204
+ args.no_sync,
205
+ cwd,
206
+ codex_home,
207
+ codexmgr_home,
208
+ stdout,
209
+ )
210
+
211
+ raise CommandError(f"Unsupported command: {args.command}")
212
+
213
+
214
+ def _finish_config_change(
215
+ message: str,
216
+ no_sync: bool,
217
+ cwd: Path,
218
+ codex_home: Path,
219
+ codexmgr_home: Path,
220
+ stdout: TextIO,
221
+ ) -> int:
222
+ """Apply generated files after a project config mutation unless opted out.
223
+
224
+ Args:
225
+ message: Command-specific success message to write after all work succeeds.
226
+ no_sync: Whether the command should skip the automatic apply step.
227
+ cwd: Project directory whose configuration changed.
228
+ codex_home: Global Codex home for resolving named skills during apply.
229
+ codexmgr_home: codexmgr home for resolving named AGENTS.md templates.
230
+ stdout: Stream for command output.
231
+
232
+ Returns:
233
+ Zero when the mutation and optional apply step succeed.
234
+ """
235
+ messages = [message]
236
+ if not no_sync:
237
+ apply_project_config(cwd, codex_home, codexmgr_home)
238
+ messages.append("Applied project Codex configuration")
239
+ stdout.write("\n".join(messages) + "\n")
240
+ return 0
codexmgr/codex.py ADDED
@@ -0,0 +1,197 @@
1
+ """Pass-through wrapper for the external codex command."""
2
+
3
+ import subprocess
4
+ import tomllib
5
+ from collections.abc import Mapping
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from .errors import CommandError
10
+ from .paths import codex_config_path
11
+ from .toml_io import format_toml_value, load_optional_toml_file
12
+
13
+
14
+ def run_codex(cwd: Path, codex_args: list[str]) -> int:
15
+ """Run the external codex command with project config prepended.
16
+
17
+ Args:
18
+ cwd: Project working directory for the external codex process.
19
+ codex_args: Arguments to pass through to codex.
20
+
21
+ Returns:
22
+ The external codex process return code.
23
+ """
24
+ command = build_codex_command(cwd, codex_args)
25
+ try:
26
+ return subprocess.run(command, cwd=cwd).returncode
27
+ except FileNotFoundError as exc:
28
+ raise CommandError("codex command not found") from exc
29
+
30
+
31
+ def build_codex_command(cwd: Path, codex_args: list[str]) -> list[str]:
32
+ """Build the external codex command invocation.
33
+
34
+ Args:
35
+ cwd: Project directory whose .codex/config.toml should be read.
36
+ codex_args: Arguments to pass through to codex.
37
+
38
+ Returns:
39
+ The complete argv for the external codex process.
40
+ """
41
+ user_config, passthrough_args = _extract_config_args(codex_args)
42
+ return ["codex", *_config_overrides(cwd, user_config), *passthrough_args]
43
+
44
+
45
+ def _config_overrides(cwd: Path, user_config: list[str]) -> list[str]:
46
+ """Build Codex ``-c`` arguments from project and user config.
47
+
48
+ Args:
49
+ cwd: Project directory whose .codex/config.toml should be read.
50
+ user_config: Raw key=value config overrides from the user command.
51
+
52
+ Returns:
53
+ A flat argv fragment containing repeated ``-c`` overrides.
54
+ """
55
+ config = _merged_config(cwd, user_config)
56
+ overrides: list[str] = []
57
+ for key, value in config.items():
58
+ overrides.extend(["-c", f"{key}={format_toml_value(value)}"])
59
+ return overrides
60
+
61
+
62
+ def _merged_config(cwd: Path, user_config: list[str]) -> dict[str, Any]:
63
+ """Merge project Codex config with user-provided overrides.
64
+
65
+ Args:
66
+ cwd: Project directory whose .codex/config.toml should be read.
67
+ user_config: Raw key=value config overrides from the user command.
68
+
69
+ Returns:
70
+ Flattened Codex config override values keyed by dotted config path.
71
+ """
72
+ merged = dict(_iter_overrides(load_optional_toml_file(codex_config_path(cwd))))
73
+ for raw_config in user_config:
74
+ key, value = _parse_config_override(raw_config)
75
+ _merge_config_value(merged, key, value)
76
+ return merged
77
+
78
+
79
+ def _extract_config_args(codex_args: list[str]) -> tuple[list[str], list[str]]:
80
+ """Split Codex config overrides from pass-through arguments.
81
+
82
+ Args:
83
+ codex_args: Raw arguments intended for the external codex command.
84
+
85
+ Returns:
86
+ The raw config override values and the remaining pass-through args.
87
+ """
88
+ config_args: list[str] = []
89
+ passthrough_args: list[str] = []
90
+ index = 0
91
+ while index < len(codex_args):
92
+ arg = codex_args[index]
93
+ if arg in {"-c", "--config"}:
94
+ if index + 1 >= len(codex_args):
95
+ raise CommandError(f"{arg} requires a key=value argument")
96
+ config_args.append(codex_args[index + 1])
97
+ index += 2
98
+ elif arg.startswith("--config="):
99
+ config_args.append(arg.removeprefix("--config="))
100
+ index += 1
101
+ else:
102
+ passthrough_args.append(arg)
103
+ index += 1
104
+ return config_args, passthrough_args
105
+
106
+
107
+ def _parse_config_override(raw_config: str) -> tuple[str, Any]:
108
+ """Parse a Codex ``key=value`` config override.
109
+
110
+ Args:
111
+ raw_config: Raw override text after ``-c`` or ``--config``.
112
+
113
+ Returns:
114
+ The config key and parsed TOML value.
115
+ """
116
+ key, separator, raw_value = raw_config.partition("=")
117
+ if not separator or not key:
118
+ raise CommandError(f"Invalid codex config override: {raw_config}")
119
+ return key, _parse_config_value(raw_value)
120
+
121
+
122
+ def _parse_config_value(raw_value: str) -> Any:
123
+ """Parse a config override value as TOML when possible.
124
+
125
+ Args:
126
+ raw_value: Raw value string from a key=value override.
127
+
128
+ Returns:
129
+ Parsed TOML value, or the raw string when it is not valid TOML.
130
+ """
131
+ try:
132
+ return tomllib.loads(f"value = {raw_value}")["value"]
133
+ except tomllib.TOMLDecodeError:
134
+ return raw_value
135
+
136
+
137
+ def _merge_config_value(config: dict[str, Any], key: str, value: Any) -> None:
138
+ """Merge one parsed user override into accumulated config.
139
+
140
+ Args:
141
+ config: Mutable flattened config dictionary.
142
+ key: Dotted config key to merge.
143
+ value: Parsed override value.
144
+ """
145
+ if isinstance(value, list):
146
+ existing = config.get(key)
147
+ config[key] = [*(existing if isinstance(existing, list) else []), *value]
148
+ else:
149
+ config[key] = value
150
+
151
+
152
+ def _iter_overrides(
153
+ config: Mapping[str, Any],
154
+ prefix: tuple[str, ...] = (),
155
+ ) -> list[tuple[str, Any]]:
156
+ """Flatten nested project Codex config into dotted override keys.
157
+
158
+ Args:
159
+ config: Parsed Codex config table to flatten.
160
+ prefix: Dotted key path accumulated during recursion.
161
+
162
+ Returns:
163
+ Flattened key/value pairs suitable for ``codex -c``.
164
+ """
165
+ overrides: list[tuple[str, Any]] = []
166
+ for key, value in config.items():
167
+ path = (*prefix, key)
168
+ if _is_nested_table(value):
169
+ overrides.extend(_iter_overrides(value, path))
170
+ else:
171
+ _validate_override_value(path, value)
172
+ overrides.append((".".join(path), value))
173
+ return overrides
174
+
175
+
176
+ def _is_nested_table(value: Any) -> bool:
177
+ """Return whether a config value should recurse as a TOML table.
178
+
179
+ Args:
180
+ value: Parsed TOML value.
181
+
182
+ Returns:
183
+ True when the value is a mapping.
184
+ """
185
+ return isinstance(value, Mapping)
186
+
187
+
188
+ def _validate_override_value(path: tuple[str, ...], value: Any) -> None:
189
+ """Validate a flattened project config value before formatting.
190
+
191
+ Args:
192
+ path: Dotted config path represented as path segments.
193
+ value: Parsed TOML value at that path.
194
+ """
195
+ if isinstance(value, list) and any(isinstance(item, Mapping) for item in value):
196
+ if not all(isinstance(item, Mapping) for item in value):
197
+ raise CommandError(f".codex/config.toml {'.'.join(path)} must not mix tables and values")
codexmgr/errors.py ADDED
@@ -0,0 +1,5 @@
1
+ """Shared exceptions for command-facing codexmgr failures."""
2
+
3
+
4
+ class CommandError(Exception):
5
+ """Raised when a command cannot complete with the provided inputs."""