dotagents-cli 0.3.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.
@@ -0,0 +1,107 @@
1
+ """`dotagents build-pyz` -- vendor deps and package a self-contained pyz."""
2
+
3
+ import shutil
4
+ import subprocess
5
+ import sys
6
+ import tempfile
7
+ from pathlib import Path
8
+ from typing import Optional
9
+
10
+ from duho import Cmd, LoggingArgs
11
+
12
+
13
+ class BuildPyz(LoggingArgs, Cmd):
14
+ """Vendor duho/pathlib_next via pip --target and package a self-contained dotagents.pyz."""
15
+
16
+ _parsername_ = "build-pyz"
17
+
18
+ out: Path = Path("dist") / "dotagents.pyz"
19
+ "Output path for the built pyz."
20
+ ("--out",)
21
+
22
+ python: str = "/usr/bin/env python3"
23
+ "Shebang line to embed in the pyz."
24
+ ("--python",)
25
+
26
+ duho_version: str = "0.4.0"
27
+ "Pinned duho version to vendor."
28
+ ("--duho-version",)
29
+
30
+ pathlib_next_version: str = "0.8.0"
31
+ "Pinned pathlib_next version to vendor."
32
+ ("--pathlib-next-version",)
33
+
34
+ tools_dir: Optional[Path] = None
35
+ "Repo tools/ dir (required tooling) to bundle as _tools (default: autodetected)."
36
+ ("--tools-dir",)
37
+
38
+ def __call__(self) -> int:
39
+ import zipapp
40
+
41
+ # This module lives at src/dotagents/cli/build_pyz.py, so the repo root
42
+ # is parents[3] (cli -> dotagents -> src -> repo) and the dotagents
43
+ # package dir is parents[1].
44
+ repo_root = Path(__file__).resolve().parents[3]
45
+ tools_src = Path(self.tools_dir) if self.tools_dir else (repo_root / "tools")
46
+ if not tools_src.exists():
47
+ raise SystemExit("error: repo tools/ not found at %s (pass --tools-dir)" % tools_src)
48
+
49
+ with tempfile.TemporaryDirectory(prefix="dotagents-pyz-") as tmp:
50
+ stage = Path(tmp) / "stage"
51
+ stage.mkdir()
52
+
53
+ self._logger_.info(
54
+ "vendoring duho==%s pathlib_next==%s via pip --target",
55
+ self.duho_version,
56
+ self.pathlib_next_version,
57
+ )
58
+ rc = subprocess.call(
59
+ [
60
+ sys.executable,
61
+ "-m",
62
+ "pip",
63
+ "install",
64
+ "--target",
65
+ str(stage),
66
+ "duho==%s" % self.duho_version,
67
+ "pathlib_next==%s" % self.pathlib_next_version,
68
+ ]
69
+ )
70
+ if rc != 0:
71
+ return rc
72
+
73
+ dotagents_pkg_src = Path(__file__).resolve().parents[1]
74
+ dotagents_pkg_dest = stage / "dotagents"
75
+ shutil.copytree(
76
+ dotagents_pkg_src,
77
+ dotagents_pkg_dest,
78
+ ignore=shutil.ignore_patterns("__pycache__", "*.pyc"),
79
+ )
80
+
81
+ tools_dest = dotagents_pkg_dest / "_tools"
82
+ shutil.copytree(
83
+ tools_src,
84
+ tools_dest,
85
+ ignore=shutil.ignore_patterns("__pycache__", "*.pyc"),
86
+ )
87
+
88
+ for path in stage.rglob("*.dist-info"):
89
+ shutil.rmtree(path, ignore_errors=True)
90
+ for path in stage.rglob("__pycache__"):
91
+ shutil.rmtree(path, ignore_errors=True)
92
+ for path in stage.rglob("tests"):
93
+ if path.is_dir():
94
+ shutil.rmtree(path, ignore_errors=True)
95
+
96
+ main_py = stage / "__main__.py"
97
+ main_py.write_text(
98
+ "from dotagents.cli import main\n\nraise SystemExit(main())\n",
99
+ encoding="utf-8",
100
+ )
101
+
102
+ out_path = Path(self.out)
103
+ out_path.parent.mkdir(parents=True, exist_ok=True)
104
+ zipapp.create_archive(str(stage), target=str(out_path), interpreter=self.python)
105
+ self._logger_.info("built %s", out_path)
106
+
107
+ return 0
@@ -0,0 +1,129 @@
1
+ """`dotagents context` -- assemble the effective context for agents (Plan 04)."""
2
+
3
+ import sys
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+ from duho import Cmd, LoggingArgs
8
+
9
+
10
+ def _write_stdout(text: str) -> None:
11
+ """Write to stdout as UTF-8, whatever the console's encoding claims to be.
12
+
13
+ A bare `print()` encodes with the console codepage -- cp1252 on a default
14
+ Windows shell -- so a single character outside Latin-1 (an arrow, a box-drawing
15
+ rule, a curly quote, any emoji) raises UnicodeEncodeError and the command dies
16
+ having emitted nothing. Context files routinely contain such characters, and
17
+ this is the SessionStart hook's payload, so the failure is both likely and
18
+ silent. Write bytes through the underlying buffer instead, replacing anything
19
+ even UTF-8 cannot represent rather than aborting.
20
+ """
21
+ data = text.encode("utf-8", errors="replace")
22
+ buffer = getattr(sys.stdout, "buffer", None)
23
+ if buffer is None: # pragma: no cover -- captured/replaced stdout in tests
24
+ sys.stdout.write(text)
25
+ return
26
+ buffer.write(data)
27
+ buffer.flush()
28
+
29
+
30
+ class Context(LoggingArgs, Cmd):
31
+ """Assemble the effective context for agents (Plan 04)."""
32
+
33
+ _parsername_ = "context"
34
+
35
+ format: str = "markdown"
36
+ "Output format: markdown, system-reminder, or json."
37
+ ("--format",)
38
+
39
+ global_scope: bool = False
40
+ "Use global scope."
41
+ ("--global", "-g")
42
+
43
+ agents: "list[str]" = []
44
+ "List of agents to generate context for (e.g. claude,gemini). Default: active agent."
45
+ ("--agents",)
46
+
47
+ out: str = "-"
48
+ "Output path (positional). Default '-' = stdout; a path writes that file; use "
49
+ "--write-agent to write each agent's native config file instead."
50
+ ("out",)
51
+
52
+ write_agent: bool = False
53
+ "Write each agent's native config file (e.g. Claude's CONTEXT.md) instead of a path."
54
+ ("--write-agent",)
55
+
56
+ def __call__(self) -> int:
57
+ from dotagents import _agents
58
+ from dotagents import _context
59
+ import json
60
+ import os
61
+
62
+ project_root = Path.cwd()
63
+ agents_dir = Path.home() / ".agents"
64
+
65
+ agent_names = []
66
+ if self.agents:
67
+ for a in self.agents:
68
+ agent_names.extend([x.strip() for x in a.split(",") if x.strip()])
69
+
70
+ if agent_names:
71
+ active_agents = []
72
+ for name in agent_names:
73
+ a = _agents.get_agent(name)
74
+ if a:
75
+ active_agents.append(a)
76
+ else:
77
+ self._logger_.warning("Unknown agent: %s", name)
78
+ else:
79
+ # Default target = the active agent (env-var detection / $AGENTS_HARNESS
80
+ # stamp / config-file detect), not "all detected".
81
+ active_agents = [
82
+ _agents.resolve_active_agent(os.environ, root=project_root)
83
+ ]
84
+
85
+ # --- JSON: emit structured data (object for one agent, array for many);
86
+ # never writes native config files. ---
87
+ if self.format == "json":
88
+ payloads = [
89
+ _context.assemble_context_data(
90
+ agent, agents_dir, project_root, global_scope=self.global_scope
91
+ )
92
+ for agent in active_agents
93
+ ]
94
+ out_obj = payloads[0] if len(payloads) == 1 else payloads
95
+ blob = json.dumps(out_obj, indent=2, ensure_ascii=False)
96
+ if self.out and self.out != "-":
97
+ Path(self.out).write_text(blob, encoding="utf-8")
98
+ self._logger_.info("Wrote JSON context to %s", self.out)
99
+ else:
100
+ print(blob) # default '-' -> stdout (json never writes native configs)
101
+ return 0
102
+
103
+ # --- markdown / system-reminder text paths ---
104
+ for agent in active_agents:
105
+ text = _context.assemble_context(
106
+ agent, agents_dir, project_root, global_scope=self.global_scope
107
+ )
108
+
109
+ if self.format == "system-reminder":
110
+ text = (
111
+ "<!-- system-reminder: begin -->\n"
112
+ + text
113
+ + "\n<!-- system-reminder: end -->"
114
+ )
115
+
116
+ if self.write_agent:
117
+ agent.write_context(agents_dir, text, force=False, dry_run=False, logger=self._logger_)
118
+ elif self.out == "-":
119
+ # Just the context on stdout. A per-agent delimiter is emitted ONLY when
120
+ # more than one agent is generated, so a single-agent run (the default)
121
+ # is clean, pipeable output with no decoration to strip.
122
+ if len(active_agents) > 1:
123
+ _write_stdout("# --- %s ---\n" % agent.name)
124
+ _write_stdout(text + "\n")
125
+ else:
126
+ Path(self.out).write_text(text, encoding="utf-8")
127
+ self._logger_.info(f"Wrote {agent.name} context to {self.out}")
128
+
129
+ return 0
dotagents/cli/env.py ADDED
@@ -0,0 +1,168 @@
1
+ """`dotagents env` -- chained env-file assembly + env.py execution (plan 07).
2
+
3
+ Self-contained block: all logic lives in `_env.py` (frozen contract B) and
4
+ `_agents.stamp_identity` (plan 08). The only umbrella touch is registering
5
+ `Env` on `Dotagents._subcommands_` (in `cli/__init__.py`). Never logs
6
+ DOTAGENTS_*/AGENTS_* VALUES -- output goes to stdout for the caller to consume;
7
+ the logger only ever names vars (Leakage rule).
8
+ """
9
+
10
+ from pathlib import Path
11
+
12
+ from duho import Cmd, LoggingArgs
13
+
14
+
15
+ def _dotenv_value(v: str) -> str:
16
+ """Dotenv quoting: bare unless the value needs quoting.
17
+
18
+ A value with whitespace, ``#``, ``"`` or a newline is wrapped in double
19
+ quotes with ``"``, ``\\`` and newline backslash-escaped; otherwise emitted
20
+ bare (the ``.env`` / ``docker --env-file`` convention).
21
+ """
22
+ if v == "":
23
+ return ""
24
+ if any(c in v for c in " \t#\"\n") or v.strip() != v:
25
+ esc = v.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")
26
+ return '"%s"' % esc
27
+ return v
28
+
29
+
30
+ def _format_env(env: "dict[str, str]", output_format: str) -> str:
31
+ """Render the assembled env in the requested (canonical or aliased) format.
32
+
33
+ Shell-sourceable / assignment forms, one var per line:
34
+
35
+ * ``export`` (aliases ``posix``/``sh``/``bash``) -- ``export KEY="value"``,
36
+ value JSON-quoted; the POSIX default a SessionStart hook sources.
37
+ * ``dotenv`` (alias ``env``) -- bare ``KEY=value`` (no ``export``), value
38
+ quoted only when it contains whitespace/``#``/``"``/newline (``.env`` /
39
+ ``docker --env-file`` rules). Distinct from ``export``: assigns, doesn't
40
+ source+export.
41
+ * ``powershell`` (aliases ``pwsh``/``ps``) -- ``$env:KEY = 'value'``,
42
+ single-quoted, ``'`` escaped as ``''``.
43
+ * ``cmd`` (aliases ``bat``/``batch``) -- ``set "KEY=value"``. cmd has NO way
44
+ to escape a literal ``"`` inside a value; any ``"`` is emitted as ``""``
45
+ best-effort (documented limitation).
46
+ * ``fish`` -- ``set -gx KEY value``, single-quoted, ``'`` escaped as ``\\'``.
47
+
48
+ Data forms:
49
+
50
+ * ``json`` -- a sorted JSON object.
51
+ * ``ini`` -- ``KEY=value`` lines under a ``[env]`` section.
52
+ * ``yaml`` -- ``KEY: value`` lines (values quoted when ambiguous).
53
+
54
+ Aliases are normalized here via :data:`dotagents._env.FORMAT_ALIASES`. Values
55
+ are emitted verbatim (this is the point of the command); callers must treat
56
+ the output as sensitive.
57
+ """
58
+ import json as _json
59
+
60
+ from dotagents._env import FORMAT_ALIASES
61
+
62
+ fmt = FORMAT_ALIASES.get(output_format, output_format)
63
+ keys = sorted(env)
64
+
65
+ if fmt == "json":
66
+ return _json.dumps({k: env[k] for k in keys}, indent=2, sort_keys=True)
67
+ if fmt == "ini":
68
+ return "\n".join(["[env]"] + ["%s=%s" % (k, env[k]) for k in keys])
69
+ if fmt == "yaml":
70
+ lines = []
71
+ for k in keys:
72
+ v = env[k]
73
+ if v == "" or any(c in v for c in ":#'\"\n") or v.strip() != v:
74
+ v = _json.dumps(v)
75
+ lines.append("%s: %s" % (k, v))
76
+ return "\n".join(lines)
77
+ if fmt == "dotenv":
78
+ return "\n".join("%s=%s" % (k, _dotenv_value(env[k])) for k in keys)
79
+ if fmt == "powershell":
80
+ return "\n".join(
81
+ "$env:%s = '%s'" % (k, env[k].replace("'", "''")) for k in keys
82
+ )
83
+ if fmt == "cmd":
84
+ return "\n".join(
85
+ 'set "%s=%s"' % (k, env[k].replace('"', '""')) for k in keys
86
+ )
87
+ if fmt == "fish":
88
+ return "\n".join(
89
+ "set -gx %s '%s'" % (k, env[k].replace("'", "\\'")) for k in keys
90
+ )
91
+ # default / "export"
92
+ return "\n".join("export %s=%s" % (k, _json.dumps(env[k])) for k in keys)
93
+
94
+
95
+ class Env(LoggingArgs, Cmd):
96
+ """Assemble the chained env (env files + env.py execution) under contract B.
97
+
98
+ Prepends overlay/level ``bin`` dirs to ``PATH`` first, then evaluates the
99
+ ``pre.*`` tier and the main tier in precedence order (overlays -> user ->
100
+ project -> project-root), chaining each file over the accumulated env so
101
+ later files win. ``.py`` files are EXECUTED and emit JSON env changes; plain
102
+ files are sourced. Standardized ``AGENTS_*``/``AGENT`` identity vars and the
103
+ ``AGENTS_PROXY`` model are wired in.
104
+
105
+ ``--diff`` emits only the vars that differ from the current environment (what
106
+ a SessionStart hook injects); the default emits the full assembled env merged
107
+ over the current one. Output is sensitive -- it may carry secret values.
108
+
109
+ ``--format`` selects the emitted syntax and defaults to ``auto``, which
110
+ detects the CALLING shell (parent-process chain) and picks a matching format
111
+ so the output is sourceable where it runs: ``export`` (aliases
112
+ ``posix``/``sh``/``bash``), ``dotenv`` (``env``), ``powershell``
113
+ (``pwsh``/``ps``), ``cmd`` (``bat``/``batch``), ``fish``, plus the data
114
+ forms ``json``/``ini``/``yaml``. An explicit ``--format`` always wins."""
115
+
116
+ _parsername_ = "env"
117
+
118
+ format: str = "auto"
119
+ (
120
+ "Output format. Default 'auto' detects the calling shell. Shell forms: "
121
+ "export (aliases posix/sh/bash), dotenv (env), powershell (pwsh/ps), "
122
+ "cmd (bat/batch), fish. Data forms: json, ini, yaml."
123
+ )
124
+ ("--format",)
125
+
126
+ diff: bool = False
127
+ "Emit only vars that differ from the current environment."
128
+ ("--diff",)
129
+
130
+ global_scope: bool = False
131
+ "Use global scope (skip project-level env files)."
132
+ ("--global", "-g")
133
+
134
+ def __call__(self) -> int:
135
+ import os
136
+
137
+ from dotagents import _env
138
+
139
+ project_root = Path.cwd()
140
+ agents_dir = Path.home() / ".agents"
141
+ base = dict(os.environ)
142
+
143
+ if self.format not in _env.KNOWN_FORMATS:
144
+ raise SystemExit(
145
+ "error: --format must be one of %s (got %r)"
146
+ % (", ".join(_env.KNOWN_FORMATS), self.format)
147
+ )
148
+ # `auto` (the default) resolves to the calling shell's format; an explicit
149
+ # --format always wins. Detection reads process names only (never values).
150
+ output_format = self.format
151
+ if output_format == "auto":
152
+ output_format = _env.detect_shell_format()
153
+
154
+ if self.diff:
155
+ env = _env.get_diff(
156
+ agents_dir=agents_dir, project_root=project_root,
157
+ base_env=base, global_scope=self.global_scope, logger=self._logger_,
158
+ )
159
+ else:
160
+ changes = _env.get_environment(
161
+ agents_dir=agents_dir, project_root=project_root,
162
+ base_env=base, global_scope=self.global_scope, logger=self._logger_,
163
+ )
164
+ env = dict(base)
165
+ env.update(changes)
166
+
167
+ print(_format_env(env, output_format))
168
+ return 0
dotagents/cli/init.py ADDED
@@ -0,0 +1,117 @@
1
+ """`dotagents init` -- lay down the neutral base config (+ optional wrappers)."""
2
+
3
+ import sys
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+ from duho import Cmd, LoggingArgs
8
+
9
+ from dotagents.cli._common import BASE_ROOT, _apply_base, _resolve_from
10
+
11
+
12
+ class Init(LoggingArgs, Cmd):
13
+ """Lay down the neutral base config -- the `AGENTS.md` scaffolding and design-log
14
+ convention, never the opinionated overlays (those come from `overlays add`).
15
+
16
+ Scope: **project** by default (``<cwd>/.agents``), or the **user** store with
17
+ ``-g/--global`` (``~/.agents``). ``--dest`` overrides the resolved location.
18
+ ``--bin-dir`` additionally writes ``dotagents`` wrapper scripts there so the
19
+ command is on your PATH (only meaningful when running from a built ``.pyz``).
20
+ """
21
+
22
+ _parsername_ = "init"
23
+
24
+ global_scope: bool = False
25
+ "Init the user store (~/.agents) instead of this project's <cwd>/.agents."
26
+ ("--global", "-g")
27
+
28
+ agents_dir: Optional[Path] = None
29
+ "User store location for -g (default ~/.agents; configurable via $AGENTS_STORE_DIR)."
30
+ ("--agents-dir",)
31
+
32
+ dest: Optional[Path] = None
33
+ "Explicit destination, overriding the resolved scope (project/user)."
34
+ ("--dest",)
35
+
36
+ from_: Optional[str] = None
37
+ "Source directory/URI for the base overlay (default: bundled overlay)."
38
+ ("--from",)
39
+
40
+ bin_dir: Optional[Path] = None
41
+ "Also write dotagents/dotagents.cmd wrapper scripts here (puts the command on PATH)."
42
+ ("--bin-dir",)
43
+
44
+ dry_run: bool = False
45
+ "Show what would be written without touching anything."
46
+ ("--dry-run",)
47
+
48
+ force: bool = False
49
+ "Replace AGENTS.md/CLAUDE.md wholesale (with backup) instead of block-merging."
50
+ ("--force",)
51
+
52
+ agents: "list[str]" = []
53
+ "List of agents to install for (e.g. claude,gemini). Default: auto-detect + claude."
54
+ ("--agents",)
55
+
56
+ no_hooks: bool = False
57
+ "Skip wiring agent hooks and the shared skills link into the agent's config dir."
58
+ ("--no-hooks",)
59
+
60
+ def __call__(self) -> int:
61
+ src = _resolve_from(self.from_, BASE_ROOT)
62
+ if self.dest is not None:
63
+ dest = Path(self.dest).expanduser().resolve()
64
+ else:
65
+ from dotagents import _scope
66
+
67
+ scope = _scope.resolve_scope(self.global_scope, agents_dir=self.agents_dir)
68
+ dest = Path(scope.agents_root).expanduser().resolve()
69
+ self._logger_.info("scope: %s (%s)", scope.level, dest)
70
+
71
+ agent_names = []
72
+ if self.agents:
73
+ for a in self.agents:
74
+ agent_names.extend([x.strip() for x in a.split(",") if x.strip()])
75
+
76
+ _apply_base(
77
+ Path(src), dest, self.force, self.dry_run, self._logger_,
78
+ agents=agent_names if agent_names else None,
79
+ wire_hooks=not self.no_hooks,
80
+ )
81
+
82
+ if not self.dry_run:
83
+ from dotagents._wrappers import check_path_warning, write_wrappers
84
+
85
+ pyz_path = Path(sys.argv[0]).resolve()
86
+ if pyz_path.suffix != ".pyz":
87
+ # Running from a plain install (not a pyz): the wrappers point at
88
+ # `python -m dotagents` instead of a nonexistent pyz path.
89
+ pyz_path = None
90
+
91
+ # `<scope>/bin/` is always populated, with a path relative to the scope
92
+ # so the store stays relocatable. Everything downstream of `init` --
93
+ # the SessionStart hook, overlay `bin/` PATH entries, an overlay setup
94
+ # script calling a sibling -- shells out to `dotagents` by name, so a
95
+ # scope without it is a scope where those silently fail.
96
+ targets = [(dest / "bin", True)]
97
+ if self.bin_dir is not None:
98
+ targets.append((Path(self.bin_dir), False))
99
+
100
+ for bin_dir, relative in targets:
101
+ if pyz_path is not None:
102
+ for w in write_wrappers(bin_dir, pyz_path, relative=relative):
103
+ self._logger_.info("wrapper: %s", w)
104
+ else:
105
+ self._logger_.info(
106
+ "skipped wrapper install in %s: not running from a .pyz "
107
+ "(use build-pyz first)", bin_dir,
108
+ )
109
+
110
+ if self.bin_dir is not None:
111
+ warning = check_path_warning(Path(self.bin_dir))
112
+ if warning:
113
+ self._logger_.warning(warning)
114
+
115
+ if self.dry_run:
116
+ self._logger_.info("dry-run: no files were written")
117
+ return 0