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 +161 -0
- package/agentic_cli/__init__.py +1 -0
- package/agentic_cli/cli.py +106 -0
- package/agentic_cli/core.py +381 -0
- package/agentic_cli/merge.py +83 -0
- package/agentic_cli/storage.py +176 -0
- package/bin/agentic.cjs +23 -0
- package/package.json +13 -0
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()
|
package/bin/agentic.cjs
ADDED
|
@@ -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
|
+
}
|