context-packs 0.2.0 → 0.3.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 +36 -15
- package/package.json +1 -1
- package/src/blocks.py +14 -34
- package/src/cli.py +54 -57
- package/src/packs.py +6 -68
- package/src/report.py +5 -7
- package/src/tree.py +176 -0
package/README.md
CHANGED
|
@@ -27,26 +27,47 @@ Run it with `npx context-packs`, or [let your agent run it](#let-your-agent-run-
|
|
|
27
27
|
|
|
28
28
|
## Write a pack
|
|
29
29
|
|
|
30
|
-
A pack is a folder in any git repo. `pack.md` holds the rules every session sees, and
|
|
30
|
+
A pack is a folder in any git repo. `pack.md` holds the rules every session sees, and every other `.md` file is a doc agents read only when it's relevant. A folder holding a `pack.md` of its own is a nested pack, and a folder without one just groups files:
|
|
31
31
|
|
|
32
32
|
```text
|
|
33
33
|
context/
|
|
34
34
|
├── pack.md # rules pasted into every repo's AGENTS.md
|
|
35
|
-
├── testing.md # a doc
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
35
|
+
├── testing.md # a doc
|
|
36
|
+
├── git/
|
|
37
|
+
│ └── prs.md # a doc, grouped in a folder
|
|
38
|
+
└── typescript/ # a nested pack
|
|
39
|
+
├── pack.md
|
|
40
|
+
├── types.md
|
|
41
|
+
└── react/ # a nested pack inside it
|
|
42
|
+
└── pack.md
|
|
39
43
|
```
|
|
40
44
|
|
|
41
|
-
Each doc
|
|
45
|
+
Each doc starts with front matter whose `when` becomes its row in the table:
|
|
42
46
|
|
|
43
47
|
```markdown
|
|
44
48
|
---
|
|
45
49
|
when: Writing or changing tests
|
|
46
|
-
tier: required
|
|
47
50
|
---
|
|
48
51
|
```
|
|
49
52
|
|
|
53
|
+
A nested pack gets a row too, from the `when` in its `pack.md`, pointing at a generated index that holds its rules and a table of its own docs and nested packs. Each nested pack is a read an agent makes before reaching the docs inside it, so nest only where a subtree is big and its `when` is sharp, and use plain folders to group the rest. Files other than `.md` files, such as code examples, are copied along with the docs beside them.
|
|
54
|
+
|
|
55
|
+
A nested pack's `pack.md` takes two more fields:
|
|
56
|
+
|
|
57
|
+
- `optional: true` leaves it out unless a repo picks it with `--docs`. Without it, the nested pack is installed whenever its parent is.
|
|
58
|
+
- `inline: true` pastes its rules and table after its parent's instead of giving it a row, for rules that apply whenever a repo has the nested pack, like a language's. An inline pack that isn't optional needs no `when`.
|
|
59
|
+
|
|
60
|
+
```markdown
|
|
61
|
+
---
|
|
62
|
+
when: Working in a TypeScript repo
|
|
63
|
+
optional: true
|
|
64
|
+
inline: true
|
|
65
|
+
---
|
|
66
|
+
Prefer unions to enums.
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Any folder with a `pack.md` can also be installed on its own, as its own pack, and it renders the way it would inside its parent. If its `pack.md` has a `when` and isn't inline, its block in `AGENTS.md` is a one-row table pointing at a generated index of its rules, so installing just `typescript` still loads its rules only when a row matches. Without a `when`, or with `inline: true`, its rules are pasted into the block. `optional` doesn't apply, since installing it is the pick.
|
|
70
|
+
|
|
50
71
|
Keep `pack.md` short, since every session loads it.
|
|
51
72
|
|
|
52
73
|
Tags are optional. A repo pins to a commit by default, and `update` moves it to the tip of your default branch. Tag releases like `v1.2.0` once you want repos to pin to versions and `update` from one release to the next.
|
|
@@ -61,13 +82,13 @@ npx context-packs add <name> <git-url> --path <folder>
|
|
|
61
82
|
- `<git-url>` is the repo holding the pack, as you'd pass it to `git clone`.
|
|
62
83
|
- `<folder>` is the pack's folder in that repo, such as `context`. Leave out `--path` if the pack is the whole repo.
|
|
63
84
|
|
|
64
|
-
That adds the pack's block to `AGENTS.md`, creating it if needed, and copies the
|
|
85
|
+
That adds the pack's block to `AGENTS.md`, creating it if needed, and copies the docs into `docs/agents/packs/<name>/`, pinned to the origin's latest commit or to `--ref <tag or commit>`. Commit the result. If `AGENTS.md` already exists, the first run prints two marker lines to paste where the shared rules should go; then run it again.
|
|
65
86
|
|
|
66
87
|
Later, `npx context-packs update` moves packs to their latest version, and `npx context-packs` resyncs at the pinned ones. Private repos work, since origins are fetched with your own git credentials.
|
|
67
88
|
|
|
68
89
|
## Sync your project's own docs
|
|
69
90
|
|
|
70
|
-
The same table works for a repo's own docs. Put
|
|
91
|
+
The same table works for a repo's own docs. Put them in a folder, each with a `when:` line, and install the folder as a pack read from the working tree:
|
|
71
92
|
|
|
72
93
|
```bash
|
|
73
94
|
npx context-packs add <name> . --path <folder>
|
|
@@ -83,7 +104,7 @@ Any name and folder work. We suggest `project` and `docs/agents/project`:
|
|
|
83
104
|
npx context-packs add project . --path docs/agents/project
|
|
84
105
|
```
|
|
85
106
|
|
|
86
|
-
Its table points at the docs where they are
|
|
107
|
+
Its table points at the docs where they are, and only its nested packs' indexes are generated, into `docs/agents/packs/<name>/`. Resync after adding a doc or changing a `when:` line or a nested pack's rules.
|
|
87
108
|
|
|
88
109
|
## Let your agent run it
|
|
89
110
|
|
|
@@ -99,7 +120,7 @@ Then ask things like:
|
|
|
99
120
|
|
|
100
121
|
- "Add the context pack in the `context` folder of github.com/acme/agent-rules"
|
|
101
122
|
- "Update our context packs"
|
|
102
|
-
- "Install the typescript
|
|
123
|
+
- "Install the typescript pack from team-rules"
|
|
103
124
|
- "Sync our project docs"
|
|
104
125
|
|
|
105
126
|
In Claude Code, `/context-packs` invokes it directly.
|
|
@@ -111,15 +132,15 @@ Each command syncs the current folder, or `--dir <folder>`, and prints what chan
|
|
|
111
132
|
| Command | Does |
|
|
112
133
|
|---|---|
|
|
113
134
|
| *(none)* | Resyncs every pack at its pinned ref |
|
|
114
|
-
| `add <name> <origin> [--path P] [--ref R] [--docs a,b]` | Installs a pack, pinned to the origin's latest commit or to `--ref` |
|
|
135
|
+
| `add <name> <origin> [--path P] [--ref R] [--docs a,b]` | Installs a pack, pinned to the origin's latest commit or to `--ref`, with the optional nested packs in `--docs` |
|
|
115
136
|
| `remove <name>` | Uninstalls a pack |
|
|
116
137
|
| `update [name…]` | Moves packs to their latest version |
|
|
117
138
|
| `update <name> --ref R` | Pins one pack to a tag or commit |
|
|
118
|
-
| `docs <name> a,b` | Replaces a pack
|
|
119
|
-
| `list` | Lists each pack and its
|
|
139
|
+
| `docs <name> a,b` | Replaces the optional nested packs a pack installs, each named by its path such as `typescript/react` (`''` for none). Picking one installs the packs above it |
|
|
140
|
+
| `list` | Lists each pack and its nested packs as a tree, marking the installed ones |
|
|
120
141
|
|
|
121
142
|
**Pinning.** `--ref` takes a tag or a commit, never a branch, since a branch moves. `update` moves a pack pinned to a commit to the tip of the default branch, and one pinned to a release tag (a prefix and dot-separated numbers, such as `v1.2.0` or `acme-v3`) to the newest release with the same prefix, skipping pre-releases. Other tags stay put.
|
|
122
143
|
|
|
123
|
-
**Edits stay upstream.** Each block ends with a hash of its text and
|
|
144
|
+
**Edits stay upstream.** Each block ends with a hash of its text and the files under its `docs/agents/packs/<name>/`. If either was edited in the repo, the sync stops and says what to fix: change the pack in its origin, then `update`.
|
|
124
145
|
|
|
125
146
|
**Contributing.** [design.md](docs/agents/project/design.md) explains the choices behind the format. Run the tests with `python3 -m unittest discover tests`.
|
package/package.json
CHANGED
package/src/blocks.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""The context-packs blocks in AGENTS.md, and the
|
|
1
|
+
"""The context-packs blocks in AGENTS.md, and the files each pack wrote beside them."""
|
|
2
2
|
import hashlib
|
|
3
3
|
import re
|
|
4
4
|
import sys
|
|
@@ -11,22 +11,20 @@ HEADER_START = '<!-- context-packs:start header -->\n'
|
|
|
11
11
|
HEADER_RULES = (
|
|
12
12
|
'## Context Packs\n\n'
|
|
13
13
|
'Context packs bring shared rules and docs in from elsewhere. Each pack has a block in this file holding its rules '
|
|
14
|
-
|
|
15
|
-
f"A pack's block, and
|
|
16
|
-
"block's start marker: change them there, never here.\n")
|
|
14
|
+
'and a table of its context docs, and a doc may hold a table of its own. Before starting work that a row in any '
|
|
15
|
+
f"of these tables matches, read its doc. A pack's block, and everything under `{PACKS.as_posix()}/`, are "
|
|
16
|
+
"generated from the `from` and `path` in the block's start marker: change them there, never here.\n")
|
|
17
17
|
BLOCK = re.compile(r'^<!-- context-packs:start (\S+)((?: \S+)*) -->\n(.*?)'
|
|
18
18
|
r'^<!-- context-packs:end sha256:([0-9a-f]{12}) -->\n?', re.MULTILINE | re.DOTALL)
|
|
19
19
|
|
|
20
20
|
|
|
21
21
|
class Region(NamedTuple):
|
|
22
|
-
"""Where AGENTS.md's context-packs blocks sit, each block's text by name, the packs they hold
|
|
23
|
-
each installed."""
|
|
22
|
+
"""Where AGENTS.md's context-packs blocks sit, each block's text by name, and the packs they hold."""
|
|
24
23
|
text: str
|
|
25
24
|
start: int
|
|
26
25
|
end: int
|
|
27
26
|
blocks: Dict[str, str]
|
|
28
27
|
packs: List[Pack]
|
|
29
|
-
installed: Dict[str, List[str]]
|
|
30
28
|
|
|
31
29
|
|
|
32
30
|
def digest(body, docs=b''):
|
|
@@ -45,33 +43,18 @@ def snapshot(folder):
|
|
|
45
43
|
for p in sorted(folder.rglob('*')) if p.is_file())
|
|
46
44
|
|
|
47
45
|
|
|
48
|
-
def installed(repo, folder):
|
|
49
|
-
"""The doc sets copied into folder: each a folder holding an index.md, or a single .md file."""
|
|
50
|
-
if not (repo / folder).is_dir():
|
|
51
|
-
return []
|
|
52
|
-
names = []
|
|
53
|
-
for entry in (repo / folder).iterdir():
|
|
54
|
-
if (entry / 'index.md').is_file():
|
|
55
|
-
names.append(entry.name)
|
|
56
|
-
elif entry.is_file() and entry.suffix == '.md':
|
|
57
|
-
names.append(entry.stem)
|
|
58
|
-
else:
|
|
59
|
-
sys.exit(f'context-packs: {(folder / entry.name).as_posix()} is not a doc set, since it\'s neither a '
|
|
60
|
-
'.md file nor a folder with an index.md. Remove it, then sync again.')
|
|
61
|
-
return sorted(names)
|
|
62
|
-
|
|
63
|
-
|
|
64
46
|
def marked_pack(agents_md, name, marker):
|
|
65
47
|
pairs = [field.split('=', 1) for field in marker.split()]
|
|
66
48
|
fields = dict(p for p in pairs if len(p) == 2)
|
|
67
49
|
origin = fields.get('from')
|
|
68
|
-
if (len(fields) != len(pairs) or set(fields) - {'from', 'path', 'ref'} or not origin
|
|
50
|
+
if (len(fields) != len(pairs) or set(fields) - {'from', 'path', 'ref', 'docs'} or not origin
|
|
69
51
|
or (origin == LOCAL) == ('ref' in fields)):
|
|
70
52
|
sys.exit(f'context-packs: {agents_md}: the {name} block\'s start marker needs from=<git URL> and '
|
|
71
53
|
'ref=<tag or commit>, or from=. for a pack in this repo, plus path=<folder> unless the pack is the '
|
|
72
|
-
'whole repo. '
|
|
54
|
+
'whole repo, and docs=<nested packs> for the optional ones picked. '
|
|
73
55
|
'Fix the marker, then sync again.')
|
|
74
|
-
|
|
56
|
+
docs = tuple(fields['docs'].split(',')) if 'docs' in fields else ()
|
|
57
|
+
return Pack(name, origin, fields.get('path', ''), fields.get('ref'), docs)
|
|
75
58
|
|
|
76
59
|
|
|
77
60
|
def region(repo):
|
|
@@ -99,18 +82,15 @@ def region(repo):
|
|
|
99
82
|
sys.exit(f'context-packs: {agents_md} has text between the context-packs blocks, which must sit together '
|
|
100
83
|
'separated by blank lines. Move it outside them, then sync again.')
|
|
101
84
|
packs = [marked_pack(agents_md, m.group(1), m.group(2)) for m in blocks[1:]]
|
|
102
|
-
copied = {p.name for p in packs if not p.local}
|
|
103
85
|
if (repo / PACKS).is_dir():
|
|
104
86
|
for entry in sorted((repo / PACKS).iterdir()):
|
|
105
|
-
if entry.name not in
|
|
106
|
-
sys.exit(f'context-packs: {(PACKS / entry.name).as_posix()} has no block in {agents_md}
|
|
107
|
-
'
|
|
108
|
-
current = {p.name: installed(repo, p.synced) for p in packs if not p.local}
|
|
87
|
+
if entry.name not in names[1:]:
|
|
88
|
+
sys.exit(f'context-packs: {(PACKS / entry.name).as_posix()} has no block in {agents_md}. '
|
|
89
|
+
'Remove it, then sync again.')
|
|
109
90
|
for match in blocks:
|
|
110
91
|
name, _, synced, sha = match.groups()
|
|
111
|
-
if digest(synced, snapshot(repo / PACKS / name)
|
|
92
|
+
if digest(synced, snapshot(repo / PACKS / name)) != sha:
|
|
112
93
|
sys.exit(f'context-packs: {agents_md}: the {name} block or a doc in {PACKS / name} was edited since '
|
|
113
94
|
'the last sync. Move the change into its context pack or outside them, revert it, '
|
|
114
95
|
'then sync again.')
|
|
115
|
-
return Region(text, blocks[0].start(), blocks[-1].end(), {m.group(1): m.group(0) for m in blocks}, packs
|
|
116
|
-
current)
|
|
96
|
+
return Region(text, blocks[0].start(), blocks[-1].end(), {m.group(1): m.group(0) for m in blocks}, packs)
|
package/src/cli.py
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""Sync context packs into a repository, or any folder such as the one above the repositories: each pack's rules as a
|
|
3
|
-
block in AGENTS.md, after a header block explaining context packs, and each pack's
|
|
4
|
-
|
|
3
|
+
block in AGENTS.md, after a header block explaining context packs, and each pack's docs and the nested packs it
|
|
4
|
+
installs in docs/agents/packs/<pack>/."""
|
|
5
5
|
import argparse
|
|
6
6
|
import contextlib
|
|
7
|
+
import itertools
|
|
7
8
|
import os
|
|
8
|
-
from pathlib import Path
|
|
9
|
+
from pathlib import Path, PurePosixPath
|
|
9
10
|
import re
|
|
10
11
|
import shutil
|
|
11
12
|
import sys
|
|
@@ -13,18 +14,18 @@ import sys
|
|
|
13
14
|
import origins
|
|
14
15
|
import report
|
|
15
16
|
from blocks import HEADER, HEADER_RULES, HEADER_START, block, region, snapshot
|
|
16
|
-
from packs import LOCAL, PACKS, Pack,
|
|
17
|
+
from packs import LOCAL, PACKS, Pack, fetch
|
|
18
|
+
import tree
|
|
17
19
|
|
|
18
20
|
|
|
19
21
|
def sets(text):
|
|
20
|
-
return {n for n in text.split(',') if n}
|
|
22
|
+
return tuple(sorted({n for n in text.split(',') if n}))
|
|
21
23
|
|
|
22
24
|
|
|
23
25
|
def apply(args, found):
|
|
24
|
-
"""The packs,
|
|
26
|
+
"""The packs, each with the optional nested packs it should have, once the command has run."""
|
|
25
27
|
packs = list(found.packs)
|
|
26
28
|
names = [p.name for p in packs]
|
|
27
|
-
picked = {p.name: set(found.installed.get(p.name, [])) for p in packs}
|
|
28
29
|
if args.command == 'add':
|
|
29
30
|
if not re.fullmatch(r'[A-Za-z0-9._-]+', args.name) or args.name == HEADER:
|
|
30
31
|
sys.exit(f'context-packs: {args.name} can\'t name a pack. '
|
|
@@ -34,13 +35,10 @@ def apply(args, found):
|
|
|
34
35
|
if args.origin == LOCAL:
|
|
35
36
|
if args.ref:
|
|
36
37
|
sys.exit('context-packs: a pack in this repo syncs from its working tree, so it takes no --ref.')
|
|
37
|
-
if args.docs:
|
|
38
|
-
sys.exit('context-packs: a pack in this repo installs every doc set it has, so it takes no --docs.')
|
|
39
38
|
ref = None
|
|
40
39
|
else:
|
|
41
40
|
ref = origins.pin(args.origin, args.ref) if args.ref else origins.tip(args.origin)
|
|
42
|
-
packs.append(Pack(args.name, args.origin, args.path, ref))
|
|
43
|
-
picked[args.name] = sets(args.docs)
|
|
41
|
+
packs.append(Pack(args.name, args.origin, args.path, ref, sets(args.docs)))
|
|
44
42
|
named = [args.name] if args.command in ('remove', 'docs') else getattr(args, 'names', [])
|
|
45
43
|
unknown = [n for n in named if n not in names]
|
|
46
44
|
if unknown:
|
|
@@ -49,10 +47,8 @@ def apply(args, found):
|
|
|
49
47
|
if args.command == 'remove':
|
|
50
48
|
packs = [p for p in packs if p.name != args.name]
|
|
51
49
|
if args.command == 'docs':
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
'Delete a doc to drop it.')
|
|
55
|
-
picked[args.name] = sets(args.sets)
|
|
50
|
+
i = names.index(args.name)
|
|
51
|
+
packs[i] = packs[i]._replace(docs=sets(args.sets))
|
|
56
52
|
if args.command == 'update':
|
|
57
53
|
if args.ref and len(named) != 1:
|
|
58
54
|
sys.exit('context-packs: --ref pins one pack, so name exactly one.')
|
|
@@ -66,21 +62,32 @@ def apply(args, found):
|
|
|
66
62
|
continue
|
|
67
63
|
packs[i] = pack._replace(ref=origins.pin(pack.origin, args.ref) if args.ref
|
|
68
64
|
else origins.latest(pack.origin, pack.ref))
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
65
|
+
# The folder inside would sync twice, maybe at another ref. Origins compare as written, so the same repo spelled
|
|
66
|
+
# two ways gets past this.
|
|
67
|
+
for a, b in itertools.combinations(packs, 2):
|
|
68
|
+
outer, inner = sorted((PurePosixPath(a.path).parts, PurePosixPath(b.path).parts), key=len)
|
|
69
|
+
if a.origin == b.origin and inner[:len(outer)] == outer:
|
|
70
|
+
sys.exit(f'context-packs: the {a.name} and {b.name} packs overlap: one\'s folder in {a.origin} holds the '
|
|
71
|
+
'other\'s, so its rules would sync twice. Keep only one of them.')
|
|
72
|
+
return packs
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def show(packs, roots):
|
|
76
|
+
"""Each pack, then its nested packs as a tree, marking the installed ones and why each was installed if it
|
|
77
|
+
wasn't picked."""
|
|
78
|
+
for pack in packs:
|
|
75
79
|
print(f'{pack.name} {pack.origin}' + f' {pack.path}' * bool(pack.path) + f' @ {pack.ref}' * bool(pack.ref))
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
80
|
+
installed = tree.installs(roots[pack.name], pack)
|
|
81
|
+
|
|
82
|
+
def lines(node, depth):
|
|
83
|
+
for child in node.children:
|
|
84
|
+
below = [p for p in pack.docs if p.startswith(f'{child.id}/')]
|
|
85
|
+
why = (f' (needed by {", ".join(below)})' if child.optional and child.id in installed
|
|
86
|
+
and child.id not in pack.docs else '')
|
|
87
|
+
when = f': {child.fields["when"]}' if 'when' in child.fields else ''
|
|
88
|
+
print(f'{" " * depth}[{"x" if child.id in installed else " "}] {child.id}{why}{when}')
|
|
89
|
+
lines(child, depth + 1)
|
|
90
|
+
lines(roots[pack.name], 1)
|
|
84
91
|
|
|
85
92
|
|
|
86
93
|
def main():
|
|
@@ -94,7 +101,7 @@ def main():
|
|
|
94
101
|
add.add_argument('origin', help='a git URL, or . for a folder in the synced repo itself')
|
|
95
102
|
add.add_argument('--path', default='', help='the folder in the origin holding the pack, if not its root')
|
|
96
103
|
add.add_argument('--ref', help='a tag or commit to pin the pack to')
|
|
97
|
-
add.add_argument('--docs', default='', help='comma-separated optional
|
|
104
|
+
add.add_argument('--docs', default='', help='comma-separated optional nested packs to install')
|
|
98
105
|
remove = commands.add_parser('remove', help='uninstall a pack')
|
|
99
106
|
remove.add_argument('name')
|
|
100
107
|
update = commands.add_parser('update', help='move pinned packs on: one pinned to a commit to the tip of its '
|
|
@@ -102,22 +109,23 @@ def main():
|
|
|
102
109
|
'release in its series')
|
|
103
110
|
update.add_argument('names', nargs='*', help='the packs to update; all of them if left out')
|
|
104
111
|
update.add_argument('--ref', help='a tag or commit to pin the one named pack to instead')
|
|
105
|
-
docs = commands.add_parser('docs', help="replace a pack's optional
|
|
112
|
+
docs = commands.add_parser('docs', help="replace a pack's optional nested packs")
|
|
106
113
|
docs.add_argument('name')
|
|
107
|
-
docs.add_argument('sets', help="comma-separated optional
|
|
108
|
-
commands.add_parser('list', help='list the packs and their
|
|
114
|
+
docs.add_argument('sets', help="comma-separated optional nested packs, or '' for none")
|
|
115
|
+
commands.add_parser('list', help='list the packs and their nested packs, marking the installed ones')
|
|
109
116
|
args = parser.parse_args()
|
|
110
117
|
repo = args.dir
|
|
111
118
|
if not repo.is_dir():
|
|
112
119
|
sys.exit(f'context-packs: {repo} is not a folder')
|
|
113
120
|
|
|
114
121
|
found = region(repo)
|
|
115
|
-
packs
|
|
122
|
+
packs = apply(args, found)
|
|
116
123
|
|
|
117
124
|
with contextlib.ExitStack() as stack:
|
|
118
|
-
|
|
125
|
+
folders = {p.name: fetch(repo, p, stack) for p in packs}
|
|
126
|
+
roots = {p.name: tree.load(folders[p.name], p) for p in packs}
|
|
119
127
|
if args.command == 'list':
|
|
120
|
-
show(
|
|
128
|
+
show(packs, roots)
|
|
121
129
|
return
|
|
122
130
|
|
|
123
131
|
claude_md = repo / 'CLAUDE.md'
|
|
@@ -126,31 +134,20 @@ def main():
|
|
|
126
134
|
sys.exit(f'context-packs: {claude_md} must be a symlink to AGENTS.md, so every agent reads the same rules. '
|
|
127
135
|
'Move its contents into AGENTS.md, delete it, then sync again.')
|
|
128
136
|
|
|
129
|
-
|
|
130
|
-
for source in sources:
|
|
131
|
-
name = source.pack.name
|
|
132
|
-
unknown = sorted(n for n in picked[name] if n not in source.available())
|
|
133
|
-
if unknown:
|
|
134
|
-
sys.exit(f'context-packs: {name} has no doc set named {", ".join(unknown)}. '
|
|
135
|
-
f'Available: {", ".join(source.available()) or "none"}. Choose from those with --docs.')
|
|
136
|
-
chosen[name] = source.installs(picked[name])
|
|
137
|
-
bodies = {s.pack.name: s.body(chosen[s.pack.name]) for s in sources}
|
|
138
|
-
starts = {s.pack.name: s.pack.start() for s in sources}
|
|
137
|
+
installed = {p.name: tree.installs(roots[p.name], p) for p in packs}
|
|
139
138
|
|
|
140
139
|
for gone in {p.name for p in found.packs} - {p.name for p in packs}:
|
|
141
140
|
if (repo / PACKS / gone).exists():
|
|
142
141
|
shutil.rmtree(repo / PACKS / gone)
|
|
143
142
|
blocks = {HEADER: block(HEADER_START, HEADER_RULES)}
|
|
144
|
-
for
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
source.copy(name, synced)
|
|
153
|
-
blocks[source.pack.name] = block(starts[source.pack.name], bodies[source.pack.name], snapshot(synced))
|
|
143
|
+
for pack in packs:
|
|
144
|
+
root = roots[pack.name]
|
|
145
|
+
generated = repo / PACKS / pack.name
|
|
146
|
+
if generated.exists():
|
|
147
|
+
shutil.rmtree(generated)
|
|
148
|
+
tree.write(root, pack, installed[pack.name], folders[pack.name], repo)
|
|
149
|
+
blocks[pack.name] = block(pack.start(), tree.body(root, pack, installed[pack.name]),
|
|
150
|
+
snapshot(generated))
|
|
154
151
|
if (repo / PACKS).is_dir() and not any((repo / PACKS).iterdir()):
|
|
155
152
|
(repo / PACKS).rmdir()
|
|
156
153
|
text = found.text[:found.start] + '\n'.join(blocks.values()) + found.text[found.end:]
|
|
@@ -158,7 +155,7 @@ def main():
|
|
|
158
155
|
(repo / 'AGENTS.md').write_bytes(text.encode('utf-8'))
|
|
159
156
|
if not linked:
|
|
160
157
|
claude_md.symlink_to('AGENTS.md')
|
|
161
|
-
for line in report.changes(found, packs,
|
|
158
|
+
for line in report.changes(found, packs, blocks, created, linked):
|
|
162
159
|
print(line)
|
|
163
160
|
|
|
164
161
|
|
package/src/packs.py
CHANGED
|
@@ -1,30 +1,29 @@
|
|
|
1
1
|
"""Context packs: where each comes from, and its content once fetched."""
|
|
2
2
|
from pathlib import Path
|
|
3
3
|
import re
|
|
4
|
-
import shutil
|
|
5
4
|
import sys
|
|
6
5
|
import tempfile
|
|
7
|
-
from typing import NamedTuple, Optional
|
|
6
|
+
from typing import NamedTuple, Optional, Tuple
|
|
8
7
|
|
|
9
8
|
import origins
|
|
10
9
|
|
|
11
10
|
PACKS = Path('docs/agents/packs')
|
|
12
11
|
# A pack whose origin is the synced repo itself, read from its working tree.
|
|
13
12
|
LOCAL = '.'
|
|
14
|
-
FRONT = re.compile(r'---\n(.*?)\n---\n', re.DOTALL)
|
|
15
|
-
TIERS = ('optional', 'required')
|
|
16
13
|
|
|
17
14
|
|
|
18
15
|
class Pack(NamedTuple):
|
|
19
|
-
"""Where a pack comes from
|
|
16
|
+
"""Where a pack comes from, a folder in a git repo pinned to a commit or a folder in the synced repo itself, and
|
|
17
|
+
the optional nested packs the repo picked from it."""
|
|
20
18
|
name: str
|
|
21
19
|
origin: str
|
|
22
20
|
path: str
|
|
23
21
|
ref: Optional[str]
|
|
22
|
+
docs: Tuple[str, ...] = ()
|
|
24
23
|
|
|
25
24
|
def start(self):
|
|
26
25
|
fields = ([f'from={self.origin}'] + [f'path={self.path}'] * bool(self.path)
|
|
27
|
-
+ [f'ref={self.ref}'] * bool(self.ref))
|
|
26
|
+
+ [f'ref={self.ref}'] * bool(self.ref) + [f'docs={",".join(self.docs)}'] * bool(self.docs))
|
|
28
27
|
if any(re.search(r'\s', field) for field in fields):
|
|
29
28
|
sys.exit(f'context-packs: the {self.name} pack\'s {" ".join(fields)} has whitespace, '
|
|
30
29
|
'which its marker can\'t hold.')
|
|
@@ -36,72 +35,11 @@ class Pack(NamedTuple):
|
|
|
36
35
|
|
|
37
36
|
@property
|
|
38
37
|
def synced(self):
|
|
39
|
-
"""Where the pack's
|
|
38
|
+
"""Where the pack's docs sit in the synced repo: copied into its own folder, or where they are for a
|
|
40
39
|
pack in that repo."""
|
|
41
40
|
return Path(self.path) if self.local else PACKS / self.name
|
|
42
41
|
|
|
43
42
|
|
|
44
|
-
class Source(NamedTuple):
|
|
45
|
-
"""A pack's content, fetched into a folder holding its pack.md and doc sets."""
|
|
46
|
-
pack: Pack
|
|
47
|
-
folder: Path
|
|
48
|
-
|
|
49
|
-
def available(self):
|
|
50
|
-
"""The pack's doc sets: each a folder holding an index.md, or a single .md file other than pack.md."""
|
|
51
|
-
if (self.folder / 'agents.md').is_file():
|
|
52
|
-
sys.exit(f'context-packs: {(Path(self.pack.path) / "agents.md").as_posix()} in {self.pack.origin} holds '
|
|
53
|
-
'rules under the name packs used before 0.2.0. Rename it to pack.md in the pack, or run update '
|
|
54
|
-
'to move to a version of the pack that has one.')
|
|
55
|
-
folders = {p.name for p in self.folder.iterdir() if p.is_dir() and not p.name.startswith('.')}
|
|
56
|
-
files = {p.stem for p in self.folder.glob('*.md') if p.name != 'pack.md'}
|
|
57
|
-
for name in sorted(folders & files):
|
|
58
|
-
sys.exit(f'context-packs: {self.pack.origin} has both {name}.md and {name}/ in '
|
|
59
|
-
f'{self.pack.path or "its root"}, so the doc set {name} is ambiguous. Keep one.')
|
|
60
|
-
return sorted(folders | files)
|
|
61
|
-
|
|
62
|
-
def entry(self, name):
|
|
63
|
-
"""A doc set's entry point, relative to the pack's folder."""
|
|
64
|
-
return Path(name, 'index.md') if (self.folder / name).is_dir() else Path(f'{name}.md')
|
|
65
|
-
|
|
66
|
-
def front(self, name):
|
|
67
|
-
"""A doc set's front matter: when to read it, and whether every repo gets it."""
|
|
68
|
-
match = FRONT.match((self.folder / self.entry(name)).read_text(encoding='utf-8'))
|
|
69
|
-
pairs = [line.split(': ', 1) for line in match.group(1).split('\n')] if match else []
|
|
70
|
-
fields = dict(p for p in pairs if len(p) == 2)
|
|
71
|
-
if len(fields) != len(pairs) or 'when' not in fields or fields.get('tier', 'optional') not in TIERS:
|
|
72
|
-
where = (Path(self.pack.path) / self.entry(name)).as_posix()
|
|
73
|
-
sys.exit(f'context-packs: {where} in {self.pack.origin} must start with front matter saying when to '
|
|
74
|
-
'read it, plus `tier: required` if every repo gets it:\n\n---\nwhen: Editing `**/*.ts`\n---')
|
|
75
|
-
return fields
|
|
76
|
-
|
|
77
|
-
def required(self):
|
|
78
|
-
return [n for n in self.available() if self.front(n).get('tier') == 'required']
|
|
79
|
-
|
|
80
|
-
def installs(self, picked):
|
|
81
|
-
"""The doc sets the pack installs: all of a pack in the synced repo, else the picked and required ones."""
|
|
82
|
-
return self.available() if self.pack.local else sorted(set(picked) | set(self.required()))
|
|
83
|
-
|
|
84
|
-
def copy(self, name, synced):
|
|
85
|
-
if (self.folder / name).is_dir():
|
|
86
|
-
shutil.copytree(self.folder / name, synced / name)
|
|
87
|
-
else:
|
|
88
|
-
synced.mkdir(parents=True, exist_ok=True)
|
|
89
|
-
shutil.copy2(self.folder / f'{name}.md', synced / f'{name}.md')
|
|
90
|
-
|
|
91
|
-
def body(self, names):
|
|
92
|
-
rules_file = self.folder / 'pack.md'
|
|
93
|
-
rules = rules_file.read_text(encoding='utf-8') if rules_file.is_file() else ''
|
|
94
|
-
# The end marker has to start its own line, or the next sync can't find the block.
|
|
95
|
-
if rules and not rules.endswith('\n'):
|
|
96
|
-
rules += '\n'
|
|
97
|
-
if not names:
|
|
98
|
-
return rules
|
|
99
|
-
rows = ''.join(f'| {self.front(n)["when"]} | `{(self.pack.synced / self.entry(n)).as_posix()}` |\n'
|
|
100
|
-
for n in names)
|
|
101
|
-
table = f'## {self.pack.name} context\n\n| When | Read |\n|---|---|\n{rows}'
|
|
102
|
-
return f'{rules}\n{table}' if rules else table
|
|
103
|
-
|
|
104
|
-
|
|
105
43
|
def fetch(repo, pack, stack):
|
|
106
44
|
"""The folder holding pack's content, fetched at its ref unless it's in the synced repo."""
|
|
107
45
|
if pack.origin == LOCAL:
|
package/src/report.py
CHANGED
|
@@ -17,10 +17,9 @@ def sets(names):
|
|
|
17
17
|
return ', '.join(names) or 'none'
|
|
18
18
|
|
|
19
19
|
|
|
20
|
-
def changes(found, after,
|
|
20
|
+
def changes(found, after, blocks, created, linked):
|
|
21
21
|
"""Lines describing what the sync did: whether it created AGENTS.md or found CLAUDE.md already linked to it, and
|
|
22
|
-
how the packs found in AGENTS.md became the packs after it, with
|
|
23
|
-
and blocks the text the sync wrote for each."""
|
|
22
|
+
how the packs found in AGENTS.md became the packs after it, with blocks the text the sync wrote for each."""
|
|
24
23
|
before = found.packs
|
|
25
24
|
old = {p.name: p for p in before}
|
|
26
25
|
new = {p.name for p in after}
|
|
@@ -29,12 +28,11 @@ def changes(found, after, chosen, blocks, created, linked):
|
|
|
29
28
|
for pack in after:
|
|
30
29
|
was = old.get(pack.name)
|
|
31
30
|
if not was:
|
|
32
|
-
docs
|
|
33
|
-
lines.append(f'{pack.name}: added{at(pack)}' + f' (docs: {sets(docs)})' * bool(docs))
|
|
31
|
+
lines.append(f'{pack.name}: added{at(pack)}' + f' (docs: {sets(pack.docs)})' * bool(pack.docs))
|
|
34
32
|
elif was.ref != pack.ref:
|
|
35
33
|
lines.append(f'{pack.name}: {short(was.ref)} → {short(pack.ref)}')
|
|
36
|
-
elif
|
|
37
|
-
lines.append(f'{pack.name}:
|
|
34
|
+
elif was.docs != pack.docs:
|
|
35
|
+
lines.append(f'{pack.name}: nested packs {sets(was.docs)} → {sets(pack.docs)}')
|
|
38
36
|
elif found.blocks[pack.name] != blocks[pack.name]:
|
|
39
37
|
lines.append(f'{pack.name}: changed')
|
|
40
38
|
lines += [f'{p.name}: removed' for p in before if p.name not in new]
|
package/src/tree.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""A pack's content as a tree of packs: the installed pack's pack.md and docs, and each folder holding a pack.md of
|
|
2
|
+
its own nested inside it. A folder without a pack.md only groups files, whose docs join the nearest pack's table."""
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import re
|
|
5
|
+
import shutil
|
|
6
|
+
import sys
|
|
7
|
+
from typing import NamedTuple, Tuple
|
|
8
|
+
|
|
9
|
+
from packs import PACKS
|
|
10
|
+
|
|
11
|
+
FRONT = re.compile(r'---\n(.*?)\n---\n', re.DOTALL)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Doc(NamedTuple):
|
|
15
|
+
path: str
|
|
16
|
+
when: str
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Node(NamedTuple):
|
|
20
|
+
"""A pack in the tree. Its id is its folder relative to the installed pack's, '' for the installed pack itself,
|
|
21
|
+
and paths are relative to that folder too. Its files are every one it brings, docs included, but not those of
|
|
22
|
+
the packs nested in it."""
|
|
23
|
+
id: str
|
|
24
|
+
fields: dict
|
|
25
|
+
rules: str
|
|
26
|
+
docs: Tuple[Doc, ...]
|
|
27
|
+
files: Tuple[str, ...]
|
|
28
|
+
children: Tuple['Node', ...]
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def optional(self):
|
|
32
|
+
return 'optional' in self.fields
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def inline(self):
|
|
36
|
+
return 'inline' in self.fields
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def row(self):
|
|
40
|
+
"""Whether the pack gets a row pointing at its index, rather than having its rules and table pasted into its
|
|
41
|
+
parent's, or into the block for the installed pack. Its parent is wherever it's installed, so a pack renders
|
|
42
|
+
the same installed on its own as inside another."""
|
|
43
|
+
return 'when' in self.fields and not self.inline
|
|
44
|
+
|
|
45
|
+
def nested(self):
|
|
46
|
+
"""Every pack nested in this one, at any depth, parents before their children."""
|
|
47
|
+
for child in self.children:
|
|
48
|
+
yield child
|
|
49
|
+
yield from child.nested()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def front(text, where, keys):
|
|
53
|
+
"""text's front matter as fields, keeping only those named in keys, and the text after it."""
|
|
54
|
+
match = FRONT.match(text)
|
|
55
|
+
pairs = [line.split(': ', 1) for line in match.group(1).split('\n')] if match else []
|
|
56
|
+
fields = dict(p for p in pairs if len(p) == 2)
|
|
57
|
+
if len(fields) != len(pairs):
|
|
58
|
+
sys.exit(f'context-packs: {where} has front matter that isn\'t `key: value` lines.')
|
|
59
|
+
unknown = sorted(set(fields) - set(keys))
|
|
60
|
+
if unknown:
|
|
61
|
+
sys.exit(f'context-packs: {where} has front matter context-packs doesn\'t know: {", ".join(unknown)}. '
|
|
62
|
+
'A doc takes only `when`, and a pack.md takes `when`, `optional` and `inline`. Remove it, or update '
|
|
63
|
+
'context-packs if the pack needs a newer version.')
|
|
64
|
+
return fields, text[match.end():] if match else text
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def load(folder, pack, id=''):
|
|
68
|
+
"""The pack in folder/id and the packs nested in it, with folder holding the pack fetched from pack."""
|
|
69
|
+
def where(path):
|
|
70
|
+
return f'{(Path(pack.path) / path).as_posix()} in {pack.origin}'
|
|
71
|
+
|
|
72
|
+
home = Path(id)
|
|
73
|
+
if not id and (folder / 'agents.md').is_file():
|
|
74
|
+
sys.exit(f'context-packs: {where("agents.md")} holds rules under the name packs used before 0.2.0. Rename it '
|
|
75
|
+
'to pack.md in the pack, or run update to move to a version of the pack that has one.')
|
|
76
|
+
pack_md = folder / home / 'pack.md'
|
|
77
|
+
fields, rules = front(pack_md.read_text(encoding='utf-8'), where(home / 'pack.md'),
|
|
78
|
+
('when', 'optional', 'inline')) if pack_md.is_file() else ({}, '')
|
|
79
|
+
for key, without in (('optional', 'installed whenever its parent is'), ('inline', 'that gets a row')):
|
|
80
|
+
if fields.get(key, 'true') != 'true':
|
|
81
|
+
sys.exit(f'context-packs: {where(home / "pack.md")} has {key}: {fields[key]}, but {key} takes only '
|
|
82
|
+
f'true. Leave it out for a pack {without}.')
|
|
83
|
+
# A nested pack's when is its row in its parent's table, or what list shows for an optional one, so only an
|
|
84
|
+
# inline pack that isn't optional goes without. The installed pack needs none: without one, it's pasted in.
|
|
85
|
+
if id and 'when' not in fields and not ('inline' in fields and 'optional' not in fields):
|
|
86
|
+
sys.exit(f'context-packs: {where(home / "pack.md")} must start with front matter saying when to read it, '
|
|
87
|
+
'since it\'s nested in another pack:\n\n---\nwhen: Editing `web/**`\n---')
|
|
88
|
+
# The end marker has to start its own line, or the next sync can't find the block.
|
|
89
|
+
if rules and not rules.endswith('\n'):
|
|
90
|
+
rules += '\n'
|
|
91
|
+
docs, files, children = [], [], []
|
|
92
|
+
|
|
93
|
+
def walk(rel):
|
|
94
|
+
for entry in sorted((folder / rel).iterdir()):
|
|
95
|
+
path = rel / entry.name
|
|
96
|
+
if entry.name.startswith('.') or path == home / 'pack.md':
|
|
97
|
+
continue
|
|
98
|
+
if entry.is_dir() and (entry / 'pack.md').is_file():
|
|
99
|
+
twin = path.parent / f'{entry.name}.md'
|
|
100
|
+
if (folder / twin).is_file():
|
|
101
|
+
sys.exit(f'context-packs: {where(path)} holds a pack.md and sits beside {where(twin)}, so its '
|
|
102
|
+
f'index and that doc would both be {twin.as_posix()}. Rename one.')
|
|
103
|
+
children.append(load(folder, pack, path.as_posix()))
|
|
104
|
+
elif entry.is_dir():
|
|
105
|
+
walk(path)
|
|
106
|
+
else:
|
|
107
|
+
files.append(path.as_posix())
|
|
108
|
+
if entry.suffix == '.md':
|
|
109
|
+
doc_fields, _ = front(entry.read_text(encoding='utf-8'), where(path), ('when',))
|
|
110
|
+
if 'when' not in doc_fields:
|
|
111
|
+
sys.exit(f'context-packs: {where(path)} must start with front matter saying when to read '
|
|
112
|
+
'it:\n\n---\nwhen: Editing `**/*.ts`\n---')
|
|
113
|
+
docs.append(Doc(path.as_posix(), doc_fields['when']))
|
|
114
|
+
|
|
115
|
+
walk(home)
|
|
116
|
+
return Node(id, fields, rules, tuple(docs), tuple(files), tuple(children))
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def installs(root, pack):
|
|
120
|
+
"""The ids of the packs nested in root that a sync installs: each one picked or above a picked one, and each
|
|
121
|
+
one not optional whose parent is installed."""
|
|
122
|
+
nested = {n.id for n in root.nested()}
|
|
123
|
+
unknown = sorted(set(pack.docs) - nested)
|
|
124
|
+
if unknown:
|
|
125
|
+
sys.exit(f'context-packs: {pack.name} has no nested pack named {", ".join(unknown)}. '
|
|
126
|
+
f'Its nested packs: {", ".join(sorted(nested)) or "none"}. Choose from those with --docs.')
|
|
127
|
+
parts = [pick.split('/') for pick in pack.docs]
|
|
128
|
+
wanted = {'/'.join(p[:i]) for p in parts for i in range(1, len(p) + 1)}
|
|
129
|
+
|
|
130
|
+
def visit(node):
|
|
131
|
+
for child in node.children:
|
|
132
|
+
if not child.optional or child.id in wanted:
|
|
133
|
+
yield child.id
|
|
134
|
+
yield from visit(child)
|
|
135
|
+
return set(visit(root))
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def index(node, pack):
|
|
139
|
+
"""Where a pack's generated index sits: pack.md for the installed pack, since no doc can have that name."""
|
|
140
|
+
return PACKS / pack.name / f'{node.id or "pack"}.md'
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def table(title, rows):
|
|
144
|
+
"""A table of (when, path) rows under its heading, or nothing without rows."""
|
|
145
|
+
return (f'## {title} context\n\n| When | Read |\n|---|---|\n'
|
|
146
|
+
+ ''.join(f'| {when} | `{path.as_posix()}` |\n' for when, path in rows)) if rows else ''
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def render(node, pack, installed):
|
|
150
|
+
"""The node's rules, then its table: a row for each of its docs and for each installed pack nested in it that
|
|
151
|
+
gets one. The others are rendered the same way after it."""
|
|
152
|
+
children = [c for c in node.children if c.id in installed]
|
|
153
|
+
rows = sorted([(d.path[:-len('.md')], d.when, pack.synced / d.path) for d in node.docs]
|
|
154
|
+
+ [(c.id, c.fields['when'], index(c, pack)) for c in children if c.row])
|
|
155
|
+
title = f'{pack.name}/{node.id}' if node.id else pack.name
|
|
156
|
+
pasted = [render(c, pack, installed) for c in children if not c.row]
|
|
157
|
+
return '\n'.join(part for part in (node.rules, table(title, [r[1:] for r in rows]), *pasted) if part)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def body(root, pack, installed):
|
|
161
|
+
"""The installed pack's block: a row pointing at its index if it gets one, else its rules and table."""
|
|
162
|
+
return table(pack.name, [(root.fields['when'], index(root, pack))]) if root.row else render(root, pack, installed)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def write(root, pack, installed, source, repo):
|
|
166
|
+
"""Copy the installed packs' files from source into repo, unless the pack is in repo already, and write the
|
|
167
|
+
index of each installed pack that gets a row."""
|
|
168
|
+
for node in [root] + [n for n in root.nested() if n.id in installed]:
|
|
169
|
+
copies = [] if pack.local else [(source / path, repo / pack.synced / path) for path in node.files]
|
|
170
|
+
for origin, target in copies:
|
|
171
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
172
|
+
shutil.copy2(origin, target)
|
|
173
|
+
if node.row:
|
|
174
|
+
target = repo / index(node, pack)
|
|
175
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
176
|
+
target.write_text(render(node, pack, installed), encoding='utf-8')
|