context-packs 0.1.1 → 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
@@ -1,97 +1,146 @@
1
1
  # context-packs
2
2
 
3
- Sync shared agent rules and docs into each repo's `AGENTS.md`.
3
+ Keep your coding agents' rules in one git repo, and sync them into the `AGENTS.md` of every repo that should follow them.
4
4
 
5
- A context pack is a folder in a git repo holding rules every agent session should see, and docs that agents read only for some kinds of work. `context-packs` copies the packs a repo installs into that repo, pinned to a tag or commit, so Claude Code, Codex and any other agent that reads `AGENTS.md` get the same rules. That includes cloud sessions and fresh clones, which see only what's committed.
5
+ Agent rules copied from repo to repo drift apart. context-packs syncs them instead:
6
6
 
7
- ## Install
7
+ - **One source.** Every repo that installs a pack gets the same text, pinned to a tag or commit, and moves on with `update`.
8
+ - **Committed, so every session sees it.** Packs are copied in, not linked or loaded by a hook, so clones, CI and cloud agent sessions get them too.
9
+ - **Read only when relevant.** Long docs don't load into every session: `AGENTS.md` gets a table saying when to read each one.
10
+ - **Any agent.** Anything that reads `AGENTS.md` works, and `CLAUDE.md` is linked to it for Claude Code.
8
11
 
9
- Run it with npx:
12
+ A synced pack in `AGENTS.md`:
10
13
 
11
- ```bash
12
- npx context-packs <command>
13
- ```
14
+ ```markdown
15
+ <!-- context-packs:start team-rules from=https://github.com/acme/agent-rules.git path=context ref=v1.2.0 -->
16
+ Prefer small PRs. Never commit secrets.
14
17
 
15
- It syncs the current folder. Pass `--dir <folder>` before the command to sync another.
18
+ ## team-rules context
19
+
20
+ | When | Read |
21
+ |---|---|
22
+ | Writing or changing tests | `docs/agents/packs/team-rules/testing.md` |
23
+ <!-- context-packs:end sha256:3f2c1aa0b9d4 -->
24
+ ```
16
25
 
