auriga-cli 1.27.0 → 1.29.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/dist/utils.d.ts CHANGED
@@ -92,6 +92,8 @@ export interface LangOption {
92
92
  label: string;
93
93
  file: string;
94
94
  }
95
+ export declare const DEFAULT_WORKFLOW_LANG = "zh-CN";
96
+ export declare const DEFAULT_WORKFLOW_TEMPLATE_FILE = "AGENTS.md";
95
97
  export declare const LANGUAGES: LangOption[];
96
98
  /**
97
99
  * Reads `version` from the packaged manifest. Throws when the package
package/dist/utils.js CHANGED
@@ -100,9 +100,11 @@ export function execAsync(cmd, opts) {
100
100
  });
101
101
  });
102
102
  }
103
+ export const DEFAULT_WORKFLOW_LANG = "zh-CN";
104
+ export const DEFAULT_WORKFLOW_TEMPLATE_FILE = "AGENTS.md";
103
105
  export const LANGUAGES = [
104
- { value: "en", label: "English", file: "CLAUDE.md" },
105
- { value: "zh-CN", label: "中文", file: "CLAUDE.zh-CN.md" },
106
+ { value: "zh-CN", label: "中文", file: "AGENTS.md" },
107
+ { value: "en", label: "English", file: "AGENTS.en.md" },
106
108
  ];
107
109
  // --- Remote content ---
108
110
  const REPO = "Ben2pc/auriga-cli";
@@ -146,7 +148,8 @@ function resolveContentRef() {
146
148
  return "main";
147
149
  }
148
150
  const CONTENT_FILES = [
149
- "CLAUDE.md",
151
+ DEFAULT_WORKFLOW_TEMPLATE_FILE,
152
+ "AGENTS.en.md",
150
153
  "skills-lock.json",
151
154
  ".claude-plugin/marketplace.json",
152
155
  ".agents/plugins/marketplace.json",
@@ -182,8 +185,10 @@ export async function fetchContentRoot() {
182
185
  return tmpDir;
183
186
  }
184
187
  export async function fetchExtraContent(tmpDir, file) {
185
- const content = await fetchFile(file);
186
188
  const dest = path.join(tmpDir, file);
189
+ if (fs.existsSync(dest))
190
+ return;
191
+ const content = await fetchFile(file);
187
192
  fs.mkdirSync(path.dirname(dest), { recursive: true });
188
193
  fs.writeFileSync(dest, content);
189
194
  }
@@ -0,0 +1,4 @@
1
+ export declare const WORKFLOW_PRIMARY_FILE = "AGENTS.md";
2
+ export declare const WORKFLOW_COMPAT_FILE = "CLAUDE.md";
3
+ export declare const WORKFLOW_COMPAT_SYMLINK_TARGET = "AGENTS.md";
4
+ export declare const LEGACY_AGENTS_SYMLINK_TARGET = "CLAUDE.md";
@@ -0,0 +1,4 @@
1
+ export const WORKFLOW_PRIMARY_FILE = "AGENTS.md";
2
+ export const WORKFLOW_COMPAT_FILE = "CLAUDE.md";
3
+ export const WORKFLOW_COMPAT_SYMLINK_TARGET = WORKFLOW_PRIMARY_FILE;
4
+ export const LEGACY_AGENTS_SYMLINK_TARGET = WORKFLOW_COMPAT_FILE;
@@ -0,0 +1,59 @@
1
+ /** Marker schema version. Frozen contract — bump only with a migration plan. */
2
+ export declare const MARKER_SCHEMA = "v1";
3
+ /** The START marker line for the given template language. Unknown languages
4
+ * fall back to English. */
5
+ export declare function workflowStartMarker(lang?: string): string;
6
+ /** Build the END marker line, embedding the managed-block content hash so a
7
+ * later upgrade can tell an untouched block from a hand-edited one. */
8
+ export declare function workflowEndMarker(hash: string): string;
9
+ /** Matches the auriga workflow H1 header in either language
10
+ * (`# auriga Workflow (vX.Y.Z)` / `# auriga 工作流 (vX.Y.Z)`). */
11
+ export declare const WORKFLOW_HEADER_RE: RegExp;
12
+ /** sha256 of the managed-block body, truncated to 16 hex chars. Not a security
13
+ * primitive — just a tamper check to distinguish untouched from hand-edited. */
14
+ export declare function hashBlock(blockBody: string): string;
15
+ export type MarkerParse = {
16
+ kind: "unmarked";
17
+ } | {
18
+ kind: "malformed";
19
+ reason: string;
20
+ } | {
21
+ kind: "marked";
22
+ /** Bytes before the START marker line (normally empty). */
23
+ prefix: string;
24
+ /** Bytes strictly between the START line and the END line — the managed
25
+ * block. Includes the trailing newline that precedes the END line. */
26
+ blockBody: string;
27
+ /** Bytes after the END marker line — the project's own user region. */
28
+ userRegion: string;
29
+ /** sha256 recorded in the END marker, or null if the marker carried none. */
30
+ endHash: string | null;
31
+ };
32
+ /**
33
+ * Classify an AGENTS.md body by its managed-block markers.
34
+ *
35
+ * - `unmarked` — neither marker present (fresh-target / foreign / old-format)
36
+ * - `malformed` — exactly one marker, or END before START (can't safely splice)
37
+ * - `marked` — a well-formed START…END pair
38
+ */
39
+ export declare function parseMarkers(content: string): MarkerParse;
40
+ /**
41
+ * Build a marked AGENTS.md from its three parts. The END marker hash is
42
+ * computed from `blockBody` here, so callers never hand-maintain it.
43
+ *
44
+ * `blockBody` is expected to end with a newline (it is the content the START
45
+ * line's newline leads into, up to the END line). `parseMarkers` and
46
+ * `composeMarkedFile` are exact inverses for the block body and user region.
47
+ *
48
+ * `lang` selects the START marker's prose language (default English); it does
49
+ * not affect the parser, which keys on the language-independent token.
50
+ */
51
+ export declare function composeMarkedFile(opts: {
52
+ prefix?: string;
53
+ blockBody: string;
54
+ userRegion?: string;
55
+ lang?: string;
56
+ }): string;
57
+ /** True when the first non-blank line is an auriga workflow header. Used to
58
+ * tell an old-format (pre-marker) auriga workflow file from a foreign one. */
59
+ export declare function hasAurigaHeader(content: string): boolean;
@@ -0,0 +1,116 @@
1
+ // Managed-block markers for the installed AGENTS.md.
2
+ //
3
+ // auriga-cli installs its workflow document wrapped in a pair of HTML-comment
4
+ // markers. Everything *between* the markers is the "managed block" — owned by
5
+ // auriga-cli, replaced wholesale on upgrade. Everything *outside* (notably the
6
+ // region after the END marker) is the project's own — auriga-cli never touches
7
+ // it. This lets a downstream project extend its AGENTS.md while still
8
+ // receiving workflow upgrades.
9
+ //
10
+ // Markers are HTML comments so both Claude Code and Codex (which read the same
11
+ // file via the CLAUDE.md → AGENTS.md symlink) treat them as inert.
12
+ //
13
+ // This module is the single source of truth for the marker contract; it is
14
+ // imported by both src/workflow.ts (install / upgrade) and src/state.ts
15
+ // (presence detection). It deliberately has no heavy imports so state.ts /
16
+ // server.ts don't pull in @inquirer/prompts transitively.
17
+ import { createHash } from "node:crypto";
18
+ /** Marker schema version. Frozen contract — bump only with a migration plan. */
19
+ export const MARKER_SCHEMA = "v1";
20
+ /**
21
+ * START marker line, one per template language. Only the prose differs — the
22
+ * structural `AURIGA:WORKFLOW:v1 START` token is language-independent, so the
23
+ * parser (`START_LINE_RE`) keys on the token alone and never needs to know the
24
+ * language. The English `AGENTS.en.md` gets the English marker; `AGENTS.md`
25
+ * gets the Chinese one, so a downstream file never carries a comment in the
26
+ * wrong language for its document.
27
+ */
28
+ const WORKFLOW_START_MARKERS = {
29
+ en: `<!-- AURIGA:WORKFLOW:${MARKER_SCHEMA} START — Managed block, maintained by auriga-cli. Do not edit by hand; upgrades replace it wholesale. Put project-specific instructions after the END marker below. -->`,
30
+ "zh-CN": `<!-- AURIGA:WORKFLOW:${MARKER_SCHEMA} START — 受管区块,由 auriga-cli 维护,请勿手改;升级会整块覆盖。工程专属规则写在下方 END 标记之后。 -->`,
31
+ };
32
+ /** The START marker line for the given template language. Unknown languages
33
+ * fall back to English. */
34
+ export function workflowStartMarker(lang) {
35
+ return WORKFLOW_START_MARKERS[lang ?? "en"] ?? WORKFLOW_START_MARKERS.en;
36
+ }
37
+ /** Build the END marker line, embedding the managed-block content hash so a
38
+ * later upgrade can tell an untouched block from a hand-edited one. */
39
+ export function workflowEndMarker(hash) {
40
+ return `<!-- AURIGA:WORKFLOW:${MARKER_SCHEMA} END sha256=${hash} -->`;
41
+ }
42
+ // Marker line regexes (multiline — matched against the whole file body).
43
+ // START tolerates any trailing comment text after `START`; END optionally
44
+ // carries `sha256=<hex>`.
45
+ const START_LINE_RE = /^<!--\s*AURIGA:WORKFLOW:v1\s+START\b.*?-->[ \t]*$/m;
46
+ const END_LINE_RE = /^<!--\s*AURIGA:WORKFLOW:v1\s+END(?:\s+sha256=([0-9a-f]+))?[ \t]*-->[ \t]*$/m;
47
+ /** Matches the auriga workflow H1 header in either language
48
+ * (`# auriga Workflow (vX.Y.Z)` / `# auriga 工作流 (vX.Y.Z)`). */
49
+ export const WORKFLOW_HEADER_RE = /^#\s+auriga\s+(?:Workflow|工作流)\s*\(v\d+\.\d+\.\d+\)/;
50
+ /** sha256 of the managed-block body, truncated to 16 hex chars. Not a security
51
+ * primitive — just a tamper check to distinguish untouched from hand-edited. */
52
+ export function hashBlock(blockBody) {
53
+ return createHash("sha256").update(blockBody, "utf8").digest("hex").slice(0, 16);
54
+ }
55
+ /**
56
+ * Classify an AGENTS.md body by its managed-block markers.
57
+ *
58
+ * - `unmarked` — neither marker present (fresh-target / foreign / old-format)
59
+ * - `malformed` — exactly one marker, or END before START (can't safely splice)
60
+ * - `marked` — a well-formed START…END pair
61
+ */
62
+ export function parseMarkers(content) {
63
+ const startMatch = START_LINE_RE.exec(content);
64
+ const endMatch = END_LINE_RE.exec(content);
65
+ if (!startMatch && !endMatch)
66
+ return { kind: "unmarked" };
67
+ if (!startMatch || !endMatch) {
68
+ return {
69
+ kind: "malformed",
70
+ reason: startMatch ? "START marker without a matching END" : "END marker without a matching START",
71
+ };
72
+ }
73
+ if (startMatch.index >= endMatch.index) {
74
+ return { kind: "malformed", reason: "END marker precedes START marker" };
75
+ }
76
+ const startLineEnd = content.indexOf("\n", startMatch.index);
77
+ if (startLineEnd < 0 || startLineEnd > endMatch.index) {
78
+ return { kind: "malformed", reason: "START marker line is not terminated before END" };
79
+ }
80
+ const endLineEnd = content.indexOf("\n", endMatch.index);
81
+ return {
82
+ kind: "marked",
83
+ prefix: content.slice(0, startMatch.index),
84
+ blockBody: content.slice(startLineEnd + 1, endMatch.index),
85
+ userRegion: endLineEnd < 0 ? "" : content.slice(endLineEnd + 1),
86
+ endHash: endMatch[1] ?? null,
87
+ };
88
+ }
89
+ /**
90
+ * Build a marked AGENTS.md from its three parts. The END marker hash is
91
+ * computed from `blockBody` here, so callers never hand-maintain it.
92
+ *
93
+ * `blockBody` is expected to end with a newline (it is the content the START
94
+ * line's newline leads into, up to the END line). `parseMarkers` and
95
+ * `composeMarkedFile` are exact inverses for the block body and user region.
96
+ *
97
+ * `lang` selects the START marker's prose language (default English); it does
98
+ * not affect the parser, which keys on the language-independent token.
99
+ */
100
+ export function composeMarkedFile(opts) {
101
+ return ((opts.prefix ?? "") +
102
+ workflowStartMarker(opts.lang) + "\n" +
103
+ opts.blockBody +
104
+ workflowEndMarker(hashBlock(opts.blockBody)) + "\n" +
105
+ (opts.userRegion ?? ""));
106
+ }
107
+ /** True when the first non-blank line is an auriga workflow header. Used to
108
+ * tell an old-format (pre-marker) auriga workflow file from a foreign one. */
109
+ export function hasAurigaHeader(content) {
110
+ for (const line of content.split("\n")) {
111
+ if (line.trim().length === 0)
112
+ continue;
113
+ return WORKFLOW_HEADER_RE.test(line);
114
+ }
115
+ return false;
116
+ }
@@ -1,14 +1,13 @@
1
1
  import { type InstallOpts } from "./utils.js";
