mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-18 16:03:24 +00:00
feat(cli): add wmill init prompts and custom override slot (#9266)
* feat(cli): add `wmill init prompts` and custom override slot Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(cli): replace init prompts with refresh prompts + AGENTS.md/AGENTS.cli.md split Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(cli): dedupe claude skills via @-includes and add prompts freshness check Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(cli): drop migration-choice flags from `refresh prompts` Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(cli): add 'Running and previewing local changes' section to AGENTS.cli.md Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(cli): write full skill content to .claude/, drop @-include wrapper Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(cli): reconcile CLAUDE.md the same way as AGENTS.md Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(cli): address PR review nits — argv parsing, lazy import, comment detection, error propagation Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
committed by
Diego Imbert
co-authored by
Claude Opus 4.7
parent
4e8d9f7445
commit
d5e39d2f3e
@@ -3,8 +3,15 @@ import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { writeAiGuidanceFiles } from "../src/guidance/writer.ts";
|
||||
import {
|
||||
currentPromptsHash,
|
||||
extractPromptsHash,
|
||||
injectPromptsHashMarker,
|
||||
shouldRunFreshnessCheck,
|
||||
warnIfPromptsStale,
|
||||
} from "../src/guidance/freshness.ts";
|
||||
|
||||
const SKILL_TARGET_ROOTS = [".claude", ".agents"] as const;
|
||||
const SKILL_TARGET_ROOTS = [".agents", ".claude"] as const;
|
||||
|
||||
async function withTempDir(fn: (tempDir: string) => Promise<void>): Promise<void> {
|
||||
const tempDir = await mkdtemp(join(tmpdir(), "wmill_guidance_writer_"));
|
||||
@@ -26,7 +33,7 @@ async function writeSkill(
|
||||
return skillPath;
|
||||
}
|
||||
|
||||
describe("writeAiGuidanceFiles", () => {
|
||||
describe("writeAiGuidanceFiles — skills", () => {
|
||||
test("preserves custom skills when refreshing generated guidance", async () => {
|
||||
await withTempDir(async (tempDir) => {
|
||||
const skillsDirs = SKILL_TARGET_ROOTS.map((root) =>
|
||||
@@ -52,19 +59,21 @@ Preserve me.
|
||||
)
|
||||
);
|
||||
|
||||
await writeAiGuidanceFiles({
|
||||
targetDir: tempDir,
|
||||
overwriteProjectGuidance: false,
|
||||
});
|
||||
await writeAiGuidanceFiles({ targetDir: tempDir });
|
||||
|
||||
// Custom skills survive on every side, untouched.
|
||||
for (const customSkillPath of customSkillPaths) {
|
||||
expect(await readFile(customSkillPath, "utf8")).toBe(customSkillContent);
|
||||
}
|
||||
|
||||
// Both `.agents/skills/` and `.claude/skills/` hold the same full
|
||||
// canonical content. (Claude's skill loader doesn't expand `@`
|
||||
// references inside SKILL.md, so we can't dedupe via `@`-include.)
|
||||
for (const generatedSkillPath of generatedSkillPaths) {
|
||||
const generatedSkillContent = await readFile(generatedSkillPath, "utf8");
|
||||
expect(generatedSkillContent).not.toBe(staleGeneratedContent);
|
||||
expect(generatedSkillContent).toContain("name: write-flow");
|
||||
expect(generatedSkillContent).not.toContain("@../../../");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -101,7 +110,7 @@ Copied from source bundle.
|
||||
writeSkill(skillsDir, "custom-skill", customSkillContent)
|
||||
)
|
||||
);
|
||||
const existingGeneratedSkillPaths = await Promise.all(
|
||||
await Promise.all(
|
||||
skillsDirs.map((skillsDir) =>
|
||||
writeSkill(skillsDir, "write-flow", "old content")
|
||||
)
|
||||
@@ -113,25 +122,28 @@ Copied from source bundle.
|
||||
|
||||
await writeAiGuidanceFiles({
|
||||
targetDir: tempDir,
|
||||
overwriteProjectGuidance: false,
|
||||
skillsSourcePath: sourceSkillsDir,
|
||||
});
|
||||
|
||||
// Custom skills survive untouched on every side.
|
||||
for (const customSkillPath of customSkillPaths) {
|
||||
expect(await readFile(customSkillPath, "utf8")).toBe(customSkillContent);
|
||||
}
|
||||
for (const existingGeneratedSkillPath of existingGeneratedSkillPaths) {
|
||||
expect(await readFile(existingGeneratedSkillPath, "utf8")).toBe(sourceSkillContent);
|
||||
}
|
||||
for (const skillsDir of skillsDirs) {
|
||||
expect(await readFile(join(skillsDir, "bundle-only", "SKILL.md"), "utf8")).toBe(
|
||||
bundleOnlySkillContent
|
||||
);
|
||||
|
||||
// Source bundle is copied verbatim into both `.agents/skills/` and
|
||||
// `.claude/skills/`. No `@`-include wrapping.
|
||||
for (const root of SKILL_TARGET_ROOTS) {
|
||||
expect(
|
||||
await readFile(join(tempDir, root, "skills/write-flow/SKILL.md"), "utf8")
|
||||
).toBe(sourceSkillContent);
|
||||
expect(
|
||||
await readFile(join(tempDir, root, "skills/bundle-only/SKILL.md"), "utf8")
|
||||
).toBe(bundleOnlySkillContent);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("builds AGENTS skill references from copied directory names", async () => {
|
||||
test("AGENTS.cli.md gets the skills reference from copied directory names", async () => {
|
||||
await withTempDir(async (tempDir) => {
|
||||
const sourceSkillsDir = join(tempDir, "source-skills");
|
||||
await writeSkill(
|
||||
@@ -148,29 +160,393 @@ Copied from source bundle.
|
||||
|
||||
await writeAiGuidanceFiles({
|
||||
targetDir: tempDir,
|
||||
overwriteProjectGuidance: false,
|
||||
skillsSourcePath: sourceSkillsDir,
|
||||
});
|
||||
|
||||
const agentsMd = await readFile(join(tempDir, "AGENTS.md"), "utf8");
|
||||
expect(agentsMd).toContain(".claude/skills/custom-folder/SKILL.md");
|
||||
expect(agentsMd).not.toContain(".claude/skills/write-flow/SKILL.md");
|
||||
const agentsCli = await readFile(join(tempDir, "AGENTS.cli.md"), "utf8");
|
||||
expect(agentsCli).toContain(".agents/skills/custom-folder/SKILL.md");
|
||||
expect(agentsCli).not.toContain(".agents/skills/write-flow/SKILL.md");
|
||||
// The skill reference points at the .agents/ tree — not .claude/ —
|
||||
// so the path is meaningful to Codex/Pi as well as Claude.
|
||||
expect(agentsCli).not.toContain(".claude/skills/custom-folder/SKILL.md");
|
||||
});
|
||||
});
|
||||
|
||||
test("writes AGENTS.md and CLAUDE.md even if skills creation fails", async () => {
|
||||
test("AGENTS.cli.md and CLAUDE.md are written even if skills creation fails", async () => {
|
||||
await withTempDir(async (tempDir) => {
|
||||
// Create a file at .claude so mkdir of .claude/skills throws.
|
||||
await writeFile(join(tempDir, ".claude"), "not a directory\n", "utf8");
|
||||
|
||||
await expect(
|
||||
writeAiGuidanceFiles({
|
||||
targetDir: tempDir,
|
||||
overwriteProjectGuidance: false,
|
||||
})
|
||||
writeAiGuidanceFiles({ targetDir: tempDir })
|
||||
).rejects.toThrow();
|
||||
|
||||
expect(await readFile(join(tempDir, "AGENTS.md"), "utf8")).toContain(".claude/skills/");
|
||||
expect(await readFile(join(tempDir, "CLAUDE.md"), "utf8")).toContain("@AGENTS.md");
|
||||
expect(await readFile(join(tempDir, "AGENTS.cli.md"), "utf8")).toContain(
|
||||
".agents/skills/"
|
||||
);
|
||||
expect(await readFile(join(tempDir, "AGENTS.md"), "utf8")).toContain(
|
||||
"@AGENTS.cli.md"
|
||||
);
|
||||
expect(await readFile(join(tempDir, "CLAUDE.md"), "utf8")).toContain(
|
||||
"@AGENTS.md"
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("writeAiGuidanceFiles — AGENTS.md reconciliation", () => {
|
||||
test("creates a skeleton AGENTS.md (with @AGENTS.cli.md include) when none exists", async () => {
|
||||
await withTempDir(async (tempDir) => {
|
||||
const result = await writeAiGuidanceFiles({ targetDir: tempDir });
|
||||
expect(result.agentsCreated).toBe(true);
|
||||
expect(result.agentsMigration).toBe("not-applicable");
|
||||
|
||||
const agentsMd = await readFile(join(tempDir, "AGENTS.md"), "utf8");
|
||||
expect(agentsMd).toContain("@AGENTS.cli.md");
|
||||
});
|
||||
});
|
||||
|
||||
test("leaves an existing AGENTS.md alone when it already references @AGENTS.cli.md", async () => {
|
||||
await withTempDir(async (tempDir) => {
|
||||
const original = "# My AGENTS.md\n\nlocal stuff\n\n@AGENTS.cli.md\n";
|
||||
await writeFile(join(tempDir, "AGENTS.md"), original, "utf8");
|
||||
|
||||
const result = await writeAiGuidanceFiles({ targetDir: tempDir });
|
||||
expect(result.agentsCreated).toBe(false);
|
||||
expect(result.agentsMigration).toBe("already-linked");
|
||||
|
||||
expect(await readFile(join(tempDir, "AGENTS.md"), "utf8")).toBe(original);
|
||||
});
|
||||
});
|
||||
|
||||
test("appends @AGENTS.cli.md when the resolver returns 'append'", async () => {
|
||||
await withTempDir(async (tempDir) => {
|
||||
const original = "# Existing custom AGENTS.md\n\nproject rules here.\n";
|
||||
await writeFile(join(tempDir, "AGENTS.md"), original, "utf8");
|
||||
|
||||
const result = await writeAiGuidanceFiles({
|
||||
targetDir: tempDir,
|
||||
resolveAgentsMdMigration: async () => "append",
|
||||
});
|
||||
expect(result.agentsCreated).toBe(false);
|
||||
expect(result.agentsMigration).toBe("append");
|
||||
|
||||
const updated = await readFile(join(tempDir, "AGENTS.md"), "utf8");
|
||||
expect(updated).toStartWith(original);
|
||||
expect(updated).toContain("@AGENTS.cli.md");
|
||||
});
|
||||
});
|
||||
|
||||
test("overwrites AGENTS.md with the managed skeleton when the resolver returns 'overwrite'", async () => {
|
||||
await withTempDir(async (tempDir) => {
|
||||
const original = "# Some old AGENTS.md to be replaced\n";
|
||||
await writeFile(join(tempDir, "AGENTS.md"), original, "utf8");
|
||||
|
||||
const result = await writeAiGuidanceFiles({
|
||||
targetDir: tempDir,
|
||||
resolveAgentsMdMigration: async () => "overwrite",
|
||||
});
|
||||
expect(result.agentsCreated).toBe(false);
|
||||
expect(result.agentsMigration).toBe("overwrite");
|
||||
|
||||
const updated = await readFile(join(tempDir, "AGENTS.md"), "utf8");
|
||||
expect(updated).not.toBe(original);
|
||||
expect(updated).toContain("@AGENTS.cli.md");
|
||||
});
|
||||
});
|
||||
|
||||
test("leaves AGENTS.md untouched when the resolver returns 'skip'", async () => {
|
||||
await withTempDir(async (tempDir) => {
|
||||
const original = "# Hand-managed AGENTS.md\n";
|
||||
await writeFile(join(tempDir, "AGENTS.md"), original, "utf8");
|
||||
|
||||
const result = await writeAiGuidanceFiles({
|
||||
targetDir: tempDir,
|
||||
resolveAgentsMdMigration: async () => "skip",
|
||||
});
|
||||
expect(result.agentsCreated).toBe(false);
|
||||
expect(result.agentsMigration).toBe("skip");
|
||||
|
||||
expect(await readFile(join(tempDir, "AGENTS.md"), "utf8")).toBe(original);
|
||||
});
|
||||
});
|
||||
|
||||
test("defaults to 'append' when the resolver is not provided", async () => {
|
||||
await withTempDir(async (tempDir) => {
|
||||
const original = "# AGENTS.md\n";
|
||||
await writeFile(join(tempDir, "AGENTS.md"), original, "utf8");
|
||||
|
||||
const result = await writeAiGuidanceFiles({ targetDir: tempDir });
|
||||
expect(result.agentsMigration).toBe("append");
|
||||
|
||||
const updated = await readFile(join(tempDir, "AGENTS.md"), "utf8");
|
||||
expect(updated).toStartWith(original);
|
||||
expect(updated).toContain("@AGENTS.cli.md");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("writeAiGuidanceFiles — CLAUDE.md reconciliation", () => {
|
||||
test("creates a skeleton CLAUDE.md (with @AGENTS.md include) when none exists", async () => {
|
||||
await withTempDir(async (tempDir) => {
|
||||
const result = await writeAiGuidanceFiles({ targetDir: tempDir });
|
||||
expect(result.claudeCreated).toBe(true);
|
||||
expect(result.claudeMigration).toBe("not-applicable");
|
||||
|
||||
const claudeMd = await readFile(join(tempDir, "CLAUDE.md"), "utf8");
|
||||
expect(claudeMd).toContain("@AGENTS.md");
|
||||
});
|
||||
});
|
||||
|
||||
test("leaves an existing CLAUDE.md alone when it already references @AGENTS.md", async () => {
|
||||
await withTempDir(async (tempDir) => {
|
||||
const original = "# My CLAUDE.md\n\nlocal stuff\n\n@AGENTS.md\n";
|
||||
await writeFile(join(tempDir, "CLAUDE.md"), original, "utf8");
|
||||
|
||||
const result = await writeAiGuidanceFiles({ targetDir: tempDir });
|
||||
expect(result.claudeCreated).toBe(false);
|
||||
expect(result.claudeMigration).toBe("already-linked");
|
||||
|
||||
expect(await readFile(join(tempDir, "CLAUDE.md"), "utf8")).toBe(original);
|
||||
});
|
||||
});
|
||||
|
||||
test("appends @AGENTS.md when the resolver returns 'append'", async () => {
|
||||
await withTempDir(async (tempDir) => {
|
||||
const original = "# Existing custom CLAUDE.md\n\nBe helpful.\n";
|
||||
await writeFile(join(tempDir, "CLAUDE.md"), original, "utf8");
|
||||
|
||||
const result = await writeAiGuidanceFiles({
|
||||
targetDir: tempDir,
|
||||
resolveAgentsMdMigration: async () => "append",
|
||||
});
|
||||
expect(result.claudeCreated).toBe(false);
|
||||
expect(result.claudeMigration).toBe("append");
|
||||
|
||||
const updated = await readFile(join(tempDir, "CLAUDE.md"), "utf8");
|
||||
expect(updated).toStartWith(original);
|
||||
expect(updated).toContain("@AGENTS.md");
|
||||
});
|
||||
});
|
||||
|
||||
test("overwrites CLAUDE.md with the managed skeleton when the resolver returns 'overwrite'", async () => {
|
||||
await withTempDir(async (tempDir) => {
|
||||
const original = "# Some old CLAUDE.md\n";
|
||||
await writeFile(join(tempDir, "CLAUDE.md"), original, "utf8");
|
||||
|
||||
const result = await writeAiGuidanceFiles({
|
||||
targetDir: tempDir,
|
||||
resolveAgentsMdMigration: async () => "overwrite",
|
||||
});
|
||||
expect(result.claudeCreated).toBe(false);
|
||||
expect(result.claudeMigration).toBe("overwrite");
|
||||
|
||||
expect(
|
||||
await readFile(join(tempDir, "CLAUDE.md"), "utf8")
|
||||
).not.toBe(original);
|
||||
expect(await readFile(join(tempDir, "CLAUDE.md"), "utf8")).toContain(
|
||||
"@AGENTS.md"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("leaves CLAUDE.md untouched when the resolver returns 'skip'", async () => {
|
||||
await withTempDir(async (tempDir) => {
|
||||
const original = "# Hand-managed CLAUDE.md\n";
|
||||
await writeFile(join(tempDir, "CLAUDE.md"), original, "utf8");
|
||||
|
||||
const result = await writeAiGuidanceFiles({
|
||||
targetDir: tempDir,
|
||||
resolveAgentsMdMigration: async () => "skip",
|
||||
});
|
||||
expect(result.claudeCreated).toBe(false);
|
||||
expect(result.claudeMigration).toBe("skip");
|
||||
|
||||
expect(await readFile(join(tempDir, "CLAUDE.md"), "utf8")).toBe(original);
|
||||
});
|
||||
});
|
||||
|
||||
test("resolver is invoked at most once even if both files need it", async () => {
|
||||
await withTempDir(async (tempDir) => {
|
||||
const original = "# old\n";
|
||||
await writeFile(join(tempDir, "AGENTS.md"), original, "utf8");
|
||||
await writeFile(join(tempDir, "CLAUDE.md"), original, "utf8");
|
||||
|
||||
let resolverCalls = 0;
|
||||
await writeAiGuidanceFiles({
|
||||
targetDir: tempDir,
|
||||
resolveAgentsMdMigration: async () => {
|
||||
resolverCalls += 1;
|
||||
return "append";
|
||||
},
|
||||
});
|
||||
expect(resolverCalls).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("writeAiGuidanceFiles — referencesAgentsCli (via reconciliation)", () => {
|
||||
test.each([
|
||||
["bare line", "@AGENTS.cli.md"],
|
||||
["between blank lines", "before\n\n@AGENTS.cli.md\n\nafter"],
|
||||
["leading whitespace then include", " @AGENTS.cli.md\n"],
|
||||
["CRLF line endings", "line one\r\n@AGENTS.cli.md\r\nline three"],
|
||||
])("treats %s as a reference (no append)", async (_label, content) => {
|
||||
await withTempDir(async (tempDir) => {
|
||||
await writeFile(join(tempDir, "AGENTS.md"), content, "utf8");
|
||||
const result = await writeAiGuidanceFiles({ targetDir: tempDir });
|
||||
expect(result.agentsMigration).toBe("already-linked");
|
||||
expect(await readFile(join(tempDir, "AGENTS.md"), "utf8")).toBe(content);
|
||||
});
|
||||
});
|
||||
|
||||
test.each([
|
||||
["@AGENTS.cli.md.backup", "@AGENTS.cli.md.backup"],
|
||||
["@AGENTS.cli.mdx", "@AGENTS.cli.mdx"],
|
||||
["@AGENTS-cli-md (lookalike)", "@AGENTS-cli-md"],
|
||||
["@AGENTS.cli.md without surrounding whitespace", "foo@AGENTS.cli.md"],
|
||||
["commented-out include", "<!-- @AGENTS.cli.md -->"],
|
||||
["blockquoted include", "> @AGENTS.cli.md"],
|
||||
])("does not treat %s as a reference (append happens)", async (_label, content) => {
|
||||
await withTempDir(async (tempDir) => {
|
||||
await writeFile(join(tempDir, "AGENTS.md"), content, "utf8");
|
||||
const result = await writeAiGuidanceFiles({
|
||||
targetDir: tempDir,
|
||||
resolveAgentsMdMigration: async () => "append",
|
||||
});
|
||||
expect(result.agentsMigration).toBe("append");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("prompts freshness — hash marker", () => {
|
||||
test("AGENTS.cli.md written by writeAiGuidanceFiles carries a hash marker", async () => {
|
||||
await withTempDir(async (tempDir) => {
|
||||
await writeAiGuidanceFiles({ targetDir: tempDir });
|
||||
const agentsCli = await readFile(join(tempDir, "AGENTS.cli.md"), "utf8");
|
||||
const hash = extractPromptsHash(agentsCli);
|
||||
expect(hash).not.toBeNull();
|
||||
expect(hash).toMatch(/^[0-9a-f]{12}$/);
|
||||
});
|
||||
});
|
||||
|
||||
test("the stored hash matches currentPromptsHash for the same nonDottedPaths", async () => {
|
||||
await withTempDir(async (tempDir) => {
|
||||
// writeAiGuidanceFiles defaults nonDottedPaths to `false` (matching
|
||||
// core/conf.ts's missing-key default).
|
||||
await writeAiGuidanceFiles({ targetDir: tempDir });
|
||||
const agentsCli = await readFile(join(tempDir, "AGENTS.cli.md"), "utf8");
|
||||
expect(extractPromptsHash(agentsCli)).toBe(currentPromptsHash(false));
|
||||
});
|
||||
});
|
||||
|
||||
test("nonDottedPaths setting changes the hash", () => {
|
||||
expect(currentPromptsHash(true)).not.toBe(currentPromptsHash(false));
|
||||
});
|
||||
|
||||
test("injectPromptsHashMarker places the marker after the H1 title", () => {
|
||||
const input = "# Title\n\nbody line\n";
|
||||
const out = injectPromptsHashMarker(input, "abc123def456");
|
||||
const lines = out.split("\n");
|
||||
expect(lines[0]).toBe("# Title");
|
||||
expect(lines[1]).toBe("<!-- wmill-prompts-hash: abc123def456 -->");
|
||||
expect(lines[2]).toBe("");
|
||||
expect(lines[3]).toBe("body line");
|
||||
});
|
||||
|
||||
test("injectPromptsHashMarker prepends when there's no H1", () => {
|
||||
const input = "no heading\nrest\n";
|
||||
const out = injectPromptsHashMarker(input, "abc123def456");
|
||||
expect(out).toStartWith("<!-- wmill-prompts-hash: abc123def456 -->");
|
||||
});
|
||||
|
||||
test("extractPromptsHash returns null when no marker is present", () => {
|
||||
expect(extractPromptsHash("# Title\n\nno marker here\n")).toBeNull();
|
||||
expect(extractPromptsHash("<!-- wmill-prompts-hash: tooshort -->")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("prompts freshness — shouldRunFreshnessCheck", () => {
|
||||
// Each input matches process.argv shape: [node, script, ...args].
|
||||
test.each<[string, string[], boolean]>([
|
||||
["empty argv", ["node", "wmill"], false],
|
||||
["wmill --help", ["node", "wmill", "--help"], false],
|
||||
["wmill -h on a subcommand", ["node", "wmill", "init", "-h"], false],
|
||||
["wmill --version", ["node", "wmill", "--version"], false],
|
||||
["wmill init", ["node", "wmill", "init"], false],
|
||||
["wmill init prompts", ["node", "wmill", "init", "prompts"], false],
|
||||
["wmill refresh prompts", ["node", "wmill", "refresh", "prompts"], false],
|
||||
["wmill completions zsh", ["node", "wmill", "completions", "zsh"], false],
|
||||
["wmill upgrade", ["node", "wmill", "upgrade"], false],
|
||||
["wmill sync push", ["node", "wmill", "sync", "push"], true],
|
||||
["wmill flow run", ["node", "wmill", "flow", "run"], true],
|
||||
["wmill --verbose sync push", ["node", "wmill", "--verbose", "sync", "push"], true],
|
||||
// Value-taking global options must skip their value when locating the
|
||||
// first subcommand. Otherwise `wmill --workspace prod refresh prompts`
|
||||
// would misread `"prod"` as the subcommand and trip the warning during
|
||||
// the very command that's meant to fix it.
|
||||
["wmill --workspace prod refresh prompts",
|
||||
["node", "wmill", "--workspace", "prod", "refresh", "prompts"], false],
|
||||
["wmill --token tok sync push",
|
||||
["node", "wmill", "--token", "tok", "sync", "push"], true],
|
||||
["wmill --base-url u --workspace w init",
|
||||
["node", "wmill", "--base-url", "u", "--workspace", "w", "init"], false],
|
||||
["wmill --config-dir /etc/wmill init prompts",
|
||||
["node", "wmill", "--config-dir", "/etc/wmill", "init", "prompts"], false],
|
||||
])("%s → %s", (_label, argv, expected) => {
|
||||
expect(shouldRunFreshnessCheck(argv)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("prompts freshness — additional invariants", () => {
|
||||
test("currentPromptsHash is deterministic across invocations in the same process", () => {
|
||||
const h1 = currentPromptsHash(true);
|
||||
const h2 = currentPromptsHash(true);
|
||||
const h3 = currentPromptsHash(false);
|
||||
const h4 = currentPromptsHash(false);
|
||||
expect(h1).toBe(h2);
|
||||
expect(h3).toBe(h4);
|
||||
});
|
||||
|
||||
test("warnIfPromptsStale writes to stderr (never stdout)", async () => {
|
||||
await withTempDir(async (tempDir) => {
|
||||
// Write a tampered AGENTS.cli.md so the freshness check trips.
|
||||
await writeFile(
|
||||
join(tempDir, "AGENTS.cli.md"),
|
||||
"# Windmill CLI Agent Instructions\n<!-- wmill-prompts-hash: 000000000000 -->\nbody\n",
|
||||
"utf8"
|
||||
);
|
||||
|
||||
const stdoutWrites: string[] = [];
|
||||
const stderrWrites: string[] = [];
|
||||
const originalStdout = process.stdout.write.bind(process.stdout);
|
||||
const originalStderr = process.stderr.write.bind(process.stderr);
|
||||
// @ts-expect-error — overriding write for the test
|
||||
process.stdout.write = (chunk: any) => {
|
||||
stdoutWrites.push(String(chunk));
|
||||
return true;
|
||||
};
|
||||
// @ts-expect-error — overriding write for the test
|
||||
process.stderr.write = (chunk: any) => {
|
||||
stderrWrites.push(String(chunk));
|
||||
return true;
|
||||
};
|
||||
|
||||
try {
|
||||
await warnIfPromptsStale({
|
||||
cwd: tempDir,
|
||||
nonDottedPaths: false,
|
||||
argv: ["node", "wmill", "sync", "push"],
|
||||
});
|
||||
} finally {
|
||||
process.stdout.write = originalStdout;
|
||||
process.stderr.write = originalStderr;
|
||||
}
|
||||
|
||||
const stderrJoined = stderrWrites.join("");
|
||||
const stdoutJoined = stdoutWrites.join("");
|
||||
expect(stderrJoined).toContain("out of date");
|
||||
expect(stdoutJoined).not.toContain("out of date");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user