17
- It needs `git`, Node 18 or later, and Python 3.9 or later. On macOS, the Xcode command line tools that provide `git` provide Python too.
26
+ Run it with `npx context-packs`, or [let your agent run it](#let-your-agent-run-it). It needs `git`, Node 18+ and Python 3.9+; on macOS, the command line tools provide both `git` and Python.
18
27
 
19
- This repo is also a Claude Code plugin with a `/context-packs` skill, which runs the commands for you. Add it to a plugin marketplace, or install just the skill for Claude Code and Codex with `npx skills add mjewell/context-packs -g -a claude-code -a codex`.
28
+ ## Write a pack
20
29
 
21
- ## Use it
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:
22
31
 
23
- ```bash
24
- npx context-packs add team-rules https://github.com/acme/agent-rules.git --path context
32
+ ```text
33
+ context/
34
+ ├── pack.md # rules pasted into every repo's AGENTS.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
25
43
  ```
26
44
 
27
- That writes two blocks into `AGENTS.md`: a header block explaining context packs, and one for the pack. It also copies the pack's doc sets into `docs/agents/packs/team-rules/`, and creates `CLAUDE.md` as a symlink to `AGENTS.md`, so Claude Code reads the same file. Commit the result.
45
+ Each doc starts with front matter whose `when` becomes its row in the table:
28
46
 
29
- If `AGENTS.md` already exists, the first run prints two marker lines instead. Paste them where the shared rules should go, after the intro and before the project-specific sections, and run it again.
30
-
31
- | Command | Does |
32
- |---|---|
33
- | *(none)* | Resyncs every pack at its pinned ref |
34
- | `add <name> <origin> [--path P] [--ref R] [--docs a,b]` | Installs a pack, pinned to the origin's latest commit or to `--ref` |
35
- | `remove <name>` | Uninstalls a pack |
36
- | `update [name…]` | Moves packs to their latest version |
37
- | `update <name> --ref R` | Pins one pack to a tag or commit |
38
- | `docs <name> a,b` | Replaces a pack's optional doc sets (`''` for none) |
39
- | `list` | Lists each pack and its doc sets, marking the installed ones |
47
+ ```markdown
48
+ ---
49
+ when: Writing or changing tests
50
+ ---
51
+ ```
40
52
 
41
- A folder that holds several repos without being one can be synced too, so sessions started there get the rules. A Claude Code session started inside one of those repos then loads both copies, since Claude Code reads `CLAUDE.md` from the folders above it as well.
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.
42
54
 
43
- ### Pinning and updates
55
+ A nested pack's `pack.md` takes two more fields:
44
56
 
45
- Each pack's start marker records where it comes from and where it's pinned:
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`.
46
59
 
47
60
  ```markdown
48
- <!-- context-packs:start team-rules from=https://github.com/acme/agent-rules.git path=context ref=v1.2.0 -->
61
+ ---
62
+ when: Working in a TypeScript repo
63
+ optional: true
64
+ inline: true
65
+ ---
66
+ Prefer unions to enums.
49
67
  ```
50
68
 
51
- `--ref` takes a tag or a commit, never a branch, since a branch moves. `update` moves a pack along what it's pinned to:
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.
52
70
 
53
- - **A commit** moves to the tip of the origin's default branch.
54
- - **A release tag**, meaning a prefix followed by dot-separated numbers such as `v1.2.0` or `acme-v3`, moves to the newest release with the same prefix. Pre-releases like `v2.0.0-rc1` are skipped.
55
- - **Any other tag** stays put.
71
+ Keep `pack.md` short, since every session loads it.
56
72
 
57
- The sync fetches origins into `~/.cache/context-packs/` using your own git credentials, so private repos work.
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.
58
74
 
59
- A pack can also live in the repo it's synced into: `add <name> . --path <folder>` reads it from the working tree, with no ref. Its table points at the docs where they are instead of copying them, and it installs every doc set it has.
75
+ ## Sync a pack from another repo
60
76
 
61
- That's the way to keep a repo's own context docs. Put each one in `docs/agents/project/` with a `when:` line, run `add project . --path docs/agents/project` once, and resync after adding a doc or changing its `when:`. The table in `AGENTS.md` stays generated from the docs.
77
+ ```bash
78
+ npx context-packs add <name> <git-url> --path <folder>
79
+ ```
62
80
 
63
- ### Edits stay upstream
81
+ - `<name>` is what this repo calls the pack, such as `team-rules`.
82
+ - `<git-url>` is the repo holding the pack, as you'd pass it to `git clone`.
83
+ - `<folder>` is the pack's folder in that repo, such as `context`. Leave out `--path` if the pack is the whole repo.
64
84
 
65
- Each block ends with a hash covering its text and its docs folder. If either was edited in the repo, the sync stops without changing anything and says what to fix. Change a pack in its origin, then update.
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.
66
86
 
67
- ## Write a pack
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.
68
88
 
69
- A pack is a folder holding an optional `agents.md` and any number of doc sets. A doc set is a single `.md` file, or a folder whose `index.md` links to its other files:
89
+ ## Sync your project's own docs
70
90
 
71
- ```text
72
- context/
73
- ├── agents.md # rules pasted into the pack's block
74
- ├── testing.md # a doc set in one file
75
- └── typescript/
76
- ├── index.md # a doc set's entry point
77
- └── react.md # other files, linked from index.md
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:
92
+
93
+ ```bash
94
+ npx context-packs add <name> . --path <folder>
78
95
  ```
79
96
 
80
- Each doc set's entry point starts with front matter saying when to read it:
97
+ - `<name>` is what this repo calls the pack.
98
+ - `.` is the origin: the pack is in this repo, so it has no ref and syncs from your working tree.
99
+ - `<folder>` holds the docs.
81
100
 
82
- ```markdown
83
- ---
84
- when: Writing or changing tests, or fixing a bug
85
- tier: required
86
- ---
101
+ Any name and folder work. We suggest `project` and `docs/agents/project`:
102
+
103
+ ```bash
104
+ npx context-packs add project . --path docs/agents/project
87
105
  ```
88
106
 
89
- The pack's block lists its installed doc sets in a table that agents check before starting work. `tier: required` installs the set in every repo that installs the pack. Leave `tier` out for an optional set, which each repo chooses with `--docs` or `docs`.
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.
90
108
 
91
- Keep `agents.md` small, since every session loads it, and put anything needed only for some work in a doc set. agy stops reading `AGENTS.md` after 24 KB.
109
+ ## Let your agent run it
92
110
 
93
- ## Tests
111
+ The `context-packs` skill lets you skip the commands: ask Claude Code or Codex in plain words, and it runs them, shows you what changed, and leaves the result uncommitted for you to review. Install it for every project:
94
112
 
95
113
  ```bash
96
- python3 -m unittest discover tests
114
+ npx skills add mjewell/context-packs -g
97
115
  ```
116
+
117
+ Leave out `-g` to install it into the current project instead.
118
+
119
+ Then ask things like:
120
+
121
+ - "Add the context pack in the `context` folder of github.com/acme/agent-rules"
122
+ - "Update our context packs"
123
+ - "Install the typescript pack from team-rules"
124
+ - "Sync our project docs"
125
+
126
+ In Claude Code, `/context-packs` invokes it directly.
127
+
128
+ ## Reference
129
+
130
+ Each command syncs the current folder, or `--dir <folder>`, and prints what changed. The folder can also hold several repos without being one itself, so sessions started there get the rules too.
131
+
132
+ | Command | Does |
133
+ |---|---|
134
+ | *(none)* | Resyncs every pack at its pinned 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` |
136
+ | `remove <name>` | Uninstalls a pack |
137
+ | `update [name…]` | Moves packs to their latest version |
138
+ | `update <name> --ref R` | Pins one pack to a tag or commit |
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 |
141
+
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.
143
+
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`.
145
+
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.1.1",
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,68 +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 agents.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 agents.md."""
51
- folders = {p.name for p in self.folder.iterdir() if p.is_dir() and not p.name.startswith('.')}
52
- files = {p.stem for p in self.folder.glob('*.md') if p.name != 'agents.md'}
53
- for name in sorted(folders & files):
54
- sys.exit(f'context-packs: {self.pack.origin} has both {name}.md and {name}/ in '
55
- f'{self.pack.path or "its root"}, so the doc set {name} is ambiguous. Keep one.')
56
- return sorted(folders | files)
57
-
58
- def entry(self, name):
59
- """A doc set's entry point, relative to the pack's folder."""
60
- return Path(name, 'index.md') if (self.folder / name).is_dir() else Path(f'{name}.md')
61
-
62
- def front(self, name):
63
- """A doc set's front matter: when to read it, and whether every repo gets it."""
64
- match = FRONT.match((self.folder / self.entry(name)).read_text(encoding='utf-8'))
65
- pairs = [line.split(': ', 1) for line in match.group(1).split('\n')] if match else []
66
- fields = dict(p for p in pairs if len(p) == 2)
67
- if len(fields) != len(pairs) or 'when' not in fields or fields.get('tier', 'optional') not in TIERS:
68
- where = (Path(self.pack.path) / self.entry(name)).as_posix()
69
- sys.exit(f'context-packs: {where} in {self.pack.origin} must start with front matter saying when to '
70
- 'read it, plus `tier: required` if every repo gets it:\n\n---\nwhen: Editing `**/*.ts`\n---')
71
- return fields
72
-
73
- def required(self):
74
- return [n for n in self.available() if self.front(n).get('tier') == 'required']
75
-
76
- def installs(self, picked):
77
- """The doc sets the pack installs: all of a pack in the synced repo, else the picked and required ones."""
78
- return self.available() if self.pack.local else sorted(set(picked) | set(self.required()))
79
-
80
- def copy(self, name, synced):
81
- if (self.folder / name).is_dir():
82
- shutil.copytree(self.folder / name, synced / name)
83
- else:
84
- synced.mkdir(parents=True, exist_ok=True)
85
- shutil.copy2(self.folder / f'{name}.md', synced / f'{name}.md')
86
-
87
- def body(self, names):
88
- rules_file = self.folder / 'agents.md'
89
- rules = rules_file.read_text(encoding='utf-8') if rules_file.is_file() else ''
90
- # The end marker has to start its own line, or the next sync can't find the block.
91
- if rules and not rules.endswith('\n'):
92
- rules += '\n'
93
- if not names:
94
- return rules
95
- rows = ''.join(f'| {self.front(n)["when"]} | `{(self.pack.synced / self.entry(n)).as_posix()}` |\n'
96
- for n in names)
97
- table = f'## {self.pack.name} context\n\n| When | Read |\n|---|---|\n{rows}'
98
- return f'{rules}\n{table}' if rules else table
99
-
100
-
101
43
  def fetch(repo, pack, stack):
102
44
  """The folder holding pack's content, fetched at its ref unless it's in the synced repo."""
103
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')