compose-md-cli 0.0.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/LICENSE +21 -0
- package/README.md +35 -0
- package/dist/cli.js +34 -0
- package/dist/commands/apply.js +49 -0
- package/dist/commands/init.js +274 -0
- package/dist/commands/view.js +15 -0
- package/dist/interactive.js +37 -0
- package/dist/lib/active.js +12 -0
- package/dist/lib/approaches.js +23 -0
- package/dist/lib/config.js +31 -0
- package/dist/lib/fragments.js +40 -0
- package/dist/startingPrompts/docs-workflow.md +9 -0
- package/dist/startingPrompts/skills/compose-docs/SKILL.md +23 -0
- package/package.json +36 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 David Johnston
|
|
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
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Compose-MD
|
|
2
|
+
|
|
3
|
+
**These docs written by AI**
|
|
4
|
+
|
|
5
|
+
Compose agent-facing markdown (`AGENTS.md`, `CLAUDE.md`, `SKILL.md`, and friends) from a pool of
|
|
6
|
+
reusable fragments. Switch harness layouts and content placement by editing YAML approaches —
|
|
7
|
+
not by rewriting the same prose in multiple files.
|
|
8
|
+
|
|
9
|
+
**Docs:** [https://dwjohnston.github.io/compose-md/](https://dwjohnston.github.io/compose-md/)
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
bun add -d compose-md
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Initialize
|
|
18
|
+
|
|
19
|
+
From your project root:
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
bunx compose init
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
You'll be prompted for a docs root name (default `projectDocs`). `init` scaffolds the fragment
|
|
26
|
+
pool, imports any existing agent prompt files, writes a `default` approach, and asks whether to
|
|
27
|
+
run `compose apply` immediately.
|
|
28
|
+
|
|
29
|
+
## Apply an approach
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
bunx compose apply default
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Or run `bunx compose` for an interactive picker.
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { runInteractive } from './interactive.js';
|
|
3
|
+
import { viewApproach } from './commands/view.js';
|
|
4
|
+
import { applyApproach } from './commands/apply.js';
|
|
5
|
+
import { runInit } from './commands/init.js';
|
|
6
|
+
import { getDocsRoot } from './lib/config.js';
|
|
7
|
+
const cwd = process.cwd();
|
|
8
|
+
const docsRoot = getDocsRoot(cwd);
|
|
9
|
+
const [, , command, ...args] = process.argv;
|
|
10
|
+
switch (command) {
|
|
11
|
+
case 'apply': {
|
|
12
|
+
const name = args[0];
|
|
13
|
+
if (!name) {
|
|
14
|
+
console.error('Usage: compose apply <approach>');
|
|
15
|
+
process.exit(1);
|
|
16
|
+
}
|
|
17
|
+
await applyApproach(docsRoot, name, cwd);
|
|
18
|
+
break;
|
|
19
|
+
}
|
|
20
|
+
case 'view': {
|
|
21
|
+
const name = args[0];
|
|
22
|
+
if (!name) {
|
|
23
|
+
console.error('Usage: compose view <approach>');
|
|
24
|
+
process.exit(1);
|
|
25
|
+
}
|
|
26
|
+
viewApproach(docsRoot, name);
|
|
27
|
+
break;
|
|
28
|
+
}
|
|
29
|
+
case 'init':
|
|
30
|
+
await runInit(cwd);
|
|
31
|
+
break;
|
|
32
|
+
default:
|
|
33
|
+
await runInteractive(docsRoot, cwd);
|
|
34
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'fs';
|
|
2
|
+
import { dirname, join } from 'path';
|
|
3
|
+
import { loadApproach } from '../lib/approaches.js';
|
|
4
|
+
import { scanFragments } from '../lib/fragments.js';
|
|
5
|
+
import { getActiveApproach, setActiveApproach } from '../lib/active.js';
|
|
6
|
+
function resolveContent(fragmentIds, fragments) {
|
|
7
|
+
const parts = [];
|
|
8
|
+
for (const id of fragmentIds) {
|
|
9
|
+
if (!id.startsWith('@')) {
|
|
10
|
+
parts.push(id.trim());
|
|
11
|
+
continue;
|
|
12
|
+
}
|
|
13
|
+
const key = id.slice(1);
|
|
14
|
+
const fragment = fragments.get(key);
|
|
15
|
+
if (!fragment)
|
|
16
|
+
throw new Error(`Fragment not found: ${id}`);
|
|
17
|
+
parts.push(fragment.content.trim());
|
|
18
|
+
}
|
|
19
|
+
return parts.join('\n\n');
|
|
20
|
+
}
|
|
21
|
+
export async function applyApproach(docsRoot, name, cwd) {
|
|
22
|
+
const previousActive = getActiveApproach(cwd);
|
|
23
|
+
if (previousActive && previousActive !== name) {
|
|
24
|
+
try {
|
|
25
|
+
const prev = loadApproach(docsRoot, previousActive);
|
|
26
|
+
for (const outputPath of Object.keys(prev.outputs)) {
|
|
27
|
+
const fullPath = join(cwd, outputPath);
|
|
28
|
+
if (existsSync(fullPath)) {
|
|
29
|
+
rmSync(fullPath);
|
|
30
|
+
console.log(`Removed: ${outputPath}`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
// previous approach file missing — skip cleanup
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
const approach = loadApproach(docsRoot, name);
|
|
39
|
+
const fragments = scanFragments(docsRoot);
|
|
40
|
+
for (const [outputPath, fragmentIds] of Object.entries(approach.outputs)) {
|
|
41
|
+
const composed = resolveContent(fragmentIds, fragments);
|
|
42
|
+
const fullPath = join(cwd, outputPath);
|
|
43
|
+
mkdirSync(dirname(fullPath), { recursive: true });
|
|
44
|
+
writeFileSync(fullPath, composed + '\n');
|
|
45
|
+
console.log(`Written: ${outputPath}`);
|
|
46
|
+
}
|
|
47
|
+
setActiveApproach(cwd, name);
|
|
48
|
+
console.log(`\nApplied: ${name}`);
|
|
49
|
+
}
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { input, confirm } from '@inquirer/prompts';
|
|
2
|
+
import { existsSync, mkdirSync, writeFileSync, readFileSync, appendFileSync, readdirSync } from 'fs';
|
|
3
|
+
import { join, relative, basename, dirname, sep } from 'path';
|
|
4
|
+
import { fileURLToPath } from 'url';
|
|
5
|
+
import { CONFIG_FILE, setDocsRootConfig } from '../lib/config.js';
|
|
6
|
+
// Filenames recognized anywhere in the project tree.
|
|
7
|
+
const AGENT_FILENAMES = new Set([
|
|
8
|
+
'AGENTS.md', // OpenAI Codex, generic
|
|
9
|
+
'CLAUDE.md', // Anthropic Claude
|
|
10
|
+
'SKILL.md', // Claude Code skills
|
|
11
|
+
'GEMINI.md', // Google Gemini
|
|
12
|
+
]);
|
|
13
|
+
// Exact relative paths recognized at that specific location only.
|
|
14
|
+
const AGENT_PATHS = new Set([
|
|
15
|
+
'.github/copilot-instructions.md',
|
|
16
|
+
]);
|
|
17
|
+
const EXCLUDED_DIRS = new Set(['node_modules', '.git']);
|
|
18
|
+
function toKebab(name) {
|
|
19
|
+
return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/, '');
|
|
20
|
+
}
|
|
21
|
+
function pathToId(p) {
|
|
22
|
+
return toKebab(p.replace(/\.[^.]+$/, '')); // e.g. src/commands/CLAUDE.md → src-commands-claude
|
|
23
|
+
}
|
|
24
|
+
function idToTitle(id) {
|
|
25
|
+
return id.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
|
|
26
|
+
}
|
|
27
|
+
// Plain .md files shipped with the package (src/startingPrompts/*.md) that
|
|
28
|
+
// get copied into every new project as ready-made fragments.
|
|
29
|
+
function startingPromptsSourceDir() {
|
|
30
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
31
|
+
return join(here, '..', 'startingPrompts');
|
|
32
|
+
}
|
|
33
|
+
// Starting prompts can reference the docs root by this placeholder; it's
|
|
34
|
+
// swapped for the actual docsRootName (which is only known at init time).
|
|
35
|
+
function substituteDocsRoot(content, docsRootName) {
|
|
36
|
+
return content.replace(/\$PROJECT_DOCS/g, docsRootName);
|
|
37
|
+
}
|
|
38
|
+
function copyStartingPrompts(docsRoot, docsRootName, created) {
|
|
39
|
+
const sourceDir = startingPromptsSourceDir();
|
|
40
|
+
if (!existsSync(sourceDir))
|
|
41
|
+
return [];
|
|
42
|
+
const files = readdirSync(sourceDir).filter(f => f.endsWith('.md'));
|
|
43
|
+
if (files.length === 0)
|
|
44
|
+
return [];
|
|
45
|
+
const destDir = join(docsRoot, 'starting');
|
|
46
|
+
mkdirSync(destDir, { recursive: true });
|
|
47
|
+
const prompts = [];
|
|
48
|
+
for (const file of files) {
|
|
49
|
+
const id = toKebab(file.replace(/\.md$/, ''));
|
|
50
|
+
const destFile = join(destDir, `${id}.md`);
|
|
51
|
+
if (!existsSync(destFile)) {
|
|
52
|
+
const content = readFileSync(join(sourceDir, file), 'utf-8');
|
|
53
|
+
writeFileSync(destFile, [
|
|
54
|
+
'---',
|
|
55
|
+
`name: ${id}`,
|
|
56
|
+
`description: ${idToTitle(id)}`,
|
|
57
|
+
'---',
|
|
58
|
+
'',
|
|
59
|
+
substituteDocsRoot(content, docsRootName).trim(),
|
|
60
|
+
'',
|
|
61
|
+
].join('\n'));
|
|
62
|
+
created.push(`${docsRootName}/starting/${id}.md`);
|
|
63
|
+
}
|
|
64
|
+
prompts.push({ id, destRelPath: `starting/${id}.md` });
|
|
65
|
+
}
|
|
66
|
+
return prompts;
|
|
67
|
+
}
|
|
68
|
+
function copyStartingSkills(docsRoot, docsRootName, created) {
|
|
69
|
+
const sourceDir = join(startingPromptsSourceDir(), 'skills');
|
|
70
|
+
if (!existsSync(sourceDir))
|
|
71
|
+
return [];
|
|
72
|
+
const skillDirs = readdirSync(sourceDir, { withFileTypes: true }).filter(e => e.isDirectory());
|
|
73
|
+
if (skillDirs.length === 0)
|
|
74
|
+
return [];
|
|
75
|
+
const destDir = join(docsRoot, 'starting');
|
|
76
|
+
mkdirSync(destDir, { recursive: true });
|
|
77
|
+
const skills = [];
|
|
78
|
+
for (const { name: skillName } of skillDirs) {
|
|
79
|
+
const sourceFile = join(sourceDir, skillName, 'SKILL.md');
|
|
80
|
+
if (!existsSync(sourceFile))
|
|
81
|
+
continue;
|
|
82
|
+
const id = `${toKebab(skillName)}-skill`;
|
|
83
|
+
const destFile = join(destDir, `${id}.md`);
|
|
84
|
+
if (!existsSync(destFile)) {
|
|
85
|
+
const content = readFileSync(sourceFile, 'utf-8');
|
|
86
|
+
writeFileSync(destFile, [
|
|
87
|
+
'---',
|
|
88
|
+
`name: ${id}`,
|
|
89
|
+
`description: ${idToTitle(id)}`,
|
|
90
|
+
'---',
|
|
91
|
+
'',
|
|
92
|
+
substituteDocsRoot(content, docsRootName).trim(),
|
|
93
|
+
'',
|
|
94
|
+
].join('\n'));
|
|
95
|
+
created.push(`${docsRootName}/starting/${id}.md`);
|
|
96
|
+
}
|
|
97
|
+
skills.push({ id, outputPath: `.agents/skills/${skillName}/SKILL.md` });
|
|
98
|
+
}
|
|
99
|
+
return skills;
|
|
100
|
+
}
|
|
101
|
+
function isAgentFile(relPath) {
|
|
102
|
+
if (AGENT_FILENAMES.has(basename(relPath)))
|
|
103
|
+
return true;
|
|
104
|
+
const posixPath = relPath.split(sep).join('/');
|
|
105
|
+
if (AGENT_PATHS.has(posixPath))
|
|
106
|
+
return true;
|
|
107
|
+
if (/^\.claude\/agents\/[^/]+\.md$/.test(posixPath))
|
|
108
|
+
return true;
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
function findAgentFiles(cwd, excludeDirs = []) {
|
|
112
|
+
const excluded = new Set(excludeDirs);
|
|
113
|
+
const results = [];
|
|
114
|
+
function walk(dir) {
|
|
115
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
116
|
+
const full = join(dir, entry.name);
|
|
117
|
+
const rel = relative(cwd, full);
|
|
118
|
+
if (entry.isDirectory()) {
|
|
119
|
+
if (EXCLUDED_DIRS.has(entry.name) || excluded.has(rel))
|
|
120
|
+
continue;
|
|
121
|
+
walk(full);
|
|
122
|
+
}
|
|
123
|
+
else if (entry.isFile() && isAgentFile(rel)) {
|
|
124
|
+
results.push({ sourcePath: rel.split(sep).join('/'), fragmentId: pathToId(rel) });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
walk(cwd);
|
|
129
|
+
return results.sort((a, b) => a.sourcePath.localeCompare(b.sourcePath));
|
|
130
|
+
}
|
|
131
|
+
function buildDefaultYaml(agentFiles, startingPrompts, startingSkills) {
|
|
132
|
+
const startingIncludes = startingPrompts.map(({ id }) => ` - "@${id}"`);
|
|
133
|
+
// The "root file" is an agent file that lives at the project root (no
|
|
134
|
+
// directory component). Starting prompts (e.g. docs-workflow) only belong
|
|
135
|
+
// there, not in every nested agent file. Prefer an existing AGENTS.md;
|
|
136
|
+
// otherwise fall back to whichever root-level file was found first.
|
|
137
|
+
const rootFiles = agentFiles.filter(f => !f.sourcePath.includes('/'));
|
|
138
|
+
const otherFiles = agentFiles.filter(f => f.sourcePath.includes('/'));
|
|
139
|
+
const rootFile = rootFiles.find(f => f.sourcePath === 'AGENTS.md') ?? rootFiles[0];
|
|
140
|
+
const rootSourcePath = rootFile?.sourcePath ?? 'AGENTS.md';
|
|
141
|
+
const remainingRootFiles = rootFiles.filter(f => f !== rootFile);
|
|
142
|
+
const lines = [
|
|
143
|
+
'name: default',
|
|
144
|
+
'description: Initial approach',
|
|
145
|
+
'',
|
|
146
|
+
'outputs:',
|
|
147
|
+
` ${rootSourcePath}:`,
|
|
148
|
+
...startingIncludes,
|
|
149
|
+
' - "@getting-started"',
|
|
150
|
+
];
|
|
151
|
+
if (rootFile) {
|
|
152
|
+
lines.push(` - "@${rootFile.fragmentId}"`);
|
|
153
|
+
}
|
|
154
|
+
for (const { sourcePath, fragmentId } of [...remainingRootFiles, ...otherFiles]) {
|
|
155
|
+
lines.push(` ${sourcePath}:`);
|
|
156
|
+
lines.push(` - "@${fragmentId}"`);
|
|
157
|
+
}
|
|
158
|
+
for (const { id, outputPath } of startingSkills) {
|
|
159
|
+
lines.push(` ${outputPath}:`);
|
|
160
|
+
lines.push(` - "@${id}"`);
|
|
161
|
+
}
|
|
162
|
+
return lines.join('\n') + '\n';
|
|
163
|
+
}
|
|
164
|
+
function ensureGitignore(cwd, docsRootName) {
|
|
165
|
+
const gitignorePath = join(cwd, '.gitignore');
|
|
166
|
+
const entries = [
|
|
167
|
+
'.compose-active',
|
|
168
|
+
CONFIG_FILE,
|
|
169
|
+
`${docsRootName}/_index.md`,
|
|
170
|
+
];
|
|
171
|
+
const gitignoreExisted = existsSync(gitignorePath);
|
|
172
|
+
const existing = gitignoreExisted ? readFileSync(gitignorePath, 'utf-8') : '';
|
|
173
|
+
const toAdd = entries.filter(e => !existing.split('\n').some(line => line.trim() === e));
|
|
174
|
+
if (toAdd.length === 0)
|
|
175
|
+
return 'unchanged';
|
|
176
|
+
const prefix = existing.endsWith('\n') || existing === '' ? '' : '\n';
|
|
177
|
+
appendFileSync(gitignorePath, prefix + toAdd.join('\n') + '\n');
|
|
178
|
+
return gitignoreExisted ? 'updated' : 'created';
|
|
179
|
+
}
|
|
180
|
+
export function scaffoldProject(cwd, docsRootName) {
|
|
181
|
+
const docsRoot = join(cwd, docsRootName);
|
|
182
|
+
const created = [];
|
|
183
|
+
// Directories
|
|
184
|
+
mkdirSync(join(docsRoot, '_approaches'), { recursive: true });
|
|
185
|
+
created.push(`${docsRootName}/_approaches/`);
|
|
186
|
+
// Sample fragment
|
|
187
|
+
const sampleFragment = join(docsRoot, 'getting-started.md');
|
|
188
|
+
if (!existsSync(sampleFragment)) {
|
|
189
|
+
writeFileSync(sampleFragment, [
|
|
190
|
+
'---',
|
|
191
|
+
'name: getting-started',
|
|
192
|
+
'description: quick-start guide for new contributors',
|
|
193
|
+
'---',
|
|
194
|
+
'',
|
|
195
|
+
'# Getting Started',
|
|
196
|
+
'',
|
|
197
|
+
'<!-- Add your getting started content here -->',
|
|
198
|
+
'',
|
|
199
|
+
].join('\n'));
|
|
200
|
+
created.push(`${docsRootName}/getting-started.md`);
|
|
201
|
+
}
|
|
202
|
+
// Curated starter fragments shipped with compose-md
|
|
203
|
+
const startingPrompts = copyStartingPrompts(docsRoot, docsRootName, created);
|
|
204
|
+
const startingSkills = copyStartingSkills(docsRoot, docsRootName, created);
|
|
205
|
+
// Scan for agent prompt files (excluding the docs root we just created)
|
|
206
|
+
const agentFiles = findAgentFiles(cwd, [docsRootName]);
|
|
207
|
+
if (agentFiles.length > 0) {
|
|
208
|
+
mkdirSync(join(docsRoot, 'existing'), { recursive: true });
|
|
209
|
+
}
|
|
210
|
+
for (const { sourcePath, fragmentId } of agentFiles) {
|
|
211
|
+
const content = readFileSync(join(cwd, sourcePath), 'utf-8');
|
|
212
|
+
const fragmentFile = join(docsRoot, 'existing', `${fragmentId}.md`);
|
|
213
|
+
if (!existsSync(fragmentFile)) {
|
|
214
|
+
writeFileSync(fragmentFile, [
|
|
215
|
+
'---',
|
|
216
|
+
`name: ${fragmentId}`,
|
|
217
|
+
`description: content of ${sourcePath}`,
|
|
218
|
+
'---',
|
|
219
|
+
'',
|
|
220
|
+
content.trim(),
|
|
221
|
+
'',
|
|
222
|
+
].join('\n'));
|
|
223
|
+
created.push(`${docsRootName}/existing/${fragmentId}.md`);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
// Default approach YAML
|
|
227
|
+
const defaultApproach = join(docsRoot, '_approaches', 'default.yaml');
|
|
228
|
+
if (!existsSync(defaultApproach)) {
|
|
229
|
+
writeFileSync(defaultApproach, buildDefaultYaml(agentFiles, startingPrompts, startingSkills));
|
|
230
|
+
created.push(`${docsRootName}/_approaches/default.yaml`);
|
|
231
|
+
}
|
|
232
|
+
// Empty _index.md
|
|
233
|
+
const indexFile = join(docsRoot, '_index.md');
|
|
234
|
+
if (!existsSync(indexFile)) {
|
|
235
|
+
writeFileSync(indexFile, '');
|
|
236
|
+
created.push(`${docsRootName}/_index.md`);
|
|
237
|
+
}
|
|
238
|
+
// Gitignore
|
|
239
|
+
const gitignoreResult = ensureGitignore(cwd, docsRootName);
|
|
240
|
+
if (gitignoreResult !== 'unchanged') {
|
|
241
|
+
created.push(`.gitignore (${gitignoreResult})`);
|
|
242
|
+
}
|
|
243
|
+
return created;
|
|
244
|
+
}
|
|
245
|
+
export async function runInit(cwd) {
|
|
246
|
+
const docsRootName = await input({
|
|
247
|
+
message: 'Docs root name:',
|
|
248
|
+
default: 'projectDocs',
|
|
249
|
+
});
|
|
250
|
+
const docsRoot = join(cwd, docsRootName);
|
|
251
|
+
if (existsSync(docsRoot)) {
|
|
252
|
+
const proceed = await confirm({
|
|
253
|
+
message: `${docsRootName}/ already exists. Continue?`,
|
|
254
|
+
default: false,
|
|
255
|
+
});
|
|
256
|
+
if (!proceed)
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
const created = scaffoldProject(cwd, docsRootName);
|
|
260
|
+
setDocsRootConfig(cwd, docsRootName);
|
|
261
|
+
const agentFiles = findAgentFiles(cwd, [docsRootName]);
|
|
262
|
+
console.log('\nCreated:');
|
|
263
|
+
for (const path of created) {
|
|
264
|
+
console.log(` ${path}`);
|
|
265
|
+
}
|
|
266
|
+
if (agentFiles.length > 0) {
|
|
267
|
+
console.log('\nRegistered agent files as fragments:');
|
|
268
|
+
for (const { sourcePath, fragmentId } of agentFiles) {
|
|
269
|
+
console.log(` ${sourcePath} → @${fragmentId}`);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
console.log('\nDone. Run `compose` to select and apply an approach.');
|
|
273
|
+
}
|
|
274
|
+
export { findAgentFiles };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { loadApproach } from '../lib/approaches.js';
|
|
2
|
+
export function viewApproach(docsRoot, name) {
|
|
3
|
+
const approach = loadApproach(docsRoot, name);
|
|
4
|
+
console.log(`approach: ${approach.name}`);
|
|
5
|
+
if (approach.description)
|
|
6
|
+
console.log(`description: ${approach.description}`);
|
|
7
|
+
console.log();
|
|
8
|
+
for (const [outputPath, fragments] of Object.entries(approach.outputs)) {
|
|
9
|
+
console.log(outputPath);
|
|
10
|
+
for (const id of fragments) {
|
|
11
|
+
console.log(` ${id}`);
|
|
12
|
+
}
|
|
13
|
+
console.log();
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { select } from '@inquirer/prompts';
|
|
2
|
+
import { listApproaches } from './lib/approaches.js';
|
|
3
|
+
import { getActiveApproach } from './lib/active.js';
|
|
4
|
+
import { viewApproach } from './commands/view.js';
|
|
5
|
+
import { applyApproach } from './commands/apply.js';
|
|
6
|
+
export async function runInteractive(docsRoot, cwd) {
|
|
7
|
+
const active = getActiveApproach(cwd);
|
|
8
|
+
const approaches = listApproaches(docsRoot);
|
|
9
|
+
if (approaches.length === 0) {
|
|
10
|
+
console.log('No approaches found in', docsRoot);
|
|
11
|
+
console.log('Run `compose init` to get started.');
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
console.log(`Active approach: ${active ?? 'none'}\n`);
|
|
15
|
+
const chosen = await select({
|
|
16
|
+
message: 'Select an approach:',
|
|
17
|
+
choices: approaches.map(a => ({
|
|
18
|
+
name: `${a.name}${a.description ? ` — ${a.description}` : ''}`,
|
|
19
|
+
value: a.name,
|
|
20
|
+
})),
|
|
21
|
+
});
|
|
22
|
+
const action = await select({
|
|
23
|
+
message: `"${chosen}" —`,
|
|
24
|
+
choices: [
|
|
25
|
+
{ name: 'View', value: 'view', description: 'Show composition without applying' },
|
|
26
|
+
{ name: 'Apply', value: 'apply', description: 'Write output files' },
|
|
27
|
+
],
|
|
28
|
+
});
|
|
29
|
+
if (action === 'view') {
|
|
30
|
+
console.log();
|
|
31
|
+
viewApproach(docsRoot, chosen);
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
console.log();
|
|
35
|
+
await applyApproach(docsRoot, chosen, cwd);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
const ACTIVE_FILE = '.compose-active';
|
|
4
|
+
export function getActiveApproach(cwd) {
|
|
5
|
+
const path = join(cwd, ACTIVE_FILE);
|
|
6
|
+
if (!existsSync(path))
|
|
7
|
+
return null;
|
|
8
|
+
return readFileSync(path, 'utf-8').trim() || null;
|
|
9
|
+
}
|
|
10
|
+
export function setActiveApproach(cwd, name) {
|
|
11
|
+
writeFileSync(join(cwd, ACTIVE_FILE), name);
|
|
12
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, existsSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { load } from 'js-yaml';
|
|
4
|
+
export function approachesDir(docsRoot) {
|
|
5
|
+
return join(docsRoot, '_approaches');
|
|
6
|
+
}
|
|
7
|
+
export function listApproaches(docsRoot) {
|
|
8
|
+
const dir = approachesDir(docsRoot);
|
|
9
|
+
if (!existsSync(dir))
|
|
10
|
+
return [];
|
|
11
|
+
return readdirSync(dir)
|
|
12
|
+
.filter(f => f.endsWith('.yaml') || f.endsWith('.yml'))
|
|
13
|
+
.map(f => {
|
|
14
|
+
const content = readFileSync(join(dir, f), 'utf-8');
|
|
15
|
+
return load(content);
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
export function loadApproach(docsRoot, name) {
|
|
19
|
+
const file = join(approachesDir(docsRoot), `${name}.yaml`);
|
|
20
|
+
if (!existsSync(file))
|
|
21
|
+
throw new Error(`Approach not found: ${name}`);
|
|
22
|
+
return load(readFileSync(file, 'utf-8'));
|
|
23
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
const CONFIG_FILE = '.compose-config.json';
|
|
4
|
+
const DEFAULT_DOCS_ROOT = 'projectDocs';
|
|
5
|
+
export function setDocsRootConfig(cwd, docsRoot) {
|
|
6
|
+
const config = { docsRoot };
|
|
7
|
+
writeFileSync(join(cwd, CONFIG_FILE), JSON.stringify(config, null, 2) + '\n');
|
|
8
|
+
}
|
|
9
|
+
// Reads the persisted docs-root name from .compose-config.json, falling
|
|
10
|
+
// back to the default ('projectDocs') if the config file doesn't exist
|
|
11
|
+
// (e.g. projects scaffolded before this config existed, or `compose init`
|
|
12
|
+
// itself, which runs before the config is written).
|
|
13
|
+
export function getDocsRootName(cwd) {
|
|
14
|
+
const path = join(cwd, CONFIG_FILE);
|
|
15
|
+
if (!existsSync(path))
|
|
16
|
+
return DEFAULT_DOCS_ROOT;
|
|
17
|
+
try {
|
|
18
|
+
const parsed = JSON.parse(readFileSync(path, 'utf-8'));
|
|
19
|
+
if (parsed && typeof parsed.docsRoot === 'string' && parsed.docsRoot.trim()) {
|
|
20
|
+
return parsed.docsRoot;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
// Malformed config file; fall back to the default.
|
|
25
|
+
}
|
|
26
|
+
return DEFAULT_DOCS_ROOT;
|
|
27
|
+
}
|
|
28
|
+
export function getDocsRoot(cwd) {
|
|
29
|
+
return join(cwd, getDocsRootName(cwd));
|
|
30
|
+
}
|
|
31
|
+
export { CONFIG_FILE, DEFAULT_DOCS_ROOT };
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, existsSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
function parseFrontmatter(raw) {
|
|
4
|
+
const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
|
|
5
|
+
if (!match)
|
|
6
|
+
return { data: {}, body: raw };
|
|
7
|
+
const data = {};
|
|
8
|
+
for (const line of (match[1] ?? '').split('\n')) {
|
|
9
|
+
const colonIdx = line.indexOf(':');
|
|
10
|
+
if (colonIdx === -1)
|
|
11
|
+
continue;
|
|
12
|
+
const key = line.slice(0, colonIdx).trim();
|
|
13
|
+
const value = line.slice(colonIdx + 1).trim().replace(/^["']|["']$/g, '');
|
|
14
|
+
data[key] = value;
|
|
15
|
+
}
|
|
16
|
+
return { data, body: match[2] ?? '' };
|
|
17
|
+
}
|
|
18
|
+
export function scanFragments(docsRoot) {
|
|
19
|
+
const fragments = new Map();
|
|
20
|
+
function scan(dir) {
|
|
21
|
+
if (!existsSync(dir))
|
|
22
|
+
return;
|
|
23
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
24
|
+
if (entry.isDirectory()) {
|
|
25
|
+
if (entry.name === '_approaches')
|
|
26
|
+
continue;
|
|
27
|
+
scan(join(dir, entry.name));
|
|
28
|
+
}
|
|
29
|
+
else if (entry.name.endsWith('.md') && entry.name !== '_index.md') {
|
|
30
|
+
const filePath = join(dir, entry.name);
|
|
31
|
+
const { data, body } = parseFrontmatter(readFileSync(filePath, 'utf-8'));
|
|
32
|
+
if (data.name) {
|
|
33
|
+
fragments.set(data.name, { name: data.name, description: data.description, content: body, filePath });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
scan(docsRoot);
|
|
39
|
+
return fragments;
|
|
40
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# This project uses compose-md
|
|
2
|
+
|
|
3
|
+
The coding agent prompts (AGENTS.md, CLAUDE.md, SKILL.md etc) in this project are .gitignored. Do not edit them directly.
|
|
4
|
+
|
|
5
|
+
Instead, edit the files in $PROJECT_DOCS
|
|
6
|
+
|
|
7
|
+
Invoke the /compose-docs skill to assist.
|
|
8
|
+
|
|
9
|
+
If your memory system would normally have you write something durable directly into one of these files, write it into the corresponding fragment in $PROJECT_DOCS instead, then run `compose apply` to regenerate the file.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: compose-docs
|
|
3
|
+
description: Edit and regenerate this project's agent instruction files (AGENTS.md, CLAUDE.md, SKILL.md, etc.), which are generated by compose-md from fragments and approaches. Use this whenever you would normally write durable memory directly into one of those files.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# compose-docs
|
|
7
|
+
|
|
8
|
+
This project's coding-agent instruction files (AGENTS.md, CLAUDE.md, SKILL.md, etc.) are generated by compose-md and are gitignored. Do not edit them directly — your changes will be overwritten the next time `compose apply` runs.
|
|
9
|
+
|
|
10
|
+
## Where the real content lives
|
|
11
|
+
|
|
12
|
+
- **Fragments** — `.md` files anywhere under `$PROJECT_DOCS/` (except `_approaches/`), each with a `name:` field in its frontmatter.
|
|
13
|
+
- **Approaches** — `.yaml` files in `$PROJECT_DOCS/_approaches/`. Each approach maps an output path (e.g. `AGENTS.md`) to a list of `@fragment-name` references and/or inline literal text (YAML block scalars, e.g. `- |`, work for multiline bespoke content like frontmatter headers).
|
|
14
|
+
|
|
15
|
+
## Workflow
|
|
16
|
+
|
|
17
|
+
1. Find the fragment behind the content you want to change — search `$PROJECT_DOCS/` for the relevant `name:` frontmatter, or look up the output path in the active approach's YAML.
|
|
18
|
+
2. Edit the fragment's markdown content.
|
|
19
|
+
3. Run `compose apply <approach>` to regenerate the output files from the fragments.
|
|
20
|
+
4. To add a new generated file, add an entry under `outputs:` in the approach YAML.
|
|
21
|
+
5. If your memory mechanism would normally write directly into AGENTS.md, CLAUDE.md, or a SKILL.md (e.g. to persist something durable you've learned), write it into the corresponding fragment instead, then run `compose apply`. Never write memory straight into a generated file — it will be overwritten.
|
|
22
|
+
|
|
23
|
+
Never hand-edit a generated file — always change the source fragment (or approach YAML) and re-run `compose apply`.
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "compose-md-cli",
|
|
3
|
+
"module": "index.ts",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"private": false,
|
|
6
|
+
"bin": {
|
|
7
|
+
"compose": "dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist"
|
|
11
|
+
],
|
|
12
|
+
"scripts": {
|
|
13
|
+
"compose": "bun run src/cli.ts",
|
|
14
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json && cp -R src/startingPrompts dist/startingPrompts && chmod +x dist/cli.js",
|
|
15
|
+
"test": "bun test",
|
|
16
|
+
"typecheck": "tsc --noEmit",
|
|
17
|
+
"clean": "rm -rf projectDocs && git checkout -- .gitignore",
|
|
18
|
+
"docs:dev": "vitepress dev",
|
|
19
|
+
"docs:build": "vitepress build",
|
|
20
|
+
"docs:preview": "vitepress preview"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@types/bun": "latest",
|
|
24
|
+
"@types/js-yaml": "^4.0.9",
|
|
25
|
+
"typescript": "^5",
|
|
26
|
+
"vitepress": "^2.0.0-alpha.18"
|
|
27
|
+
},
|
|
28
|
+
"peerDependencies": {
|
|
29
|
+
"typescript": "^5"
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"@inquirer/prompts": "^8.5.2",
|
|
33
|
+
"js-yaml": "^5.2.0"
|
|
34
|
+
},
|
|
35
|
+
"version": "0.0.0"
|
|
36
|
+
}
|