agentic-home-cli 0.1.0

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.
package/README.md ADDED
@@ -0,0 +1,161 @@
1
+ # agentic-home-cli
2
+
3
+ One command to gather global agent skills and shared instructions into a managed home, then connect installed agents with symlinks.
4
+
5
+ **Requirements:** macOS or Linux, Python 3.11+, and Git for three-way text merges. No runtime Python dependencies, embedded LLM, network requests, or background service.
6
+
7
+ ## Install and preview
8
+
9
+ With npm (Node.js 18+):
10
+
11
+ ```sh
12
+ npm install -g agentic-home-cli
13
+ agentic sync --dry-run
14
+ ```
15
+
16
+ Or run `npx agentic-home-cli sync --dry-run`. Python 3.11+ must already be installed; npm does not install Python or Git. The launcher searches common Python executable names. Set `AGENTIC_PYTHON=/absolute/path/to/python3.12` to select an interpreter explicitly. Git is needed for three-way merges.
17
+
18
+ From this checkout with pipx:
19
+
20
+ ```sh
21
+ pipx install .
22
+ agentic sync --dry-run
23
+ agentic sync
24
+ ```
25
+
26
+ The distribution is `agentic-home-cli`; the command is `agentic`. Check `command -v agentic` if you already have a command with that name.
27
+
28
+ Without installing:
29
+
30
+ ```sh
31
+ python3.12 -m agentic_cli.cli sync --dry-run --json
32
+ ```
33
+
34
+ Only existing agent setups are detected. Project files, credentials, sessions, caches, plugin installations, and unrelated settings are not managed. Skills can contain executable scripts and instructions: review their contents before sharing them with more agents. The CLI never executes skill content.
35
+
36
+ ## Choose a directory
37
+
38
+ ```sh
39
+ agentic sync --root "$HOME/my shared agent files"
40
+ agentic sync # remembers the selected directory
41
+ agentic status
42
+ ```
43
+
44
+ The default is `~/.agentic`. The saved selection lives in `$XDG_CONFIG_HOME/agentic/config.json`, or `~/.config/agentic/config.json` when XDG_CONFIG_HOME is unset. `--root` overrides that selection. Relative paths become absolute; spaces are supported. Dry runs do not save a selection or modify the home.
45
+
46
+ A root can already contain `skills/<name>/SKILL.md` and `AGENTS.md`. Other files in that root are left alone, including an existing `index.json`.
47
+
48
+ To switch roots later, run `sync --root NEW_PATH`. The previous root becomes another import source, not an automatically preferred version. Old content is retained. Conflicts are resolved normally. A pending-root pointer routes `status`, `resolve`, and `continue` to the new workspace; the saved active root switches only once the transition is complete. Finish pending work before selecting another root. Nested/overlapping roots and roots within agent/configuration directories are rejected.
49
+
50
+ ## Supported discovery locations
51
+
52
+ | Agent detected | Skills destination | Shared instructions |
53
+ | --- | --- | --- |
54
+ | `~/.claude` | `~/.claude/skills/<name>` | `~/.claude/CLAUDE.md` |
55
+ | `~/.codex` | `~/.agents/skills/<name>` | `~/.codex/AGENTS.md` |
56
+ | `~/.cursor` | `~/.cursor/skills/<name>` | Unsupported global file integration; reported explicitly |
57
+ | `~/.pi/agent` | `~/.pi/agent/skills/<name>` | `~/.pi/agent/AGENTS.md` |
58
+
59
+ Existing legacy `~/.codex/skills` entries are imported and linked too. Existing shared `~/.agents/skills` entries are imported when Codex or Pi is detected. Hidden catalogs such as `.system` are left untouched. Custom agent-home environment variables/settings are not interpreted in this first version.
60
+
61
+ Portable skills are directories containing a regular `SKILL.md` file (a directory with that name is invalid). Pi-only standalone Markdown skills are reported and left untouched, rather than made undiscoverable by other agents. Instructions have one shared body, without agent-specific additions.
62
+
63
+ Cursor's documented global User Rules are configured in Settings, not discovered from a documented global file path. The CLI therefore does **not** create a misleading `~/.cursor/rules` wrapper or modify Cursor's database. A thin wrapper/reference is permitted if a supported global hook becomes available; it must reference the selected root rather than hardcode `~/.agentic`.
64
+
65
+ `AGENTS.override.md` in Codex or Pi blocks instruction publication and is reported for explicit reconciliation. It does not stop unrelated skills. Additional custom instruction settings and project-local instructions remain outside this tool's scope.
66
+
67
+ ### Discovery evidence
68
+
69
+ Checked against primary documentation during implementation:
70
+
71
+ - [Claude Code skills](https://code.claude.com/docs/en/skills): personal `~/.claude/skills/<name>/SKILL.md`.
72
+ - [Claude Code memory](https://code.claude.com/docs/en/memory): user `~/.claude/CLAUDE.md`. Cowork has additional symlink/import restrictions and is **not** a supported target.
73
+ - [Codex skills](https://developers.openai.com/codex/skills/): user `~/.agents/skills`; symlinked skill folders explicitly supported.
74
+ - [Codex instructions](https://developers.openai.com/codex/guides/agents-md/): global `~/.codex/AGENTS.md`, with `AGENTS.override.md` taking precedence.
75
+ - [Cursor skills](https://cursor.com/docs/context/skills): global `~/.cursor/skills` and compatibility discovery locations.
76
+ - [Cursor rules](https://cursor.com/docs/context/rules): `.cursor/rules` is project-scoped; global User Rules live in Settings.
77
+ - [Pi README](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/README.md#context-files) and [skills documentation](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/skills.md): global `~/.pi/agent/AGENTS.md`, skill catalogs, and override behavior. Also verified against the locally installed Pi documentation.
78
+
79
+ These are documented discovery contracts, not a claim of live end-to-end validation in each agent application. Restart/reload your agents and inspect their loaded skills/instructions after setup.
80
+
81
+ ## Conflicts, like Git
82
+
83
+ ```sh
84
+ agentic sync
85
+ agentic status --json
86
+ agentic resolve CONFLICT_ID --take claude
87
+ agentic continue
88
+ ```
89
+
90
+ There is **no source precedence**. Identical content deduplicates; unique files combine. First-import differences without a recorded common ancestor become conflicts. Subsequent text changes use `git merge-file` when a baseline exists. Multiple distinct variants, binary differences, and type collisions are never guessed away.
91
+
92
+ Non-conflicting skills are organized immediately. An entire conflicting skill remains inactive in the new home until its files are resolved; the agents keep their original files. Shared instructions are a separate activation unit. Conflict-marked files live only in private working copies, never in active discovery locations.
93
+
94
+ In a terminal, the CLI offers a source label, an edited file/directory, or deferral. For manual or LLM editing:
95
+
96
+ ```sh
97
+ agentic sync --json
98
+ # Inspect conflicts[].variants and conflicts[].working_copy.
99
+ # Edit the working copy; for a skill, preserve the entire directory tree.
100
+ agentic resolve CONFLICT_ID --file '/reported/working/copy'
101
+ agentic continue --json
102
+ ```
103
+
104
+ `resolve` stages a snapshot, so edit first and stage afterward. `continue` validates the resolved node type and rediscovers sources, including agents and legacy catalogs added since staging. New or changed sources invalidate the staged result and refresh the conflict workspace; previously unseen content is never silently selected against. An instruction override that appears meanwhile also blocks publication. Repeated `sync` calls retain unfinished working copies.
105
+
106
+ Conflict markers beginning with `<<<<<<<`, `=======`, or `>>>>>>>` are rejected anywhere in staged text. Binary files require explicit source selection or replacement content. If documentation intentionally contains lines with these prefixes, rewrite/indent those lines before staging.
107
+
108
+ **LLM contract:** use `--json` to avoid prompts; stable conflict IDs, labeled live source paths, snapshot locations, relative conflicting paths, and resolution commands are returned. No PTY is required. Exit codes: `0` success, `1` operational failure, `2` unresolved conflicts or blocked units. JSON goes to stdout; normal human diagnostics go to stderr. `--dry-run` uses the publication validation path and reports prospective conflicts, invalid units, and catalog normalization without creating workspaces.
109
+
110
+ ## Repeated runs and symlinks
111
+
112
+ - New skills and regular files that replace managed links are imported through the same workflow.
113
+ - Missing discovery links are repaired when the canonical content exists.
114
+ - Absence is not a deletion instruction. Missing children retained in a baseline are restored; missing whole units with no live source are reported instead of silently recreated. Deletion propagation is intentionally unsupported.
115
+ - Valid external source links are retained when the selected content matches. Their targets are not moved or modified. When a merge requires a local copy, content is materialized; nested source links are followed into that copy, not rewritten in the external source.
116
+ - Whole-catalog links to the selected canonical catalog can remain as-is. Other whole-catalog links are backed up and replaced by a local catalog of child links before individual skills are adopted. External targets remain untouched.
117
+ - Broken links, cycles, special files, and unsafe directory-link writes are reported. A missing/invalid unit does not normally block independent units.
118
+ - An unchanged successful rerun does not rewrite content, links, configuration, or backups.
119
+
120
+ ## Backups and recovery
121
+
122
+ Private state lives under `<root>/.agentic/`:
123
+
124
+ ```text
125
+ state.json ownership, baselines, pending resolutions/publication
126
+ journal.json one interrupted filesystem replacement (when present)
127
+ conflicts/<id>/... retained source variants and editable working copies
128
+ backups/<uuid>.json original path plus lossless snapshot before replacement
129
+ ```
130
+
131
+ Snapshots preserve bytes (base64), ordinary POSIX permissions, directory entries, and original symlink text. They do not preserve ACLs, extended attributes, ownership, timestamps, or hard-link relationships. Full snapshots favor simplicity over storage efficiency; backups are never automatically pruned.
132
+
133
+ A process lock prevents concurrent writers through the same configuration directory. Journaled publication resumes on the next mutating command. File/link replacement is atomic where POSIX allows it. Replacing nonempty directories or changing between directory and non-directory types has a brief recoverable gap; the tool does not claim a cross-directory filesystem transaction. Recovery refuses to overwrite newer changes.
134
+
135
+ Manual restore: inspect a backup's `path` first, then reconstruct it into a **new scratch path**, not over live files:
136
+
137
+ ```sh
138
+ python3.12 - /absolute/backup.json /absolute/new-scratch-path <<'PY'
139
+ import json, sys
140
+ from pathlib import Path
141
+ from agentic_cli.storage import exists, materialize
142
+ record = json.loads(Path(sys.argv[1]).read_text())
143
+ target = Path(sys.argv[2])
144
+ assert not exists(target), 'Choose a new scratch path'
145
+ assert record['original'] is not None, 'There was no original at this path'
146
+ materialize(target, record['original'])
147
+ print('Original location:', record['path'])
148
+ print('Restored snapshot:', target)
149
+ PY
150
+ ```
151
+
152
+ Run this from the checkout with a compatible Python, or from an environment with the package installed. Review the result before manually replacing the live path. A restored relative symlink keeps its original text and may only resolve correctly at its original location. The CLI intentionally has no undo/delete-backups command. Its private state is trusted local data; do not edit journals or accept them from untrusted sources.
153
+
154
+ ## Development checks
155
+
156
+ ```sh
157
+ python3.12 -m unittest discover -s tests -v
158
+ python3.12 -m pip wheel --no-deps . --wheel-dir /tmp/agentic-wheels
159
+ ```
160
+
161
+ Tests use temporary homes, including subprocess CLI tests. They never run `sync` against the developer's real home. CI is configured to run the suite and package installation on macOS/Linux with Python 3.11 and 3.12. No test framework dependency is needed.
@@ -0,0 +1 @@
1
+ """Shared agent home manager."""
@@ -0,0 +1,106 @@
1
+ """Standalone and LLM-friendly command-line interface."""
2
+ import argparse
3
+ import json
4
+ import sys
5
+
6
+ from .core import Home
7
+ from .storage import lock
8
+
9
+
10
+ def parser():
11
+ root = argparse.ArgumentParser(description="Share global agent skills and instructions safely.")
12
+ commands = root.add_subparsers(dest="command", required=True)
13
+ for name in ("sync", "status", "resolve", "continue"):
14
+ command = commands.add_parser(name)
15
+ command.add_argument("--json", action="store_true", help="Machine-readable output; never prompt")
16
+ if name == "sync":
17
+ command.add_argument("--root", help="Managed directory (remembered after initialization)")
18
+ command.add_argument("--dry-run", action="store_true", help="Inspect without changing the home or configuration")
19
+ if name == "resolve":
20
+ command.add_argument("id")
21
+ choice = command.add_mutually_exclusive_group(required=True)
22
+ choice.add_argument("--take", metavar="SOURCE")
23
+ choice.add_argument("--file", help="Resolved file, or complete directory for a skill")
24
+ return root
25
+
26
+
27
+ def display(report, as_json=False):
28
+ if as_json:
29
+ print(json.dumps(report, indent=2))
30
+ return
31
+ if "root" in report:
32
+ print("Managed home:", report["root"])
33
+ for key in report.get("changed", []):
34
+ print("Would organize:" if report.get("dry_run") else "Organized:", key)
35
+ for warning in report.get("warnings", []):
36
+ print("Warning:", warning, file=sys.stderr)
37
+ for issue in report.get("issues", []):
38
+ print(f"Blocked: {issue['path']}: {issue['reason']}", file=sys.stderr)
39
+ for conflict in report.get("conflicts", []):
40
+ print(f"Conflict {conflict['id']}: {conflict['unit']}")
41
+ for source, info in conflict["sources"].items():
42
+ print(f" {source}: {info['path']}")
43
+ if "working_copy" in conflict:
44
+ print(" Working copy:", conflict["working_copy"])
45
+ for command in conflict.get("commands", []):
46
+ print(" ", command)
47
+ for link in report.get("links", []):
48
+ print("Linked:" if link["linked"] else "Not linked:", link["path"])
49
+ if report.get("staged"):
50
+ print("Resolution staged; run agentic continue.")
51
+ if report.get("interrupted"):
52
+ print("Interrupted operation: run agentic sync to resume.")
53
+ if report.get("backups"):
54
+ print("Backups:", report["backups"])
55
+
56
+
57
+ def interact(manager, report):
58
+ for conflict in report.get("conflicts", []):
59
+ sources = list(conflict.get("variants", {}))
60
+ print(f"\nResolve {conflict['unit']}: " + ", ".join(sources))
61
+ try:
62
+ answer = input("Source label, 'file' for an edited result, or Enter to defer: ").strip()
63
+ if answer == "file":
64
+ manager.resolve(conflict["id"], file=input("Resolved file/directory path: ").strip())
65
+ elif answer:
66
+ manager.resolve(conflict["id"], source=answer)
67
+ except (EOFError, KeyboardInterrupt):
68
+ print("\nDeferred; originals remain active.")
69
+ break
70
+ except (OSError, ValueError) as exc:
71
+ print(str(exc), file=sys.stderr)
72
+ return manager.continue_()
73
+
74
+
75
+ def main(argv=None):
76
+ args = parser().parse_args(argv)
77
+ try:
78
+ manager = Home(root=getattr(args, "root", None))
79
+ if args.command == "status":
80
+ report = manager.status()
81
+ elif args.command == "sync" and args.dry_run:
82
+ report = manager.sync(dry_run=True)
83
+ else:
84
+ with lock(manager.config_dir):
85
+ manager = Home(root=getattr(args, "root", None))
86
+ if args.command == "sync":
87
+ report = manager.sync()
88
+ elif args.command == "continue":
89
+ report = manager.continue_()
90
+ else:
91
+ report = manager.resolve(args.id, source=args.take, file=args.file)
92
+ if args.command in ("sync", "continue") and not args.json and sys.stdin.isatty() and report.get("conflicts"):
93
+ display(report)
94
+ report = interact(manager, report)
95
+ display(report, args.json)
96
+ return 2 if report.get("conflicts") or report.get("issues") else 0
97
+ except (OSError, RuntimeError, ValueError) as exc:
98
+ if args.json:
99
+ print(json.dumps({"error": str(exc)}))
100
+ else:
101
+ print(f"agentic: {exc}", file=sys.stderr)
102
+ return 1
103
+
104
+
105
+ if __name__ == "__main__":
106
+ sys.exit(main())
@@ -0,0 +1,381 @@
1
+ """Reconcile discovery paths with a shared home, one activation unit at a time."""
2
+ import os
3
+ import shlex
4
+ from pathlib import Path
5
+ import uuid
6
+
7
+ from .merge import merge, has_markers
8
+ from .storage import (Unsafe, Transaction, atomic_json, digest, exists, guarded_parent,
9
+ load_json, materialize, same_content, snapshot)
10
+
11
+ # Codex's documented global catalog is now ~/.agents/skills; import legacy too.
12
+ AGENTS = {
13
+ "claude": (".claude", ".claude/skills", ".claude/CLAUDE.md"),
14
+ "codex": (".codex", ".agents/skills", ".codex/AGENTS.md"),
15
+ "cursor": (".cursor", ".cursor/skills", None),
16
+ "pi": (".pi/agent", ".pi/agent/skills", ".pi/agent/AGENTS.md"),
17
+ }
18
+
19
+
20
+ def fingerprint(path):
21
+ return digest({"raw": snapshot(path), "content": snapshot(path, True)})
22
+
23
+
24
+ def link_node(target):
25
+ return {"kind": "link", "target": str(target)}
26
+
27
+
28
+ class Home:
29
+ def __init__(self, home=None, root=None, config_dir=None):
30
+ self.home = Path(home or Path.home()).resolve()
31
+ self.config_dir = Path(config_dir or os.environ.get("XDG_CONFIG_HOME", self.home / ".config")).resolve() / "agentic"
32
+ self.config_path = self.config_dir / "config.json"
33
+ self.config = load_json(self.config_path, {})
34
+ saved = self.config.get("pending_root") or self.config.get("root")
35
+ selected = Path(root).expanduser().absolute() if root else Path(saved or self.home / ".agentic")
36
+ self.root = selected.resolve()
37
+ self.old_root = Path(self.config["root"]) if self.config.get("root") else None
38
+ if saved and root and self.root != Path(saved):
39
+ previous = load_json(Path(saved) / ".agentic" / "state.json", {})
40
+ if previous.get("conflicts") or previous.get("applying") or self.config.get("pending_root"):
41
+ raise Unsafe("Finish pending work before selecting another root")
42
+ for base, skills, instructions in AGENTS.values():
43
+ # Even an absent agent may be installed later; reserve its entire home.
44
+ location = self.home / base
45
+ if self.root == location or self.root.is_relative_to(location) or location.is_relative_to(self.root):
46
+ raise Unsafe(f"Managed root overlaps an agent directory: {location}")
47
+ for reserved in (self.home / ".agents", self.config_dir):
48
+ if self.root == reserved or self.root.is_relative_to(reserved) or reserved.is_relative_to(self.root):
49
+ raise Unsafe(f"Managed root overlaps reserved directory: {reserved}")
50
+ if self.old_root and self.old_root != self.root:
51
+ if self.root.is_relative_to(self.old_root) or self.old_root.is_relative_to(self.root):
52
+ raise Unsafe("Old and new managed roots must not contain one another")
53
+ self.meta = self.root / ".agentic"
54
+ for path in (self.meta, self.root / "skills"):
55
+ if path.is_symlink():
56
+ raise Unsafe(f"Managed directory must not be a symlink: {path}")
57
+ self.state_path = self.meta / "state.json"
58
+ self.state = load_json(self.state_path, {"version": 1, "baselines": {}, "conflicts": {}})
59
+ if self.state.get("version") != 1:
60
+ raise Unsafe("Unsupported managed-state version")
61
+ self.agents = {name: spec for name, spec in AGENTS.items() if (self.home / spec[0]).is_dir()}
62
+ catalogs = [self.home / spec[1] for spec in self.agents.values()]
63
+ if "codex" in self.agents:
64
+ catalogs.append(self.home / ".codex/skills")
65
+ if "pi" in self.agents:
66
+ catalogs.append(self.home / ".agents/skills")
67
+ for catalog in catalogs:
68
+ if not catalog.is_dir():
69
+ continue
70
+ candidates = [catalog, *(p for p in catalog.iterdir() if p.is_dir())]
71
+ for source in candidates:
72
+ if self.root == source.resolve() or self.root.is_relative_to(source.resolve()):
73
+ raise Unsafe(f"Managed root overlaps a source catalog or skill: {source}")
74
+ self.tx = Transaction(self.meta)
75
+
76
+ def save(self):
77
+ atomic_json(self.state_path, self.state)
78
+
79
+ def route_pending(self):
80
+ if self.config == {"root": str(self.root)}:
81
+ return
82
+ config = {**self.config, "pending_root": str(self.root)}
83
+ if config != self.config:
84
+ atomic_json(self.config_path, config)
85
+ self.config = config
86
+
87
+ def finish_config(self):
88
+ config = {"root": str(self.root)}
89
+ if self.config != config:
90
+ atomic_json(self.config_path, config)
91
+ self.config = config
92
+
93
+ def inventories(self):
94
+ units = {}
95
+ warnings = []
96
+ issues = []
97
+ catalogs = [("managed", self.root / "skills")]
98
+ catalogs += [(name, self.home / spec[1]) for name, spec in self.agents.items()]
99
+ if "codex" in self.agents and exists(self.home / ".codex/skills"):
100
+ catalogs.append(("codex-legacy", self.home / ".codex/skills"))
101
+ if "pi" in self.agents and "codex" not in self.agents and exists(self.home / ".agents/skills"):
102
+ catalogs.append(("shared-agents", self.home / ".agents/skills"))
103
+ if self.old_root and self.old_root != self.root:
104
+ catalogs.append(("previous-root", self.old_root / "skills"))
105
+ for label, catalog in catalogs:
106
+ if not exists(catalog):
107
+ continue
108
+ if not catalog.is_dir():
109
+ issues.append({"path": str(catalog), "reason": "Skill catalog is not a directory"})
110
+ continue
111
+ for entry in sorted(catalog.iterdir()):
112
+ if entry.name.startswith("."):
113
+ continue # System/plugin-owned hidden catalogs are not adopted.
114
+ if entry.is_file() and entry.suffix == ".md":
115
+ warnings.append(f"Standalone Markdown skill left untouched (not portable): {entry}")
116
+ continue
117
+ key = "skills/" + entry.name
118
+ units.setdefault(key, []).append((label, entry))
119
+ instructions = [("managed", self.root / "AGENTS.md")]
120
+ instructions += [(name, self.home / spec[2]) for name, spec in self.agents.items() if spec[2]]
121
+ if self.old_root and self.old_root != self.root:
122
+ instructions.append(("previous-root", self.old_root / "AGENTS.md"))
123
+ overrides = [self.home / self.agents[name][0] / "AGENTS.override.md"
124
+ for name in ("codex", "pi") if name in self.agents]
125
+ shadowed = [p for p in overrides if exists(p)]
126
+ if shadowed:
127
+ issues.extend({"path": str(p), "reason": "Instruction override shadows shared instructions; reconcile/remove explicitly"} for p in shadowed)
128
+ elif any(exists(p) for _, p in instructions):
129
+ units["AGENTS.md"] = instructions
130
+ if "cursor" in self.agents:
131
+ warnings.append("Cursor instructions unsupported: global User Rules have no documented file-based discovery hook; skills are supported.")
132
+ for key in self.state["baselines"]:
133
+ if key == "AGENTS.md" and shadowed:
134
+ continue
135
+ units.setdefault(key, [])
136
+ for record in self.state["conflicts"].values():
137
+ if record["unit"] == "AGENTS.md" and shadowed:
138
+ continue
139
+ units.setdefault(record["unit"], [(label, Path(source["path"]))
140
+ for label, source in record["sources"].items()])
141
+ return units, warnings, issues
142
+
143
+ def destinations(self, key, sources):
144
+ paths = []
145
+ if key == "AGENTS.md":
146
+ paths.extend(self.home / spec[2] for spec in self.agents.values() if spec[2])
147
+ else:
148
+ name = key.split("/", 1)[1]
149
+ paths.extend(self.home / spec[1] / name for spec in self.agents.values())
150
+ paths.extend(path for label, path in sources if label in ("codex-legacy", "shared-agents"))
151
+ return list(dict.fromkeys(paths))
152
+
153
+ def inspect_unit(self, key, sources):
154
+ target = self.root / key
155
+ sources = list(sources)
156
+ if not any(p == target for _, p in sources):
157
+ sources.insert(0, ("managed", target))
158
+ for path in self.destinations(key, sources):
159
+ if not any(p == path for _, p in sources):
160
+ sources.append(("destination-" + str(len(sources)), path))
161
+ variants, captured = [], {}
162
+ for label, path in sources:
163
+ captured[label] = {"path": str(path), "fingerprint": fingerprint(path)}
164
+ value = snapshot(path, True)
165
+ if value is not None:
166
+ variants.append((label, value))
167
+ if not variants:
168
+ raise Unsafe(f"Managed target is missing; no source to restore: {target}")
169
+ if key.startswith("skills/") and any(n["kind"] != "dir" for _, n in variants):
170
+ # Type collisions are resolvable; a lone invalid skill is not publishable.
171
+ if len(variants) == 1:
172
+ raise Unsafe(f"Skill must be a directory: {variants[0][0]}: {key}")
173
+ base = self.state["baselines"].get(key)
174
+ result, pending = merge(variants, base)
175
+ return sources, captured, variants, result, pending
176
+
177
+ def conflict(self, key, captured, variants, result, pending):
178
+ identifier = digest(key)[:12]
179
+ old = self.state["conflicts"].get(identifier)
180
+ if old and old["sources"] == captured:
181
+ return old
182
+ directory = self.meta / "conflicts" / identifier / uuid.uuid4().hex
183
+ guarded_parent(directory)
184
+ directory.mkdir(parents=True)
185
+ paths = {}
186
+ for label, node in variants:
187
+ path = directory / label
188
+ materialize(path, node)
189
+ paths[label] = str(path)
190
+ work = directory / "working"
191
+ materialize(work, result)
192
+ record = {"id": identifier, "unit": key, "sources": captured, "variants": paths,
193
+ "working_copy": str(work), "conflicting_paths": pending,
194
+ "resolution": None, "commands": [f"agentic resolve {identifier} --take SOURCE",
195
+ f"agentic resolve {identifier} --file {shlex.quote(str(work))}", "agentic continue"]}
196
+ self.state["conflicts"][identifier] = record
197
+ self.save()
198
+ return record
199
+
200
+ def publish(self, key, sources, result, dry_run=False):
201
+ if key.startswith("skills/") and (result["kind"] != "dir" or
202
+ result["entries"].get("SKILL.md", {}).get("kind") != "file"):
203
+ raise Unsafe(f"Resolved skill must be a directory containing a regular SKILL.md file: {key}")
204
+ if key == "AGENTS.md":
205
+ if result["kind"] != "file":
206
+ raise Unsafe("Shared instructions must be a regular file")
207
+ for name in ("codex", "pi"):
208
+ if name in self.agents and exists(self.home / self.agents[name][0] / "AGENTS.override.md"):
209
+ raise Unsafe("Instruction override appeared; reconcile/remove it before continuing")
210
+ target = self.root / key
211
+ current = snapshot(target, True)
212
+ canonical = result
213
+ if current is not None and same_content(current, result):
214
+ canonical = snapshot(target)
215
+ elif current is None:
216
+ # Preserve an external source link only when the complete selected tree matches.
217
+ discovery = [self.home / spec[0] for spec in AGENTS.values()]
218
+ for _, path in sources:
219
+ if path.is_symlink() and same_content(snapshot(path, True), result):
220
+ external = path.resolve()
221
+ excluded = [self.root, *(discovery), self.home / ".agents"]
222
+ if self.old_root:
223
+ excluded.append(self.old_root)
224
+ if not any(external == p or external.is_relative_to(p) for p in excluded):
225
+ canonical = link_node(external)
226
+ break
227
+ operations = []
228
+ for path, node in [(target, canonical), *[(p, link_node(target)) for p in self.destinations(key, sources)]]:
229
+ # A whole-catalog link already pointing at this root needs no child writes.
230
+ if path != target and path.parent.is_symlink() and path.parent.resolve() == target.parent:
231
+ continue
232
+ # A dry run has planned, but not performed, catalog normalization.
233
+ guarded_parent(path.parent if dry_run and key.startswith("skills/") and path != target
234
+ and path.parent.is_symlink() and path.parent.is_dir() else path)
235
+ before = snapshot(path)
236
+ if before != node:
237
+ operations.append({"path": str(path), "before": before, "after": node})
238
+ if not operations and self.state["baselines"].get(key) == result:
239
+ return False
240
+ if dry_run:
241
+ return True
242
+ self.state["applying"] = {"unit": key, "result": result, "operations": operations}
243
+ self.save()
244
+ self.resume_apply()
245
+ return True
246
+
247
+ def resume_apply(self):
248
+ self.tx.recover()
249
+ pending = self.state.get("applying")
250
+ if not pending:
251
+ return
252
+ for operation in pending["operations"]:
253
+ path = Path(operation["path"])
254
+ current = snapshot(path)
255
+ if current == operation["after"]:
256
+ continue
257
+ if current != operation["before"]:
258
+ raise Unsafe(f"Newer changes block recovery at {path}; backups retained")
259
+ self.tx.replace(path, operation["after"])
260
+ self.state["baselines"][pending["unit"]] = pending["result"]
261
+ self.state["conflicts"].pop(digest(pending["unit"])[:12], None)
262
+ del self.state["applying"]
263
+ self.save()
264
+
265
+ def sync(self, dry_run=False):
266
+ if not dry_run:
267
+ self.resume_apply()
268
+ units, warnings, issues = self.inventories()
269
+ report = {"root": str(self.root), "changed": [], "conflicts": [], "issues": issues, "warnings": warnings,
270
+ "backups": str(self.meta / "backups"), "dry_run": dry_run}
271
+ inspected = []
272
+ # Inventory and read every unit before any user-file mutation.
273
+ for key, sources in sorted(units.items()):
274
+ try:
275
+ inspected.append((key, self.inspect_unit(key, sources)))
276
+ except (OSError, RuntimeError, ValueError) as exc:
277
+ issues.append({"path": key, "reason": str(exc)})
278
+ if not dry_run:
279
+ self.meta.mkdir(mode=0o700, parents=True, exist_ok=True)
280
+ self.route_pending()
281
+ normalized = self.normalize_catalogs(units, dry_run=dry_run)
282
+ if normalized:
283
+ if not dry_run:
284
+ # Only topology changed: all old targets remain active via child links.
285
+ inspected = [(key, self.inspect_unit(key, data[0])) for key, data in inspected]
286
+ report["changed"].extend(normalized)
287
+ for key, (sources, captured, variants, result, pending) in inspected:
288
+ try:
289
+ old = self.state["conflicts"].get(digest(key)[:12])
290
+ if pending or old:
291
+ if dry_run:
292
+ report["conflicts"].append({"id": digest(key)[:12], "unit": key,
293
+ "sources": captured, "conflicting_paths": pending})
294
+ else:
295
+ report["conflicts"].append(self.conflict(key, captured, variants, result, pending))
296
+ continue
297
+ if any(fingerprint(Path(s["path"])) != s["fingerprint"] for s in captured.values()):
298
+ raise Unsafe(f"Source changed during inventory: {key}; rerun sync")
299
+ if self.publish(key, sources, result, dry_run=dry_run):
300
+ report["changed"].append(key)
301
+ except (OSError, RuntimeError, ValueError) as exc:
302
+ if self.state.get("applying"):
303
+ raise # Do not overwrite an interrupted publication with another unit.
304
+ issues.append({"path": key, "reason": str(exc)})
305
+ reported = {c["id"] for c in report["conflicts"]}
306
+ report["conflicts"].extend(c for identifier, c in self.state["conflicts"].items() if identifier not in reported)
307
+ if not dry_run and not report["conflicts"] and not issues:
308
+ self.finish_config()
309
+ return report
310
+
311
+ def normalize_catalogs(self, units, dry_run=False):
312
+ """Replace whole-catalog links with per-entry links without touching targets."""
313
+ catalogs = set()
314
+ for key, sources in units.items():
315
+ if key.startswith("skills/"):
316
+ catalogs.update(p.parent for p in self.destinations(key, sources))
317
+ changed = []
318
+ for catalog in sorted(catalogs):
319
+ if not catalog.is_symlink() or not catalog.is_dir():
320
+ continue
321
+ if catalog.resolve() == self.root / "skills":
322
+ continue
323
+ node = {"kind": "dir", "mode": catalog.stat().st_mode & 0o777,
324
+ "entries": {p.name: link_node(p.resolve()) for p in catalog.iterdir()}}
325
+ # Validate the old tree before converting its discovery entry.
326
+ snapshot(catalog, True)
327
+ guarded_parent(catalog)
328
+ if not dry_run:
329
+ self.tx.replace(catalog, node)
330
+ changed.append(str(catalog))
331
+ return changed
332
+
333
+ def resolve(self, identifier, source=None, file=None):
334
+ self.resume_apply()
335
+ record = self.state["conflicts"].get(identifier)
336
+ if not record:
337
+ raise Unsafe(f"Unknown conflict: {identifier}")
338
+ if source is not None:
339
+ if source not in record["variants"]:
340
+ raise Unsafe("Choose a source from: " + ", ".join(record["variants"]))
341
+ path = Path(record["variants"][source])
342
+ else:
343
+ path = Path(file).expanduser().absolute()
344
+ node = snapshot(path, True)
345
+ if node is None or has_markers(node):
346
+ raise Unsafe("Resolution missing or still contains conflict markers")
347
+ record["resolution"] = node
348
+ self.save()
349
+ return {"id": identifier, "staged": True, "next": "agentic continue"}
350
+
351
+ def continue_(self):
352
+ self.resume_apply()
353
+ for identifier, record in list(self.state["conflicts"].items()):
354
+ if record["resolution"] is None:
355
+ continue
356
+ units, _, _ = self.inventories()
357
+ sources = list(units.get(record["unit"], []))
358
+ sources.extend((label, Path(s["path"])) for label, s in record["sources"].items()
359
+ if not any(str(p) == s["path"] for _, p in sources))
360
+ captured = self.inspect_unit(record["unit"], sources)[1]
361
+ # Compare paths, not labels: absent destinations acquire agent labels on discovery.
362
+ before = {s["path"]: s["fingerprint"] for s in record["sources"].values()}
363
+ now = {s["path"]: s["fingerprint"] for s in captured.values()}
364
+ if now != before:
365
+ record["resolution"] = None
366
+ self.save()
367
+ continue # sync refreshes source snapshots and the working copy.
368
+ self.publish(record["unit"], sources, record["resolution"])
369
+ return self.sync()
370
+
371
+ def status(self):
372
+ units, warnings, issues = self.inventories()
373
+ links = []
374
+ for key, sources in sorted(units.items()):
375
+ for path in self.destinations(key, sources):
376
+ links.append({"path": str(path), "target": str(self.root / key),
377
+ "linked": path.is_symlink() and path.exists() and path.resolve() == (self.root / key).resolve()})
378
+ return {"root": str(self.root), "conflicts": list(self.state["conflicts"].values()),
379
+ "links": links, "warnings": warnings, "issues": issues,
380
+ "interrupted": bool(self.state.get("applying") or self.tx.journal.exists()),
381
+ "backups": str(self.meta / "backups")}
@@ -0,0 +1,83 @@
1
+ """Conservative tree merging: no invented ancestors or source precedence."""
2
+ import base64
3
+ import copy
4
+ import subprocess
5
+ import tempfile
6
+ from pathlib import Path
7
+
8
+ from .storage import Unsafe, same_content
9
+
10
+
11
+ def text(node):
12
+ if node is None or node["kind"] != "file":
13
+ return None
14
+ data = base64.b64decode(node["data"])
15
+ try:
16
+ result = data.decode("utf-8")
17
+ except UnicodeDecodeError:
18
+ return None
19
+ return None if "\0" in result else result
20
+
21
+
22
+ def file_node(value, mode=0o644):
23
+ return {"kind": "file", "mode": mode,
24
+ "data": base64.b64encode(value.encode()).decode()}
25
+
26
+
27
+ def merge(variants, base=None, relative=""):
28
+ """Return a complete working tree and unresolved relative paths."""
29
+ unique = []
30
+ for label, node in variants:
31
+ if node is not None and not any(same_content(node, old) for _, old in unique):
32
+ unique.append((label, node))
33
+ if not unique:
34
+ return copy.deepcopy(base), []
35
+ if all(node["kind"] == "dir" for _, node in unique):
36
+ entries = {}
37
+ conflicts = []
38
+ base_entries = base["entries"] if base and base["kind"] == "dir" else {}
39
+ names = set(base_entries)
40
+ for _, node in unique:
41
+ names.update(node["entries"])
42
+ for name in sorted(names):
43
+ entries[name], pending = merge(
44
+ [(label, node["entries"].get(name)) for label, node in unique],
45
+ base_entries.get(name), f"{relative}/{name}".lstrip("/"))
46
+ conflicts.extend(pending)
47
+ return {"kind": "dir", "mode": unique[0][1]["mode"], "entries": entries}, conflicts
48
+ if len(unique) == 1:
49
+ return copy.deepcopy(unique[0][1]), []
50
+ if base is not None:
51
+ changed = [(label, node) for label, node in unique if not same_content(node, base)]
52
+ if len(changed) == 1:
53
+ return copy.deepcopy(changed[0][1]), []
54
+ unique = changed
55
+ if len(unique) == 2 and base is not None and text(base) is not None and all(text(n) is not None for _, n in unique):
56
+ with tempfile.TemporaryDirectory(prefix="agentic-merge-") as directory:
57
+ paths = [Path(directory) / str(i) for i in range(3)]
58
+ for path, node in zip(paths, (unique[0][1], base, unique[1][1])):
59
+ path.write_bytes(base64.b64decode(node["data"]))
60
+ result = subprocess.run(
61
+ ["git", "merge-file", "-p", "-L", unique[0][0], "-L", "baseline", "-L", unique[1][0],
62
+ *map(str, paths)], capture_output=True)
63
+ if result.returncode < 0 or result.returncode > 127:
64
+ raise Unsafe(result.stderr.decode(errors="replace") or "git merge-file failed")
65
+ node = {"kind": "file", "mode": unique[0][1]["mode"],
66
+ "data": base64.b64encode(result.stdout).decode()}
67
+ return node, [relative or "."] if result.returncode else []
68
+ if all(text(n) is not None for _, n in unique):
69
+ parts = []
70
+ for i, (label, node) in enumerate(unique):
71
+ parts.append(("<<<<<<< " if i == 0 else "======= ") + label + "\n")
72
+ value = text(node)
73
+ parts.append(value + ("" if value.endswith("\n") else "\n"))
74
+ parts.append(">>>>>>> resolve all variants\n")
75
+ return file_node("".join(parts), unique[0][1]["mode"]), [relative or "."]
76
+ return copy.deepcopy(unique[0][1]), [relative or "."]
77
+
78
+
79
+ def has_markers(node):
80
+ if node["kind"] == "dir":
81
+ return any(has_markers(child) for child in node["entries"].values())
82
+ value = text(node)
83
+ return value is not None and any(line.startswith(("<<<<<<<", "=======", ">>>>>>>")) for line in value.splitlines())
@@ -0,0 +1,176 @@
1
+ """Lossless snapshots and journaled, backed-up filesystem replacements."""
2
+ import base64
3
+ import contextlib
4
+ import fcntl
5
+ import hashlib
6
+ import json
7
+ import os
8
+ from pathlib import Path
9
+ import shutil
10
+ import stat
11
+ import tempfile
12
+ import uuid
13
+
14
+
15
+ class Unsafe(ValueError):
16
+ pass
17
+
18
+
19
+ def exists(path):
20
+ return path.exists() or path.is_symlink()
21
+
22
+
23
+ # ponytail: full snapshots suit small skill trees; use content-addressed blobs if storage becomes material.
24
+ def snapshot(path, follow=False, ancestors=()):
25
+ path = Path(path)
26
+ if not exists(path):
27
+ return None
28
+ mode = path.lstat().st_mode
29
+ if stat.S_ISLNK(mode):
30
+ try:
31
+ target = path.resolve(strict=True)
32
+ except (OSError, RuntimeError) as exc:
33
+ raise Unsafe(f"Broken or cyclic link: {path}") from exc
34
+ if target in ancestors:
35
+ raise Unsafe(f"Cyclic link: {path}")
36
+ if follow:
37
+ return snapshot(target, True, ancestors)
38
+ return {"kind": "link", "target": os.readlink(path)}
39
+ if stat.S_ISREG(mode):
40
+ return {"kind": "file", "mode": stat.S_IMODE(mode),
41
+ "data": base64.b64encode(path.read_bytes()).decode()}
42
+ if stat.S_ISDIR(mode):
43
+ real = path.resolve()
44
+ if real in ancestors:
45
+ raise Unsafe(f"Cyclic directory: {path}")
46
+ return {"kind": "dir", "mode": stat.S_IMODE(mode), "entries": {
47
+ p.name: snapshot(p, follow, (*ancestors, real))
48
+ for p in sorted(path.iterdir())}}
49
+ raise Unsafe(f"Special file is not supported: {path}")
50
+
51
+
52
+ def digest(node):
53
+ return hashlib.sha256(json.dumps(node, sort_keys=True).encode()).hexdigest()
54
+
55
+
56
+ def same_content(a, b):
57
+ # Permissions are retained from the selected source; content decides conflicts.
58
+ def clean(n):
59
+ if n is None:
60
+ return None
61
+ result = {k: v for k, v in n.items() if k != "mode"}
62
+ if n["kind"] == "dir":
63
+ result["entries"] = {name: clean(child) for name, child in n["entries"].items()}
64
+ return result
65
+ return clean(a) == clean(b)
66
+
67
+
68
+ def materialize(path, node):
69
+ if node["kind"] == "file":
70
+ path.write_bytes(base64.b64decode(node["data"]))
71
+ path.chmod(node["mode"])
72
+ elif node["kind"] == "link":
73
+ path.symlink_to(node["target"])
74
+ elif node["kind"] == "dir":
75
+ path.mkdir()
76
+ for name, child in node["entries"].items():
77
+ if name in (".", "..") or "/" in name or not name:
78
+ raise Unsafe(f"Unsafe snapshot entry: {name!r}")
79
+ materialize(path / name, child)
80
+ path.chmod(node["mode"])
81
+ else:
82
+ raise Unsafe("Invalid snapshot kind")
83
+
84
+
85
+ def remove(path):
86
+ if path.is_dir() and not path.is_symlink():
87
+ shutil.rmtree(path)
88
+ elif exists(path):
89
+ path.unlink()
90
+
91
+
92
+ def atomic_json(path, value):
93
+ guarded_parent(path)
94
+ path.parent.mkdir(parents=True, exist_ok=True)
95
+ fd, name = tempfile.mkstemp(prefix=".agentic-", dir=path.parent)
96
+ try:
97
+ with os.fdopen(fd, "w") as stream:
98
+ json.dump(value, stream, indent=2, sort_keys=True)
99
+ stream.write("\n")
100
+ stream.flush()
101
+ os.fsync(stream.fileno())
102
+ os.replace(name, path)
103
+ finally:
104
+ if os.path.lexists(name):
105
+ os.unlink(name)
106
+
107
+
108
+ def load_json(path, default):
109
+ return json.loads(path.read_text()) if path.exists() else default
110
+
111
+
112
+ @contextlib.contextmanager
113
+ def lock(config_dir):
114
+ config_dir.mkdir(parents=True, exist_ok=True)
115
+ path = config_dir / "lock"
116
+ fd = os.open(path, os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600)
117
+ with os.fdopen(fd, "w") as stream:
118
+ try:
119
+ fcntl.flock(stream, fcntl.LOCK_EX | fcntl.LOCK_NB)
120
+ except BlockingIOError as exc:
121
+ raise Unsafe("Another agentic operation is running") from exc
122
+ yield
123
+
124
+
125
+ def guarded_parent(path):
126
+ """Do not write through an external directory symlink."""
127
+ for parent in path.parents:
128
+ if parent.is_symlink():
129
+ raise Unsafe(f"Refusing to write through directory symlink: {parent}")
130
+
131
+
132
+ class Transaction:
133
+ def __init__(self, metadata):
134
+ self.metadata = metadata
135
+ self.journal = metadata / "journal.json"
136
+
137
+ def recover(self):
138
+ op = load_json(self.journal, None)
139
+ if op is None:
140
+ return
141
+ path = Path(op["path"])
142
+ guarded_parent(path)
143
+ current = snapshot(path)
144
+ if current == op["after"]:
145
+ self.journal.unlink()
146
+ return
147
+ if current != op["before"] and not (current is None and op["phase"] == "install"):
148
+ raise Unsafe(f"Interrupted operation has newer changes at {path}; backup: {op['backup']}")
149
+ backup = Path(op["backup"])
150
+ if not backup.exists():
151
+ atomic_json(backup, {"path": str(path), "original": op["before"]})
152
+ temp = Path(op["temporary"])
153
+ remove(temp)
154
+ materialize(temp, op["after"])
155
+ op["phase"] = "install"
156
+ atomic_json(self.journal, op)
157
+ if (path.is_dir() and not path.is_symlink()) or (op["after"]["kind"] == "dir" and exists(path)):
158
+ # POSIX cannot atomically replace directories across node types.
159
+ # The journal + retained snapshot make this short gap recoverable.
160
+ remove(path)
161
+ os.replace(temp, path)
162
+ self.journal.unlink()
163
+
164
+ def replace(self, path, node):
165
+ self.recover()
166
+ guarded_parent(path)
167
+ before = snapshot(path)
168
+ if before == node:
169
+ return
170
+ path.parent.mkdir(parents=True, exist_ok=True)
171
+ token = uuid.uuid4().hex
172
+ op = {"path": str(path), "before": before, "after": node,
173
+ "phase": "prepare", "backup": str(self.metadata / "backups" / f"{token}.json"),
174
+ "temporary": str(path.parent / f".agentic-{token}")}
175
+ atomic_json(self.journal, op)
176
+ self.recover()
@@ -0,0 +1,23 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ const { spawnSync } = require('node:child_process');
4
+ const path = require('node:path');
5
+
6
+ // Use an installed interpreter; never download runtimes or run pip during install.
7
+ const candidates = process.env.AGENTIC_PYTHON
8
+ ? [process.env.AGENTIC_PYTHON]
9
+ : ['python3', 'python3.14', 'python3.13', 'python3.12', 'python3.11', 'python'];
10
+ const python = candidates.find(command => spawnSync(command, [
11
+ '-I', '-c', 'import sys; sys.exit(0 if sys.version_info >= (3, 11) else 1)'
12
+ ], { stdio: 'ignore' }).status === 0);
13
+ if (!python) {
14
+ console.error('agentic requires Python 3.11+. Install it or set AGENTIC_PYTHON to its executable path.');
15
+ process.exit(1);
16
+ }
17
+ const result = spawnSync(python, ['-I', '-c',
18
+ 'import sys; sys.path.insert(0, sys.argv.pop(1)); from agentic_cli.cli import main; sys.exit(main())',
19
+ path.resolve(__dirname, '..'), ...process.argv.slice(2)
20
+ ], { stdio: 'inherit' });
21
+ if (result.error) console.error(`agentic: ${result.error.message}`);
22
+ if (result.signal) process.kill(process.pid, result.signal);
23
+ else process.exit(result.status ?? 1);
package/package.json ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "name": "agentic-home-cli",
3
+ "version": "0.1.0",
4
+ "description": "Consolidate global agent skills and instructions with safe, resumable conflicts",
5
+ "license": "UNLICENSED",
6
+ "bin": { "agentic": "bin/agentic.cjs" },
7
+ "files": ["bin/agentic.cjs", "agentic_cli/*.py", "README.md"],
8
+ "engines": { "node": ">=18" },
9
+ "os": ["darwin", "linux"],
10
+ "publishConfig": { "access": "public" },
11
+ "scripts": { "test": "node --test tests/npm-launcher.cjs" },
12
+ "keywords": ["ai", "agents", "skills", "claude", "codex", "cursor", "pi"]
13
+ }