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 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 each doc set, one `.md` file or a folder with an `index.md`, covers a topic agents read about only when it's relevant:
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 set in one file
36
- └── typescript/
37
- ├── index.md # a doc set's entry point
38
- └── react.md # linked from index.md
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 set starts with front matter. `when` becomes its row in the table, and `tier: required` installs it in every repo; without it, the set is optional and each repo chooses it with `--docs`.
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 doc sets 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.
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 each in a folder with a `when:` line, and install the folder as a pack read from the working tree:
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. Resync after adding a doc or changing a `when:` line.
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 docs from team-rules"
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's optional doc sets (`''` for none) |
119
- | `list` | Lists each pack and its doc sets, marking the installed ones |
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 synced docs. If either was edited in the repo, the sync stops and says what to fix: change the pack in its origin, then `update`.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "context-packs",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Sync shared agent rules and docs into each repo's AGENTS.md",
5
5
  "license": "MIT",
6
6
  "bin": {
package/src/blocks.py CHANGED
@@ -1,4 +1,4 @@
1
- """The context-packs blocks in AGENTS.md, and the doc sets each pack installed beside them."""
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
- "and a table of its context docs. Before starting work that a row in a pack's table matches, read its doc. "
15
- f"A pack's block, and any of its docs under `{PACKS.as_posix()}/`, are copied from the `from` and `path` in the "
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, and the doc sets
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
- return Pack(name, origin, fields.get('path', ''), fields.get('ref'))
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 copied:
106
- sys.exit(f'context-packs: {(PACKS / entry.name).as_posix()} has no block in {agents_md} for a pack '
107
- 'copied from another repo. Remove it, then sync again.')
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) if name in copied else b'') != sha:
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 required doc sets and the ones the
4
- repository chose in docs/agents/packs/<pack>/."""
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, Source, fetch
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, and the optional doc sets each should have, once the command has run."""
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
- if next(p for p in packs if p.name == args.name).local:
53
- sys.exit(f'context-packs: the {args.name} pack is in this repo, so it installs every doc set it has. '
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
- return packs, picked
70
-
71
-
72
- def show(sources, picked):
73
- for source in sources:
74
- pack = source.pack
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
- for name in source.available():
77
- fields = source.front(name)
78
- if pack.local:
79
- print(f' [x] {name}: {fields["when"]}')
80
- elif fields.get('tier') == 'required':
81
- print(f' [x] {name} (required): {fields["when"]}')
82
- else:
83
- print(f' [{"x" if name in picked[pack.name] else " "}] {name}: {fields["when"]}')
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 doc sets to install')
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 doc sets")
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 doc sets, or '' for none")
108
- commands.add_parser('list', help='list the packs and their doc sets, marking the installed ones')
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, picked = apply(args, found)
122
+ packs = apply(args, found)
116
123
 
117
124
  with contextlib.ExitStack() as stack:
118
- sources = [Source(p, fetch(repo, p, stack)) for p in packs]
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(sources, picked)
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
- chosen = {}
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 source in sources:
145
- if source.pack.local:
146
- blocks[source.pack.name] = block(starts[source.pack.name], bodies[source.pack.name])
147
- continue
148
- synced = repo / source.pack.synced
149
- if synced.exists():
150
- shutil.rmtree(synced)
151
- for name in chosen[source.pack.name]:
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, chosen, blocks, created, linked):
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: a folder in a git repo pinned to a commit, or a folder in the synced repo itself."""
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 doc sets sit in the synced repo: copied into its own folder, or where they are for a
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, chosen, blocks, created, linked):
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 chosen holding the doc sets each pack installs
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 = chosen[pack.name]
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 not pack.local and found.installed[pack.name] != chosen[pack.name]:
37
- lines.append(f'{pack.name}: doc sets {sets(found.installed[pack.name])} → {sets(chosen[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')