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.
dotagents/_hooks.py ADDED
@@ -0,0 +1,172 @@
1
+ """Merge dotagents' hooks into an agent's ``settings.json`` without disturbing it.
2
+
3
+ An agent's settings file belongs to the **user**: it may carry unrelated keys and
4
+ hooks we did not write, in shapes we did not choose. So every function here is
5
+ additive and defensive -- foreign entries survive verbatim, malformed ones are
6
+ coerced or dropped rather than raising, and re-running is a no-op.
7
+
8
+ Idempotence is the property that matters: ``dotagents init`` is re-run often, and a
9
+ merge that appended a duplicate hook each time would quietly grow the file until
10
+ the agent ran our command N times per session.
11
+
12
+ The schema (verified against current Claude Code docs, 2026-07-24) is::
13
+
14
+ hooks: { "<Event>": [ { "matcher"?: str,
15
+ "hooks": [ {"type": "command", "command": str,
16
+ "statusMessage"?: str} ] } ] }
17
+
18
+ ``hooks.<Event>`` is a **list of matcher-objects**, each holding its own ``hooks``
19
+ list -- not a flat list of commands. ``statusMessage`` is a supported key.
20
+
21
+ Pure stdlib (``json``/``pathlib``) so it works in a plain ``pip install`` and inside
22
+ the ``.pyz``.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ from pathlib import Path
29
+ from typing import Any, Optional
30
+
31
+
32
+ def build_hook_entry(
33
+ command: str,
34
+ *,
35
+ matcher: "Optional[str]" = None,
36
+ status_message: "Optional[str]" = None,
37
+ ) -> "dict[str, Any]":
38
+ """One matcher-object wrapping a single ``type: command`` hook.
39
+
40
+ ``matcher``/``statusMessage`` are omitted entirely when None rather than
41
+ written as null -- an absent key is the documented "no matcher" form.
42
+ """
43
+ hook: "dict[str, Any]" = {"type": "command", "command": command}
44
+ if status_message:
45
+ hook["statusMessage"] = status_message
46
+ entry: "dict[str, Any]" = {}
47
+ if matcher is not None:
48
+ entry["matcher"] = matcher
49
+ entry["hooks"] = [hook]
50
+ return entry
51
+
52
+
53
+ def _has_status(entry: Any, status_message: str) -> bool:
54
+ """True if `entry` carries a hook stamped with our `statusMessage`.
55
+
56
+ The status message is a stable label we choose, so it identifies our hook
57
+ across revisions of the command text -- unlike the command itself, which
58
+ changes and would leave the old version orphaned beside the new one.
59
+ """
60
+ if not isinstance(entry, dict):
61
+ return False
62
+ nested = entry.get("hooks")
63
+ if not isinstance(nested, list):
64
+ return False
65
+ return any(
66
+ isinstance(h, dict) and h.get("statusMessage") == status_message
67
+ for h in nested
68
+ )
69
+
70
+
71
+ def _is_ours(entry: Any, command: str) -> bool:
72
+ """True if `entry` is a well-formed matcher-object containing our command."""
73
+ if not isinstance(entry, dict):
74
+ return False
75
+ nested = entry.get("hooks")
76
+ if not isinstance(nested, list):
77
+ return False
78
+ return any(
79
+ isinstance(h, dict) and h.get("command") == command for h in nested
80
+ )
81
+
82
+
83
+ def merge_hook(
84
+ existing: Any,
85
+ command: str,
86
+ *,
87
+ matcher: "Optional[str]" = None,
88
+ status_message: "Optional[str]" = None,
89
+ ) -> "tuple[list, bool]":
90
+ """Fold our hook into `existing`, returning ``(normalized_list, changed)``.
91
+
92
+ Rules, in order:
93
+
94
+ * absent / not-a-list ``existing`` -> a fresh single-entry list (changed).
95
+ * an entry already carrying our exact ``command`` -> kept as-is; the first
96
+ such entry wins and any later duplicate is dropped (changed), so repeated
97
+ ``init`` runs converge instead of accumulating.
98
+ * foreign entries -> preserved verbatim, untouched, in their original order.
99
+ * malformed entries (bare strings, dicts without a ``hooks`` list, non-dicts)
100
+ -> dropped, flagged changed. Never raises: a user's hand-edited settings
101
+ file must not make ``init`` explode.
102
+
103
+ ``status_message`` doubles as our hook's IDENTITY. An entry carrying the same
104
+ status message is treated as an older shape of our own hook and replaced, not
105
+ left beside the new one. Without that, dedup is by exact command string, so
106
+ revising the command we write silently orphans the previous version and the
107
+ old (often broken) hook keeps running next to the new one. The status message
108
+ is a stable label we choose, which makes it a better key than the command text
109
+ it describes.
110
+ """
111
+ if not isinstance(existing, list):
112
+ # None/absent is the common case; a non-list is a malformed file we replace.
113
+ return [build_hook_entry(command, matcher=matcher, status_message=status_message)], True
114
+
115
+ normalized: list = []
116
+ changed = False
117
+ seen_ours = False
118
+
119
+ for entry in existing:
120
+ if _is_ours(entry, command):
121
+ if seen_ours:
122
+ changed = True # collapse duplicates of our own command
123
+ continue
124
+ seen_ours = True
125
+ normalized.append(entry)
126
+ continue
127
+ if status_message and _has_status(entry, status_message):
128
+ changed = True # an older shape of OUR hook -- replace, don't duplicate
129
+ continue
130
+ if not isinstance(entry, dict) or not isinstance(entry.get("hooks"), list):
131
+ changed = True # malformed -- drop it
132
+ continue
133
+ normalized.append(entry) # foreign but well-formed: leave alone
134
+
135
+ if not seen_ours:
136
+ normalized.append(
137
+ build_hook_entry(command, matcher=matcher, status_message=status_message)
138
+ )
139
+ changed = True
140
+
141
+ return normalized, changed
142
+
143
+
144
+ def load_settings(path: Path) -> "dict[str, Any]":
145
+ """Read a settings.json, tolerating absence but never corruption.
146
+
147
+ A missing or empty file yields ``{}``. Invalid JSON raises ``SystemExit`` with
148
+ the path and parse error: silently starting from ``{}`` there would overwrite a
149
+ user's whole settings file on the next write.
150
+ """
151
+ if not path.is_file():
152
+ return {}
153
+ raw = path.read_text(encoding="utf-8").strip()
154
+ if not raw:
155
+ return {}
156
+ try:
157
+ data = json.loads(raw)
158
+ except json.JSONDecodeError as exc:
159
+ raise SystemExit(
160
+ "error: %s is not valid JSON (%s). Fix or move it, then re-run." % (path, exc)
161
+ ) from exc
162
+ if not isinstance(data, dict):
163
+ raise SystemExit("error: %s must contain a JSON object, got %s" % (path, type(data).__name__))
164
+ return data
165
+
166
+
167
+ def write_settings(path: Path, data: "dict[str, Any]", *, dry_run: bool = False) -> None:
168
+ """Write settings.json with a stable 2-space indent and trailing newline."""
169
+ if dry_run:
170
+ return
171
+ path.parent.mkdir(parents=True, exist_ok=True)
172
+ path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
dotagents/_merge.py ADDED
@@ -0,0 +1,122 @@
1
+ """Managed-block merge for `init`'s AGENTS.md/CLAUDE.md (D-init-merge).
2
+
3
+ `init` must never clobber a user-customized AGENTS.md. Content owned by dotagents
4
+ is delimited by literal marker lines and treated as a block *within* the file:
5
+ create if missing, prepend if present-without-markers, refresh-in-place if the
6
+ markers are already there. Detection is by marker presence only, never by prose
7
+ matching, so it survives user reformatting.
8
+ """
9
+
10
+ import shutil
11
+ import time
12
+ from pathlib import Path
13
+
14
+ BEGIN_MARKER = "<!-- dotagents:begin -->"
15
+ END_MARKER = "<!-- dotagents:end -->"
16
+
17
+
18
+ def _extract_block(
19
+ text: str, begin_marker: str = BEGIN_MARKER, end_marker: str = END_MARKER
20
+ ) -> str:
21
+ """Return the managed block's inner text (without the marker lines) from
22
+ a skeleton/template file that is itself fully marker-wrapped."""
23
+ start = text.index(begin_marker)
24
+ end = text.index(end_marker)
25
+ return text[start : end + len(end_marker)]
26
+
27
+
28
+ def _backup(target: Path, backup_root: Path) -> None:
29
+ backup_root.mkdir(parents=True, exist_ok=True)
30
+ shutil.copy2(target, backup_root / target.name)
31
+
32
+
33
+ def merge_block(
34
+ target: Path,
35
+ block_source_text: str,
36
+ *,
37
+ force: bool = False,
38
+ dry_run: bool = False,
39
+ backup_root: "Path | None" = None,
40
+ begin_marker: str = BEGIN_MARKER,
41
+ end_marker: str = END_MARKER,
42
+ append: bool = False,
43
+ ) -> str:
44
+ """Merge `block_source_text` (a fully marker-wrapped skeleton file's
45
+ contents) into `target`, returning the branch taken:
46
+ "created" / "block-inserted" / "block-refreshed" / "replaced (--force, backed up)".
47
+
48
+ Never overwrites content outside the markers unless `force` is True (then
49
+ the whole file is replaced, after being backed up to `backup_root` if the
50
+ target pre-exists).
51
+
52
+ `begin_marker`/`end_marker` default to the Markdown/HTML comment pair; pass a
53
+ different pair for other comment syntaxes (e.g. `#`-prefixed markers for TOML).
54
+
55
+ `append` puts the block at the END of an existing file rather than the start.
56
+ Required for TOML: a `[table]` header captures every key line that follows it,
57
+ so prepending a table would silently swallow the user's existing top-level keys
58
+ into our table.
59
+ """
60
+ block = _extract_block(block_source_text, begin_marker, end_marker)
61
+
62
+ if force:
63
+ existed = target.exists()
64
+ if existed and backup_root is not None:
65
+ if not dry_run:
66
+ _backup(target, backup_root)
67
+ if not dry_run:
68
+ target.parent.mkdir(parents=True, exist_ok=True)
69
+ target.write_text(block_source_text, encoding="utf-8")
70
+ return "replaced (--force, backed up)" if existed else "created"
71
+
72
+ if not target.exists():
73
+ if not dry_run:
74
+ target.parent.mkdir(parents=True, exist_ok=True)
75
+ target.write_text(block_source_text, encoding="utf-8")
76
+ return "created"
77
+
78
+ existing = target.read_text(encoding="utf-8")
79
+
80
+ if begin_marker in existing and end_marker in existing:
81
+ start = existing.index(begin_marker)
82
+ end = existing.index(end_marker) + len(end_marker)
83
+ new_text = existing[:start] + block + existing[end:]
84
+ if new_text == existing:
85
+ return "skipped (present)"
86
+ if not dry_run:
87
+ target.write_text(new_text, encoding="utf-8")
88
+ return "block-refreshed"
89
+
90
+ if append:
91
+ sep = "" if existing.endswith("\n") else "\n"
92
+ new_text = existing + sep + "\n" + block + "\n"
93
+ else:
94
+ new_text = block + "\n\n" + existing
95
+ if not dry_run:
96
+ target.write_text(new_text, encoding="utf-8")
97
+ return "block-inserted"
98
+
99
+
100
+ def merge_claude_md(
101
+ target: Path,
102
+ block_source_text: str,
103
+ *,
104
+ force: bool = False,
105
+ dry_run: bool = False,
106
+ backup_root: "Path | None" = None,
107
+ ) -> str:
108
+ """Same managed-block rule, but for the CLAUDE.md one-liner: if the file
109
+ already contains an `@AGENTS.md` reference (any form), leave it untouched
110
+ (branch "skipped (present)") instead of requiring exact marker match --
111
+ a bare `@AGENTS.md` file written by hand still counts as "present"."""
112
+ if not force and target.exists():
113
+ existing = target.read_text(encoding="utf-8")
114
+ if "@AGENTS.md" in existing:
115
+ return "skipped (present)"
116
+ return merge_block(
117
+ target, block_source_text, force=force, dry_run=dry_run, backup_root=backup_root
118
+ )
119
+
120
+
121
+ def timestamped_backup_root(dest: Path) -> Path:
122
+ return dest / "install_backup" / time.strftime("%Y%m%d-%H%M%S")
@@ -0,0 +1,31 @@
1
+ <!-- dotagents:begin -->
2
+ # Agent Directives
3
+
4
+ Startup: annotate that you read `~/.agents/AGENTS.md`.
5
+
6
+ ## Always-on rules
7
+ - **Permissions**: read any `AGENTS.md`; full read/write/create/delete inside any
8
+ `.agents/` directory and this config directory (`~/.agents` unless installed
9
+ elsewhere) — never ask.
10
+ - **Read the local `AGENTS.md`**: before working in a directory, read the ones
11
+ governing it — at each level from this global file down to that directory, any of
12
+ `AGENTS.md`, `AGENTS.local.md`, `.agents/AGENTS.md`, `.agents/AGENTS.local.md`.
13
+ Deeper extends/overrides broader; `.agents/` beats the plain file beside it, and
14
+ `.local` wins at its own level: it is the unshared override (machine- or
15
+ user-specific — either way, never committed or copied into a repo, plan, or shared
16
+ config). These files say what lives there and how to work in it, so reading them
17
+ first is cheaper than rediscovering it from the source. How `.agents/` is
18
+ populated — real directory, symlink, committed or not — is yours to decide.
19
+ - **Global-config misses**: if these instructions caused a mistake or rework, or you
20
+ have an improvement idea, drop a note in `~/.agents/dotagents/findings/` and move on
21
+ — don't edit the config. Triage later folds them into `.../dotagents/DECISIONS.md`
22
+ and moves each (never deletes) to `.../findings/processed/`.
23
+ - **This file**: everything between the `dotagents:begin`/`dotagents:end` markers is
24
+ managed — `dotagents init` refreshes it and leaves anything outside untouched. Add
25
+ your own rules and routing below the end marker, not inside.
26
+
27
+ ## Load on demand
28
+ Read the matching file BEFORE such a task; skip it otherwise, never preemptively.
29
+ Nothing ships here by default — add one routing line per file as you grow this
30
+ config. A named agent with its own `~/.agents/<agent>.md` reads that too.
31
+ <!-- dotagents:end -->
@@ -0,0 +1,3 @@
1
+ <!-- dotagents:begin -->
2
+ @AGENTS.md
3
+ <!-- dotagents:end -->
@@ -0,0 +1,41 @@
1
+ # dotagents skeleton — understanding the hierarchy
2
+
3
+ This is the minimal starter installed by `dotagents init`. It gives you the
4
+ scaffolding to build your own agent config from scratch — it intentionally carries
5
+ no opinionated flows, model-routing, or repo-standards content.
6
+
7
+ ## Layout
8
+
9
+ - `AGENTS.md` — the root config, read by every agent session. Keep it lean: always-
10
+ on rules plus a "load on demand" routing list. Its content between the
11
+ `<!-- dotagents:begin -->` / `<!-- dotagents:end -->` markers is managed by
12
+ `dotagents init` — re-running `init` refreshes only that block, never touching
13
+ anything you add outside it.
14
+ - `CLAUDE.md` (and any other per-agent entry file, e.g. `ANTIGRAVITY.md`) — a
15
+ one-liner (`@AGENTS.md`) that points that agent runner at the shared root config.
16
+ Add one per agent runner you use; they all converge on the same `AGENTS.md`.
17
+ - `dotagents/DECISIONS.md` + `dotagents/decisions/` — a design log for this config
18
+ itself (why a rule exists, what changed and when), as a lean index + one file per
19
+ decision. Not loaded in normal sessions.
20
+ - `dotagents/findings/` — where agents drop short notes when the config caused a
21
+ mistake or gap, instead of editing the config mid-task. `dotagents/findings/
22
+ processed/` is where triaged notes land (moved, never deleted) once folded into
23
+ `dotagents/DECISIONS.md`.
24
+
25
+ ## Growing your own config
26
+
27
+ As you build out routines you want agents to follow repeatedly (planning, execution,
28
+ code review, repo standards, language-specific conventions), put each in its own file
29
+ under `~/.agents/` and add one routing line to `AGENTS.md`'s "Load on demand" list —
30
+ loaded only when the task matches, never preemptively. This keeps every-session cost
31
+ low regardless of how much topical detail accumulates. How you group those files is
32
+ up to you; the overlays in this project happen to use `flows/` and `kb/`, but nothing
33
+ in `dotagents` requires that layout.
34
+
35
+ One filename *is* conventional: a named agent (Claude, Antigravity, …) reads its own
36
+ `~/.agents/<agent>.md` on top of this file, so per-runner directives go there.
37
+
38
+ If you'd rather start from a fuller, opinionated example than build from scratch, see
39
+ this project's opt-in overlays (`engineering` rules, planning/execution flows, language
40
+ knowledge bases, CI/reference templates) — layer them in deliberately with
41
+ `dotagents install --overlays <path>`, never wholesale.
@@ -0,0 +1,20 @@
1
+ # Config Design Decisions — index
2
+
3
+ Not loaded in normal sessions. Read only when deliberately iterating on
4
+ `~/.agents/AGENTS.md` or its flows.
5
+
6
+ **How findings become config**: when an agent hits a mistake or gap caused by this
7
+ config, it drops a short note in `~/.agents/dotagents/findings/` instead of editing the
8
+ config mid-task. On request, triage that directory: fold each settled note into a
9
+ decision below, then **move** (never delete) the processed file to
10
+ `~/.agents/dotagents/findings/processed/`. This keeps the config's evolution auditable
11
+ without letting routine work rewrite always-on rules under time pressure.
12
+
13
+ **Structure** (mirrors the memory pattern — a lean index plus one file per entry, so a
14
+ session reads this short index and opens only what it needs): each decision is its own
15
+ file `decisions/D<nn>.md` with a one-line `description:` scan key; add one index line
16
+ here per decision. Ships empty — this is your log to grow.
17
+
18
+ ## Decisions
19
+
20
+ _(none yet)_
@@ -0,0 +1,46 @@
1
+ # `dotagents/cmds/` — your own commands go here
2
+
3
+ `init` creates this directory in your store (`<store>/dotagents/cmds/`). Any
4
+ `*.py` file you drop in it that defines a `duho` command class becomes a
5
+ `dotagents <name>` subcommand — no registration, no config.
6
+
7
+ ```python
8
+ # <store>/dotagents/cmds/hello.py
9
+ from duho import Cmd, LoggingArgs
10
+
11
+
12
+ class Hello(LoggingArgs, Cmd):
13
+ """Say hello."""
14
+
15
+ _parsername_ = "hello"
16
+
17
+ who: str = "world"
18
+ ("--who",)
19
+
20
+ def __call__(self) -> int:
21
+ print("hello, %s" % self.who)
22
+ return 0
23
+ ```
24
+
25
+ Then `dotagents hello --who you`.
26
+
27
+ Files whose name starts with `_` are skipped by discovery — use that prefix for
28
+ shared helper modules.
29
+
30
+ ## Precedence
31
+
32
+ Discovery layers sources so a later one overrides a same-named command:
33
+
34
+ built-ins < installed overlays' cmds/ < system < user < project
35
+
36
+ So a command you drop here (user scope) overrides one an overlay ships, and a
37
+ project's `.agents/dotagents/cmds/` overrides yours.
38
+
39
+ ## dotagents itself ships none
40
+
41
+ This directory is intentionally empty in a fresh install (D85). `link`/`sync`
42
+ used to ship here; they are now `link-project`/`sync-project`, shipped by the
43
+ opt-in **private-sync** overlay together with their logic — so plain dotagents
44
+ carries no private-sync workflow. Install it with:
45
+
46
+ dotagents overlays add private-sync --source <overlays-checkout>