2
2
  export declare function installWorkflow(packageRoot: string, opts: InstallOpts): Promise<void>;
3
3
  /**
4
- * Uninstall the workflow (CLAUDE.md + AGENTS.md) from `opts.cwd`.
4
+ * Uninstall the workflow (AGENTS.md + CLAUDE.md) from `opts.cwd`.
5
5
  *
6
6
  * Safety contract:
7
7
  * - `opts.force` MUST be true. The CLI / server caller is responsible for
8
8
  * confirming user intent BEFORE invoking this; we refuse otherwise.
9
- * - `AGENTS.md` is removed ONLY if it's a symlink (the install-time shape).
10
- * A real-file AGENTS.md is left in place with a warning — the user has
11
- * diverged from the install pattern and probably hand-edited it.
9
+ * - Real files are removed only when they are recognizable auriga workflow
10
+ * files. Foreign instruction files are left in place with a warning.
12
11
  * - Missing files are a no-op: callers can re-run uninstall idempotently.
13
12
  * - `.claude/` is not touched; skills / plugins / hooks have their own
14
13
  * uninstall paths.
package/dist/workflow.js CHANGED
@@ -1,15 +1,58 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { input, select } from "@inquirer/prompts";
4
- import { LANGUAGES, fetchExtraContent, log, withEsc, } from "./utils.js";
4
+ import { DEFAULT_WORKFLOW_LANG, DEFAULT_WORKFLOW_TEMPLATE_FILE, LANGUAGES, fetchExtraContent, log, withEsc, } from "./utils.js";
5
+ import { composeMarkedFile, hasAurigaHeader, hashBlock, parseMarkers, } from "./workflow-markers.js";
6
+ import { LEGACY_AGENTS_SYMLINK_TARGET, WORKFLOW_COMPAT_FILE, WORKFLOW_COMPAT_SYMLINK_TARGET, WORKFLOW_PRIMARY_FILE, } from "./workflow-docs.js";
7
+ /**
8
+ * Back up `filePath` once. The canonical `<file>.bak` slot is reserved for the
9
+ * FIRST capture (the user's pre-auriga original) and is never overwritten — a
10
+ * later capture spills to a timestamped `<file>.bak.<stamp>`. Returns the path
11
+ * the backup was written to.
12
+ *
13
+ * `verbatimSymlinks` copies a symlink AS a symlink, preserving its literal
14
+ * (possibly relative) target — a foreign AGENTS.md may be a symlink pointing
15
+ * elsewhere, and we want the backup to preserve that target verbatim rather
16
+ * than snapshot whatever it currently resolves to. A real workflow file copies
17
+ * as a real file. `lstat` (not `existsSync`) probes the `.bak` slot so
18
+ * a backup that is itself a possibly-broken symlink still counts as present
19
+ * and is not silently overwritten.
20
+ */
21
+ function backupOnce(filePath) {
22
+ const bakPath = filePath + ".bak";
23
+ let bakExists = true;
24
+ try {
25
+ fs.lstatSync(bakPath);
26
+ }
27
+ catch {
28
+ bakExists = false;
29
+ }
30
+ const dest = bakExists
31
+ ? `${bakPath}.${new Date().toISOString().replace(/[:.]/g, "-")}`
32
+ : bakPath;
33
+ fs.cpSync(filePath, dest, { verbatimSymlinks: true });
34
+ return dest;
35
+ }
36
+ function lstatMaybe(filePath) {
37
+ try {
38
+ return fs.lstatSync(filePath);
39
+ }
40
+ catch {
41
+ return undefined;
42
+ }
43
+ }
44
+ function isSymlinkTo(filePath, target) {
45
+ const stat = lstatMaybe(filePath);
46
+ return !!stat?.isSymbolicLink() && fs.readlinkSync(filePath) === target;
47
+ }
5
48
  export async function installWorkflow(packageRoot, opts) {
6
49
  const lang = opts.interactive
7
50
  ? await withEsc(select({
8
- message: "CLAUDE.md language:",
51
+ message: "Workflow language:",
9
52
  choices: LANGUAGES.map((l) => ({ name: l.label, value: l.value })),
10
- default: "en",
53
+ default: DEFAULT_WORKFLOW_LANG,
11
54
  }))
12
- : (opts.lang ?? "en");
55
+ : (opts.lang ?? DEFAULT_WORKFLOW_LANG);
13
56
  const targetDir = opts.interactive
14
57
  ? await withEsc(input({
15
58
  message: "Workflow install target directory:",
@@ -26,70 +69,142 @@ export async function installWorkflow(packageRoot, opts) {
26
69
  throw new Error(msg);
27
70
  }
28
71
  const langOpt = LANGUAGES.find((l) => l.value === lang);
29
- // Lazy fetch: only download non-default language file when needed
30
- if (langOpt.file !== "CLAUDE.md") {
72
+ // Lazy fetch: only download non-default language files when needed.
73
+ if (langOpt.file !== DEFAULT_WORKFLOW_TEMPLATE_FILE) {
31
74
  console.log(`Fetching ${langOpt.label} template...`);
32
75
  await fetchExtraContent(packageRoot, langOpt.file);
33
76
  }
34
- const sourceClaude = path.join(packageRoot, langOpt.file);
35
- const targetClaude = path.join(resolved, "CLAUDE.md");
36
- const targetAgents = path.join(resolved, "AGENTS.md");
37
- // Back up an existing CLAUDE.md before overwriting, but never clobber
38
- // a prior .bak.
39
- //
40
- // Two regressions to defend against:
41
- // 1. F1 (v1.19.0 Slice 0): re-install is the update path now, so a
42
- // second install must not overwrite the user's pre-auriga .bak with
43
- // our previous workflow version.
44
- // 2. Codex adversarial review: if the user later replaces an
45
- // auriga-managed CLAUDE.md with foreign content (hand-paste, manual
46
- // edits, etc.) and re-runs install, the foreign content must NOT
47
- // be silently overwritten just because .bak already exists.
48
- //
49
- // Strategy: only consider the file "safe to overwrite without backup"
50
- // when its bytes match the packaged source (i.e. it's the workflow we
51
- // installed last time, untouched). Otherwise capture it — to .bak when
52
- // free, else to a timestamped slot so .bak stays canonical.
53
- if (fs.existsSync(targetClaude)) {
54
- const currentBytes = fs.readFileSync(targetClaude);
55
- const sourceBytes = fs.readFileSync(sourceClaude);
56
- const diverged = !currentBytes.equals(sourceBytes);
57
- if (diverged) {
58
- const bakPath = targetClaude + ".bak";
59
- if (fs.existsSync(bakPath)) {
60
- const stamp = new Date().toISOString().replace(/[:.]/g, "-");
61
- const stampedPath = `${bakPath}.${stamp}`;
62
- fs.copyFileSync(targetClaude, stampedPath);
63
- log.warn(`CLAUDE.md.bak already exists; current CLAUDE.md backed up to ${path.basename(stampedPath)}`);
64
- }
65
- else {
66
- fs.copyFileSync(targetClaude, bakPath);
67
- log.warn(`Existing CLAUDE.md backed up to CLAUDE.md.bak`);
77
+ const sourceWorkflow = path.join(packageRoot, langOpt.file);
78
+ const targetPrimary = path.join(resolved, WORKFLOW_PRIMARY_FILE);
79
+ const targetCompat = path.join(resolved, WORKFLOW_COMPAT_FILE);
80
+ // The packaged template is authored with managed-block markers. Extract its
81
+ // managed block (the auriga workflow body) and its user-region placeholder.
82
+ // Defensive fallback: if the template somehow lacks markers, treat the whole
83
+ // file as the managed block with an empty user region.
84
+ const sourceContent = fs.readFileSync(sourceWorkflow, "utf8");
85
+ const sourceParsed = parseMarkers(sourceContent);
86
+ const sourceBlock = sourceParsed.kind === "marked"
87
+ ? sourceParsed.blockBody
88
+ : sourceContent.endsWith("\n")
89
+ ? sourceContent
90
+ : sourceContent + "\n";
91
+ const templateUserRegion = sourceParsed.kind === "marked" ? sourceParsed.userRegion : "";
92
+ const primaryStat = lstatMaybe(targetPrimary);
93
+ const compatStat = lstatMaybe(targetCompat);
94
+ const legacyShape = primaryStat?.isSymbolicLink() === true &&
95
+ fs.readlinkSync(targetPrimary) === LEGACY_AGENTS_SYMLINK_TARGET &&
96
+ compatStat?.isFile() === true;
97
+ const primaryForeignSymlink = primaryStat?.isSymbolicLink() === true &&
98
+ fs.readlinkSync(targetPrimary) !== LEGACY_AGENTS_SYMLINK_TARGET;
99
+ const compatIsCurrentPrimary = !primaryStat &&
100
+ compatStat !== undefined &&
101
+ !isSymlinkTo(targetCompat, WORKFLOW_COMPAT_SYMLINK_TARGET);
102
+ const currentPath = primaryStat && !primaryStat.isSymbolicLink()
103
+ ? targetPrimary
104
+ : legacyShape || compatIsCurrentPrimary
105
+ ? targetCompat
106
+ : undefined;
107
+ let wrotePrimary = false;
108
+ const writePrimary = (content) => {
109
+ if (primaryStat?.isSymbolicLink()) {
110
+ if (primaryForeignSymlink) {
111
+ const bak = backupOnce(targetPrimary);
112
+ log.warn(`AGENTS.md 是指向其它目标的软链;已备份到 ${path.basename(bak)} 后改为主文件。`);
68
113
  }
114
+ fs.unlinkSync(targetPrimary);
69
115
  }
116
+ fs.writeFileSync(targetPrimary, content);
117
+ wrotePrimary = true;
118
+ };
119
+ // Installing the workflow doc is one of five cases. The managed block is
120
+ // always replaced with the packaged version; the cases differ in how the
121
+ // project's own content (the user region) is preserved or backed up.
122
+ if (!currentPath) {
123
+ // 1. Fresh install — write the marked template as-is, no backup.
124
+ writePrimary(composeMarkedFile({ blockBody: sourceBlock, userRegion: templateUserRegion, lang }));
125
+ log.ok(`AGENTS.md installed (${langOpt.label})`);
70
126
  }
71
- fs.copyFileSync(sourceClaude, targetClaude);
72
- log.ok(`CLAUDE.md copied (${langOpt.label})`);
73
- // Create AGENTS.md symlink
74
- try {
75
- fs.lstatSync(targetAgents);
76
- fs.unlinkSync(targetAgents);
127
+ else {
128
+ const current = fs.readFileSync(currentPath, "utf8");
129
+ const parsed = parseMarkers(current);
130
+ if (parsed.kind === "marked") {
131
+ // 2. Upgrade — splice the managed block, preserve the user region.
132
+ // The END marker carries the block's hash. Three cases:
133
+ // - hash present and matches → block untouched, no backup
134
+ // - hash present and mismatch → block hand-edited, back up + warn
135
+ // - hash absent → unverifiable (e.g. the file was
136
+ // copied straight from the template, which ships a no-hash END
137
+ // marker). Can't prove the block is untouched, so back up
138
+ // conservatively rather than risk silently dropping an edit.
139
+ if (parsed.endHash === null) {
140
+ const bak = backupOnce(currentPath);
141
+ log.warn(`工作流文档的受管区块缺少校验标记,无法确认是否被改动;升级前已备份到 ${path.basename(bak)}`);
142
+ }
143
+ else if (parsed.endHash !== hashBlock(parsed.blockBody)) {
144
+ const bak = backupOnce(currentPath);
145
+ log.warn(`工作流文档的受管区块曾被手改;升级已整块覆盖该区块,改动前的文件见 ${path.basename(bak)}`);
146
+ }
147
+ writePrimary(composeMarkedFile({
148
+ prefix: parsed.prefix,
149
+ blockBody: sourceBlock,
150
+ userRegion: parsed.userRegion,
151
+ lang,
152
+ }));
153
+ log.ok(`AGENTS.md upgraded (${langOpt.label}); your project section was preserved`);
154
+ }
155
+ else if (parsed.kind === "unmarked" && hasAurigaHeader(current)) {
156
+ // 3. Old-format migration — an auriga workflow doc from before markers
157
+ // existed. The user region can't be recovered from an unmarked file,
158
+ // so back the whole thing up and install fresh.
159
+ const bak = backupOnce(currentPath);
160
+ writePrimary(composeMarkedFile({ blockBody: sourceBlock, userRegion: templateUserRegion, lang }));
161
+ log.warn(`检测到旧版工作流文档(无受管标记);已备份到 ${path.basename(bak)}。` +
162
+ `若你改过它,请从备份把工程定制手动迁移到 END 标记之后的用户区。`);
163
+ log.ok(`AGENTS.md migrated to the managed-block format (${langOpt.label})`);
164
+ }
165
+ else if (parsed.kind === "unmarked") {
166
+ // 4. Foreign first install — a workflow doc from another tool. Keep its
167
+ // content in place as the user region; no backup needed.
168
+ const foreign = current.endsWith("\n") ? current : current + "\n";
169
+ writePrimary(composeMarkedFile({ blockBody: sourceBlock, userRegion: "\n" + foreign, lang }));
170
+ log.ok(`AGENTS.md installed (${langOpt.label}); your existing content was kept below the managed block`);
171
+ if (currentPath === targetPrimary) {
172
+ log.warn("AGENTS.md already existed; its content was kept below the managed block.");
173
+ }
174
+ }
175
+ else {
176
+ // 5. Malformed markers — can't locate the block boundaries safely.
177
+ // Back up and reinstall fresh rather than splice into a broken file.
178
+ const bak = backupOnce(currentPath);
179
+ writePrimary(composeMarkedFile({ blockBody: sourceBlock, userRegion: templateUserRegion, lang }));
180
+ log.warn(`工作流文档的受管标记已损坏(${parsed.reason});已备份到 ${path.basename(bak)} 并重装。`);
181
+ }
77
182
  }
78
- catch {
79
- // does not exist, proceed
183
+ // Point CLAUDE.md at AGENTS.md via a compatibility symlink. If CLAUDE.md was
184
+ // the old primary file and its content was migrated above, replacing it with
185
+ // the symlink is safe. Otherwise preserve any real file or foreign symlink
186
+ // before replacing it.
187
+ const latestCompatStat = lstatMaybe(targetCompat);
188
+ if (latestCompatStat) {
189
+ const pointsToPrimary = isSymlinkTo(targetCompat, WORKFLOW_COMPAT_SYMLINK_TARGET);
190
+ const migratedFromCompat = currentPath === targetCompat && wrotePrimary && !latestCompatStat.isSymbolicLink();
191
+ if (!pointsToPrimary && !migratedFromCompat) {
192
+ const bak = backupOnce(targetCompat);
193
+ log.warn(`CLAUDE.md 不是指向 AGENTS.md 的软链;已备份到 ${path.basename(bak)} 后替换为软链。`);
194
+ }
195
+ fs.unlinkSync(targetCompat);
80
196
  }
81
- fs.symlinkSync("CLAUDE.md", targetAgents);
82
- log.ok("AGENTS.md -> CLAUDE.md symlink created");
197
+ fs.symlinkSync(WORKFLOW_COMPAT_SYMLINK_TARGET, targetCompat);
198
+ log.ok("CLAUDE.md -> AGENTS.md symlink created");
83
199
  }
84
200
  /**
85
- * Uninstall the workflow (CLAUDE.md + AGENTS.md) from `opts.cwd`.
201
+ * Uninstall the workflow (AGENTS.md + CLAUDE.md) from `opts.cwd`.
86
202
  *
87
203
  * Safety contract:
88
204
  * - `opts.force` MUST be true. The CLI / server caller is responsible for
89
205
  * confirming user intent BEFORE invoking this; we refuse otherwise.
90
- * - `AGENTS.md` is removed ONLY if it's a symlink (the install-time shape).
91
- * A real-file AGENTS.md is left in place with a warning — the user has
92
- * diverged from the install pattern and probably hand-edited it.
206
+ * - Real files are removed only when they are recognizable auriga workflow
207
+ * files. Foreign instruction files are left in place with a warning.
93
208
  * - Missing files are a no-op: callers can re-run uninstall idempotently.
94
209
  * - `.claude/` is not touched; skills / plugins / hooks have their own
95
210
  * uninstall paths.
@@ -109,38 +224,58 @@ export async function uninstallWorkflow(opts) {
109
224
  const emit = (line) => {
110
225
  opts.onLog?.(line);
111
226
  };
112
- const targetClaude = path.join(resolved, "CLAUDE.md");
113
- const targetAgents = path.join(resolved, "AGENTS.md");
114
- // CLAUDE.md flat file. lstat to avoid following a symlink (would be
115
- // unusual but we'd rather refuse to traverse than chase one out).
116
- if (fs.existsSync(targetClaude)) {
117
- fs.unlinkSync(targetClaude);
118
- log.ok("CLAUDE.md removed");
119
- emit("removed CLAUDE.md");
120
- }
121
- else {
122
- log.skip("CLAUDE.md not present");
123
- emit("CLAUDE.md not present");
124
- }
125
- // AGENTS.md — only remove symlinks (our install shape). lstatSync
126
- // refuses to follow the link so we inspect the link itself.
127
- try {
128
- const stat = fs.lstatSync(targetAgents);
227
+ const targetClaude = path.join(resolved, WORKFLOW_COMPAT_FILE);
228
+ const targetAgents = path.join(resolved, WORKFLOW_PRIMARY_FILE);
229
+ const isAurigaWorkflowFile = (filePath) => {
230
+ try {
231
+ const content = fs.readFileSync(filePath, "utf8");
232
+ return parseMarkers(content).kind === "marked" || hasAurigaHeader(content);
233
+ }
234
+ catch {
235
+ return false;
236
+ }
237
+ };
238
+ const removeWorkflowPath = (filePath, name) => {
239
+ let stat;
240
+ try {
241
+ stat = fs.lstatSync(filePath);
242
+ }
243
+ catch (err) {
244
+ const code = err.code;
245
+ if (code === "ENOENT") {
246
+ log.skip(`${name} not present`);
247
+ emit(`${name} not present`);
248
+ return;
249
+ }
250
+ throw err;
251
+ }
129
252
  if (stat.isSymbolicLink()) {
130
- fs.unlinkSync(targetAgents);
131
- log.ok("AGENTS.md symlink removed");
132
- emit("removed AGENTS.md symlink");
253
+ const linkTarget = fs.readlinkSync(filePath);
254
+ const isManagedSymlink = (name === WORKFLOW_PRIMARY_FILE && linkTarget === LEGACY_AGENTS_SYMLINK_TARGET) ||
255
+ (name === WORKFLOW_COMPAT_FILE && linkTarget === WORKFLOW_COMPAT_SYMLINK_TARGET);
256
+ if (isManagedSymlink) {
257
+ fs.unlinkSync(filePath);
258
+ log.ok(`${name} symlink removed`);
259
+ emit(`removed ${name} symlink`);
260
+ }
261
+ else {
262
+ log.warn(`foreign ${name} symlink left in place`);
263
+ emit(`foreign ${name} symlink left in place`);
264
+ }
265
+ }
266
+ else if (stat.isFile() && isAurigaWorkflowFile(filePath)) {
267
+ fs.unlinkSync(filePath);
268
+ log.ok(`${name} removed`);
269
+ emit(`removed ${name}`);
133
270
  }
134
271
  else {
135
- // Real file (or directory) — user diverged from install. Don't
136
- // silently destroy their content; warn and leave it.
137
- log.warn("AGENTS.md is not a symlink; left in place");
138
- emit("AGENTS.md is not a symlink; left in place");
272
+ log.warn(`foreign ${name} left in place`);
273
+ emit(`foreign ${name} left in place`);
139
274
  }
140
- }
141
- catch {
142
- // ENOENT already gone, idempotent no-op.
143
- log.skip("AGENTS.md not present");
144
- emit("AGENTS.md not present");
145
- }
275
+ };
276
+ // Remove AGENTS.md first because it is the current primary. lstatSync refuses
277
+ // to follow symlinks, so the legacy AGENTS.md -> CLAUDE.md shape is handled
278
+ // without deleting CLAUDE.md through the link.
279
+ removeWorkflowPath(targetAgents, "AGENTS.md");
280
+ removeWorkflowPath(targetClaude, "CLAUDE.md");
146
281
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auriga-cli",
3
- "version": "1.27.0",
3
+ "version": "1.29.0",
4
4
  "description": "Interactive CLI to install Claude Code harness modules (Workflow, Skills, Recommended Skills, Plugins)",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -25,8 +25,8 @@
25
25
  "dev": "tsc --watch",
26
26
  "start": "node dist/cli.js",
27
27
  "pretest": "npm run build",
28
- "test": "tsc -p tsconfig.test.json && DEV=1 node --test --experimental-test-module-mocks dist-test/tests/skills.test.js dist-test/tests/skills-uninstall.test.js dist-test/tests/catalog.test.js dist-test/tests/cli-parse.test.js dist-test/tests/install-nontty.test.js dist-test/tests/preset.test.js dist-test/tests/legacy-menu.test.js dist-test/tests/plugins.test.js dist-test/tests/plugins-uninstall.test.js dist-test/tests/content-fetch.test.js dist-test/tests/utils.test.js dist-test/tests/guide.test.js dist-test/tests/validators.test.js dist-test/tests/entrypoint.test.js dist-test/tests/state.test.js dist-test/tests/server.test.js dist-test/tests/server-auth.test.js dist-test/tests/server-apply.test.js dist-test/tests/apply-handlers.test.js dist-test/tests/ui-fetch.test.js dist-test/tests/workflow-install.test.js dist-test/tests/workflow-uninstall.test.js dist-test/tests/tarball-shape.test.js dist-test/tests/spec-design.test.js dist-test/tests/plugin-skill-frontmatter.test.js",
29
- "test:watch": "tsc -p tsconfig.test.json --watch & node --test --watch --experimental-test-module-mocks dist-test/tests/skills.test.js dist-test/tests/skills-uninstall.test.js dist-test/tests/catalog.test.js dist-test/tests/cli-parse.test.js dist-test/tests/install-nontty.test.js dist-test/tests/preset.test.js dist-test/tests/legacy-menu.test.js dist-test/tests/plugins.test.js dist-test/tests/plugins-uninstall.test.js dist-test/tests/content-fetch.test.js dist-test/tests/utils.test.js dist-test/tests/guide.test.js dist-test/tests/validators.test.js dist-test/tests/entrypoint.test.js dist-test/tests/state.test.js dist-test/tests/server.test.js dist-test/tests/server-auth.test.js dist-test/tests/server-apply.test.js dist-test/tests/apply-handlers.test.js dist-test/tests/ui-fetch.test.js dist-test/tests/workflow-install.test.js dist-test/tests/workflow-uninstall.test.js dist-test/tests/tarball-shape.test.js dist-test/tests/spec-design.test.js dist-test/tests/plugin-skill-frontmatter.test.js",
28
+ "test": "tsc -p tsconfig.test.json && DEV=1 node --test --experimental-test-module-mocks dist-test/tests/skills.test.js dist-test/tests/skills-uninstall.test.js dist-test/tests/catalog.test.js dist-test/tests/cli-parse.test.js dist-test/tests/install-nontty.test.js dist-test/tests/preset.test.js dist-test/tests/legacy-menu.test.js dist-test/tests/plugins.test.js dist-test/tests/plugins-uninstall.test.js dist-test/tests/content-fetch.test.js dist-test/tests/utils.test.js dist-test/tests/guide.test.js dist-test/tests/validators.test.js dist-test/tests/entrypoint.test.js dist-test/tests/state.test.js dist-test/tests/server.test.js dist-test/tests/server-auth.test.js dist-test/tests/server-apply.test.js dist-test/tests/apply-handlers.test.js dist-test/tests/ui-fetch.test.js dist-test/tests/workflow-markers.test.js dist-test/tests/workflow-install.test.js dist-test/tests/workflow-uninstall.test.js dist-test/tests/tarball-shape.test.js dist-test/tests/spec-design.test.js dist-test/tests/goalify.test.js dist-test/tests/plugin-skill-frontmatter.test.js",
29
+ "test:watch": "tsc -p tsconfig.test.json --watch & node --test --watch --experimental-test-module-mocks dist-test/tests/skills.test.js dist-test/tests/skills-uninstall.test.js dist-test/tests/catalog.test.js dist-test/tests/cli-parse.test.js dist-test/tests/install-nontty.test.js dist-test/tests/preset.test.js dist-test/tests/legacy-menu.test.js dist-test/tests/plugins.test.js dist-test/tests/plugins-uninstall.test.js dist-test/tests/content-fetch.test.js dist-test/tests/utils.test.js dist-test/tests/guide.test.js dist-test/tests/validators.test.js dist-test/tests/entrypoint.test.js dist-test/tests/state.test.js dist-test/tests/server.test.js dist-test/tests/server-auth.test.js dist-test/tests/server-apply.test.js dist-test/tests/apply-handlers.test.js dist-test/tests/ui-fetch.test.js dist-test/tests/workflow-markers.test.js dist-test/tests/workflow-install.test.js dist-test/tests/workflow-uninstall.test.js dist-test/tests/tarball-shape.test.js dist-test/tests/spec-design.test.js dist-test/tests/goalify.test.js dist-test/tests/plugin-skill-frontmatter.test.js",
30
30
  "pretest:e2e": "npm run build",
31
31
  "test:e2e": "tsc -p tsconfig.test.json && node --test dist-test/tests/e2e-install.test.js",
32
32
  "pretest:web-ui-e2e": "npm run build && npm --prefix ui ci && npm --prefix ui run build",