context-packs 0.0.0-stage → 0.1.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Michael Jewell
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,97 @@
1
- # Temporary Holding Version
1
+ # context-packs
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Sync shared agent rules and docs into each repo's `AGENTS.md`.
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.
6
+
7
+ ## Install
8
+
9
+ Run it with npx:
10
+
11
+ ```bash
12
+ npx context-packs <command>
13
+ ```
14
+
15
+ It syncs the current folder. Pass `--dir <folder>` before the command to sync another.
16
+
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.
18
+
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`.
20
+
21
+ ## Use it
22
+
23
+ ```bash
24
+ npx context-packs add team-rules https://github.com/acme/agent-rules.git --path context
25
+ ```
26
+
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.
28
+
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 |
40
+
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.
42
+
43
+ ### Pinning and updates
44
+
45
+ Each pack's start marker records where it comes from and where it's pinned:
46
+
47
+ ```markdown
48
+ <!-- context-packs:start team-rules from=https://github.com/acme/agent-rules.git path=context ref=v1.2.0 -->
49
+ ```
50
+
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:
52
+
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.
56
+
57
+ The sync fetches origins into `~/.cache/context-packs/` using your own git credentials, so private repos work.
58
+
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.
60
+
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.
62
+
63
+ ### Edits stay upstream
64
+
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.
66
+
67
+ ## Write a pack
68
+
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:
70
+
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
78
+ ```
79
+
80
+ Each doc set's entry point starts with front matter saying when to read it:
81
+
82
+ ```markdown
83
+ ---
84
+ when: Writing or changing tests, or fixing a bug
85
+ tier: required
86
+ ---
87
+ ```
88
+
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`.
90
+
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.
92
+
93
+ ## Tests
94
+
95
+ ```bash
96
+ python3 -m unittest discover tests
97
+ ```
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env node
2
+ // Runs the Python CLI bundled with this package, passing its arguments, output and exit code through.
3
+ const { spawnSync } = require('node:child_process');
4
+ const path = require('node:path');
5
+
6
+ const cli = path.join(__dirname, '..', 'src', 'cli.py');
7
+ const result = spawnSync('python3', [cli, ...process.argv.slice(2)], { stdio: 'inherit' });
8
+ if (result.error) {
9
+ console.error(`context-packs: couldn't run python3 (${result.error.message}). Install Python 3.9 or later.`);
10
+ process.exit(1);
11
+ }
12
+ process.exit(result.status ?? 1);
package/package.json CHANGED
@@ -1,6 +1,20 @@
1
1
  {
2
2
  "name": "context-packs",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "Sync shared agent rules and docs into each repo's AGENTS.md",
5
+ "license": "MIT",
6
+ "bin": {
7
+ "context-packs": "bin/context-packs.js"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "src/*.py"
12
+ ],
13
+ "engines": {
14
+ "node": ">=18"
15
+ },
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/mjewell/context-packs.git"
19
+ }
20
+ }
package/src/blocks.py ADDED
@@ -0,0 +1,116 @@
1
+ """The context-packs blocks in AGENTS.md, and the doc sets each pack installed beside them."""
2
+ import hashlib
3
+ import re
4
+ import sys
5
+ from typing import Dict, List, NamedTuple
6
+
7
+ from packs import LOCAL, PACKS, Pack
8
+
9
+ HEADER = 'header'
10
+ HEADER_START = '<!-- context-packs:start header -->\n'
11
+ HEADER_RULES = (
12
+ '## Context Packs\n\n'
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")
17
+ BLOCK = re.compile(r'^<!-- context-packs:start (\S+)((?: \S+)*) -->\n(.*?)'
18
+ r'^<!-- context-packs:end sha256:([0-9a-f]{12}) -->\n?', re.MULTILINE | re.DOTALL)
19
+
20
+
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."""
24
+ text: str
25
+ start: int
26
+ end: int
27
+ blocks: Dict[str, str]
28
+ packs: List[Pack]
29
+ installed: Dict[str, List[str]]
30
+
31
+
32
+ def digest(body, docs=b''):
33
+ return hashlib.sha256(body.encode() + docs).hexdigest()[:12]
34
+
35
+
36
+ def block(start, body, docs=b''):
37
+ return f'{start}{body}<!-- context-packs:end sha256:{digest(body, docs)} -->\n'
38
+
39
+
40
+ def snapshot(folder):
41
+ """The synced docs' paths and contents, which the block's hash covers so edits to them are caught too."""
42
+ if not folder.is_dir():
43
+ return b''
44
+ return b''.join(f'{p.relative_to(folder).as_posix()}\0'.encode() + p.read_bytes() + b'\0'
45
+ for p in sorted(folder.rglob('*')) if p.is_file())
46
+
47
+
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
+ def marked_pack(agents_md, name, marker):
65
+ pairs = [field.split('=', 1) for field in marker.split()]
66
+ fields = dict(p for p in pairs if len(p) == 2)
67
+ origin = fields.get('from')
68
+ if (len(fields) != len(pairs) or set(fields) - {'from', 'path', 'ref'} or not origin
69
+ or (origin == LOCAL) == ('ref' in fields)):
70
+ sys.exit(f'context-packs: {agents_md}: the {name} block\'s start marker needs from=<git URL> and '
71
+ 'ref=<tag or commit>, or from=. for a pack in this repo, plus path=<folder> unless the pack is the '
72
+ 'whole repo. '
73
+ 'Fix the marker, then sync again.')
74
+ return Pack(name, origin, fields.get('path', ''), fields.get('ref'))
75
+
76
+
77
+ def region(repo):
78
+ """AGENTS.md's context-packs blocks, after checking that nothing in them was edited since the last sync."""
79
+ agents_md = repo / 'AGENTS.md'
80
+ if agents_md.is_symlink():
81
+ sys.exit(f'context-packs: {agents_md} is a symlink, and the sync would write through it. '
82
+ 'Delete it, then sync again to get a real file.')
83
+ # Bytes, so Python doesn't quietly turn the repo's CRLF line endings into LF.
84
+ text = agents_md.read_bytes().decode('utf-8') if agents_md.exists() else block(HEADER_START, '')
85
+ if '\r' in text:
86
+ sys.exit(f'context-packs: {agents_md} has CRLF line endings. Convert it to LF, then sync again.')
87
+ blocks = list(BLOCK.finditer(text))
88
+ if not blocks:
89
+ sys.exit(f'context-packs: {agents_md} has no context-packs blocks. '
90
+ f'Add these two lines where the shared rules should go, then sync again:\n\n{block(HEADER_START, "")}')
91
+ names = [match.group(1) for match in blocks]
92
+ if names[0] != HEADER:
93
+ sys.exit(f'context-packs: {agents_md}: the context-packs blocks must start with the header block. '
94
+ f'Add these two lines before them, then sync again:\n\n{block(HEADER_START, "")}')
95
+ for name in sorted({n for n in names if names.count(n) > 1}):
96
+ sys.exit(f'context-packs: {agents_md} has more than one {name} block. Delete all but one, then sync again.')
97
+ for before, after in zip(blocks, blocks[1:]):
98
+ if text[before.end():after.start()] != '\n':
99
+ sys.exit(f'context-packs: {agents_md} has text between the context-packs blocks, which must sit together '
100
+ 'separated by blank lines. Move it outside them, then sync again.')
101
+ 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
+ if (repo / PACKS).is_dir():
104
+ 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}
109
+ for match in blocks:
110
+ name, _, synced, sha = match.groups()
111
+ if digest(synced, snapshot(repo / PACKS / name) if name in copied else b'') != sha:
112
+ sys.exit(f'context-packs: {agents_md}: the {name} block or a doc in {PACKS / name} was edited since '
113
+ 'the last sync. Move the change into its context pack or outside them, revert it, '
114
+ '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)
package/src/cli.py ADDED
@@ -0,0 +1,166 @@
1
+ #!/usr/bin/env python3
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>/."""
5
+ import argparse
6
+ import contextlib
7
+ import os
8
+ from pathlib import Path
9
+ import re
10
+ import shutil
11
+ import sys
12
+
13
+ import origins
14
+ import report
15
+ from blocks import HEADER, HEADER_RULES, HEADER_START, block, region, snapshot
16
+ from packs import LOCAL, PACKS, Pack, Source, fetch
17
+
18
+
19
+ def sets(text):
20
+ return {n for n in text.split(',') if n}
21
+
22
+
23
+ def apply(args, found):
24
+ """The packs, and the optional doc sets each should have, once the command has run."""
25
+ packs = list(found.packs)
26
+ names = [p.name for p in packs]
27
+ picked = {p.name: set(found.installed.get(p.name, [])) for p in packs}
28
+ if args.command == 'add':
29
+ if not re.fullmatch(r'[A-Za-z0-9._-]+', args.name) or args.name == HEADER:
30
+ sys.exit(f'context-packs: {args.name} can\'t name a pack. '
31
+ 'Use letters, digits, dots, dashes and underscores.')
32
+ if args.name in names:
33
+ sys.exit(f'context-packs: {args.dir} already has a pack named {args.name}.')
34
+ if args.origin == LOCAL:
35
+ if args.ref:
36
+ 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
+ ref = None
40
+ else:
41
+ 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)
44
+ named = [args.name] if args.command in ('remove', 'docs') else getattr(args, 'names', [])
45
+ unknown = [n for n in named if n not in names]
46
+ if unknown:
47
+ sys.exit(f'context-packs: {args.dir} has no pack named {", ".join(unknown)}. '
48
+ f'Its packs: {", ".join(names) or "none"}.')
49
+ if args.command == 'remove':
50
+ packs = [p for p in packs if p.name != args.name]
51
+ 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)
56
+ if args.command == 'update':
57
+ if args.ref and len(named) != 1:
58
+ sys.exit('context-packs: --ref pins one pack, so name exactly one.')
59
+ for i, pack in enumerate(packs):
60
+ if pack.name not in (named or names):
61
+ continue
62
+ if pack.origin == LOCAL:
63
+ if args.ref:
64
+ sys.exit(f'context-packs: the {pack.name} pack syncs from this repo\'s working tree, '
65
+ 'so it takes no --ref.')
66
+ continue
67
+ packs[i] = pack._replace(ref=origins.pin(pack.origin, args.ref) if args.ref
68
+ 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
75
+ 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"]}')
84
+
85
+
86
+ def main():
87
+ parser = argparse.ArgumentParser(prog='context-packs', description=__doc__)
88
+ parser.add_argument('--dir', type=Path, default=Path.cwd(),
89
+ help='the folder to sync, if not the current one')
90
+ commands = parser.add_subparsers(dest='command', metavar='command',
91
+ description='leave out the command to resync every pack at its pinned ref')
92
+ add = commands.add_parser('add', help="install a pack, pinned to its origin's latest commit or to --ref")
93
+ add.add_argument('name', help='what to call the pack in AGENTS.md and docs/agents/packs/')
94
+ add.add_argument('origin', help='a git URL, or . for a folder in the synced repo itself')
95
+ add.add_argument('--path', default='', help='the folder in the origin holding the pack, if not its root')
96
+ 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')
98
+ remove = commands.add_parser('remove', help='uninstall a pack')
99
+ remove.add_argument('name')
100
+ update = commands.add_parser('update', help='move pinned packs on: one pinned to a commit to the tip of its '
101
+ "origin's default branch, one pinned to a release tag to the newest "
102
+ 'release in its series')
103
+ update.add_argument('names', nargs='*', help='the packs to update; all of them if left out')
104
+ 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")
106
+ 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')
109
+ args = parser.parse_args()
110
+ repo = args.dir
111
+ if not repo.is_dir():
112
+ sys.exit(f'context-packs: {repo} is not a folder')
113
+
114
+ found = region(repo)
115
+ packs, picked = apply(args, found)
116
+
117
+ with contextlib.ExitStack() as stack:
118
+ sources = [Source(p, fetch(repo, p, stack)) for p in packs]
119
+ if args.command == 'list':
120
+ show(sources, picked)
121
+ return
122
+
123
+ claude_md = repo / 'CLAUDE.md'
124
+ linked = claude_md.is_symlink() and os.readlink(claude_md) == 'AGENTS.md'
125
+ if not linked and os.path.lexists(claude_md):
126
+ sys.exit(f'context-packs: {claude_md} must be a symlink to AGENTS.md, so every agent reads the same rules. '
127
+ 'Move its contents into AGENTS.md, delete it, then sync again.')
128
+
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}
139
+
140
+ for gone in {p.name for p in found.packs} - {p.name for p in packs}:
141
+ if (repo / PACKS / gone).exists():
142
+ shutil.rmtree(repo / PACKS / gone)
143
+ 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))
154
+ if (repo / PACKS).is_dir() and not any((repo / PACKS).iterdir()):
155
+ (repo / PACKS).rmdir()
156
+ text = found.text[:found.start] + '\n'.join(blocks.values()) + found.text[found.end:]
157
+ created = not (repo / 'AGENTS.md').exists()
158
+ (repo / 'AGENTS.md').write_bytes(text.encode('utf-8'))
159
+ if not linked:
160
+ claude_md.symlink_to('AGENTS.md')
161
+ for line in report.changes(found, packs, chosen, blocks, created, linked):
162
+ print(line)
163
+
164
+
165
+ if __name__ == '__main__':
166
+ main()
package/src/origins.py ADDED
@@ -0,0 +1,100 @@
1
+ """Fetch context packs from their git origins, through a cache of bare clones that every sync shares."""
2
+ import io
3
+ import os
4
+ from pathlib import Path
5
+ import re
6
+ import subprocess
7
+ import sys
8
+ import tarfile
9
+
10
+ COMMIT = re.compile(r'[0-9a-f]{40}')
11
+ # A release tag: a series prefix, then only dot-separated numbers, such as v1.2.0 or acme-v3.
12
+ RELEASE = re.compile(r'(\D*)(\d+(?:\.\d+)*)')
13
+
14
+
15
+ def git(*args):
16
+ result = subprocess.run(['git', *args], capture_output=True)
17
+ if result.returncode != 0:
18
+ sys.exit(f'context-packs: git {" ".join(args)} failed:\n{result.stderr.decode().strip()}')
19
+ return result.stdout.decode().strip()
20
+
21
+
22
+ def clone(url):
23
+ """The cached bare clone of url, cloning it the first time."""
24
+ cache = Path(os.environ.get('XDG_CACHE_HOME') or Path.home() / '.cache') / 'context-packs'
25
+ folder = cache / re.sub(r'[^A-Za-z0-9._-]+', '_', url)
26
+ if not folder.exists():
27
+ cache.mkdir(parents=True, exist_ok=True)
28
+ git('clone', '--quiet', '--bare', url, str(folder))
29
+ return folder
30
+
31
+
32
+ def commit(folder, ref):
33
+ """The full commit ref names in the clone at folder, or None if it names none."""
34
+ result = subprocess.run(['git', '--git-dir', str(folder), 'rev-parse', '--verify', '--quiet', f'{ref}^{{commit}}'],
35
+ capture_output=True)
36
+ return result.stdout.decode().strip() if result.returncode == 0 else None
37
+
38
+
39
+ def fetched(url, ref):
40
+ """The cached clone of url, fetched until it has ref, or None if the origin has no such commit or tag."""
41
+ folder = clone(url)
42
+ if not commit(folder, ref):
43
+ git('--git-dir', str(folder), 'fetch', '--quiet', 'origin', '+refs/heads/*:refs/heads/*',
44
+ '+refs/tags/*:refs/tags/*')
45
+ return folder if commit(folder, ref) else None
46
+
47
+
48
+ def remote(url, kind):
49
+ """url's tags or branches, by name."""
50
+ lines = git('ls-remote', f'--{kind}', '--refs', url).splitlines()
51
+ return {ref.split('/', 2)[2]: sha for sha, ref in (line.split('\t') for line in lines)}
52
+
53
+
54
+ def tip(url):
55
+ """The commit at the tip of url's default branch."""
56
+ head = git('ls-remote', url, 'HEAD').split()
57
+ if not head:
58
+ sys.exit(f'context-packs: {url} has no default branch to pin to.')
59
+ return head[0]
60
+
61
+
62
+ def pin(url, ref):
63
+ """ref as a pack's marker holds it: a tag stays a tag, and a commit becomes its full hash."""
64
+ if ref in remote(url, 'tags'):
65
+ return ref
66
+ if ref in remote(url, 'heads'):
67
+ sys.exit(f'context-packs: {ref} is a branch in {url}, and a branch moves. Pin to a tag or a commit, '
68
+ 'or leave out --ref to follow the default branch on update.')
69
+ folder = fetched(url, ref)
70
+ if not folder:
71
+ sys.exit(f'context-packs: {url} has no tag or commit {ref}.')
72
+ return commit(folder, ref)
73
+
74
+
75
+ def latest(url, ref):
76
+ """Where an update moves a pack pinned to ref: a commit to the tip of the default branch, a release tag to the
77
+ newest release in its series, and any other tag nowhere."""
78
+ if COMMIT.fullmatch(ref):
79
+ return tip(url)
80
+ current = RELEASE.fullmatch(ref)
81
+ if not current:
82
+ return ref
83
+ releases = [(tuple(map(int, match.group(2).split('.'))), name)
84
+ for name in [ref, *remote(url, 'tags')]
85
+ for match in [RELEASE.fullmatch(name)] if match and match.group(1) == current.group(1)]
86
+ return max(releases)[1]
87
+
88
+
89
+ def extract(url, ref, path, into):
90
+ """Write the folder at path in url, as of ref, into the folder into, and return where it landed."""
91
+ folder = fetched(url, ref)
92
+ if not folder:
93
+ sys.exit(f'context-packs: {url} has no tag or commit {ref}. Pin the pack to one that exists, then sync again.')
94
+ archive = subprocess.run(['git', '--git-dir', str(folder), 'archive', '--format=tar', ref, '--', path or '.'],
95
+ capture_output=True)
96
+ if archive.returncode != 0:
97
+ sys.exit(f'context-packs: {url} has no folder {path} at {ref}:\n{archive.stderr.decode().strip()}')
98
+ with tarfile.open(fileobj=io.BytesIO(archive.stdout)) as tar:
99
+ tar.extractall(into)
100
+ return into / path
package/src/packs.py ADDED
@@ -0,0 +1,109 @@
1
+ """Context packs: where each comes from, and its content once fetched."""
2
+ from pathlib import Path
3
+ import re
4
+ import shutil
5
+ import sys
6
+ import tempfile
7
+ from typing import NamedTuple, Optional
8
+
9
+ import origins
10
+
11
+ PACKS = Path('docs/agents/packs')
12
+ # A pack whose origin is the synced repo itself, read from its working tree.
13
+ LOCAL = '.'
14
+ FRONT = re.compile(r'---\n(.*?)\n---\n', re.DOTALL)
15
+ TIERS = ('optional', 'required')
16
+
17
+
18
+ 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."""
20
+ name: str
21
+ origin: str
22
+ path: str
23
+ ref: Optional[str]
24
+
25
+ def start(self):
26
+ fields = ([f'from={self.origin}'] + [f'path={self.path}'] * bool(self.path)
27
+ + [f'ref={self.ref}'] * bool(self.ref))
28
+ if any(re.search(r'\s', field) for field in fields):
29
+ sys.exit(f'context-packs: the {self.name} pack\'s {" ".join(fields)} has whitespace, '
30
+ 'which its marker can\'t hold.')
31
+ return f'<!-- context-packs:start {" ".join([self.name, *fields])} -->\n'
32
+
33
+ @property
34
+ def local(self):
35
+ return self.origin == LOCAL
36
+
37
+ @property
38
+ 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
40
+ pack in that repo."""
41
+ return Path(self.path) if self.local else PACKS / self.name
42
+
43
+
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
+ def fetch(repo, pack, stack):
102
+ """The folder holding pack's content, fetched at its ref unless it's in the synced repo."""
103
+ if pack.origin == LOCAL:
104
+ folder = repo / pack.path
105
+ if not folder.is_dir():
106
+ sys.exit(f'context-packs: the {pack.name} pack\'s folder {pack.path} isn\'t in {repo}.')
107
+ return folder
108
+ into = Path(stack.enter_context(tempfile.TemporaryDirectory(prefix='context-packs-')))
109
+ return origins.extract(pack.origin, pack.ref, pack.path, into)
package/src/report.py ADDED
@@ -0,0 +1,43 @@
1
+ """What a sync changed, in lines for the person who ran it."""
2
+ from blocks import HEADER
3
+ from origins import COMMIT
4
+
5
+
6
+ def short(ref):
7
+ """ref as git shows it: a commit abbreviated, a tag in full."""
8
+ return ref[:7] if COMMIT.fullmatch(ref) else ref
9
+
10
+
11
+ def at(pack):
12
+ """Where the pack is pinned, or nothing for one that syncs from the repo's working tree."""
13
+ return f' @ {short(pack.ref)}' if pack.ref else ''
14
+
15
+
16
+ def sets(names):
17
+ return ', '.join(names) or 'none'
18
+
19
+
20
+ def changes(found, after, chosen, blocks, created, linked):
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."""
24
+ before = found.packs
25
+ old = {p.name: p for p in before}
26
+ new = {p.name for p in after}
27
+ lines = (['Created AGENTS.md'] * created + ['Linked CLAUDE.md to AGENTS.md'] * (not linked)
28
+ + ['Updated the header block'] * (not created and found.blocks[HEADER] != blocks[HEADER]))
29
+ for pack in after:
30
+ was = old.get(pack.name)
31
+ if not was:
32
+ docs = chosen[pack.name]
33
+ lines.append(f'{pack.name}: added{at(pack)}' + f' (docs: {sets(docs)})' * bool(docs))
34
+ elif was.ref != pack.ref:
35
+ 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])}')
38
+ elif found.blocks[pack.name] != blocks[pack.name]:
39
+ lines.append(f'{pack.name}: changed')
40
+ lines += [f'{p.name}: removed' for p in before if p.name not in new]
41
+ if lines:
42
+ return lines
43
+ return [f'Nothing changed: {", ".join(p.name + at(p) for p in after) or "no packs installed"}']