mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-23 00:00:33 +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:
@@ -16,13 +16,8 @@ import {
|
||||
type Workspace,
|
||||
} from "../workspace/workspace.ts";
|
||||
import { generateRTNamespace } from "../resource-type/resource-type.ts";
|
||||
import {
|
||||
WMILL_INIT_AI_AGENTS_SOURCE_ENV,
|
||||
WMILL_INIT_AI_CLAUDE_SOURCE_ENV,
|
||||
WMILL_INIT_AI_SKILLS_SOURCE_ENV,
|
||||
writeAiGuidanceFiles,
|
||||
} from "../../guidance/writer.ts";
|
||||
import { generateCommentedTemplate } from "./template.ts";
|
||||
import { refreshPrompts } from "../refresh/prompts.ts";
|
||||
|
||||
export interface InitOptions {
|
||||
useDefault?: boolean;
|
||||
@@ -241,45 +236,7 @@ async function initAction(opts: InitOptions) {
|
||||
}
|
||||
}
|
||||
|
||||
// Read nonDottedPaths from config
|
||||
let nonDottedPaths = true; // default for new inits
|
||||
try {
|
||||
const { readConfigFile } = await import("../../core/conf.ts");
|
||||
const config = await readConfigFile();
|
||||
nonDottedPaths = config.nonDottedPaths ?? true;
|
||||
} catch {
|
||||
// If config can't be read, use defaults
|
||||
}
|
||||
|
||||
// Create guidance files (AGENTS.md, CLAUDE.md, and agent skills)
|
||||
try {
|
||||
const guidanceResult = await writeAiGuidanceFiles({
|
||||
targetDir: ".",
|
||||
nonDottedPaths,
|
||||
overwriteProjectGuidance: false,
|
||||
skillsSourcePath: process.env[WMILL_INIT_AI_SKILLS_SOURCE_ENV],
|
||||
agentsSourcePath: process.env[WMILL_INIT_AI_AGENTS_SOURCE_ENV],
|
||||
claudeSourcePath: process.env[WMILL_INIT_AI_CLAUDE_SOURCE_ENV],
|
||||
});
|
||||
|
||||
if (guidanceResult.agentsWritten) {
|
||||
log.info(colors.green("Created AGENTS.md"));
|
||||
}
|
||||
if (guidanceResult.claudeWritten) {
|
||||
log.info(colors.green("Created CLAUDE.md"));
|
||||
}
|
||||
log.info(
|
||||
colors.green(
|
||||
`Created .claude/skills/ and .agents/skills/ with ${guidanceResult.skillCount} skills`
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
log.warn(`Could not create guidance files: ${error.message}`);
|
||||
} else {
|
||||
log.warn(`Could not create guidance files: ${error}`);
|
||||
}
|
||||
}
|
||||
await refreshPrompts({ yes: opts.useDefault === true });
|
||||
|
||||
// Generate resource type namespace (only if a workspace was bound)
|
||||
if (didBindWorkspace && boundProfile) {
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import { colors } from "@cliffy/ansi/colors";
|
||||
import { Command } from "@cliffy/command";
|
||||
import { Select } from "@cliffy/prompt/select";
|
||||
import * as log from "../../core/log.ts";
|
||||
import {
|
||||
type AgentsMdMigration,
|
||||
type ReconcileOutcome,
|
||||
WMILL_INIT_AI_AGENTS_SOURCE_ENV,
|
||||
WMILL_INIT_AI_CLAUDE_SOURCE_ENV,
|
||||
WMILL_INIT_AI_SKILLS_SOURCE_ENV,
|
||||
writeAiGuidanceFiles,
|
||||
} from "../../guidance/writer.ts";
|
||||
|
||||
/**
|
||||
* Programmatic entry point reused by `wmill init`. The init flow doesn't
|
||||
* register the cliffy command itself — it imports and calls this directly so
|
||||
* that prompt regeneration is part of every init.
|
||||
*/
|
||||
export async function refreshPrompts(opts: {
|
||||
yes?: boolean;
|
||||
}): Promise<void> {
|
||||
// Match `core/conf.ts`'s missing-key default (`?? false`) so legacy
|
||||
// wmill.yaml files without the key don't drift from how sync renders
|
||||
// paths. New projects get `true` via the wmill.yaml template, not via
|
||||
// this fallback.
|
||||
let nonDottedPaths = false;
|
||||
try {
|
||||
const { readConfigFile } = await import("../../core/conf.ts");
|
||||
const config = await readConfigFile();
|
||||
nonDottedPaths = config.nonDottedPaths ?? false;
|
||||
} catch {
|
||||
// If config can't be read, use the conservative default above.
|
||||
}
|
||||
|
||||
const interactive = process.stdin.isTTY && !opts.yes;
|
||||
|
||||
try {
|
||||
const result = await writeAiGuidanceFiles({
|
||||
targetDir: ".",
|
||||
nonDottedPaths,
|
||||
skillsSourcePath: process.env[WMILL_INIT_AI_SKILLS_SOURCE_ENV],
|
||||
agentsSourcePath: process.env[WMILL_INIT_AI_AGENTS_SOURCE_ENV],
|
||||
claudeSourcePath: process.env[WMILL_INIT_AI_CLAUDE_SOURCE_ENV],
|
||||
resolveAgentsMdMigration: async () => {
|
||||
if (!interactive) return "append";
|
||||
return await promptMigration();
|
||||
},
|
||||
});
|
||||
|
||||
log.info(colors.green("Refreshed AGENTS.cli.md"));
|
||||
|
||||
reportReconciliation({
|
||||
file: "AGENTS.md",
|
||||
includeLine: "@AGENTS.cli.md",
|
||||
created: result.agentsCreated,
|
||||
migration: result.agentsMigration,
|
||||
});
|
||||
|
||||
reportReconciliation({
|
||||
file: "CLAUDE.md",
|
||||
includeLine: "@AGENTS.md",
|
||||
created: result.claudeCreated,
|
||||
migration: result.claudeMigration,
|
||||
});
|
||||
|
||||
log.info(
|
||||
colors.green(
|
||||
`Refreshed .claude/skills/ and .agents/skills/ with ${result.skillCount} skills`
|
||||
)
|
||||
);
|
||||
log.info(
|
||||
colors.gray(
|
||||
"Project-specific instructions live in AGENTS.md (never overwritten unless you opt in)."
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
// Log first so the user sees what happened, then rethrow so `wmill
|
||||
// refresh prompts` (and `wmill init`, which delegates here) exits
|
||||
// non-zero. Silent swallowing would hide a broken refresh from CI.
|
||||
if (error instanceof Error) {
|
||||
log.error(`Could not refresh guidance files: ${error.message}`);
|
||||
} else {
|
||||
log.error(`Could not refresh guidance files: ${error}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function reportReconciliation(opts: {
|
||||
file: string;
|
||||
includeLine: string;
|
||||
created: boolean;
|
||||
migration: ReconcileOutcome;
|
||||
}): void {
|
||||
if (opts.created) {
|
||||
log.info(colors.green(`Created ${opts.file} (user-owned)`));
|
||||
return;
|
||||
}
|
||||
switch (opts.migration) {
|
||||
case "already-linked":
|
||||
log.info(
|
||||
colors.gray(
|
||||
`${opts.file} already references ${opts.includeLine} — left as-is`
|
||||
)
|
||||
);
|
||||
break;
|
||||
case "append":
|
||||
log.info(
|
||||
colors.green(`Appended ${opts.includeLine} include to existing ${opts.file}`)
|
||||
);
|
||||
break;
|
||||
case "overwrite":
|
||||
log.info(colors.yellow(`Overwrote ${opts.file} with managed skeleton`));
|
||||
break;
|
||||
case "skip":
|
||||
log.info(
|
||||
colors.gray(
|
||||
`${opts.file} left unchanged — wire \`${opts.includeLine}\` in manually when ready`
|
||||
)
|
||||
);
|
||||
break;
|
||||
case "not-applicable":
|
||||
// unreachable when created is false, but keep exhaustive
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
async function promptMigration(): Promise<AgentsMdMigration> {
|
||||
log.info("");
|
||||
log.info(
|
||||
colors.yellow(
|
||||
"An existing AGENTS.md or CLAUDE.md was found that does not reference Windmill's managed guidance."
|
||||
)
|
||||
);
|
||||
log.info(
|
||||
colors.gray(
|
||||
"Choose how to link the managed files in (we'll apply the same choice to AGENTS.md and CLAUDE.md):"
|
||||
)
|
||||
);
|
||||
|
||||
const choice = await Select.prompt({
|
||||
message: "How should we handle the existing file(s)?",
|
||||
options: [
|
||||
{
|
||||
name:
|
||||
"Append the include line " +
|
||||
"(preserves your content — recommended if you have custom instructions)",
|
||||
value: "append",
|
||||
},
|
||||
{
|
||||
name:
|
||||
"Overwrite with the managed skeleton " +
|
||||
"(replaces your content — pick if the file only had the default template)",
|
||||
value: "overwrite",
|
||||
},
|
||||
{
|
||||
name: "Skip — leave the file alone; I'll wire it up manually",
|
||||
value: "skip",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return choice as AgentsMdMigration;
|
||||
}
|
||||
|
||||
interface CommandOptions {
|
||||
yes?: boolean;
|
||||
}
|
||||
|
||||
async function promptsAction(opts: CommandOptions): Promise<void> {
|
||||
await refreshPrompts({ yes: opts.yes === true });
|
||||
}
|
||||
|
||||
const command = new Command()
|
||||
.description("Refresh AGENTS.cli.md and managed skills. User-owned AGENTS.md and CLAUDE.md are never overwritten unless you opt in.")
|
||||
.option(
|
||||
"--yes",
|
||||
"Non-interactive: skip the migration prompt for existing AGENTS.md / CLAUDE.md without the expected include; defaults to appending the include."
|
||||
)
|
||||
.action(promptsAction as any);
|
||||
|
||||
export default command;
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Command } from "@cliffy/command";
|
||||
import promptsCommand from "./prompts.ts";
|
||||
|
||||
const command = new Command()
|
||||
.description("Refresh wmill-managed project files (AGENTS.cli.md and skills)")
|
||||
.command("prompts", promptsCommand);
|
||||
|
||||
export default command;
|
||||
@@ -1,17 +1,55 @@
|
||||
/**
|
||||
* Core guidance content for AGENTS.md
|
||||
* Core guidance content for the AGENTS files Windmill writes during init.
|
||||
*
|
||||
* This module exports the template for the AGENTS.md file that provides
|
||||
* AI agent instructions for working with Windmill projects.
|
||||
* `wmill` writes two files:
|
||||
*
|
||||
* - `AGENTS.cli.md` — managed CLI / workspace guidance, refreshed by
|
||||
* `wmill refresh prompts` (and the implicit refresh inside `wmill init`).
|
||||
* - `AGENTS.md` — user-owned project entry point. The default skeleton
|
||||
* references `AGENTS.cli.md` via an `@`-include so the managed content is
|
||||
* pulled in automatically.
|
||||
*/
|
||||
|
||||
export const AGENTS_CLI_INCLUDE_LINE = "@AGENTS.cli.md";
|
||||
|
||||
/**
|
||||
* Generate the AGENTS.md content with the given skills reference.
|
||||
* @param skillsReference - A formatted list of skills to include in the document
|
||||
* @returns The complete AGENTS.md content
|
||||
* Lightweight, user-owned AGENTS.md skeleton. Written only when no AGENTS.md
|
||||
* exists in the project. Everything below the `@AGENTS.cli.md` include is for
|
||||
* the user to edit; nothing in this file is refreshed by `wmill`.
|
||||
*/
|
||||
export function generateAgentsMdContent(skillsReference: string): string {
|
||||
return `# Windmill AI Agent Instructions
|
||||
export function generateAgentsMdSkeleton(): string {
|
||||
return `# Project AI Agent Instructions
|
||||
|
||||
This file is the entry point for AI agents working in this repository. It is
|
||||
**user-owned** — \`wmill\` never overwrites it. Add your project-specific
|
||||
guidance below the include line.
|
||||
|
||||
The line below pulls in Windmill's managed CLI guidance (skills, deploy flow,
|
||||
debugging jobs, etc.). Refresh it with \`wmill refresh prompts\`. Remove the
|
||||
include line if you don't want the managed guidance in this project.
|
||||
|
||||
${AGENTS_CLI_INCLUDE_LINE}
|
||||
|
||||
## Project-specific instructions
|
||||
|
||||
<!-- Add anything specific to this repo here. Examples:
|
||||
- Deploy commands or environments unique to this project.
|
||||
- Domain glossary, naming conventions, or "ask before X" rules.
|
||||
- Overrides for the managed guidance above (be explicit that they
|
||||
supersede the managed rule). -->
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Managed AGENTS.cli.md content. Rewritten by `wmill init` and
|
||||
* `wmill refresh prompts` every time.
|
||||
*/
|
||||
export function generateAgentsCliMdContent(skillsReference: string): string {
|
||||
return `# Windmill CLI Agent Instructions
|
||||
|
||||
> Managed by \`wmill\`. This file is regenerated on \`wmill init\` and
|
||||
> \`wmill refresh prompts\` — edit AGENTS.md (user-owned) for project-specific
|
||||
> instructions instead.
|
||||
|
||||
You are a helpful assistant that can help with Windmill scripts, flows, apps, and resources management.
|
||||
|
||||
@@ -55,6 +93,50 @@ You MUST use the \`preview\` skill any time the user wants to see/open/visualize
|
||||
|
||||
You MUST use the \`cli-commands\` skill to use the CLI.
|
||||
|
||||
## Running and previewing local changes
|
||||
|
||||
Local previews exist for every entity type and don't deploy:
|
||||
|
||||
- \`wmill script preview <path> -d '<args>'\` — run a local script.
|
||||
- \`wmill flow preview <flow_path> -d '<args>'\` — run a local flow.yaml.
|
||||
- \`wmill app dev\` — live-reload dev server for raw apps.
|
||||
|
||||
Argument shapes and per-language details live in the \`write-script-<lang>\`, \`write-flow\`, and \`raw-app\` skills.
|
||||
|
||||
## Deploying
|
||||
|
||||
There are two ways local changes reach the workspace. Pick based on how the repo is wired, not habit.
|
||||
|
||||
### Detecting the setup
|
||||
|
||||
Before deploying, check whether this repo has a **GitHub Actions (or other CI) workflow that runs \`wmill sync push\` on push**. That workflow is the signal that pushing a branch will deploy:
|
||||
|
||||
- Look for \`.github/workflows/*.yml\` (or other CI configs) that invoke \`wmill sync push\`, \`wmill\` deployment commands, or similar.
|
||||
- Cache the result for the rest of the session — don't re-scan on every deploy.
|
||||
|
||||
If such a workflow exists → **use \`git push\`** (Option A). Otherwise → **use \`wmill sync push\`** directly (Option B).
|
||||
|
||||
### Option A — \`git push\` (CI is wired to sync)
|
||||
|
||||
The CI workflow will pick up the commit and run \`wmill sync push\` on the backend, which is how deployments are intended to happen in this repo. Don't bypass it.
|
||||
|
||||
1. \`git add\` + \`git commit\` the local changes.
|
||||
2. \`git push\` to the branch the CI runs on.
|
||||
3. The workflow deploys to the workspace.
|
||||
|
||||
Only fall back to Option B if the user explicitly asks to bypass CI for this change (e.g. CI is broken, urgent hotfix), or if the workflow doesn't cover the current branch.
|
||||
|
||||
### Option B — \`wmill sync push\` (no CI wiring)
|
||||
|
||||
No CI workflow runs \`wmill sync push\` automatically, so deploy directly from the CLI:
|
||||
|
||||
- \`wmill sync push --dry-run\` to preview.
|
||||
- \`wmill sync push\` to apply.
|
||||
|
||||
### In both cases
|
||||
|
||||
Only deploy when the user explicitly asks to deploy, publish, push, or ship — not when they say "run", "try", or "test". For testing local edits use the per-entity \`preview\` commands (\`wmill script preview\`, \`wmill flow preview\`) — they don't deploy.
|
||||
|
||||
## Debugging Jobs
|
||||
|
||||
When the user reports a script or flow failure, is investigating unexpected output, or asks why something ran the way it did, use the CLI to fetch job details before speculating. See the \`cli-commands\` skill for all flags.
|
||||
@@ -70,7 +152,7 @@ For flow failures, start with \`wmill job get <id>\` to identify the failing ste
|
||||
|
||||
## Skills
|
||||
|
||||
For specific guidance, ALWAYS use the skills listed below.
|
||||
For specific guidance, ALWAYS use the skills listed below. Paths point at \`.agents/skills/\` — Claude Code reads identical copies under \`.claude/skills/\`.
|
||||
|
||||
${skillsReference}
|
||||
`;
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Versioning + freshness check for the managed AGENTS.cli.md bundle.
|
||||
*
|
||||
* We embed a short hash of "what this CLI would write" into AGENTS.cli.md as
|
||||
* an HTML comment. On every `wmill` command (with a few exceptions), we read
|
||||
* the stored hash and compare against the current CLI's hash. Mismatch =>
|
||||
* one-line warning telling the user to `wmill refresh prompts`.
|
||||
*
|
||||
* The hash covers all inputs that affect the rendered bundle: the
|
||||
* AGENTS.cli.md template, every skill body, schemas and schema mappings, and
|
||||
* the nonDottedPaths setting. It is *not* tied to the CLI's package version,
|
||||
* so non-prompt CLI releases don't produce false positives.
|
||||
*/
|
||||
import { createHash } from "node:crypto";
|
||||
import { stat } from "node:fs/promises";
|
||||
import { colors } from "@cliffy/ansi/colors";
|
||||
import { readTextFile } from "../utils/utils.ts";
|
||||
import { generateAgentsCliMdContent } from "./core.ts";
|
||||
import {
|
||||
SCHEMAS,
|
||||
SCHEMA_MAPPINGS,
|
||||
SKILLS,
|
||||
SKILL_CONTENT,
|
||||
} from "./skills.gen.ts";
|
||||
|
||||
// Re-export from the gate module so existing callers (and tests) keep working.
|
||||
// `shouldRunFreshnessCheck` lives there to avoid pulling skills.gen.ts (~360 KB)
|
||||
// into main.ts's static import graph; main.ts now imports the gate directly
|
||||
// and only `await import`s this file lazily.
|
||||
import { shouldRunFreshnessCheck } from "./freshness_gate.ts";
|
||||
export { shouldRunFreshnessCheck };
|
||||
|
||||
export const PROMPTS_HASH_MARKER_PREFIX = "<!-- wmill-prompts-hash: ";
|
||||
const PROMPTS_HASH_REGEX = /<!-- wmill-prompts-hash: ([0-9a-f]{12}) -->/;
|
||||
|
||||
export function buildPromptsHashMarker(hash: string): string {
|
||||
return `${PROMPTS_HASH_MARKER_PREFIX}${hash} -->`;
|
||||
}
|
||||
|
||||
export function extractPromptsHash(content: string): string | null {
|
||||
const match = content.match(PROMPTS_HASH_REGEX);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert the hash marker into rendered AGENTS.cli.md content. The marker
|
||||
* goes on the line right after the title so it's easy to find and doesn't
|
||||
* break the rendered Markdown structure.
|
||||
*/
|
||||
export function injectPromptsHashMarker(
|
||||
content: string,
|
||||
hash: string
|
||||
): string {
|
||||
const lines = content.split("\n");
|
||||
const marker = buildPromptsHashMarker(hash);
|
||||
// Insert right after the first line if it's an H1 title; otherwise
|
||||
// prepend so the marker is always near the top.
|
||||
const insertAt = lines[0].startsWith("# ") ? 1 : 0;
|
||||
lines.splice(insertAt, 0, marker);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the hash for the rendered bundle. The hash is deterministic for a
|
||||
* given (CLI bundle, nonDottedPaths) pair.
|
||||
*/
|
||||
export function currentPromptsHash(nonDottedPaths: boolean): string {
|
||||
const hasher = createHash("sha256");
|
||||
|
||||
// Template structure (without the skills reference — that's hashed
|
||||
// separately from the SKILLS metadata).
|
||||
hasher.update("template:");
|
||||
hasher.update(generateAgentsCliMdContent("__PLACEHOLDER__"));
|
||||
|
||||
// Skill metadata (names + descriptions) — fed into the skills reference
|
||||
// line in AGENTS.cli.md and the wrapper frontmatter.
|
||||
hasher.update("\nskills:");
|
||||
hasher.update(JSON.stringify(SKILLS));
|
||||
|
||||
// Skill bodies — what actually lands in .agents/skills/<name>/SKILL.md.
|
||||
// Sort entries for stable ordering.
|
||||
hasher.update("\nbodies:");
|
||||
for (const [name, content] of Object.entries(SKILL_CONTENT).sort()) {
|
||||
hasher.update("\n");
|
||||
hasher.update(name);
|
||||
hasher.update("\n");
|
||||
hasher.update(content);
|
||||
}
|
||||
|
||||
// Schemas + their mappings — embedded inside specific skills.
|
||||
hasher.update("\nschemas:");
|
||||
hasher.update(JSON.stringify(SCHEMAS));
|
||||
hasher.update("\nmappings:");
|
||||
hasher.update(JSON.stringify(SCHEMA_MAPPINGS));
|
||||
|
||||
// Path-style setting — controls __flow vs .flow rendering in skill bodies.
|
||||
hasher.update("\nnonDotted:");
|
||||
hasher.update(String(nonDottedPaths));
|
||||
|
||||
return hasher.digest("hex").slice(0, 12);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read AGENTS.cli.md in the current working directory, compare its embedded
|
||||
* hash to the current CLI's hash, and print a one-line warning if they
|
||||
* differ. Silent on every other code path (no AGENTS.cli.md, no marker,
|
||||
* matching hash, IO error, …) so it never gets in the user's way.
|
||||
*/
|
||||
export async function warnIfPromptsStale(opts?: {
|
||||
cwd?: string;
|
||||
nonDottedPaths?: boolean;
|
||||
argv?: readonly string[];
|
||||
}): Promise<void> {
|
||||
if (opts?.argv && !shouldRunFreshnessCheck(opts.argv)) return;
|
||||
|
||||
const cwd = opts?.cwd ?? process.cwd();
|
||||
const path = `${cwd}/AGENTS.cli.md`;
|
||||
|
||||
if (!(await stat(path).catch(() => null))) return;
|
||||
|
||||
let content: string;
|
||||
try {
|
||||
content = await readTextFile(path);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const stored = extractPromptsHash(content);
|
||||
if (!stored) {
|
||||
// Older AGENTS.cli.md without a marker. Warn so the user re-runs
|
||||
// refresh and picks up the new format.
|
||||
emitWarning(
|
||||
"Your AGENTS.cli.md predates prompt versioning. Run `wmill refresh prompts` to refresh and add a version marker."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let nonDottedPaths = opts?.nonDottedPaths;
|
||||
if (nonDottedPaths === undefined) {
|
||||
try {
|
||||
const { readConfigFile } = await import("../core/conf.ts");
|
||||
const config = await readConfigFile();
|
||||
// Match `core/conf.ts`'s missing-key default (`?? false`); otherwise
|
||||
// legacy wmill.yaml files without the key trip a permanent freshness
|
||||
// warning even though the prompts are objectively up to date.
|
||||
nonDottedPaths = config.nonDottedPaths ?? false;
|
||||
} catch {
|
||||
nonDottedPaths = false;
|
||||
}
|
||||
}
|
||||
|
||||
const current = currentPromptsHash(nonDottedPaths);
|
||||
if (stored !== current) {
|
||||
emitWarning(
|
||||
"Your AGENTS.cli.md is out of date. Run `wmill refresh prompts` to refresh."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the freshness warning to **stderr** so it never contaminates a
|
||||
* downstream pipe (e.g. `wmill job result <id> | jq`). The rest of the CLI
|
||||
* uses `log.warn` which writes to stdout — that's wrong for an always-on
|
||||
* notification like this one, but we don't want to fix `log.warn` globally
|
||||
* in this PR.
|
||||
*/
|
||||
function emitWarning(message: string): void {
|
||||
process.stderr.write(`${colors.yellow(message)}\n`);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Argv-only gate for the prompts freshness check. Kept in its own module so
|
||||
* `main.ts` can import it without pulling in the heavy `skills.gen.ts`
|
||||
* bundle (~360 KB) on every `wmill` invocation. The full check (which does
|
||||
* touch the bundle) lives in `./freshness.ts` and is loaded lazily after
|
||||
* this gate returns `true`.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Subcommands where a freshness warning is noise (the user is either fixing
|
||||
* it, asking for help, or doing something orthogonal).
|
||||
*/
|
||||
const SKIP_FRESHNESS_FOR_SUBCOMMANDS = new Set([
|
||||
"init",
|
||||
"refresh",
|
||||
"completions",
|
||||
"upgrade",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Cliffy global options that consume the *next* argv element as their value.
|
||||
* Must be kept in sync with the option declarations on the top-level
|
||||
* `command` in `cli/src/main.ts`.
|
||||
*/
|
||||
const VALUE_GLOBAL_OPTS = new Set([
|
||||
"--workspace",
|
||||
"--token",
|
||||
"--base-url",
|
||||
"--config-dir",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Returns `true` if the freshness check should run for this invocation.
|
||||
*
|
||||
* Bypasses:
|
||||
* - bare `wmill` (no subcommand → shows help)
|
||||
* - `--help`, `-h`, `--version`, `-V` anywhere in the args
|
||||
* - subcommands in {init, refresh, completions, upgrade}
|
||||
*
|
||||
* Handles cliffy global options that take a value (`--workspace foo`,
|
||||
* `--token tok`, `--base-url https://…`, `--config-dir /etc/wmill`) by
|
||||
* skipping their value when scanning for the first positional argument.
|
||||
* Without that, `wmill --workspace prod refresh prompts` would misread
|
||||
* `"prod"` as the subcommand and fire the warning during the very command
|
||||
* meant to fix it.
|
||||
*/
|
||||
export function shouldRunFreshnessCheck(argv: readonly string[]): boolean {
|
||||
const args = argv.slice(2); // strip node + script
|
||||
if (args.length === 0) return false;
|
||||
if (args.includes("--help") || args.includes("-h")) return false;
|
||||
if (args.includes("--version") || args.includes("-V")) return false;
|
||||
|
||||
let i = 0;
|
||||
while (i < args.length) {
|
||||
const arg = args[i];
|
||||
if (VALUE_GLOBAL_OPTS.has(arg)) {
|
||||
i += 2; // skip flag + its value
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith("-")) {
|
||||
i += 1; // flag with no value
|
||||
continue;
|
||||
}
|
||||
return !SKIP_FRESHNESS_FOR_SUBCOMMANDS.has(arg);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -7001,6 +7001,15 @@ List all queues with their metrics
|
||||
- \`--instance [instance]\` - Name of the instance to push to, override the active instance
|
||||
- \`--base-url [baseUrl]\` - If used with --token, will be used as the base url for the instance
|
||||
|
||||
### refresh
|
||||
|
||||
Refresh wmill-managed project files (AGENTS.cli.md and skills)
|
||||
|
||||
**Subcommands:**
|
||||
|
||||
- \`refresh prompts\` - Refresh AGENTS.cli.md and managed skills. User-owned AGENTS.md and CLAUDE.md are never overwritten unless you opt in.
|
||||
- \`--yes\` - Non-interactive: skip the migration prompt for existing AGENTS.md / CLAUDE.md without the expected include; defaults to appending the include.
|
||||
|
||||
### resource
|
||||
|
||||
resource related commands
|
||||
|
||||
+197
-43
@@ -1,7 +1,15 @@
|
||||
import { cp, mkdir, readdir, stat, writeFile } from "node:fs/promises";
|
||||
import { readTextFile } from "../utils/utils.ts";
|
||||
import { join } from "node:path";
|
||||
import { generateAgentsMdContent } from "./core.ts";
|
||||
import {
|
||||
AGENTS_CLI_INCLUDE_LINE,
|
||||
generateAgentsCliMdContent,
|
||||
generateAgentsMdSkeleton,
|
||||
} from "./core.ts";
|
||||
import {
|
||||
currentPromptsHash,
|
||||
injectPromptsHashMarker,
|
||||
} from "./freshness.ts";
|
||||
import {
|
||||
SCHEMAS,
|
||||
SCHEMA_MAPPINGS,
|
||||
@@ -14,18 +22,47 @@ type ResolvedSkillMetadata = SkillMetadata & {
|
||||
directoryName: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* How to reconcile an existing user-owned guidance file (AGENTS.md or
|
||||
* CLAUDE.md) that doesn't reference the managed file below it
|
||||
* (`@AGENTS.cli.md` for AGENTS.md, `@AGENTS.md` for CLAUDE.md).
|
||||
*
|
||||
* - `append`: leave the file as-is and append the include line.
|
||||
* - `overwrite`: replace the file with the managed skeleton.
|
||||
* - `skip`: leave the file alone. The managed downstream file is still
|
||||
* written/refreshed, but no link to it — the user is expected to wire it
|
||||
* manually later.
|
||||
*/
|
||||
export type AgentsMdMigration = "append" | "overwrite" | "skip";
|
||||
|
||||
export type ReconcileOutcome =
|
||||
| AgentsMdMigration
|
||||
| "already-linked"
|
||||
| "not-applicable";
|
||||
|
||||
export interface WriteAiGuidanceOptions {
|
||||
targetDir: string;
|
||||
nonDottedPaths?: boolean;
|
||||
overwriteProjectGuidance?: boolean;
|
||||
/** Skill source override (testing / source-of-truth bundling). */
|
||||
skillsSourcePath?: string;
|
||||
/** AGENTS.cli.md source override (testing). */
|
||||
agentsSourcePath?: string;
|
||||
/** CLAUDE.md source override (testing). */
|
||||
claudeSourcePath?: string;
|
||||
/**
|
||||
* Optional resolver invoked when an existing AGENTS.md lacks an
|
||||
* `@AGENTS.cli.md` reference. Callers are expected to prompt the user; if
|
||||
* omitted, the writer defaults to `append` (non-destructive).
|
||||
*/
|
||||
resolveAgentsMdMigration?: () => Promise<AgentsMdMigration>;
|
||||
}
|
||||
|
||||
export interface WriteAiGuidanceResult {
|
||||
agentsWritten: boolean;
|
||||
claudeWritten: boolean;
|
||||
agentsCliWritten: boolean;
|
||||
agentsCreated: boolean;
|
||||
agentsMigration: ReconcileOutcome;
|
||||
claudeCreated: boolean;
|
||||
claudeMigration: ReconcileOutcome;
|
||||
skillCount: number;
|
||||
}
|
||||
|
||||
@@ -34,32 +71,73 @@ export const WMILL_INIT_AI_AGENTS_SOURCE_ENV = "WMILL_INIT_AI_AGENTS_SOURCE";
|
||||
export const WMILL_INIT_AI_CLAUDE_SOURCE_ENV = "WMILL_INIT_AI_CLAUDE_SOURCE";
|
||||
|
||||
const CLAUDE_MD_DEFAULT = "Instructions are in @AGENTS.md\n";
|
||||
const SKILL_TARGET_ROOTS = [".claude", ".agents"] as const;
|
||||
const CLAUDE_MD_INCLUDE_LINE = "@AGENTS.md";
|
||||
|
||||
/**
|
||||
* Both `.agents/skills/` (read by Codex, Pi) and `.claude/skills/` (read by
|
||||
* Claude Code) receive the full skill content. We can't use `@<path>` to
|
||||
* deduplicate because Claude's skill loader reads SKILL.md as-is — it does
|
||||
* not expand `@` references inside skill bodies (those work only in
|
||||
* AGENTS.md / CLAUDE.md).
|
||||
*/
|
||||
const SKILL_TARGET_ROOTS = [".agents", ".claude"] as const;
|
||||
|
||||
export async function writeAiGuidanceFiles(
|
||||
options: WriteAiGuidanceOptions
|
||||
): Promise<WriteAiGuidanceResult> {
|
||||
const nonDottedPaths = options.nonDottedPaths ?? true;
|
||||
// Match `core/conf.ts`'s missing-key default — if a legacy wmill.yaml
|
||||
// omits `nonDottedPaths`, sync treats it as `false`, so we must too or
|
||||
// the freshness hash will be permanently out of sync with the rest of
|
||||
// the CLI's view of the project.
|
||||
const nonDottedPaths = options.nonDottedPaths ?? false;
|
||||
const skillMetadata = options.skillsSourcePath
|
||||
? await readSkillMetadataFromDirectory(options.skillsSourcePath)
|
||||
: getGeneratedSkillMetadata();
|
||||
|
||||
const agentsWritten = await writeProjectGuidanceFile({
|
||||
targetPath: join(options.targetDir, "AGENTS.md"),
|
||||
overwrite: options.overwriteProjectGuidance ?? false,
|
||||
content:
|
||||
options.agentsSourcePath != null
|
||||
? await readTextFile(options.agentsSourcePath)
|
||||
: generateAgentsMdContent(buildSkillsReference(skillMetadata)),
|
||||
// AGENTS.cli.md — always (re)written, this is the managed file.
|
||||
// We embed a content-hash marker so other `wmill` commands can detect a
|
||||
// stale bundle and prompt the user to `wmill refresh prompts`.
|
||||
const rawAgentsCliContent =
|
||||
options.agentsSourcePath != null
|
||||
? await readTextFile(options.agentsSourcePath)
|
||||
: generateAgentsCliMdContent(buildSkillsReference(skillMetadata));
|
||||
const agentsCliContent = injectPromptsHashMarker(
|
||||
rawAgentsCliContent,
|
||||
currentPromptsHash(nonDottedPaths)
|
||||
);
|
||||
const agentsCliPath = join(options.targetDir, "AGENTS.cli.md");
|
||||
await writeFile(agentsCliPath, agentsCliContent, "utf8");
|
||||
const agentsCliWritten = true;
|
||||
|
||||
// Cache the user's first migration answer and reuse it for every file
|
||||
// that needs reconciling in this run — there's never a good reason to ask
|
||||
// the same question twice in a row.
|
||||
const resolveMigration = cacheOnce(options.resolveAgentsMdMigration);
|
||||
|
||||
// AGENTS.md — user-owned. Three paths:
|
||||
// 1. doesn't exist → create skeleton (which already includes @AGENTS.cli.md).
|
||||
// 2. exists and already references @AGENTS.cli.md → leave alone.
|
||||
// 3. exists but doesn't reference @AGENTS.cli.md → ask caller via
|
||||
// resolveMigration (defaults to append).
|
||||
const agentsMdResult = await reconcileIncludingFile({
|
||||
path: join(options.targetDir, "AGENTS.md"),
|
||||
includeLine: AGENTS_CLI_INCLUDE_LINE,
|
||||
skeleton: generateAgentsMdSkeleton(),
|
||||
resolveMigration,
|
||||
});
|
||||
|
||||
const claudeWritten = await writeProjectGuidanceFile({
|
||||
targetPath: join(options.targetDir, "CLAUDE.md"),
|
||||
overwrite: options.overwriteProjectGuidance ?? false,
|
||||
content:
|
||||
options.claudeSourcePath != null
|
||||
? await readTextFile(options.claudeSourcePath)
|
||||
: CLAUDE_MD_DEFAULT,
|
||||
// CLAUDE.md — user-owned wrapper that points at @AGENTS.md. Same three-way
|
||||
// reconciliation: create if missing, leave alone if it already references
|
||||
// AGENTS.md, otherwise ask via resolveMigration.
|
||||
const claudeSkeleton =
|
||||
options.claudeSourcePath != null
|
||||
? await readTextFile(options.claudeSourcePath)
|
||||
: CLAUDE_MD_DEFAULT;
|
||||
const claudeMdResult = await reconcileIncludingFile({
|
||||
path: join(options.targetDir, "CLAUDE.md"),
|
||||
includeLine: CLAUDE_MD_INCLUDE_LINE,
|
||||
skeleton: claudeSkeleton,
|
||||
resolveMigration,
|
||||
});
|
||||
|
||||
if (options.skillsSourcePath) {
|
||||
@@ -69,17 +147,87 @@ export async function writeAiGuidanceFiles(
|
||||
}
|
||||
|
||||
return {
|
||||
agentsWritten,
|
||||
claudeWritten,
|
||||
agentsCliWritten,
|
||||
agentsCreated: agentsMdResult.created,
|
||||
agentsMigration: agentsMdResult.migration,
|
||||
claudeCreated: claudeMdResult.created,
|
||||
claudeMigration: claudeMdResult.migration,
|
||||
skillCount: skillMetadata.length,
|
||||
};
|
||||
}
|
||||
|
||||
function cacheOnce(
|
||||
resolver: (() => Promise<AgentsMdMigration>) | undefined
|
||||
): (() => Promise<AgentsMdMigration>) | undefined {
|
||||
if (!resolver) return undefined;
|
||||
let cached: AgentsMdMigration | null = null;
|
||||
return async () => {
|
||||
if (cached !== null) return cached;
|
||||
cached = await resolver();
|
||||
return cached;
|
||||
};
|
||||
}
|
||||
|
||||
async function reconcileIncludingFile(options: {
|
||||
path: string;
|
||||
includeLine: string;
|
||||
skeleton: string;
|
||||
resolveMigration?: () => Promise<AgentsMdMigration>;
|
||||
}): Promise<{ created: boolean; migration: ReconcileOutcome }> {
|
||||
const exists = (await stat(options.path).catch(() => null)) != null;
|
||||
if (!exists) {
|
||||
await writeFile(options.path, options.skeleton, "utf8");
|
||||
return { created: true, migration: "not-applicable" };
|
||||
}
|
||||
|
||||
const existing = await readTextFile(options.path);
|
||||
if (referencesIncludeLine(existing, options.includeLine)) {
|
||||
return { created: false, migration: "already-linked" };
|
||||
}
|
||||
|
||||
const choice = options.resolveMigration
|
||||
? await options.resolveMigration()
|
||||
: "append";
|
||||
|
||||
if (choice === "skip") {
|
||||
return { created: false, migration: "skip" };
|
||||
}
|
||||
|
||||
if (choice === "overwrite") {
|
||||
await writeFile(options.path, options.skeleton, "utf8");
|
||||
return { created: false, migration: "overwrite" };
|
||||
}
|
||||
|
||||
// append — add the include at the end, leaving existing content untouched.
|
||||
const appended = existing.endsWith("\n")
|
||||
? `${existing}\n${options.includeLine}\n`
|
||||
: `${existing}\n\n${options.includeLine}\n`;
|
||||
await writeFile(options.path, appended, "utf8");
|
||||
return { created: false, migration: "append" };
|
||||
}
|
||||
|
||||
function referencesIncludeLine(content: string, includeLine: string): boolean {
|
||||
// Match only when the include sits on a line by itself (allowing leading
|
||||
// and trailing whitespace). Earlier we split on `\s+`, but that
|
||||
// false-positives on commented-out includes like `<!-- @AGENTS.cli.md -->`
|
||||
// where the middle token equals the include. CRLF is handled by the
|
||||
// `\r?\n` split.
|
||||
for (const line of content.split(/\r?\n/)) {
|
||||
if (line.trim() === includeLine) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function buildSkillsReference(
|
||||
skills: Pick<ResolvedSkillMetadata, "directoryName" | "description">[]
|
||||
): string {
|
||||
return skills
|
||||
.map((skill) => `- \`.claude/skills/${skill.directoryName}/SKILL.md\` - ${skill.description}`)
|
||||
.map(
|
||||
(skill) =>
|
||||
`- \`.agents/skills/${skill.directoryName}/SKILL.md\` - ${skill.description}`
|
||||
)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
@@ -89,7 +237,9 @@ async function copySkillsFromSource(
|
||||
): Promise<ResolvedSkillMetadata[]> {
|
||||
const skillsDirs = await ensureSkillsDirectories(targetDir);
|
||||
await Promise.all(
|
||||
skillsDirs.map((skillsDir) => copyDirectoryContents(skillsSourcePath, skillsDir))
|
||||
skillsDirs.map((skillsDir) =>
|
||||
copyDirectoryContents(skillsSourcePath, skillsDir)
|
||||
)
|
||||
);
|
||||
return await readSkillMetadataFromDirectory(skillsDirs[0]);
|
||||
}
|
||||
@@ -137,7 +287,10 @@ async function ensureSkillsDirectories(targetDir: string): Promise<string[]> {
|
||||
return skillsDirs;
|
||||
}
|
||||
|
||||
async function copyDirectoryContents(sourceDir: string, targetDir: string): Promise<void> {
|
||||
async function copyDirectoryContents(
|
||||
sourceDir: string,
|
||||
targetDir: string
|
||||
): Promise<void> {
|
||||
const entries = await readdir(sourceDir, { withFileTypes: true });
|
||||
|
||||
await Promise.all(
|
||||
@@ -150,7 +303,10 @@ async function copyDirectoryContents(sourceDir: string, targetDir: string): Prom
|
||||
);
|
||||
}
|
||||
|
||||
function renderGeneratedSkillContent(skillName: string, nonDottedPaths: boolean): string {
|
||||
function renderGeneratedSkillContent(
|
||||
skillName: string,
|
||||
nonDottedPaths: boolean
|
||||
): string {
|
||||
let skillContent = SKILL_CONTENT[skillName];
|
||||
if (!skillContent) {
|
||||
throw new Error(`Missing generated skill content for ${skillName}`);
|
||||
@@ -187,7 +343,11 @@ function renderGeneratedSkillContent(skillName: string, nonDottedPaths: boolean)
|
||||
if (!schemaYaml) {
|
||||
return null;
|
||||
}
|
||||
return formatSchemaForMarkdown(schemaYaml, mapping.name, mapping.filePattern);
|
||||
return formatSchemaForMarkdown(
|
||||
schemaYaml,
|
||||
mapping.name,
|
||||
mapping.filePattern
|
||||
);
|
||||
})
|
||||
.filter((entry): entry is string => entry !== null);
|
||||
|
||||
@@ -198,11 +358,15 @@ function renderGeneratedSkillContent(skillName: string, nonDottedPaths: boolean)
|
||||
return `${skillContent}\n\n${schemaDocs.join("\n\n")}`;
|
||||
}
|
||||
|
||||
async function readSkillMetadataFromDirectory(skillsDir: string): Promise<ResolvedSkillMetadata[]> {
|
||||
async function readSkillMetadataFromDirectory(
|
||||
skillsDir: string
|
||||
): Promise<ResolvedSkillMetadata[]> {
|
||||
const entries = await readdir(skillsDir, { withFileTypes: true });
|
||||
const skills: ResolvedSkillMetadata[] = [];
|
||||
|
||||
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
|
||||
for (const entry of entries.sort((left, right) =>
|
||||
left.name.localeCompare(right.name)
|
||||
)) {
|
||||
if (!entry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
@@ -219,7 +383,10 @@ async function readSkillMetadataFromDirectory(skillsDir: string): Promise<Resolv
|
||||
return skills;
|
||||
}
|
||||
|
||||
function parseSkillMetadata(content: string, fallbackName: string): ResolvedSkillMetadata {
|
||||
function parseSkillMetadata(
|
||||
content: string,
|
||||
fallbackName: string
|
||||
): ResolvedSkillMetadata {
|
||||
const frontMatterMatch = content.match(/^---\s*\n([\s\S]*?)\n---/);
|
||||
if (!frontMatterMatch) {
|
||||
return {
|
||||
@@ -251,19 +418,6 @@ function parseSkillMetadata(content: string, fallbackName: string): ResolvedSkil
|
||||
return { name, description, directoryName: fallbackName };
|
||||
}
|
||||
|
||||
async function writeProjectGuidanceFile(options: {
|
||||
targetPath: string;
|
||||
content: string;
|
||||
overwrite: boolean;
|
||||
}): Promise<boolean> {
|
||||
if (!options.overwrite && (await stat(options.targetPath).catch(() => null))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await writeFile(options.targetPath, options.content, "utf8");
|
||||
return true;
|
||||
}
|
||||
|
||||
function formatSchemaForMarkdown(
|
||||
schemaYaml: string,
|
||||
schemaName: string,
|
||||
|
||||
@@ -40,6 +40,8 @@ import workers from "./commands/workers/workers.ts";
|
||||
import queues from "./commands/queues/queues.ts";
|
||||
import dependencies from "./commands/dependencies/dependencies.ts";
|
||||
import init from "./commands/init/init.ts";
|
||||
import refresh from "./commands/refresh/refresh.ts";
|
||||
import { shouldRunFreshnessCheck } from "./guidance/freshness_gate.ts";
|
||||
import jobs from "./commands/jobs/jobs.ts";
|
||||
import job from "./commands/job/job.ts";
|
||||
import group from "./commands/group/group.ts";
|
||||
@@ -175,6 +177,7 @@ const command = new Command()
|
||||
},
|
||||
})
|
||||
.command("init", init)
|
||||
.command("refresh", refresh)
|
||||
.command("app", app)
|
||||
.command("flow", flow)
|
||||
.command("script", script)
|
||||
@@ -291,6 +294,15 @@ async function main() {
|
||||
await detectAuthGatewayChallenge(response);
|
||||
return response;
|
||||
});
|
||||
|
||||
// Warn (one line) if AGENTS.cli.md predates this CLI's prompts bundle.
|
||||
// The check is gated on argv parsing (cheap) so the ~360 KB skills.gen.ts
|
||||
// bundle stays out of the import graph for help/version/init/refresh/etc.
|
||||
if (shouldRunFreshnessCheck(process.argv)) {
|
||||
const { warnIfPromptsStale } = await import("./guidance/freshness.ts");
|
||||
await warnIfPromptsStale({ argv: process.argv }).catch(() => {});
|
||||
}
|
||||
|
||||
await command.parse(args);
|
||||
} catch (e) {
|
||||
if (e && typeof e === "object" && "name" in e && e.name === "ApiError") {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -387,6 +387,15 @@ List all queues with their metrics
|
||||
- `--instance [instance]` - Name of the instance to push to, override the active instance
|
||||
- `--base-url [baseUrl]` - If used with --token, will be used as the base url for the instance
|
||||
|
||||
### refresh
|
||||
|
||||
Refresh wmill-managed project files (AGENTS.cli.md and skills)
|
||||
|
||||
**Subcommands:**
|
||||
|
||||
- `refresh prompts` - Refresh AGENTS.cli.md and managed skills. User-owned AGENTS.md and CLAUDE.md are never overwritten unless you opt in.
|
||||
- `--yes` - Non-interactive: skip the migration prompt for existing AGENTS.md / CLAUDE.md without the expected include; defaults to appending the include.
|
||||
|
||||
### resource
|
||||
|
||||
resource related commands
|
||||
|
||||
@@ -2918,6 +2918,15 @@ List all queues with their metrics
|
||||
- \`--instance [instance]\` - Name of the instance to push to, override the active instance
|
||||
- \`--base-url [baseUrl]\` - If used with --token, will be used as the base url for the instance
|
||||
|
||||
### refresh
|
||||
|
||||
Refresh wmill-managed project files (AGENTS.cli.md and skills)
|
||||
|
||||
**Subcommands:**
|
||||
|
||||
- \`refresh prompts\` - Refresh AGENTS.cli.md and managed skills. User-owned AGENTS.md and CLAUDE.md are never overwritten unless you opt in.
|
||||
- \`--yes\` - Non-interactive: skip the migration prompt for existing AGENTS.md / CLAUDE.md without the expected include; defaults to appending the include.
|
||||
|
||||
### resource
|
||||
|
||||
resource related commands
|
||||
|
||||
@@ -392,6 +392,15 @@ List all queues with their metrics
|
||||
- `--instance [instance]` - Name of the instance to push to, override the active instance
|
||||
- `--base-url [baseUrl]` - If used with --token, will be used as the base url for the instance
|
||||
|
||||
### refresh
|
||||
|
||||
Refresh wmill-managed project files (AGENTS.cli.md and skills)
|
||||
|
||||
**Subcommands:**
|
||||
|
||||
- `refresh prompts` - Refresh AGENTS.cli.md and managed skills. User-owned AGENTS.md and CLAUDE.md are never overwritten unless you opt in.
|
||||
- `--yes` - Non-interactive: skip the migration prompt for existing AGENTS.md / CLAUDE.md without the expected include; defaults to appending the include.
|
||||
|
||||
### resource
|
||||
|
||||
resource related commands
|
||||
|
||||
@@ -1828,7 +1828,7 @@ CONTEXT7_REPO_NAME = "windmill-cli-docs"
|
||||
|
||||
|
||||
def extract_agents_md_template() -> str:
|
||||
"""Extract the AGENTS.md template string from cli/src/guidance/core.ts.
|
||||
"""Extract the AGENTS.cli.md template string from cli/src/guidance/core.ts.
|
||||
|
||||
Keeping a single source of truth in TypeScript avoids drift between what
|
||||
`wmill init` writes locally and what we publish for context7 ingestion.
|
||||
@@ -1836,14 +1836,16 @@ def extract_agents_md_template() -> str:
|
||||
core_ts_path = SCRIPT_DIR.parent / "cli" / "src" / "guidance" / "core.ts"
|
||||
content = core_ts_path.read_text()
|
||||
# Anchor on the function name so adding other template-literal-returning
|
||||
# functions to core.ts can't silently re-target the regex.
|
||||
# functions to core.ts can't silently re-target the regex. The function
|
||||
# was renamed from `generateAgentsMdContent` → `generateAgentsCliMdContent`
|
||||
# when the managed file split out of AGENTS.md into AGENTS.cli.md.
|
||||
match = re.search(
|
||||
r"function\s+generateAgentsMdContent\b[\s\S]*?return\s+`([\s\S]*?)`;",
|
||||
r"function\s+generateAgentsCliMdContent\b[\s\S]*?return\s+`([\s\S]*?)`;",
|
||||
content,
|
||||
)
|
||||
if not match:
|
||||
raise RuntimeError(
|
||||
f"Could not extract AGENTS.md template from {core_ts_path}"
|
||||
f"Could not extract AGENTS.cli.md template from {core_ts_path}"
|
||||
)
|
||||
return _unescape_ts_template_literal(match.group(1))
|
||||
|
||||
@@ -1865,10 +1867,16 @@ def _unescape_ts_template_literal(raw: str) -> str:
|
||||
def render_agents_md_for_docs(
|
||||
skills: list[str], skill_desc_map: dict[str, str]
|
||||
) -> str:
|
||||
"""Render AGENTS.md exactly as `wmill init` would, for the docs repo."""
|
||||
"""Render AGENTS.cli.md exactly as `wmill init` would, for the docs repo.
|
||||
|
||||
The skill reference paths point at `.agents/skills/` (the canonical tree
|
||||
that Codex/Pi read directly and that Claude Code mirrors under
|
||||
`.claude/skills/`) — matching `buildSkillsReference` in
|
||||
`cli/src/guidance/writer.ts`.
|
||||
"""
|
||||
template = extract_agents_md_template()
|
||||
skills_reference = "\n".join(
|
||||
f"- `.claude/skills/{name}/SKILL.md` - {skill_desc_map[name]}"
|
||||
f"- `.agents/skills/{name}/SKILL.md` - {skill_desc_map[name]}"
|
||||
for name in skills
|
||||
if name in skill_desc_map
|
||||
)
|
||||
@@ -2002,7 +2010,10 @@ def generate_context7_repo(
|
||||
|
||||
skill_desc_map = build_skill_desc_map(skills)
|
||||
|
||||
# AGENTS.md — the same content `wmill init` writes locally.
|
||||
# AGENTS.md — the managed CLI guidance (what `wmill init` writes as
|
||||
# AGENTS.cli.md locally). Kept under the `AGENTS.md` filename here to
|
||||
# preserve the existing context7 ingest path; docs consumers read this
|
||||
# as the canonical AGENTS file.
|
||||
(target_dir / "AGENTS.md").write_text(
|
||||
render_agents_md_for_docs(skills, skill_desc_map)
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user