context-packs 0.1.0 → 0.2.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 +88 -60
- package/package.json +5 -2
- package/src/blocks.py +5 -2
- package/src/cli.py +8 -4
- package/src/packs.py +8 -4
- package/src/report.py +43 -0
package/README.md
CHANGED
|
@@ -1,97 +1,125 @@
|
|
|
1
1
|
# context-packs
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
5
|
+
Agent rules copied from repo to repo drift apart. context-packs syncs them instead:
|
|
6
6
|
|
|
7
|
-
|
|
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
|
-
|
|
12
|
+
A synced pack in `AGENTS.md`:
|
|
10
13
|
|
|
11
|
-
```
|
|
12
|
-
|
|
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`.
|
|
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.
|
|
20
17
|
|
|
21
|
-
##
|
|
18
|
+
## team-rules context
|
|
22
19
|
|
|
23
|
-
|
|
24
|
-
|
|
20
|
+
| When | Read |
|
|
21
|
+
|---|---|
|
|
22
|
+
| Writing or changing tests | `docs/agents/packs/team-rules/testing.md` |
|
|
23
|
+
<!-- context-packs:end sha256:3f2c1aa0b9d4 -->
|
|
25
24
|
```
|
|
26
25
|
|
|
27
|
-
|
|
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.
|
|
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.
|
|
30
27
|
|
|
31
|
-
|
|
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 |
|
|
28
|
+
## Write a pack
|
|
40
29
|
|
|
41
|
-
A
|
|
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:
|
|
42
31
|
|
|
43
|
-
|
|
32
|
+
```text
|
|
33
|
+
context/
|
|
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
|
|
39
|
+
```
|
|
44
40
|
|
|
45
|
-
Each
|
|
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`.
|
|
46
42
|
|
|
47
43
|
```markdown
|
|
48
|
-
|
|
44
|
+
---
|
|
45
|
+
when: Writing or changing tests
|
|
46
|
+
tier: required
|
|
47
|
+
---
|
|
49
48
|
```
|
|
50
49
|
|
|
51
|
-
|
|
50
|
+
Keep `pack.md` short, since every session loads it.
|
|
52
51
|
|
|
53
|
-
|
|
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.
|
|
52
|
+
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.
|
|
56
53
|
|
|
57
|
-
|
|
54
|
+
## Sync a pack from another repo
|
|
58
55
|
|
|
59
|
-
|
|
56
|
+
```bash
|
|
57
|
+
npx context-packs add <name> <git-url> --path <folder>
|
|
58
|
+
```
|
|
60
59
|
|
|
61
|
-
|
|
60
|
+
- `<name>` is what this repo calls the pack, such as `team-rules`.
|
|
61
|
+
- `<git-url>` is the repo holding the pack, as you'd pass it to `git clone`.
|
|
62
|
+
- `<folder>` is the pack's folder in that repo, such as `context`. Leave out `--path` if the pack is the whole repo.
|
|
62
63
|
|
|
63
|
-
|
|
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.
|
|
64
65
|
|
|
65
|
-
|
|
66
|
+
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.
|
|
66
67
|
|
|
67
|
-
##
|
|
68
|
+
## Sync your project's own docs
|
|
68
69
|
|
|
69
|
-
|
|
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:
|
|
70
71
|
|
|
71
|
-
```
|
|
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
|
|
72
|
+
```bash
|
|
73
|
+
npx context-packs add <name> . --path <folder>
|
|
78
74
|
```
|
|
79
75
|
|
|
80
|
-
|
|
76
|
+
- `<name>` is what this repo calls the pack.
|
|
77
|
+
- `.` is the origin: the pack is in this repo, so it has no ref and syncs from your working tree.
|
|
78
|
+
- `<folder>` holds the docs.
|
|
81
79
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
---
|
|
80
|
+
Any name and folder work. We suggest `project` and `docs/agents/project`:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
npx context-packs add project . --path docs/agents/project
|
|
87
84
|
```
|
|
88
85
|
|
|
89
|
-
|
|
86
|
+
Its table points at the docs where they are. Resync after adding a doc or changing a `when:` line.
|
|
90
87
|
|
|
91
|
-
|
|
88
|
+
## Let your agent run it
|
|
92
89
|
|
|
93
|
-
|
|
90
|
+
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
91
|
|
|
95
92
|
```bash
|
|
96
|
-
|
|
93
|
+
npx skills add mjewell/context-packs -g
|
|
97
94
|
```
|
|
95
|
+
|
|
96
|
+
Leave out `-g` to install it into the current project instead.
|
|
97
|
+
|
|
98
|
+
Then ask things like:
|
|
99
|
+
|
|
100
|
+
- "Add the context pack in the `context` folder of github.com/acme/agent-rules"
|
|
101
|
+
- "Update our context packs"
|
|
102
|
+
- "Install the typescript docs from team-rules"
|
|
103
|
+
- "Sync our project docs"
|
|
104
|
+
|
|
105
|
+
In Claude Code, `/context-packs` invokes it directly.
|
|
106
|
+
|
|
107
|
+
## Reference
|
|
108
|
+
|
|
109
|
+
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.
|
|
110
|
+
|
|
111
|
+
| Command | Does |
|
|
112
|
+
|---|---|
|
|
113
|
+
| *(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` |
|
|
115
|
+
| `remove <name>` | Uninstalls a pack |
|
|
116
|
+
| `update [name…]` | Moves packs to their latest version |
|
|
117
|
+
| `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 |
|
|
120
|
+
|
|
121
|
+
**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
|
+
|
|
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`.
|
|
124
|
+
|
|
125
|
+
**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.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Sync shared agent rules and docs into each repo's AGENTS.md",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"bin": {
|
|
@@ -13,5 +13,8 @@
|
|
|
13
13
|
"engines": {
|
|
14
14
|
"node": ">=18"
|
|
15
15
|
},
|
|
16
|
-
"repository":
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/mjewell/context-packs.git"
|
|
19
|
+
}
|
|
17
20
|
}
|
package/src/blocks.py
CHANGED
|
@@ -19,10 +19,12 @@ BLOCK = re.compile(r'^<!-- context-packs:start (\S+)((?: \S+)*) -->\n(.*?)'
|
|
|
19
19
|
|
|
20
20
|
|
|
21
21
|
class Region(NamedTuple):
|
|
22
|
-
"""Where AGENTS.md's context-packs blocks sit, the packs they hold, and the doc sets
|
|
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."""
|
|
23
24
|
text: str
|
|
24
25
|
start: int
|
|
25
26
|
end: int
|
|
27
|
+
blocks: Dict[str, str]
|
|
26
28
|
packs: List[Pack]
|
|
27
29
|
installed: Dict[str, List[str]]
|
|
28
30
|
|
|
@@ -110,4 +112,5 @@ def region(repo):
|
|
|
110
112
|
sys.exit(f'context-packs: {agents_md}: the {name} block or a doc in {PACKS / name} was edited since '
|
|
111
113
|
'the last sync. Move the change into its context pack or outside them, revert it, '
|
|
112
114
|
'then sync again.')
|
|
113
|
-
return Region(text, blocks[0].start(), blocks[-1].end(), packs,
|
|
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
CHANGED
|
@@ -11,6 +11,7 @@ import shutil
|
|
|
11
11
|
import sys
|
|
12
12
|
|
|
13
13
|
import origins
|
|
14
|
+
import report
|
|
14
15
|
from blocks import HEADER, HEADER_RULES, HEADER_START, block, region, snapshot
|
|
15
16
|
from packs import LOCAL, PACKS, Pack, Source, fetch
|
|
16
17
|
|
|
@@ -139,23 +140,26 @@ def main():
|
|
|
139
140
|
for gone in {p.name for p in found.packs} - {p.name for p in packs}:
|
|
140
141
|
if (repo / PACKS / gone).exists():
|
|
141
142
|
shutil.rmtree(repo / PACKS / gone)
|
|
142
|
-
blocks =
|
|
143
|
+
blocks = {HEADER: block(HEADER_START, HEADER_RULES)}
|
|
143
144
|
for source in sources:
|
|
144
145
|
if source.pack.local:
|
|
145
|
-
blocks.
|
|
146
|
+
blocks[source.pack.name] = block(starts[source.pack.name], bodies[source.pack.name])
|
|
146
147
|
continue
|
|
147
148
|
synced = repo / source.pack.synced
|
|
148
149
|
if synced.exists():
|
|
149
150
|
shutil.rmtree(synced)
|
|
150
151
|
for name in chosen[source.pack.name]:
|
|
151
152
|
source.copy(name, synced)
|
|
152
|
-
blocks.
|
|
153
|
+
blocks[source.pack.name] = block(starts[source.pack.name], bodies[source.pack.name], snapshot(synced))
|
|
153
154
|
if (repo / PACKS).is_dir() and not any((repo / PACKS).iterdir()):
|
|
154
155
|
(repo / PACKS).rmdir()
|
|
155
|
-
text = found.text[:found.start] + '\n'.join(blocks) + found.text[found.end:]
|
|
156
|
+
text = found.text[:found.start] + '\n'.join(blocks.values()) + found.text[found.end:]
|
|
157
|
+
created = not (repo / 'AGENTS.md').exists()
|
|
156
158
|
(repo / 'AGENTS.md').write_bytes(text.encode('utf-8'))
|
|
157
159
|
if not linked:
|
|
158
160
|
claude_md.symlink_to('AGENTS.md')
|
|
161
|
+
for line in report.changes(found, packs, chosen, blocks, created, linked):
|
|
162
|
+
print(line)
|
|
159
163
|
|
|
160
164
|
|
|
161
165
|
if __name__ == '__main__':
|
package/src/packs.py
CHANGED
|
@@ -42,14 +42,18 @@ class Pack(NamedTuple):
|
|
|
42
42
|
|
|
43
43
|
|
|
44
44
|
class Source(NamedTuple):
|
|
45
|
-
"""A pack's content, fetched into a folder holding its
|
|
45
|
+
"""A pack's content, fetched into a folder holding its pack.md and doc sets."""
|
|
46
46
|
pack: Pack
|
|
47
47
|
folder: Path
|
|
48
48
|
|
|
49
49
|
def available(self):
|
|
50
|
-
"""The pack's doc sets: each a folder holding an index.md, or a single .md file other than
|
|
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.')
|
|
51
55
|
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 != '
|
|
56
|
+
files = {p.stem for p in self.folder.glob('*.md') if p.name != 'pack.md'}
|
|
53
57
|
for name in sorted(folders & files):
|
|
54
58
|
sys.exit(f'context-packs: {self.pack.origin} has both {name}.md and {name}/ in '
|
|
55
59
|
f'{self.pack.path or "its root"}, so the doc set {name} is ambiguous. Keep one.')
|
|
@@ -85,7 +89,7 @@ class Source(NamedTuple):
|
|
|
85
89
|
shutil.copy2(self.folder / f'{name}.md', synced / f'{name}.md')
|
|
86
90
|
|
|
87
91
|
def body(self, names):
|
|
88
|
-
rules_file = self.folder / '
|
|
92
|
+
rules_file = self.folder / 'pack.md'
|
|
89
93
|
rules = rules_file.read_text(encoding='utf-8') if rules_file.is_file() else ''
|
|
90
94
|
# The end marker has to start its own line, or the next sync can't find the block.
|
|
91
95
|
if rules and not rules.endswith('\n'):
|
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"}']
|