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:
hugocasa
2026-05-21 12:44:15 +02:00
committed by Diego Imbert
parent 4e8d9f7445
commit d5e39d2f3e
14 changed files with 1185 additions and 131 deletions
+2 -45
View File
@@ -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) {
+182
View File
@@ -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;
+8
View File
@@ -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;
+91 -9
View File
@@ -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}
`;
+169
View File
@@ -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`);
}
+67
View File
@@ -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;
}
+9
View File
@@ -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
View File
@@ -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,
+12
View File
@@ -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") {