sequant 2.6.1 → 2.7.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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +4 -0
- package/dist/bin/cli.js +28 -4
- package/dist/marketplace/external_plugins/sequant/.claude-plugin/plugin.json +1 -1
- package/dist/marketplace/external_plugins/sequant/skills/assess/SKILL.md +3 -0
- package/dist/marketplace/external_plugins/sequant/skills/clean/SKILL.md +3 -0
- package/dist/marketplace/external_plugins/sequant/skills/docs/SKILL.md +3 -0
- package/dist/marketplace/external_plugins/sequant/skills/exec/SKILL.md +3 -0
- package/dist/marketplace/external_plugins/sequant/skills/fullsolve/SKILL.md +3 -0
- package/dist/marketplace/external_plugins/sequant/skills/improve/SKILL.md +4 -1
- package/dist/marketplace/external_plugins/sequant/skills/loop/SKILL.md +3 -0
- package/dist/marketplace/external_plugins/sequant/skills/merger/SKILL.md +3 -0
- package/dist/marketplace/external_plugins/sequant/skills/qa/SKILL.md +3 -0
- package/dist/marketplace/external_plugins/sequant/skills/reflect/SKILL.md +3 -0
- package/dist/marketplace/external_plugins/sequant/skills/security-review/SKILL.md +3 -0
- package/dist/marketplace/external_plugins/sequant/skills/setup/SKILL.md +3 -0
- package/dist/marketplace/external_plugins/sequant/skills/solve/SKILL.md +3 -0
- package/dist/marketplace/external_plugins/sequant/skills/spec/SKILL.md +3 -0
- package/dist/marketplace/external_plugins/sequant/skills/test/SKILL.md +3 -0
- package/dist/marketplace/external_plugins/sequant/skills/testgen/SKILL.md +3 -0
- package/dist/marketplace/external_plugins/sequant/skills/verify/SKILL.md +3 -0
- package/dist/src/commands/ready.js +1 -1
- package/dist/src/commands/run.js +1 -1
- package/dist/src/commands/sync.d.ts +44 -5
- package/dist/src/commands/sync.js +244 -18
- package/dist/src/commands/update.d.ts +1 -0
- package/dist/src/commands/update.js +80 -68
- package/dist/src/lib/templates.d.ts +50 -0
- package/dist/src/lib/templates.js +134 -15
- package/dist/src/ui/tui/App.js +24 -2
- package/dist/src/ui/tui/IssueBox.js +4 -4
- package/dist/src/ui/tui/load.d.ts +25 -0
- package/dist/src/ui/tui/load.js +41 -0
- package/dist/src/ui/tui/theme.d.ts +21 -3
- package/dist/src/ui/tui/theme.js +22 -4
- package/package.json +1 -1
- package/templates/skills/assess/SKILL.md +3 -0
- package/templates/skills/clean/SKILL.md +3 -0
- package/templates/skills/docs/SKILL.md +3 -0
- package/templates/skills/exec/SKILL.md +3 -0
- package/templates/skills/fullsolve/SKILL.md +3 -0
- package/templates/skills/improve/SKILL.md +4 -1
- package/templates/skills/loop/SKILL.md +3 -0
- package/templates/skills/merger/SKILL.md +3 -0
- package/templates/skills/qa/SKILL.md +3 -0
- package/templates/skills/reflect/SKILL.md +3 -0
- package/templates/skills/security-review/SKILL.md +3 -0
- package/templates/skills/setup/SKILL.md +3 -0
- package/templates/skills/solve/SKILL.md +3 -0
- package/templates/skills/spec/SKILL.md +3 -0
- package/templates/skills/test/SKILL.md +3 -0
- package/templates/skills/testgen/SKILL.md +3 -0
- package/templates/skills/verify/SKILL.md +3 -0
|
@@ -5,14 +5,23 @@
|
|
|
5
5
|
* Designed for plugin users who need to update after upgrading sequant.
|
|
6
6
|
*/
|
|
7
7
|
import chalk from "chalk";
|
|
8
|
+
import { join } from "path";
|
|
9
|
+
import { createHash } from "crypto";
|
|
8
10
|
import { getManifest, updateManifest, getPackageVersion, } from "../lib/manifest.js";
|
|
9
|
-
import { copyTemplates } from "../lib/templates.js";
|
|
11
|
+
import { copyTemplates, computeTemplateChanges, listTemplateFiles, getTemplatesDir, } from "../lib/templates.js";
|
|
10
12
|
import { getConfig } from "../lib/config.js";
|
|
11
|
-
import { writeFile, readFile, fileExists } from "../lib/fs.js";
|
|
13
|
+
import { writeFile, readFile, fileExists, getFileStats } from "../lib/fs.js";
|
|
12
14
|
import { generateAgentsMd, writeAgentsMd, AGENTS_MD_PATH, } from "../lib/agents-md.js";
|
|
13
15
|
import { getProjectName } from "../lib/project-name.js";
|
|
14
16
|
import { getStackConfig } from "../lib/stacks.js";
|
|
15
17
|
const SKILLS_VERSION_PATH = ".claude/skills/.sequant-version";
|
|
18
|
+
// Where the cheap drift-fingerprint cache lives (gitignored via `**/.sequant/`).
|
|
19
|
+
const DRIFT_CACHE_PATH = ".claude/.sequant/.skills-drift-cache.json";
|
|
20
|
+
// Mirrors config.ts / manifest.ts (those constants are module-private). These
|
|
21
|
+
// install paths are stable; we stat them only to invalidate the drift cache
|
|
22
|
+
// when the project's config tokens or manifest stack change.
|
|
23
|
+
const CONFIG_FILE_PATH = ".claude/.sequant/config.json";
|
|
24
|
+
const MANIFEST_FILE_PATH = ".sequant-manifest.json";
|
|
16
25
|
/**
|
|
17
26
|
* Get the version of skills currently installed
|
|
18
27
|
*/
|
|
@@ -29,16 +38,141 @@ export async function getSkillsVersion() {
|
|
|
29
38
|
}
|
|
30
39
|
}
|
|
31
40
|
/**
|
|
32
|
-
*
|
|
41
|
+
* Cheap stat-only fingerprint of every input that can change the content-drift
|
|
42
|
+
* result: package version plus the mtime (or absence) of each bundled template,
|
|
43
|
+
* its installed counterpart, any `.claude/.local/` override, and the config and
|
|
44
|
+
* manifest. A full read+render+diff scan is ~15ms per command; this fingerprint
|
|
45
|
+
* is ~2-5ms, so the per-command pre-flight can skip the scan when nothing that
|
|
46
|
+
* affects drift has changed (AC-5). A per-file hash (not a max-mtime) is used so
|
|
47
|
+
* editing an *older* file — whose new mtime may still trail another file's —
|
|
48
|
+
* still changes the fingerprint and forces a rescan (no missed warnings).
|
|
49
|
+
*
|
|
50
|
+
* Returns `null` if it cannot be computed; the caller then scans uncached.
|
|
33
51
|
*/
|
|
34
|
-
|
|
52
|
+
async function computeDriftFingerprint(packageVersion) {
|
|
53
|
+
try {
|
|
54
|
+
const templateFiles = await listTemplateFiles();
|
|
55
|
+
const templatesDir = getTemplatesDir();
|
|
56
|
+
const lines = [`v=${packageVersion}`];
|
|
57
|
+
const addPath = async (fsPath, key) => {
|
|
58
|
+
try {
|
|
59
|
+
const stats = await getFileStats(fsPath);
|
|
60
|
+
lines.push(`${key}:${Math.round(stats.mtimeMs)}`);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
// Missing file is itself signal: a `.local` override or installed file
|
|
64
|
+
// appearing/disappearing flips this line and invalidates the cache.
|
|
65
|
+
lines.push(`${key}:absent`);
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
for (const templatePath of templateFiles) {
|
|
69
|
+
const normalized = templatePath.replace(/\\/g, "/");
|
|
70
|
+
const localPath = normalized.replace("templates/", ".claude/");
|
|
71
|
+
if (localPath.includes(".local/"))
|
|
72
|
+
continue;
|
|
73
|
+
const templateFsPath = join(templatesDir, normalized.replace("templates/", ""));
|
|
74
|
+
const overridePath = localPath.replace(".claude/", ".claude/.local/");
|
|
75
|
+
await addPath(templateFsPath, `t:${normalized}`);
|
|
76
|
+
await addPath(localPath, `l:${localPath}`);
|
|
77
|
+
await addPath(overridePath, `o:${overridePath}`);
|
|
78
|
+
}
|
|
79
|
+
await addPath(CONFIG_FILE_PATH, "config");
|
|
80
|
+
await addPath(MANIFEST_FILE_PATH, "manifest");
|
|
81
|
+
lines.sort();
|
|
82
|
+
return createHash("sha1").update(lines.join("\n")).digest("hex");
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
async function readDriftCache() {
|
|
89
|
+
try {
|
|
90
|
+
if (!(await fileExists(DRIFT_CACHE_PATH)))
|
|
91
|
+
return null;
|
|
92
|
+
const parsed = JSON.parse(await readFile(DRIFT_CACHE_PATH));
|
|
93
|
+
if (typeof parsed?.fingerprint === "string" &&
|
|
94
|
+
typeof parsed?.contentDrift === "number") {
|
|
95
|
+
return parsed;
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
// Corrupt/unreadable cache → treat as a miss; the scan path rebuilds it.
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
async function writeDriftCache(cache) {
|
|
105
|
+
try {
|
|
106
|
+
await writeFile(DRIFT_CACHE_PATH, JSON.stringify(cache));
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
// The cache is a pure optimization — never fail a command over a write miss
|
|
110
|
+
// (e.g. the `.claude/.sequant/` dir not existing yet).
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Run the content-drift scan (the source-of-truth `computeTemplateChanges` diff),
|
|
115
|
+
* returning the count of `new`+`modified` files. When `useCache` is true (the
|
|
116
|
+
* per-command pre-flight), a stat-only fingerprint short-circuits the scan if no
|
|
117
|
+
* drift-affecting input changed since the last run. Callers that need fresh
|
|
118
|
+
* truth (`doctor`, and `sync` itself) leave caching off — the default.
|
|
119
|
+
*/
|
|
120
|
+
async function computeContentDrift(packageVersion, useCache) {
|
|
121
|
+
let fingerprint = null;
|
|
122
|
+
if (useCache) {
|
|
123
|
+
fingerprint = await computeDriftFingerprint(packageVersion);
|
|
124
|
+
if (fingerprint) {
|
|
125
|
+
const cached = await readDriftCache();
|
|
126
|
+
if (cached && cached.fingerprint === fingerprint) {
|
|
127
|
+
return cached.contentDrift;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
try {
|
|
132
|
+
const manifest = await getManifest();
|
|
133
|
+
if (!manifest)
|
|
134
|
+
return 0;
|
|
135
|
+
const config = await getConfig();
|
|
136
|
+
const tokens = config?.tokens || {};
|
|
137
|
+
const changes = await computeTemplateChanges(manifest.stack, tokens);
|
|
138
|
+
const contentDrift = changes.filter((c) => c.status === "new" || c.status === "modified").length;
|
|
139
|
+
if (useCache && fingerprint) {
|
|
140
|
+
await writeDriftCache({ fingerprint, contentDrift });
|
|
141
|
+
}
|
|
142
|
+
return contentDrift;
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
// The pre-flight must never break the actual command. If the content diff
|
|
146
|
+
// fails (missing templates, read error), treat it as "no detectable drift"
|
|
147
|
+
// and let the command proceed.
|
|
148
|
+
return 0;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Check if skills are outdated compared to package version.
|
|
153
|
+
*
|
|
154
|
+
* The version marker is only a cheap hint: a tree at the matching version can
|
|
155
|
+
* still have drifted bundled content in place (the #708 root cause). So when the
|
|
156
|
+
* marker matches we run the same content diff `sync` uses (`computeTemplateChanges`,
|
|
157
|
+
* the single source of truth from #708/#710) and surface a `contentDrift` count.
|
|
158
|
+
* On a version *mismatch* we skip the diff entirely — the install is already stale
|
|
159
|
+
* and the copy path handles it — keeping the per-command pre-flight cheap (AC-5).
|
|
160
|
+
*
|
|
161
|
+
* `options.cache` opts into a stat-only fingerprint cache for the content scan,
|
|
162
|
+
* so the hot pre-flight path (which runs before most commands, including batched
|
|
163
|
+
* `/assess` dashboard calls) pays the full ~15ms scan only when something that
|
|
164
|
+
* affects drift actually changed. Off by default so diagnostic callers (`doctor`)
|
|
165
|
+
* always see fresh truth.
|
|
166
|
+
*/
|
|
167
|
+
export async function areSkillsOutdated(options = {}) {
|
|
35
168
|
const currentVersion = await getSkillsVersion();
|
|
36
169
|
const packageVersion = getPackageVersion();
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
packageVersion,
|
|
41
|
-
}
|
|
170
|
+
const outdated = currentVersion !== packageVersion;
|
|
171
|
+
let contentDrift = 0;
|
|
172
|
+
if (!outdated) {
|
|
173
|
+
contentDrift = await computeContentDrift(packageVersion, options.cache === true);
|
|
174
|
+
}
|
|
175
|
+
return { outdated, currentVersion, packageVersion, contentDrift };
|
|
42
176
|
}
|
|
43
177
|
/**
|
|
44
178
|
* Update the skills version marker
|
|
@@ -47,7 +181,7 @@ async function updateSkillsVersion() {
|
|
|
47
181
|
await writeFile(SKILLS_VERSION_PATH, getPackageVersion());
|
|
48
182
|
}
|
|
49
183
|
export async function syncCommand(options = {}) {
|
|
50
|
-
const { force = false, quiet = false } = options;
|
|
184
|
+
const { force = false, quiet = false, dryRun = false } = options;
|
|
51
185
|
if (!quiet) {
|
|
52
186
|
console.log(chalk.blue("\nSyncing templates...\n"));
|
|
53
187
|
console.log(chalk.yellow("Note: For seamless auto-updates, install sequant as a Claude Code plugin:\n" +
|
|
@@ -68,16 +202,90 @@ export async function syncCommand(options = {}) {
|
|
|
68
202
|
console.log(chalk.gray(`Package version: ${packageVersion}`));
|
|
69
203
|
console.log(chalk.gray(`Stack: ${manifest.stack}\n`));
|
|
70
204
|
}
|
|
71
|
-
//
|
|
205
|
+
// Get config tokens for template processing
|
|
206
|
+
const config = await getConfig();
|
|
207
|
+
const tokens = config?.tokens || {};
|
|
208
|
+
// The version marker is only a fast-path hint — verify actual content before
|
|
209
|
+
// claiming "up to date". On a version match we still diff bundled templates
|
|
210
|
+
// against installed content (rendered with the same variables) so we never
|
|
211
|
+
// declare success while real drift sits in place (#708).
|
|
72
212
|
if (!force && skillsVersion === packageVersion) {
|
|
213
|
+
const changes = await computeTemplateChanges(manifest.stack, tokens);
|
|
214
|
+
const drifted = changes.filter((c) => c.status === "new" || c.status === "modified");
|
|
215
|
+
if (drifted.length === 0) {
|
|
216
|
+
// Truthful no-op: content is actually identical.
|
|
217
|
+
if (!quiet) {
|
|
218
|
+
console.log(chalk.green("✔ Skills are already up to date!"));
|
|
219
|
+
}
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
// Version current but content differs — report, don't mutate (report-only
|
|
223
|
+
// keeps the fast path from silently overwriting in-place customizations).
|
|
73
224
|
if (!quiet) {
|
|
74
|
-
console.log(chalk.
|
|
225
|
+
console.log(chalk.yellow(`! Version current, but ${drifted.length} file(s) differ — run \`update\` or \`sync --force\``));
|
|
226
|
+
}
|
|
227
|
+
// Signal drift with a non-zero exit code even under --quiet. The exit code
|
|
228
|
+
// is the machine signal the (suppressible) message is not, so the
|
|
229
|
+
// non-interactive / CI path we recommend can't treat a drifted tree as
|
|
230
|
+
// success — the original failure mode in #708.
|
|
231
|
+
process.exitCode = 1;
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
// Preview path: report exactly what the apply would write, then stop without
|
|
235
|
+
// mutating (#722). This branch is only reached when `force` is set or the
|
|
236
|
+
// version marker mismatches — i.e. the path that runs `copyTemplates(force:
|
|
237
|
+
// true)` and rewrites the whole tree. (A matching-version, non-force dry-run
|
|
238
|
+
// already returned at the report-only short-circuit above, which never
|
|
239
|
+
// mutates.) `copyTemplates` does NOT protect in-place customizations the way
|
|
240
|
+
// `update` does — the force copy overwrites them — so the preview counts
|
|
241
|
+
// `local-override` files alongside `new`/`modified`. Reporting only
|
|
242
|
+
// new+modified would under-report the write-set, the exact divergence #722
|
|
243
|
+
// is about.
|
|
244
|
+
if (dryRun) {
|
|
245
|
+
const changes = await computeTemplateChanges(manifest.stack, tokens);
|
|
246
|
+
const newFiles = changes.filter((c) => c.status === "new");
|
|
247
|
+
const modifiedFiles = changes.filter((c) => c.status === "modified");
|
|
248
|
+
const localOverrides = changes.filter((c) => c.status === "local-override");
|
|
249
|
+
const toWrite = [...newFiles, ...modifiedFiles, ...localOverrides];
|
|
250
|
+
if (!quiet) {
|
|
251
|
+
console.log(chalk.bold("Summary (dry-run):"));
|
|
252
|
+
console.log(chalk.green(` New files: ${newFiles.length}`));
|
|
253
|
+
console.log(chalk.yellow(` Modified: ${modifiedFiles.length}`));
|
|
254
|
+
console.log(chalk.blue(` Local overrides (overwritten by sync): ${localOverrides.length}`));
|
|
255
|
+
if (modifiedFiles.length > 0) {
|
|
256
|
+
console.log(chalk.bold("\nModified files:"));
|
|
257
|
+
for (const file of modifiedFiles) {
|
|
258
|
+
console.log(chalk.yellow(` ${file.path}`));
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
if (newFiles.length > 0) {
|
|
262
|
+
console.log(chalk.bold("\nNew files:"));
|
|
263
|
+
for (const file of newFiles) {
|
|
264
|
+
console.log(chalk.green(` ${file.path}`));
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
if (localOverrides.length > 0) {
|
|
268
|
+
console.log(chalk.bold("\nLocal overrides (will be overwritten by sync):"));
|
|
269
|
+
for (const file of localOverrides) {
|
|
270
|
+
console.log(chalk.blue(` ${file.path}`));
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
if (toWrite.length === 0) {
|
|
274
|
+
console.log(chalk.green("\n✔ Skills are already up to date!"));
|
|
275
|
+
}
|
|
276
|
+
else {
|
|
277
|
+
console.log(chalk.gray("\n(dry-run mode - no changes made)"));
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
// Non-zero exit when work is pending so the documented preview surface can
|
|
281
|
+
// gate CI/automation (the #709 intent): a dry-run reporting nothing must
|
|
282
|
+
// mean nothing to do. The matching-version short-circuit signals drift the
|
|
283
|
+
// same way.
|
|
284
|
+
if (toWrite.length > 0) {
|
|
285
|
+
process.exitCode = 1;
|
|
75
286
|
}
|
|
76
287
|
return;
|
|
77
288
|
}
|
|
78
|
-
// Get config tokens for template processing
|
|
79
|
-
const config = await getConfig();
|
|
80
|
-
const tokens = config?.tokens || {};
|
|
81
289
|
// Copy templates with force to overwrite existing files
|
|
82
290
|
const copyOptions = {
|
|
83
291
|
force: true, // Always overwrite when syncing
|
|
@@ -118,14 +326,32 @@ export async function syncCommand(options = {}) {
|
|
|
118
326
|
}
|
|
119
327
|
}
|
|
120
328
|
/**
|
|
121
|
-
* Check and warn if skills are outdated (for use by other commands)
|
|
329
|
+
* Check and warn if skills are outdated (for use by other commands).
|
|
330
|
+
*
|
|
331
|
+
* Warns on either signal: a version-marker mismatch, or in-place content drift at
|
|
332
|
+
* a matching version (#708/#713). The content-drift path is warn-only by design —
|
|
333
|
+
* it never mutates files and never sets `process.exitCode` (this is a pre-flight,
|
|
334
|
+
* not the command itself), so customized installs (#711) are left intact.
|
|
335
|
+
*
|
|
336
|
+
* Callers that have already computed the status (e.g. the `preAction` hook) can
|
|
337
|
+
* pass it in to avoid a second template scan on the hot path (AC-5).
|
|
338
|
+
*
|
|
339
|
+
* @returns `true` if a warning was emitted, `false` if up to date.
|
|
122
340
|
*/
|
|
123
|
-
export async function checkAndWarnSkillsOutdated() {
|
|
124
|
-
const { outdated, currentVersion, packageVersion } = await areSkillsOutdated();
|
|
341
|
+
export async function checkAndWarnSkillsOutdated(status) {
|
|
342
|
+
const { outdated, currentVersion, packageVersion, contentDrift } = status ?? (await areSkillsOutdated());
|
|
125
343
|
if (outdated) {
|
|
126
344
|
console.log(chalk.yellow(`\n! Skills are outdated (${currentVersion || "unknown"} → ${packageVersion})`));
|
|
127
345
|
console.log(chalk.yellow(" Run: npx sequant sync\n"));
|
|
128
346
|
return true;
|
|
129
347
|
}
|
|
348
|
+
if (contentDrift > 0) {
|
|
349
|
+
// Mirror syncCommand's own drift remediation: a bare `sync` at a matching
|
|
350
|
+
// version is report-only (it won't copy), so point at the commands that
|
|
351
|
+
// actually resolve in-place drift — `sync --force` or `update`.
|
|
352
|
+
console.log(chalk.yellow(`\n! Version current, but ${contentDrift} file(s) differ from bundled content`));
|
|
353
|
+
console.log(chalk.yellow(" Run: npx sequant sync --force (or npx sequant update)\n"));
|
|
354
|
+
return true;
|
|
355
|
+
}
|
|
130
356
|
return false;
|
|
131
357
|
}
|
|
@@ -2,14 +2,36 @@
|
|
|
2
2
|
* sequant update - Update templates from the package
|
|
3
3
|
*/
|
|
4
4
|
import chalk from "chalk";
|
|
5
|
-
import { diffLines } from "diff";
|
|
6
5
|
import inquirer from "inquirer";
|
|
7
6
|
import { spawnSync } from "child_process";
|
|
8
7
|
import { getManifest, updateManifest, getPackageVersion, } from "../lib/manifest.js";
|
|
9
|
-
import {
|
|
8
|
+
import { computeTemplateChanges } from "../lib/templates.js";
|
|
10
9
|
import { getConfig, saveConfig } from "../lib/config.js";
|
|
11
10
|
import { getStackConfig, PM_CONFIG, getPackageManagerCommands, } from "../lib/stacks.js";
|
|
12
|
-
import {
|
|
11
|
+
import { writeFile } from "../lib/fs.js";
|
|
12
|
+
import { isStdinTTY, isCI, getNonInteractiveReason } from "../lib/tty.js";
|
|
13
|
+
/**
|
|
14
|
+
* True when `update` must not prompt: stdin is not a terminal (piped input) or
|
|
15
|
+
* we are running in a recognized CI environment. CI is checked explicitly
|
|
16
|
+
* because some runners allocate a pseudo-TTY — `isStdinTTY()` alone would let
|
|
17
|
+
* the prompt render and then hang an unattended job forever.
|
|
18
|
+
*/
|
|
19
|
+
function isNonInteractive() {
|
|
20
|
+
return !isStdinTTY() || isCI();
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Print an actionable message and set a non-zero exit code when a prompt is
|
|
24
|
+
* required but the shell is non-interactive (piped/CI). Prevents inquirer from
|
|
25
|
+
* throwing a raw ExitPromptError stack trace. Callers should `return`
|
|
26
|
+
* immediately after.
|
|
27
|
+
*/
|
|
28
|
+
function refuseNonInteractive() {
|
|
29
|
+
const reason = getNonInteractiveReason() ?? "stdin is not a terminal";
|
|
30
|
+
console.error(chalk.red(`\n❌ non-interactive shell (${reason}): \`update\` needs to prompt to continue.`));
|
|
31
|
+
console.error(chalk.yellow(" Re-run with `--yes` (or `-y`) to apply updates without prompting,"));
|
|
32
|
+
console.error(chalk.yellow(" or use `--dry-run` to preview changes without applying."));
|
|
33
|
+
process.exitCode = 1;
|
|
34
|
+
}
|
|
13
35
|
export async function updateCommand(options) {
|
|
14
36
|
console.log(chalk.blue("\nChecking for updates...\n"));
|
|
15
37
|
console.log(chalk.yellow("Note: For seamless auto-updates, install sequant as a Claude Code plugin:\n" +
|
|
@@ -58,10 +80,19 @@ export async function updateCommand(options) {
|
|
|
58
80
|
// Get package manager run command
|
|
59
81
|
const pm = manifest.packageManager || "npm";
|
|
60
82
|
const pmConfig = getPackageManagerCommands(pm);
|
|
61
|
-
if (options.force) {
|
|
83
|
+
if (options.force || options.yes) {
|
|
62
84
|
tokens = { DEV_URL: defaultDevUrl, PM_RUN: pmConfig.run };
|
|
63
85
|
console.log(chalk.blue(`Using default dev URL: ${defaultDevUrl}`));
|
|
64
86
|
}
|
|
87
|
+
else if (options.dryRun) {
|
|
88
|
+
// Dry-run is read-only: preview with defaults, never prompt or persist.
|
|
89
|
+
tokens = { DEV_URL: defaultDevUrl, PM_RUN: pmConfig.run };
|
|
90
|
+
console.log(chalk.blue(`Using default dev URL for preview: ${defaultDevUrl}`));
|
|
91
|
+
}
|
|
92
|
+
else if (isNonInteractive()) {
|
|
93
|
+
refuseNonInteractive();
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
65
96
|
else {
|
|
66
97
|
const { inputDevUrl } = await inquirer.prompt([
|
|
67
98
|
{
|
|
@@ -73,57 +104,22 @@ export async function updateCommand(options) {
|
|
|
73
104
|
]);
|
|
74
105
|
tokens = { DEV_URL: inputDevUrl, PM_RUN: pmConfig.run };
|
|
75
106
|
}
|
|
76
|
-
//
|
|
107
|
+
// Persist the new config — but not on a dry-run preview, which must leave
|
|
108
|
+
// the project untouched.
|
|
77
109
|
config = {
|
|
78
110
|
tokens,
|
|
79
111
|
stack: manifest.stack,
|
|
80
112
|
initialized: manifest.installedAt,
|
|
81
113
|
};
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
// Get list of template files
|
|
86
|
-
const templateFiles = await listTemplateFiles();
|
|
87
|
-
const changes = [];
|
|
88
|
-
for (const templatePath of templateFiles) {
|
|
89
|
-
const localPath = templatePath.replace("templates/", ".claude/");
|
|
90
|
-
// Skip if in .local directory (user customizations)
|
|
91
|
-
if (localPath.includes(".local/")) {
|
|
92
|
-
continue;
|
|
93
|
-
}
|
|
94
|
-
const templateContent = await getTemplateContent(templatePath);
|
|
95
|
-
const exists = await fileExists(localPath);
|
|
96
|
-
if (!exists) {
|
|
97
|
-
changes.push({ path: localPath, status: "new" });
|
|
98
|
-
}
|
|
99
|
-
else {
|
|
100
|
-
const localContent = await readFile(localPath);
|
|
101
|
-
if (localContent === templateContent) {
|
|
102
|
-
changes.push({ path: localPath, status: "unchanged" });
|
|
103
|
-
}
|
|
104
|
-
else {
|
|
105
|
-
// Check if there's a local override
|
|
106
|
-
const localOverridePath = localPath.replace(".claude/", ".claude/.local/");
|
|
107
|
-
const hasLocalOverride = await fileExists(localOverridePath);
|
|
108
|
-
if (hasLocalOverride) {
|
|
109
|
-
changes.push({ path: localPath, status: "local-override" });
|
|
110
|
-
}
|
|
111
|
-
else {
|
|
112
|
-
const diff = diffLines(localContent, templateContent)
|
|
113
|
-
.map((part) => {
|
|
114
|
-
const prefix = part.added ? "+" : part.removed ? "-" : " ";
|
|
115
|
-
return part.value
|
|
116
|
-
.split("\n")
|
|
117
|
-
.filter((l) => l)
|
|
118
|
-
.map((l) => `${prefix} ${l}`)
|
|
119
|
-
.join("\n");
|
|
120
|
-
})
|
|
121
|
-
.join("\n");
|
|
122
|
-
changes.push({ path: localPath, status: "modified", diff });
|
|
123
|
-
}
|
|
124
|
-
}
|
|
114
|
+
if (!options.dryRun) {
|
|
115
|
+
await saveConfig(config);
|
|
116
|
+
console.log(chalk.green("✔ Configuration saved\n"));
|
|
125
117
|
}
|
|
126
118
|
}
|
|
119
|
+
// Compute changes using the shared, variable-aware comparison.
|
|
120
|
+
// Templates are rendered (PROJECT_NAME, STACK_NOTES, etc.) before diffing,
|
|
121
|
+
// and in-place-customizable files (constitution) are protected as overrides.
|
|
122
|
+
const changes = await computeTemplateChanges(manifest.stack, tokens);
|
|
127
123
|
// Show summary
|
|
128
124
|
const newFiles = changes.filter((c) => c.status === "new");
|
|
129
125
|
const modifiedFiles = changes.filter((c) => c.status === "modified");
|
|
@@ -134,8 +130,17 @@ export async function updateCommand(options) {
|
|
|
134
130
|
console.log(chalk.yellow(` Modified: ${modifiedFiles.length}`));
|
|
135
131
|
console.log(chalk.gray(` ✓ Unchanged: ${unchangedFiles.length}`));
|
|
136
132
|
console.log(chalk.blue(` Local overrides: ${localOverrides.length}`));
|
|
137
|
-
|
|
138
|
-
|
|
133
|
+
// Local overrides are protected by default — only --force overwrites them.
|
|
134
|
+
const applySet = options.force
|
|
135
|
+
? [...newFiles, ...modifiedFiles, ...localOverrides]
|
|
136
|
+
: [...newFiles, ...modifiedFiles];
|
|
137
|
+
if (applySet.length === 0) {
|
|
138
|
+
if (localOverrides.length > 0) {
|
|
139
|
+
console.log(chalk.blue(`\n✔ No updates to apply. ${localOverrides.length} local override(s) protected (use --force to overwrite).`));
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
console.log(chalk.green("\n✔ Everything is up to date!"));
|
|
143
|
+
}
|
|
139
144
|
return;
|
|
140
145
|
}
|
|
141
146
|
// Show changes
|
|
@@ -154,12 +159,30 @@ export async function updateCommand(options) {
|
|
|
154
159
|
console.log(chalk.green(` ${file.path}`));
|
|
155
160
|
}
|
|
156
161
|
}
|
|
162
|
+
if (options.force && localOverrides.length > 0) {
|
|
163
|
+
console.log(chalk.bold("\nLocal overrides (forced overwrite):"));
|
|
164
|
+
for (const file of localOverrides) {
|
|
165
|
+
console.log(chalk.blue(` ${file.path}`));
|
|
166
|
+
}
|
|
167
|
+
}
|
|
157
168
|
if (options.dryRun) {
|
|
158
169
|
console.log(chalk.gray("\n(dry-run mode - no changes made)"));
|
|
170
|
+
// Non-zero exit when work is pending so a CI/automation job can gate on the
|
|
171
|
+
// preview, matching `sync --dry-run` (#724 / #709 intent): a dry-run that
|
|
172
|
+
// reports nothing must mean nothing to do. The no-op case short-circuits at
|
|
173
|
+
// the "Everything is up to date!" return above, so it correctly stays 0.
|
|
174
|
+
if (applySet.length > 0) {
|
|
175
|
+
process.exitCode = 1;
|
|
176
|
+
}
|
|
159
177
|
return;
|
|
160
178
|
}
|
|
161
|
-
// Confirm update
|
|
162
|
-
|
|
179
|
+
// Confirm update. --yes and --force both auto-confirm; otherwise we need a
|
|
180
|
+
// prompt, which is impossible without a TTY — bail cleanly instead of crashing.
|
|
181
|
+
if (!options.force && !options.yes) {
|
|
182
|
+
if (isNonInteractive()) {
|
|
183
|
+
refuseNonInteractive();
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
163
186
|
const { proceed } = await inquirer.prompt([
|
|
164
187
|
{
|
|
165
188
|
type: "confirm",
|
|
@@ -173,30 +196,19 @@ export async function updateCommand(options) {
|
|
|
173
196
|
return;
|
|
174
197
|
}
|
|
175
198
|
}
|
|
176
|
-
// Apply updates
|
|
199
|
+
// Apply updates — content was already rendered with the shared variable set
|
|
200
|
+
// during change detection, so just write it.
|
|
177
201
|
console.log(chalk.blue("\nApplying updates..."));
|
|
178
202
|
let updated = 0;
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
const variables = {
|
|
182
|
-
...stackConfig.variables,
|
|
183
|
-
...tokens,
|
|
184
|
-
PROJECT_NAME: process.cwd().split("/").pop() || "project",
|
|
185
|
-
STACK: manifest.stack,
|
|
186
|
-
};
|
|
187
|
-
for (const file of [...newFiles, ...modifiedFiles]) {
|
|
188
|
-
const templatePath = file.path.replace(".claude/", "templates/");
|
|
189
|
-
let content = await getTemplateContent(templatePath);
|
|
190
|
-
// Process templates with tokens to replace {{DEV_URL}} etc.
|
|
191
|
-
content = processTemplate(content, variables);
|
|
192
|
-
await writeFile(file.path, content);
|
|
203
|
+
for (const file of applySet) {
|
|
204
|
+
await writeFile(file.path, file.rendered);
|
|
193
205
|
updated++;
|
|
194
206
|
}
|
|
195
207
|
// Update manifest
|
|
196
208
|
await updateManifest();
|
|
197
209
|
console.log(chalk.green(`\n✔ Updated ${updated} files`));
|
|
198
210
|
// Check if package.json was updated and run install
|
|
199
|
-
const packageJsonUpdated =
|
|
211
|
+
const packageJsonUpdated = applySet.some((f) => f.path === "package.json" || f.path.endsWith("/package.json"));
|
|
200
212
|
if (packageJsonUpdated) {
|
|
201
213
|
// Use detected package manager or default to npm
|
|
202
214
|
const pm = manifest.packageManager || "npm";
|
|
@@ -14,6 +14,56 @@ export declare function listTemplateFiles(): Promise<string[]>;
|
|
|
14
14
|
* Get content of a template file
|
|
15
15
|
*/
|
|
16
16
|
export declare function getTemplateContent(templatePath: string): Promise<string>;
|
|
17
|
+
/**
|
|
18
|
+
* Files that are meant to be edited in place per project (e.g. the
|
|
19
|
+
* constitution). When one of these diverges from the rendered template
|
|
20
|
+
* without a parallel `.claude/.local/` file, it is treated as a protected
|
|
21
|
+
* local override rather than a stale "modified" file — so the default
|
|
22
|
+
* (non-`--force`) update/sync path never silently overwrites it.
|
|
23
|
+
*/
|
|
24
|
+
export declare const CUSTOMIZABLE_FILES: string[];
|
|
25
|
+
/**
|
|
26
|
+
* Whether a local path is a customizable file edited in place per project.
|
|
27
|
+
*/
|
|
28
|
+
export declare function isCustomizableFile(localPath: string): boolean;
|
|
29
|
+
/**
|
|
30
|
+
* Build the full set of template variables used when rendering templates.
|
|
31
|
+
*
|
|
32
|
+
* This is the single source of truth shared by `copyTemplates` (write time)
|
|
33
|
+
* and `computeTemplateChanges` (diff time) so the two can never drift — a
|
|
34
|
+
* mismatch here is what caused `constitution.md` to read as "modified" on
|
|
35
|
+
* every project (the diff used a different/incomplete variable set than the
|
|
36
|
+
* write). See #708.
|
|
37
|
+
*/
|
|
38
|
+
export declare function buildTemplateVariables(stack: string, tokens?: Record<string, string>, options?: {
|
|
39
|
+
additionalStacks?: string[];
|
|
40
|
+
}): Promise<Record<string, string>>;
|
|
41
|
+
/**
|
|
42
|
+
* A single template file's status relative to the installed copy.
|
|
43
|
+
*/
|
|
44
|
+
export interface TemplateChange {
|
|
45
|
+
/** Installed path under `.claude/` */
|
|
46
|
+
path: string;
|
|
47
|
+
/** Source template path under `templates/` */
|
|
48
|
+
templatePath: string;
|
|
49
|
+
status: "new" | "modified" | "unchanged" | "local-override";
|
|
50
|
+
/** Template content rendered with the project's variables */
|
|
51
|
+
rendered: string;
|
|
52
|
+
/** Unified-ish diff (installed → rendered), only set for `modified` */
|
|
53
|
+
diff?: string;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Compare bundled template content against what's installed under `.claude/`.
|
|
57
|
+
*
|
|
58
|
+
* Templates are rendered with the project's variables *before* comparison, so
|
|
59
|
+
* an unmodified file (e.g. a constitution with `{{PROJECT_NAME}}` expanded)
|
|
60
|
+
* reads as `unchanged` rather than `modified`. A file that diverges in place is
|
|
61
|
+
* `local-override` (skip-by-default) when it has a parallel `.claude/.local/`
|
|
62
|
+
* file or is in the customizable allow-list; otherwise it is `modified`.
|
|
63
|
+
*/
|
|
64
|
+
export declare function computeTemplateChanges(stack: string, tokens?: Record<string, string>, options?: {
|
|
65
|
+
additionalStacks?: string[];
|
|
66
|
+
}): Promise<TemplateChange[]>;
|
|
17
67
|
/**
|
|
18
68
|
* Result of symlink creation attempt
|
|
19
69
|
*/
|