@ulysses-ai/create-workspace 0.20.0-beta.0 → 0.22.0-beta.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 +1 -1
- package/lib/init.mjs +9 -0
- package/lib/init.test.mjs +75 -0
- package/lib/scaffold.mjs +8 -0
- package/lib/scaffold.test.mjs +20 -0
- package/package.json +1 -1
- package/template/CLAUDE.md.tmpl +2 -2
- package/template/_claude/hooks/workspace-update-check.mjs +4 -4
- package/template/_claude/scripts/build-workspace-context.mjs +2 -0
- package/template/_claude/scripts/classify-update.mjs +368 -17
- package/template/_claude/scripts/maintenance-audit.mjs +697 -0
- package/template/_claude/scripts/template-baseline.mjs +215 -0
- package/template/_claude/skills/maintenance/SKILL.md +48 -108
- package/template/_claude/skills/workspace-init/SKILL.md +6 -0
- package/template/_claude/skills/workspace-update/SKILL.md +71 -37
- package/template/workspace.json.tmpl +0 -1
|
@@ -4,22 +4,80 @@
|
|
|
4
4
|
//
|
|
5
5
|
// Usage:
|
|
6
6
|
// node classify-update.mjs [--root <dir>] [--payload <dir>]
|
|
7
|
+
// node classify-update.mjs --root <dir> --payload <dir> --write-baseline
|
|
8
|
+
// node classify-update.mjs --root <dir> --payload <dir> --merge-claude-md
|
|
7
9
|
//
|
|
8
10
|
// --root workspace root; defaults to the current working directory (never
|
|
9
11
|
// derived from this script's location — the upgrade payload runs
|
|
10
12
|
// this file from <workspace>/.workspace-update/.claude/scripts/)
|
|
11
13
|
// --payload the staged payload; defaults to <root>/.workspace-update
|
|
12
14
|
//
|
|
13
|
-
//
|
|
14
|
-
// new
|
|
15
|
-
//
|
|
16
|
-
//
|
|
15
|
+
// The default mode prints JSON with these lists:
|
|
16
|
+
// new — no installed counterpart and no baseline entry; safe to
|
|
17
|
+
// batch-apply after one confirm
|
|
18
|
+
// identical — installed file already equals the payload
|
|
19
|
+
// updated — installed file equals the BASELINE (what the template last
|
|
20
|
+
// shipped here) but not the payload: a pure template change the
|
|
21
|
+
// user never touched. Batched with `new` behind one confirm.
|
|
22
|
+
// differs — installed file matches neither the payload nor the baseline
|
|
23
|
+
// while the payload also differs from the baseline: a local
|
|
24
|
+
// edit AND a template change — the one case that needs a
|
|
25
|
+
// per-file decision (or the workspace predates baselines and
|
|
26
|
+
// has no entry to compare).
|
|
27
|
+
// localOnly — installed file differs from the payload, but the payload
|
|
28
|
+
// equals the baseline: the template hasn't touched the file
|
|
29
|
+
// since the last update, so the difference is purely local.
|
|
30
|
+
// Listed for information only — never asked about, never
|
|
31
|
+
// applied.
|
|
32
|
+
// deletedLocally — the baseline records the file and the payload still
|
|
33
|
+
// ships it, but it is missing from the workspace: deleted
|
|
34
|
+
// locally (or never installed at /workspace-init). The skill
|
|
35
|
+
// asks once whether to restore the list.
|
|
36
|
+
// activated — the payload ships rules/{name}.md.skip while the workspace
|
|
37
|
+
// deliberately keeps {name}.md active; nothing to install, the
|
|
38
|
+
// active rule stays (gh:180)
|
|
39
|
+
// removed — installed file with no payload counterpart: the template
|
|
40
|
+
// stopped shipping it. Excludes what the workspace owns:
|
|
41
|
+
// *.test.mjs (see staleTests), anything gitignored
|
|
42
|
+
// (machine-local), paths under .claude/worktrees/, and entries
|
|
43
|
+
// of workspace.json → workspace.localFiles (array of
|
|
44
|
+
// .claude/-relative paths or globs for files this workspace
|
|
45
|
+
// owns) (gh:180)
|
|
46
|
+
// staleTests — *.test.mjs files under .claude/ with no payload counterpart.
|
|
47
|
+
// The npm tarball ships no tests, so these came from a dev
|
|
48
|
+
// checkout and are never updated by /workspace-update; the
|
|
49
|
+
// skill offers to remove them (tests live in the template repo)
|
|
50
|
+
//
|
|
51
|
+
// Plus `hasBaseline`: whether .claude/.template-baseline.json exists. Without
|
|
52
|
+
// it (workspaces older than the baseline's introduction) template changes
|
|
53
|
+
// cannot be told from local edits, so they land in `differs` — the first
|
|
54
|
+
// update after v0.21 asks per file; once it writes the baseline, later updates
|
|
55
|
+
// won't.
|
|
56
|
+
//
|
|
57
|
+
// Content comparisons hash with CRLF normalized to LF on both sides (binary
|
|
58
|
+
// files hash byte-exact), so a git autocrlf checkout that stores CRLF where
|
|
59
|
+
// the payload ships LF classifies as identical rather than locally modified.
|
|
17
60
|
//
|
|
18
61
|
// Only verbatim-installed files are classified: everything under .claude/,
|
|
19
62
|
// plus .mcp.json and .claudeignore. The payload's templates (*.tmpl, which
|
|
20
63
|
// install with {{project-name}} substitution), _gitignore (merged line-by-line
|
|
21
64
|
// into the workspace's .gitignore), and .manifest.json (payload metadata) are
|
|
22
65
|
// handled by their own steps in /workspace-update and are excluded here.
|
|
66
|
+
//
|
|
67
|
+
// The other two modes are /workspace-update bookends:
|
|
68
|
+
// --write-baseline write .claude/.template-baseline.json recording the
|
|
69
|
+
// hash of every verbatim payload file — what the template
|
|
70
|
+
// now ships. Run at the END of an update, after all
|
|
71
|
+
// per-file decisions. Entries record the PAYLOAD hash —
|
|
72
|
+
// except unapplied updates (workspace still holds the old
|
|
73
|
+
// baseline content), which keep the old entry so they
|
|
74
|
+
// present as `updated` again next time; see
|
|
75
|
+
// template-baseline.mjs. Throws rather than writing an
|
|
76
|
+
// empty baseline.
|
|
77
|
+
// --merge-claude-md print CLAUDE.md with the payload's CLAUDE.md.tmpl
|
|
78
|
+
// merged in: template lines updated, the workspace's own
|
|
79
|
+
// lines (custom skill entries, sections) kept. The skill
|
|
80
|
+
// shows the diff against the current file before writing.
|
|
23
81
|
|
|
24
82
|
import {
|
|
25
83
|
existsSync,
|
|
@@ -28,8 +86,10 @@ import {
|
|
|
28
86
|
statSync,
|
|
29
87
|
realpathSync,
|
|
30
88
|
} from 'node:fs';
|
|
31
|
-
import { join, resolve } from 'node:path';
|
|
89
|
+
import { basename, join, resolve } from 'node:path';
|
|
32
90
|
import { fileURLToPath } from 'node:url';
|
|
91
|
+
import { gitIgnoredPaths } from './build-workspace-context.mjs';
|
|
92
|
+
import { BASELINE_PATH, hashBytes, readBaseline, writeBaseline } from './template-baseline.mjs';
|
|
33
93
|
|
|
34
94
|
function isMainModule(metaUrl) {
|
|
35
95
|
if (!process.argv[1]) return false;
|
|
@@ -39,11 +99,13 @@ function isMainModule(metaUrl) {
|
|
|
39
99
|
}
|
|
40
100
|
|
|
41
101
|
function parseArgs(argv) {
|
|
42
|
-
const args = { root: process.cwd(), payload: null };
|
|
102
|
+
const args = { root: process.cwd(), payload: null, writeBaseline: false, mergeClaudeMd: false };
|
|
43
103
|
for (let i = 2; i < argv.length; i++) {
|
|
44
104
|
const a = argv[i];
|
|
45
105
|
if (a === '--root') args.root = argv[++i];
|
|
46
106
|
else if (a === '--payload') args.payload = argv[++i];
|
|
107
|
+
else if (a === '--write-baseline') args.writeBaseline = true;
|
|
108
|
+
else if (a === '--merge-claude-md') args.mergeClaudeMd = true;
|
|
47
109
|
else throw new Error(`Unknown arg: ${a}`);
|
|
48
110
|
}
|
|
49
111
|
return args;
|
|
@@ -58,7 +120,12 @@ function isClassified(payloadRelPath) {
|
|
|
58
120
|
return VERBATIM_ROOTS.includes(first);
|
|
59
121
|
}
|
|
60
122
|
|
|
61
|
-
|
|
123
|
+
// Directories never walked when looking for removed files. .claude/worktrees/
|
|
124
|
+
// holds entire nested worktrees — walking them is slow and every file inside
|
|
125
|
+
// is unmanaged by the template.
|
|
126
|
+
const SKIPPED_DIRS = new Set(['worktrees']);
|
|
127
|
+
|
|
128
|
+
function* walkFiles(dir, prefix = '', skipDirs = null) {
|
|
62
129
|
let entries;
|
|
63
130
|
try {
|
|
64
131
|
entries = readdirSync(dir).sort();
|
|
@@ -66,15 +133,69 @@ function* walkFiles(dir, prefix = '') {
|
|
|
66
133
|
return;
|
|
67
134
|
}
|
|
68
135
|
for (const name of entries) {
|
|
136
|
+
if (skipDirs && skipDirs.has(name)) continue;
|
|
69
137
|
const rel = prefix ? `${prefix}/${name}` : name;
|
|
70
138
|
const full = join(dir, name);
|
|
71
139
|
let st;
|
|
72
140
|
try { st = statSync(full); } catch { continue; }
|
|
73
|
-
if (st.isDirectory()) yield* walkFiles(full, rel);
|
|
141
|
+
if (st.isDirectory()) yield* walkFiles(full, rel, skipDirs);
|
|
74
142
|
else if (st.isFile()) yield rel;
|
|
75
143
|
}
|
|
76
144
|
}
|
|
77
145
|
|
|
146
|
+
// Installed files under the verbatim-managed roots: the .claude/ tree (minus
|
|
147
|
+
// skipped directories) plus the two standalone files. Nothing else in the
|
|
148
|
+
// workspace root is walked — repos/ and work-sessions/ hold entire worktrees
|
|
149
|
+
// the template never manages.
|
|
150
|
+
function* walkInstalledFiles(absRoot) {
|
|
151
|
+
yield* walkFiles(join(absRoot, '.claude'), '.claude', SKIPPED_DIRS);
|
|
152
|
+
for (const name of ['.mcp.json', '.claudeignore']) {
|
|
153
|
+
if (existsSync(join(absRoot, name))) yield name;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** workspace.json → workspace.localFiles, normalized to .claude/-relative globs. */
|
|
158
|
+
function readLocalFiles(absRoot) {
|
|
159
|
+
const configPath = join(absRoot, 'workspace.json');
|
|
160
|
+
try {
|
|
161
|
+
const config = JSON.parse(readFileSync(configPath, 'utf8'));
|
|
162
|
+
const entries = config?.workspace?.localFiles;
|
|
163
|
+
if (!Array.isArray(entries)) return [];
|
|
164
|
+
return entries
|
|
165
|
+
.filter((e) => typeof e === 'string' && e.length > 0)
|
|
166
|
+
.map((e) => e.replace(/^(\.claude\/)+/, ''));
|
|
167
|
+
} catch {
|
|
168
|
+
return [];
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Match `rel` (a .claude/-relative posix path) against a localFiles entry —
|
|
174
|
+
* an exact path or a glob where `**` spans separators and `*` does not.
|
|
175
|
+
* No glob library: the shapes localFiles needs are these two stars.
|
|
176
|
+
*/
|
|
177
|
+
function globMatches(pattern, rel) {
|
|
178
|
+
if (pattern === rel) return true;
|
|
179
|
+
if (!pattern.includes('*')) return false;
|
|
180
|
+
const re = new RegExp(
|
|
181
|
+
`^${pattern.split('**').map(
|
|
182
|
+
(part) => part.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '[^/]*'),
|
|
183
|
+
).join('.*')}$`,
|
|
184
|
+
);
|
|
185
|
+
return re.test(rel);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function isOwnedByWorkspace(rel, localFiles) {
|
|
189
|
+
// The template's own .gitignore declares these machine-local; the baseline
|
|
190
|
+
// is per-workspace state the template never ships.
|
|
191
|
+
if (rel === '.claude/settings.local.json' || rel === '.claude/.active-session.json' || rel === BASELINE_PATH) {
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
194
|
+
if (!rel.startsWith('.claude/')) return false;
|
|
195
|
+
const claudeRel = rel.slice('.claude/'.length);
|
|
196
|
+
return localFiles.some((pattern) => globMatches(pattern, claudeRel));
|
|
197
|
+
}
|
|
198
|
+
|
|
78
199
|
export function classifyUpdate({ root, payload }) {
|
|
79
200
|
const absRoot = resolve(root);
|
|
80
201
|
const absPayload = resolve(payload ?? join(absRoot, '.workspace-update'));
|
|
@@ -82,29 +203,259 @@ export function classifyUpdate({ root, payload }) {
|
|
|
82
203
|
throw new Error(`No payload found at ${absPayload} — run npx @ulysses-ai/create-workspace --upgrade first`);
|
|
83
204
|
}
|
|
84
205
|
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
206
|
+
const payloadFiles = [...walkFiles(absPayload)].filter(isClassified);
|
|
207
|
+
const payloadSet = new Set(payloadFiles);
|
|
208
|
+
const baseline = readBaseline(absRoot);
|
|
209
|
+
|
|
210
|
+
const result = {
|
|
211
|
+
new: [],
|
|
212
|
+
identical: [],
|
|
213
|
+
updated: [],
|
|
214
|
+
differs: [],
|
|
215
|
+
localOnly: [],
|
|
216
|
+
deletedLocally: [],
|
|
217
|
+
activated: [],
|
|
218
|
+
removed: [],
|
|
219
|
+
staleTests: [],
|
|
220
|
+
hasBaseline: baseline !== null,
|
|
221
|
+
};
|
|
222
|
+
for (const rel of payloadFiles) {
|
|
223
|
+
// A .skip rule whose active counterpart is installed was deliberately
|
|
224
|
+
// activated by this workspace: report it as activated, not new.
|
|
225
|
+
if (rel.startsWith('.claude/rules/') && rel.endsWith('.md.skip')) {
|
|
226
|
+
const active = rel.replace(/\.skip$/, '');
|
|
227
|
+
if (existsSync(join(absRoot, active)) && !existsSync(join(absRoot, rel))) {
|
|
228
|
+
result.activated.push({ skip: rel, active });
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
88
232
|
const installed = join(absRoot, rel);
|
|
89
233
|
if (!existsSync(installed)) {
|
|
90
|
-
|
|
234
|
+
// A file the baseline records and the payload still ships, yet missing
|
|
235
|
+
// from the workspace: deleted locally (or declined at install time) —
|
|
236
|
+
// not new, the template has carried it all along.
|
|
237
|
+
if (baseline && typeof baseline.files[rel] === 'string') {
|
|
238
|
+
result.deletedLocally.push(rel);
|
|
239
|
+
} else {
|
|
240
|
+
result.new.push(rel);
|
|
241
|
+
}
|
|
91
242
|
continue;
|
|
92
243
|
}
|
|
93
|
-
const
|
|
94
|
-
const
|
|
95
|
-
if (
|
|
244
|
+
const wsHash = hashBytes(readFileSync(installed));
|
|
245
|
+
const payloadHash = hashBytes(readFileSync(join(absPayload, rel)));
|
|
246
|
+
if (wsHash === payloadHash) {
|
|
96
247
|
result.identical.push(rel);
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
const baseHash = baseline ? baseline.files[rel] : undefined;
|
|
251
|
+
if (baseHash !== undefined && wsHash === baseHash) {
|
|
252
|
+
// Workspace still holds exactly what the template last shipped here —
|
|
253
|
+
// the difference is the template's own change since then.
|
|
254
|
+
result.updated.push(rel);
|
|
255
|
+
} else if (baseHash !== undefined && payloadHash === baseHash) {
|
|
256
|
+
// The payload is unchanged since the baseline; the workspace's
|
|
257
|
+
// difference is purely local. Informational — nothing to apply.
|
|
258
|
+
result.localOnly.push(rel);
|
|
97
259
|
} else {
|
|
260
|
+
// A local edit on top of a template change (or no baseline entry to
|
|
261
|
+
// compare) — the one case that needs a per-file decision.
|
|
98
262
|
result.differs.push(rel);
|
|
99
263
|
}
|
|
100
264
|
}
|
|
265
|
+
|
|
266
|
+
// Removed: installed verbatim-managed files with no payload counterpart.
|
|
267
|
+
const skipSet = new Set(payloadFiles);
|
|
268
|
+
const localFiles = readLocalFiles(absRoot);
|
|
269
|
+
const installedFiles = [...walkInstalledFiles(absRoot)];
|
|
270
|
+
const gitignored = gitIgnoredPaths(absRoot, installedFiles);
|
|
271
|
+
for (const rel of installedFiles) {
|
|
272
|
+
if (skipSet.has(rel)) continue;
|
|
273
|
+
// An active rule whose .skip twin is in the payload is an activated rule,
|
|
274
|
+
// not a removed one.
|
|
275
|
+
if (rel.startsWith('.claude/rules/') && rel.endsWith('.md') && skipSet.has(`${rel}.skip`)) continue;
|
|
276
|
+
if (gitignored.has(rel)) continue;
|
|
277
|
+
// Test files never come from the npm tarball; the payload not carrying one
|
|
278
|
+
// means the template's test suite moved on without this copy.
|
|
279
|
+
if (rel.endsWith('.test.mjs')) {
|
|
280
|
+
result.staleTests.push(rel);
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
if (isOwnedByWorkspace(rel, localFiles)) continue;
|
|
284
|
+
result.removed.push(rel);
|
|
285
|
+
}
|
|
101
286
|
return result;
|
|
102
287
|
}
|
|
103
288
|
|
|
289
|
+
// ---------- CLAUDE.md merge ----------
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Split markdown into blocks: the preamble (heading null) plus one block per
|
|
293
|
+
* `## ` heading. Deeper headings belong to their enclosing section, and `## `
|
|
294
|
+
* lines inside fenced code blocks (``` or ~~~) stay content of their section.
|
|
295
|
+
*/
|
|
296
|
+
function splitBlocks(text) {
|
|
297
|
+
const blocks = [];
|
|
298
|
+
let cur = { heading: null, lines: [] };
|
|
299
|
+
let fenced = false;
|
|
300
|
+
for (const line of text.split(/\r?\n/)) {
|
|
301
|
+
if (/^\s*(```|~~~)/.test(line)) fenced = !fenced;
|
|
302
|
+
if (!fenced && /^##\s/.test(line)) {
|
|
303
|
+
blocks.push(cur);
|
|
304
|
+
cur = { heading: line.trim(), lines: [] };
|
|
305
|
+
} else {
|
|
306
|
+
cur.lines.push(line);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
blocks.push(cur);
|
|
310
|
+
return blocks;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* A heading's merge key. Identical headings match; beyond that, any
|
|
315
|
+
* `## Workspace:` heading matches any other — the intro heading carries the
|
|
316
|
+
* workspace name, which differs the moment a workspace is renamed (or the
|
|
317
|
+
* fallback directory name was used), and treating them as two sections
|
|
318
|
+
* duplicated the template's intro alongside the renamed original.
|
|
319
|
+
*/
|
|
320
|
+
function headingKey(heading) {
|
|
321
|
+
if (heading !== null && heading.startsWith('## Workspace:')) return '## Workspace:';
|
|
322
|
+
return heading;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* A list entry's merge key: the name of its first backticked `/command`
|
|
327
|
+
* token (`- \`/start-work [handoff|blank]\` — …` → start-work). Two entries
|
|
328
|
+
* with the same name are the same skill, so the template's reworded line
|
|
329
|
+
* replaces the workspace's instead of duplicating it.
|
|
330
|
+
*/
|
|
331
|
+
function entryKey(line) {
|
|
332
|
+
const m = line.match(/^\s*[-*]\s+`\/([a-z0-9][a-z0-9-]*)[^`]*`/);
|
|
333
|
+
return m ? m[1] : null;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function trimTrailingBlanks(lines) {
|
|
337
|
+
let end = lines.length;
|
|
338
|
+
while (end > 0 && lines[end - 1].trim() === '') end--;
|
|
339
|
+
return lines.slice(0, end);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function trimLeadingBlanks(lines) {
|
|
343
|
+
let start = 0;
|
|
344
|
+
while (start < lines.length && lines[start].trim() === '') start++;
|
|
345
|
+
return lines.slice(start);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* One section's bodies merged: the template's new lines, then the workspace's
|
|
350
|
+
* lines that the template no longer carries (matched by entry name for list
|
|
351
|
+
* entries, by trimmed text otherwise).
|
|
352
|
+
*/
|
|
353
|
+
function mergeBody(curLines, nxtLines) {
|
|
354
|
+
const nxtKeys = new Set(nxtLines.map(entryKey).filter(Boolean));
|
|
355
|
+
const nxtTrimmed = new Set(nxtLines.map((l) => l.trim()).filter(Boolean));
|
|
356
|
+
const kept = [];
|
|
357
|
+
for (const line of curLines) {
|
|
358
|
+
const key = entryKey(line);
|
|
359
|
+
if (key !== null && nxtKeys.has(key)) continue; // template owns this entry — its line updates ours
|
|
360
|
+
const t = line.trim();
|
|
361
|
+
if (t !== '' && nxtTrimmed.has(t)) continue; // unchanged line, already present
|
|
362
|
+
kept.push(line);
|
|
363
|
+
}
|
|
364
|
+
const body = trimTrailingBlanks(nxtLines);
|
|
365
|
+
return kept.length === 0 ? body : [...body, ...trimLeadingBlanks(trimTrailingBlanks(kept))];
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function renderBlocks(blocks, eol) {
|
|
369
|
+
const parts = [];
|
|
370
|
+
for (const b of blocks) {
|
|
371
|
+
const body = trimTrailingBlanks(b.lines);
|
|
372
|
+
if (b.heading === null) {
|
|
373
|
+
if (body.length > 0) parts.push(body.join(eol));
|
|
374
|
+
} else {
|
|
375
|
+
parts.push([b.heading, ...body].join(eol));
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
return parts.join(eol + eol) + eol;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Merge an updated template CLAUDE.md (`nextText`, already {{project-name}}-
|
|
383
|
+
* substituted) into the workspace's current one. Template-owned lines take the
|
|
384
|
+
* template's new versions; lines the template doesn't have — the workspace's
|
|
385
|
+
* own skill entries, custom bullets, whole sections — are kept. Sections are
|
|
386
|
+
* matched by heading (`## Workspace:` headings match regardless of name): the
|
|
387
|
+
* result follows the workspace's section order, new template sections are
|
|
388
|
+
* appended at the end, and kept lines land at the end of their section. The
|
|
389
|
+
* output keeps the current file's line endings — CRLF in, CRLF out.
|
|
390
|
+
*/
|
|
391
|
+
export function mergeClaudeMd(currentText, nextText) {
|
|
392
|
+
const eol = currentText != null && currentText.includes('\r\n') ? '\r\n' : '\n';
|
|
393
|
+
const nxtBlocks = splitBlocks(nextText);
|
|
394
|
+
if (currentText == null || currentText.trim() === '') return renderBlocks(nxtBlocks, eol);
|
|
395
|
+
const nxtByHeading = new Map(nxtBlocks.map((b) => [headingKey(b.heading), b]));
|
|
396
|
+
const used = new Set();
|
|
397
|
+
const out = [];
|
|
398
|
+
for (const cur of splitBlocks(currentText)) {
|
|
399
|
+
const nxt = nxtByHeading.get(headingKey(cur.heading));
|
|
400
|
+
if (nxt) {
|
|
401
|
+
used.add(nxt);
|
|
402
|
+
out.push({ heading: nxt.heading, lines: mergeBody(cur.lines, nxt.lines) });
|
|
403
|
+
} else {
|
|
404
|
+
out.push(cur); // a section the template doesn't have — the workspace's own
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
for (const nxt of nxtBlocks) {
|
|
408
|
+
if (!used.has(nxt)) out.push({ heading: nxt.heading, lines: trimTrailingBlanks(nxt.lines) });
|
|
409
|
+
}
|
|
410
|
+
return renderBlocks(out, eol);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// ---------- CLI modes ----------
|
|
414
|
+
|
|
415
|
+
function resolvePayload(args) {
|
|
416
|
+
return resolve(args.payload ?? join(resolve(args.root), '.workspace-update'));
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function writeBaselineMode(args) {
|
|
420
|
+
const baseline = writeBaseline(args.root, resolvePayload(args));
|
|
421
|
+
process.stdout.write(JSON.stringify({
|
|
422
|
+
written: true,
|
|
423
|
+
path: BASELINE_PATH,
|
|
424
|
+
templateVersion: baseline.templateVersion,
|
|
425
|
+
files: Object.keys(baseline.files).length,
|
|
426
|
+
}, null, 2) + '\n');
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function mergeClaudeMdMode(args) {
|
|
430
|
+
const absRoot = resolve(args.root);
|
|
431
|
+
const absPayload = resolvePayload(args);
|
|
432
|
+
const tmplPath = join(absPayload, 'CLAUDE.md.tmpl');
|
|
433
|
+
if (!existsSync(tmplPath)) {
|
|
434
|
+
throw new Error(`No CLAUDE.md.tmpl in ${absPayload} — nothing to merge`);
|
|
435
|
+
}
|
|
436
|
+
// The workspace name for {{project-name}} substitution: workspace.json is
|
|
437
|
+
// the source of truth; the directory name is the fallback.
|
|
438
|
+
let name = basename(absRoot);
|
|
439
|
+
try {
|
|
440
|
+
const config = JSON.parse(readFileSync(join(absRoot, 'workspace.json'), 'utf8'));
|
|
441
|
+
if (typeof config?.workspace?.name === 'string' && config.workspace.name) name = config.workspace.name;
|
|
442
|
+
} catch { /* no workspace.json — keep the directory name */ }
|
|
443
|
+
const next = readFileSync(tmplPath, 'utf8').replace(/\{\{project-name\}\}/g, name);
|
|
444
|
+
const claudeMdPath = join(absRoot, 'CLAUDE.md');
|
|
445
|
+
const current = existsSync(claudeMdPath) ? readFileSync(claudeMdPath, 'utf8') : '';
|
|
446
|
+
process.stdout.write(mergeClaudeMd(current, next));
|
|
447
|
+
}
|
|
448
|
+
|
|
104
449
|
function main() {
|
|
105
450
|
const args = parseArgs(process.argv);
|
|
106
|
-
|
|
107
|
-
|
|
451
|
+
if (args.writeBaseline) {
|
|
452
|
+
writeBaselineMode(args);
|
|
453
|
+
} else if (args.mergeClaudeMd) {
|
|
454
|
+
mergeClaudeMdMode(args);
|
|
455
|
+
} else {
|
|
456
|
+
const result = classifyUpdate({ root: args.root, payload: args.payload });
|
|
457
|
+
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
458
|
+
}
|
|
108
459
|
}
|
|
109
460
|
|
|
110
461
|
if (isMainModule(import.meta.url)) {
|