mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-21 08:02:26 +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") {
|
||||
|
||||
Reference in New Issue
Block a user