feat(cli): improve agent prompts/skills and workspace fork workflow (#9531)

* feat(cli): improve agent prompts/skills and workspace fork workflow

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cli): refuse fork --from-branch rename of a base branch

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(cli): auto-detect fork branch workflow, drop rt.d.ts refresh and legacy-name warning

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(skills): reconcile raw-app generate-metadata stance (agent offers+runs)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(skills): agent runs all CLI commands, gated on intent not on user typing them

Extends #9467's safe-vs-destructive model: the agent runs consequential commands (sync push, generate-metadata) itself too, gated on explicit user intent rather than handed to the user to type. The explicit-intent rule is the safeguard; an approval prompt is treated as a possible backstop, not assumed (auto-approve/headless runs have none).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Revert "docs(skills): agent runs all CLI commands, gated on intent not on user typing them"

Reverts 9225e1759b. That commit over-reached: #9467 already established the safe-vs-destructive split, and the targeted item-6 fix already removed the passive "tell the user they can run <safe next step>" phrasing. The blanket "agent runs everything" principle pushed deploys to be more eager and carried a wrong "permission layer prompts for approval" claim (untrue in auto-approve/headless mode). Keep deploys conservative.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(cli): default fork workspace name/id to the current branch when renaming it

When 'wmill workspace fork' converts the current working branch into the fork branch, default the fork's name and id to that branch (sanitized to a slug, since branch names can contain '/'). Interactive: the prompt is pre-filled (enter to accept); non-interactive (--yes): used automatically. Adds a unit test for the slug derivation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cli): address fork review — guard fork-branch rename, cap+validate fork id

Two P2s from review:
- --from-branch refused when the current branch is already a fork branch (would detach the existing fork by renaming its branch).
- fork id slug capped to 42 chars (backend max 50 incl. wm-fork- prefix); auto-derived id is slugged; full id validated client-side before existsWorkspace/datatable cloning so an invalid id fails fast instead of leaving cloned Postgres databases behind.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hugocasa
2026-06-12 00:16:16 +02:00
committed by GitHub
parent 57e627eabf
commit 5bdc4f83ce
28 changed files with 806 additions and 153 deletions
+12 -4
View File
@@ -53,11 +53,19 @@ export async function refreshPrompts(opts: {
},
});
log.info(colors.green("Refreshed AGENTS.cli.md"));
log.info(colors.green("Refreshed AGENTS.wmill.md"));
if (result.legacyManagedRemoved) {
log.info(
colors.yellow(
"Migrated legacy AGENTS.cli.md → AGENTS.wmill.md (removed the old file and rewrote any @AGENTS.cli.md include)."
)
);
}
reportReconciliation({
file: "AGENTS.md",
includeLine: "@AGENTS.cli.md",
includeLine: "@AGENTS.wmill.md",
created: result.agentsCreated,
migration: result.agentsMigration,
});
@@ -178,10 +186,10 @@ async function promptsAction(opts: CommandOptions): Promise<void> {
}
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.")
.description("Refresh AGENTS.wmill.md and managed skills. User-owned AGENTS.md and CLAUDE.md are never overwritten unless you opt in.")
.option(
"--yes",
"Non-interactive: append the @AGENTS.cli.md include to an existing AGENTS.md / CLAUDE.md without prompting. Without it, a non-interactive run leaves an unlinked file untouched."
"Non-interactive: append the @AGENTS.wmill.md include to an existing AGENTS.md / CLAUDE.md without prompting. Without it, a non-interactive run leaves an unlinked file untouched."
)
.action(promptsAction as any);
+1 -1
View File
@@ -4,7 +4,7 @@ import tsconfigCommand from "./tsconfig.ts";
const command = new Command()
.description(
"Refresh wmill-managed project files (AGENTS.cli.md, skills, tsconfig.wmill.json)"
"Refresh wmill-managed project files (AGENTS.wmill.md, skills, tsconfig.wmill.json)"
)
.command("prompts", promptsCommand)
.command("tsconfig", tsconfigCommand);
+3 -3
View File
@@ -22,7 +22,7 @@ const WORKSPACE_IMPORT_DIRS = ["f", "u"];
// wmill-managed files holding the recommended config. They are always
// (re)written so we can ship updated recommendations over time; users keep
// their own overrides in tsconfig.json / deno.json, which reference these
// managed files and are never overwritten. This mirrors how AGENTS.cli.md
// managed files and are never overwritten. This mirrors how AGENTS.wmill.md
// (managed) and AGENTS.md (user-owned) work for AI prompts.
const MANAGED_TSCONFIG = "tsconfig.wmill.json";
const MANAGED_IMPORT_MAP = "import_map.wmill.json";
@@ -33,7 +33,7 @@ const MANAGED_NOTICE =
// Embedded in tsconfig.wmill.json so any command can detect a stale managed file
// (the recommended config changed) and nudge the user to `wmill refresh tsconfig`
// — mirroring the prompts freshness marker in AGENTS.cli.md.
// — mirroring the prompts freshness marker in AGENTS.wmill.md.
const TSCONFIG_HASH_PREFIX = "// wmill-tsconfig-hash: ";
const TSCONFIG_HASH_REGEX = /^\/\/ wmill-tsconfig-hash: ([0-9a-f]{12})/m;
@@ -291,7 +291,7 @@ async function refreshManagedDenoImportMap(mode: WireMode) {
/**
* Ensure a user-owned config file references the wmill-managed file. Mirrors how
* `wmill refresh prompts` wires `@AGENTS.cli.md` into AGENTS.md:
* `wmill refresh prompts` wires `@AGENTS.wmill.md` into AGENTS.md:
* - missing → create the minimal file (already linked);
* - exists & linked → leave it alone;
* - exists & unlinked → auto-wire it (parse JSON, apply `wire`, write back).
+281 -31
View File
@@ -5,15 +5,29 @@ import * as log from "../../core/log.ts";
import { setClient } from "../../core/client.ts";
import { allWorkspaces, list, removeWorkspace } from "./workspace.ts";
import * as wmill from "../../../gen/services.gen.ts";
import { getCurrentGitBranch, getOriginalBranchForWorkspaceForks, isGitRepository } from "../../utils/git.ts";
import {
getCurrentGitBranch,
getOriginalBranchForWorkspaceForks,
gitBranchExists,
isGitRepository,
renameCurrentGitBranch,
} from "../../utils/git.ts";
import process from "node:process";
import { WM_FORK_PREFIX } from "../../core/constants.ts";
import { tryResolveBranchWorkspace } from "../../core/context.ts";
import {
findWorkspaceByGitBranch,
getEffectiveGitBranch,
getWorkspaceNames,
readConfigFile,
} from "../../core/conf.ts";
async function createWorkspaceFork(
opts: GlobalOptions & {
createWorkspaceName: string | undefined;
color: string | undefined;
datatableBehavior: string | undefined;
fromBranch: string | undefined;
yes: boolean | undefined;
},
workspaceName: string | undefined,
@@ -23,7 +37,84 @@ async function createWorkspaceFork(
throw new Error("You can only create forks within a git repo. Forks are tracked with git and synced to your instance with the git sync workflow.");
}
const workspace = await tryResolveBranchWorkspace(opts);
const currentBranch = getCurrentGitBranch()
if (!currentBranch) {
throw new Error("Could not get git branch name");
}
const config = await readConfigFile({ warnIfMissing: false });
const originalBranchIfForked = getOriginalBranchForWorkspaceForks(currentBranch);
// A "base branch" is one we must not rename onto a fork branch: mapped to a
// workspace in wmill.yaml, or a conventional default (main/master).
const isBaseBranch = (branch: string): boolean =>
branch === "main" ||
branch === "master" ||
findWorkspaceByGitBranch(config.workspaces, branch) !== undefined;
// Decide the base branch the fork links to, and whether to rename the
// current working branch onto the fork branch. Auto-detected from where you
// are; `--from-branch` is the explicit/non-interactive override.
let clonedBranchName: string;
let renameCurrent: boolean;
if (opts.fromBranch) {
// Explicit override: base on <fromBranch>, rename the current branch.
if (opts.fromBranch === currentBranch) {
throw new Error(
`--from-branch is for converting a *different* working branch into the fork branch, but you are already on \`${currentBranch}\`. ` +
`Omit --from-branch to create a fresh fork branch with \`git checkout -b\`.`,
);
}
if (isBaseBranch(currentBranch)) {
throw new Error(
`Refusing to rename your current branch \`${currentBranch}\` — it looks like a base branch (mapped to a workspace in wmill.yaml, or main/master). ` +
`Check out the disposable working branch you want to convert first.`,
);
}
if (getOriginalBranchForWorkspaceForks(currentBranch)) {
// Current branch is itself a fork branch (wm-fork/<base>/<old-id>).
// Renaming it onto the new fork branch would detach the existing fork.
throw new Error(
`Refusing to rename your current branch \`${currentBranch}\` — it is already a fork branch. ` +
`To fork a fork, omit --from-branch: \`wmill workspace fork\` bases the new fork on this fork's original branch and creates a fresh fork branch without renaming.`,
);
}
if (!findWorkspaceByGitBranch(config.workspaces, opts.fromBranch)) {
throw new Error(
`Could not find a workspace mapped to branch \`${opts.fromBranch}\` in wmill.yaml's workspaces section. ` +
`Pass the base branch your fork should be based on (e.g. the branch bound to the parent workspace).`,
);
}
clonedBranchName = opts.fromBranch;
renameCurrent = true;
} else if (originalBranchIfForked) {
// Fork of a fork: link to the original branch; user checks out a new branch.
log.info(`You are creating a fork of a fork. The branch will be linked to the original branch this was forked from, i.e. \`${originalBranchIfForked}\`, for all settings and overrides.`);
clonedBranchName = originalBranchIfForked;
renameCurrent = false;
} else if (isBaseBranch(currentBranch)) {
// On a base branch: base the fork on it; user checks out a fresh fork branch.
clonedBranchName = currentBranch;
renameCurrent = false;
} else {
// On a non-base working branch: offer to base the fork on it and rename it.
clonedBranchName = await resolveWorkingBranchBase(config, opts, currentBranch);
renameCurrent = true;
}
// Resolve the parent workspace. When the base differs from the current
// branch (the rename workflows), resolve via the base branch's workspace;
// otherwise use plain branch resolution.
let workspace;
if (clonedBranchName === currentBranch) {
workspace = await tryResolveBranchWorkspace(opts);
} else {
const baseMatch = findWorkspaceByGitBranch(config.workspaces, clonedBranchName);
workspace = baseMatch
? await tryResolveBranchWorkspace(opts, baseMatch[0])
: await tryResolveBranchWorkspace(opts);
}
if (!workspace) {
throw new Error("Could not resolve workspace from branch name. Make sure you are in a git repo to use workspace forks");
@@ -31,24 +122,6 @@ async function createWorkspaceFork(
log.info(`You are forking workspace (${workspace.workspaceId})`)
const currentBranch = getCurrentGitBranch()
if (!currentBranch) {
throw new Error("Could not get git branch name");
}
const originalBranchIfForked = getOriginalBranchForWorkspaceForks(currentBranch);
let clonedBranchName: string | null;
if (originalBranchIfForked) {
log.info(`You are creating a fork of a fork. The branch will be linked to the original branch this was forked from, i.e. \`${originalBranchIfForked}\`, for all settings and overrides.`);
clonedBranchName = originalBranchIfForked;
} else {
clonedBranchName = currentBranch;
}
if (!clonedBranchName) {
throw new Error("Failed to get current branch name, aborting operation");
}
if (opts.workspace) {
log.info(
colors.red.bold(
@@ -58,18 +131,39 @@ async function createWorkspaceFork(
return;
}
while (workspaceName === undefined) {
if (!workspaceName) {
workspaceName = await Input.prompt("Name this forked workspace:");
// When we're converting the current branch into the fork branch, default
// the fork's name/id to that branch — almost always what you want, and it
// keeps the fork branch named after the work you already have
// (wm-fork/<base>/<branch>). Interactive: pre-fill the prompt (press enter
// to accept). Non-interactive (`--yes`): use it automatically.
const branchDefaultId = renameCurrent ? branchToForkId(currentBranch) : undefined;
const interactive = process.stdin.isTTY && opts.yes !== true;
if (workspaceName === undefined) {
if (branchDefaultId && !interactive) {
workspaceName = branchDefaultId;
log.info(`Naming the fork after the current branch: \`${workspaceName}\``);
} else {
workspaceName = await Input.prompt({
message: "Name this forked workspace:",
default: branchDefaultId,
});
}
}
if (!workspaceId) {
workspaceId = await Input.prompt({
message: `Enter the ID of this forked workspace, it will then be prefixed by ${WM_FORK_PREFIX}. It will also determine the branch name`,
default: workspaceName,
suggestions: [workspaceName],
});
// The id (unlike the display name) must be a valid slug — derive it from
// the name rather than using the free-form name verbatim.
const idDefault = branchToForkId(workspaceName);
if (branchDefaultId && !interactive) {
workspaceId = idDefault;
} else {
workspaceId = await Input.prompt({
message: `Enter the ID of this forked workspace, it will then be prefixed by ${WM_FORK_PREFIX}. It will also determine the branch name`,
default: idDefault,
suggestions: [idDefault],
});
}
}
const token = workspace.token;
@@ -87,6 +181,11 @@ async function createWorkspaceFork(
log.info(colors.blue(`Creating forked workspace: ${workspaceName}...`));
const trueWorkspaceId = `${WM_FORK_PREFIX}-${workspaceId}`;
// Fail fast on an invalid id (e.g. an explicit positional id, or a long
// branch name under --yes) before existsWorkspace, datatable cloning, and
// branch creation — a late backend rejection would leave cloned databases
// behind.
validateForkWorkspaceId(trueWorkspaceId);
let alreadyExists = false;
try {
alreadyExists = await wmill.existsWorkspace({
@@ -248,9 +347,39 @@ async function createWorkspaceFork(
const newBranchName = `${WM_FORK_PREFIX}/${clonedBranchName}/${workspaceId}`
log.info(`Created forked workspace ${trueWorkspaceId}. To start contributing to your fork, create and push edits to the branch \`${newBranchName}\` by using the command:
// Rename workflow: turn the current working branch into the fork branch in
// place so its commits become the fork's. (Consent was already established —
// by `--from-branch`, or the interactive prompt for a non-base branch.)
// Otherwise: leave the user on their branch and have them check out a fresh
// fork branch.
let onForkBranch = false;
if (renameCurrent) {
if (currentBranch === newBranchName) {
onForkBranch = true;
log.info(colors.green(`Your current branch is already \`${newBranchName}\`.`));
} else if (gitBranchExists(newBranchName)) {
log.warn(
`Branch \`${newBranchName}\` already exists locally, so the current branch \`${currentBranch}\` was not renamed. ` +
`Check out the fork branch yourself (e.g. \`git checkout ${newBranchName}\`).`,
);
} else {
renameCurrentGitBranch(newBranchName);
onForkBranch = true;
log.info(
colors.green(
`Renamed \`${currentBranch}\`\`${newBranchName}\`. Your existing commits are now on the fork branch.`,
),
);
}
}
\t`+colors.white(`git checkout -b ${newBranchName}`) + `
const checkoutHint = onForkBranch
? `Created forked workspace ${trueWorkspaceId}. You are on the fork branch \`${newBranchName}\` — push it to sync your fork.`
: `Created forked workspace ${trueWorkspaceId}. To start contributing to your fork, create and push edits to the branch \`${newBranchName}\` by using the command:
\t` + colors.white(`git checkout -b ${newBranchName}`);
log.info(`${checkoutHint}
When doing operations on the forked workspace, it will use the remote setup in the workspaces section for the branch it was forked from.
@@ -261,6 +390,122 @@ To merge changes back to the parent workspace, you can:
See: https://www.windmill.dev/docs/advanced/workspace_forks`);
}
/**
* When `wmill workspace fork` is run from a non-base working branch, confirm
* the user wants to turn it into a fork branch, and resolve which base branch
* the fork should be linked to. Throws in non-interactive mode (where the user
* must pass `--from-branch <base>` instead).
*/
async function resolveWorkingBranchBase(
config: Awaited<ReturnType<typeof readConfigFile>>,
opts: { yes?: boolean },
currentBranch: string,
): Promise<string> {
const interactive = process.stdin.isTTY && opts.yes !== true;
if (!interactive) {
throw new Error(
`You are on working branch \`${currentBranch}\`, which is not a base branch. ` +
`Pass --from-branch <base> to base the fork on a base branch and rename this branch onto the fork branch, ` +
`or check out a base branch and run \`wmill workspace fork\` to create a fresh fork branch.`,
);
}
const { Select } = await import("@cliffy/prompt/select");
const proceed = await Select.prompt({
message: `You're on working branch \`${currentBranch}\`, not a base branch. Base a fork on it and rename it onto the fork branch?`,
options: [
{ name: "Yes, base the fork on this branch and rename it", value: "yes" },
{ name: "No, cancel", value: "no" },
],
});
if (proceed !== "yes") {
throw new Error("Fork cancelled. Check out a base branch to create a fresh fork branch instead.");
}
const baseBranches = listConfiguredBaseBranches(config);
if (baseBranches.length === 0) {
throw new Error(
`No base branches are configured in wmill.yaml's workspaces section, so the fork can't be linked to a parent. ` +
`Add the parent workspace to wmill.yaml, or pass --from-branch <base>.`,
);
}
if (baseBranches.length === 1) {
log.info(`Basing the fork on \`${baseBranches[0]}\`.`);
return baseBranches[0];
}
return await Select.prompt({
message: "Which base branch is this fork based on (the parent)?",
options: baseBranches.map((b) => ({ name: b, value: b })),
});
}
// The backend caps a fork workspace id (`wm-fork-<slug>`) at 50 chars total
// (validate_fork_workspace_id in windmill-common), so the slug is at most
// 50 - "wm-fork-".length (8) = 42.
const MAX_FORK_ID_SLUG = 42;
/**
* Derive a workspace-id-safe slug from a git branch name. Branch names can
* contain `/` and other characters that aren't valid in a workspace id and
* would break the `wm-fork/<base>/<id>` branch-name parsing, so collapse any
* invalid run to a single dash, trim, and cap to the backend length limit.
*/
function branchToForkId(branch: string): string {
const slug = branch
.replace(/[^a-zA-Z0-9_-]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, MAX_FORK_ID_SLUG)
.replace(/-+$/g, ""); // re-trim if the cut landed on a dash
return slug || "fork";
}
/**
* Mirror the backend `validate_fork_workspace_id` so an invalid id fails fast
* — before `existsWorkspace`, datatable cloning (which creates real per-fork
* Postgres databases), and branch creation, none of which get cleaned up on a
* late backend rejection. `id` is the full `wm-fork-<slug>` workspace id.
*/
function validateForkWorkspaceId(id: string): void {
const reject = (reason: string): never => {
throw new Error(
`Fork workspace id \`${id}\` is invalid: ${reason}. Choose a shorter or simpler name/id.`,
);
};
if (id.length > 50) {
reject(`too long (${id.length} chars; max 50 including the \`${WM_FORK_PREFIX}-\` prefix)`);
}
if (id.endsWith(".")) reject("cannot end with '.'");
if (id.endsWith(".lock")) reject("cannot end with '.lock'");
if (id.includes("..")) reject("cannot contain '..'");
if (id.includes("@{")) reject("cannot contain '@{'");
if (id.includes("//")) reject("cannot contain '//'");
for (const ch of id) {
if (":~^?*[\\ ".includes(ch)) reject(`contains forbidden character '${ch}'`);
const code = ch.charCodeAt(0);
if (code < 0x20 || code === 0x7f) reject("contains a control character");
}
for (const component of id.split("/")) {
if (component.startsWith(".")) reject("a path component cannot start with '.'");
if (component.endsWith(".lock")) reject("a path component cannot end with '.lock'");
}
}
/**
* Base branches configured in wmill.yaml, mirroring how `findWorkspaceByGitBranch`
* keys them (effective git branch = `gitBranch ?? workspaceName`, reserved keys
* excluded) so the chosen base resolves to a workspace afterwards.
*/
function listConfiguredBaseBranches(
config: Awaited<ReturnType<typeof readConfigFile>>,
): string[] {
const workspaces = config.workspaces;
const branches = new Set<string>();
for (const name of getWorkspaceNames(workspaces)) {
branches.add(getEffectiveGitBranch(name, workspaces![name]));
}
return [...branches];
}
async function deleteWorkspaceFork(
opts: GlobalOptions & {
yes?: boolean;
@@ -332,4 +577,9 @@ async function deleteWorkspaceFork(
}
}
export { createWorkspaceFork, deleteWorkspaceFork };
export {
branchToForkId,
createWorkspaceFork,
deleteWorkspaceFork,
validateForkWorkspaceId,
};
+5 -1
View File
@@ -812,7 +812,11 @@ const command = new Command()
"--datatable-behavior <behavior:string>",
"How to handle datatables: skip, schema_only, or schema_and_data (default: interactive prompt)"
)
.option("-y --yes", "Skip interactive prompts (defaults datatable behavior to 'skip')")
.option(
"--from-branch <branch:string>",
"Non-interactive override for the 'turn my current working branch into the fork' workflow: base the fork on <branch> (its bound workspace is the parent) and rename the current branch onto wm-fork/<branch>/<id>. Usually unneeded — from a working branch `wmill workspace fork` offers this interactively; from a base branch it creates a fresh fork branch."
)
.option("-y --yes", "Skip interactive prompts (defaults datatable behavior to 'skip'). On a non-base branch, requires --from-branch since the base branch can't be prompted for.")
.action(createWorkspaceFork as any)
.command("delete-fork")
.description("Delete a forked workspace and git branch")
+35 -7
View File
@@ -3,18 +3,28 @@
*
* `wmill` writes two files:
*
* - `AGENTS.cli.md` — managed CLI / workspace guidance, refreshed by
* - `AGENTS.wmill.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
* references `AGENTS.wmill.md` via an `@`-include so the managed content is
* pulled in automatically.
*
* The managed file used to be named `AGENTS.cli.md`; `wmill init` /
* `wmill refresh prompts` migrate the old name to `AGENTS.wmill.md` (and
* rewrite the `@`-include) automatically. The legacy constants below exist
* solely for that migration.
*/
export const AGENTS_CLI_INCLUDE_LINE = "@AGENTS.cli.md";
export const AGENTS_WMILL_FILENAME = "AGENTS.wmill.md";
export const AGENTS_WMILL_INCLUDE_LINE = "@AGENTS.wmill.md";
/** Legacy managed filename / include line, migrated away from on init/refresh. */
export const LEGACY_AGENTS_CLI_FILENAME = "AGENTS.cli.md";
export const LEGACY_AGENTS_CLI_INCLUDE_LINE = "@AGENTS.cli.md";
/**
* 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
* exists in the project. Everything below the `@AGENTS.wmill.md` include is for
* the user to edit; nothing in this file is refreshed by `wmill`.
*/
export function generateAgentsMdSkeleton(): string {
@@ -28,7 +38,7 @@ 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}
${AGENTS_WMILL_INCLUDE_LINE}
## Project-specific instructions
@@ -41,8 +51,12 @@ ${AGENTS_CLI_INCLUDE_LINE}
}
/**
* Managed AGENTS.cli.md content. Rewritten by `wmill init` and
* Managed AGENTS.wmill.md content. Rewritten by `wmill init` and
* `wmill refresh prompts` every time.
*
* NOTE: `system_prompts/generate.py` extracts this template by anchoring on
* the function name `generateAgentsCliMdContent` — keep the name in sync if
* you rename it.
*/
export function generateAgentsCliMdContent(skillsReference: string): string {
return `# Windmill CLI Agent Instructions
@@ -112,10 +126,11 @@ There are two ways local changes reach the workspace. Pick based on how the repo
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).
**Save the preference so you don't re-detect it every session.** Once you've determined which option this repo uses (or the user tells you), record it in the **project-specific instructions** section of \`AGENTS.md\` (user-owned — never overwritten by \`wmill refresh prompts\`), e.g. a line like \`Deploy mode: git push (CI runs wmill sync push)\` or \`Deploy mode: wmill sync push (no CI wiring)\`. On later sessions, read that line first and skip the scan. Re-detect only if the CI wiring visibly changed.
### 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.
@@ -137,6 +152,19 @@ No CI workflow runs \`wmill sync push\` automatically, so deploy directly from t
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.
## Workspace forks
A **fork** is an isolated copy of a workspace for parallel or experimental work — make changes (including to datatables, which are cloned per fork) without touching the parent, then merge back after review. Each fork is paired with a git branch named \`wm-fork/<base>/<id>\`. Forks require a git repo.
Just run \`wmill workspace fork\` — it adapts to where you are:
- **On a base branch** (e.g. \`main\`, or a branch bound to a workspace): it bases the fork on that branch and prints a \`git checkout -b wm-fork/<base>/<id>\` to start a fresh fork branch.
- **On a working branch** (e.g. you've branched and already edited a forked datatable): it offers to base the fork on that branch and rename it onto \`wm-fork/<base>/<id>\` in place, preserving its commits — asking which base branch is the parent if there's more than one.
For non-interactive runs from a working branch, pass \`--from-branch <base>\` to skip the prompts. The CLI refuses to rename a base branch.
Merge a fork back into its parent with \`wmill workspace merge\` (or the Merge UI on the fork's home page). Full reference: https://www.windmill.dev/docs/advanced/workspace_forks
## 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.
+34 -15
View File
@@ -1,13 +1,13 @@
/**
* Versioning + freshness check for the managed AGENTS.cli.md bundle.
* Versioning + freshness check for the managed AGENTS.wmill.md bundle.
*
* We embed a short hash of "what this CLI would write" into AGENTS.cli.md as
* We embed a short hash of "what this CLI would write" into AGENTS.wmill.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
* AGENTS.wmill.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.
*/
@@ -15,7 +15,11 @@ 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 {
AGENTS_WMILL_FILENAME,
LEGACY_AGENTS_CLI_FILENAME,
generateAgentsCliMdContent,
} from "./core.ts";
import {
SCHEMAS,
SCHEMA_MAPPINGS,
@@ -43,7 +47,7 @@ export function extractPromptsHash(content: string): string | null {
}
/**
* Insert the hash marker into rendered AGENTS.cli.md content. The marker
* Insert the hash marker into rendered AGENTS.wmill.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.
*/
@@ -73,7 +77,7 @@ export function currentPromptsHash(nonDottedPaths: boolean): string {
hasher.update(generateAgentsCliMdContent("__PLACEHOLDER__"));
// Skill metadata (names + descriptions) — fed into the skills reference
// line in AGENTS.cli.md and the wrapper frontmatter.
// line in AGENTS.wmill.md and the wrapper frontmatter.
hasher.update("\nskills:");
hasher.update(JSON.stringify(SKILLS));
@@ -101,10 +105,16 @@ export function currentPromptsHash(nonDottedPaths: boolean): string {
}
/**
* 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,
* Read the managed guidance file 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 managed file, no marker,
* matching hash, IO error, …) so it never gets in the user's way.
*
* Back-compat: prefers `AGENTS.wmill.md` but falls back to the legacy
* `AGENTS.cli.md` and runs the exact same staleness check on it. We do NOT
* warn merely because the old filename is in use — an up-to-date `AGENTS.cli.md`
* stays quiet; only a stale hash (which `wmill refresh prompts` fixes, and
* which also migrates the filename) trips the warning.
*/
export async function warnIfPromptsStale(opts?: {
cwd?: string;
@@ -114,9 +124,18 @@ export async function warnIfPromptsStale(opts?: {
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;
// Prefer the current filename; fall back to the legacy one for back-compat.
let fileName = AGENTS_WMILL_FILENAME;
let path = `${cwd}/${fileName}`;
if (!(await stat(path).catch(() => null))) {
fileName = LEGACY_AGENTS_CLI_FILENAME;
path = `${cwd}/${fileName}`;
if (!(await stat(path).catch(() => null))) {
// Neither file present — this project just isn't wmill-managed.
return;
}
}
let content: string;
try {
@@ -127,10 +146,10 @@ export async function warnIfPromptsStale(opts?: {
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.
// Managed file without a marker. Warn so the user re-runs refresh and
// picks up the new format (and the new filename).
emitWarning(
"Your AGENTS.cli.md predates prompt versioning. Run `wmill refresh prompts` to refresh and add a version marker."
`Your ${fileName} predates prompt versioning. Run \`wmill refresh prompts\` to refresh and add a version marker.`
);
return;
}
@@ -152,7 +171,7 @@ export async function warnIfPromptsStale(opts?: {
const current = currentPromptsHash(nonDottedPaths);
if (stored !== current) {
emitWarning(
"Your AGENTS.cli.md is out of date. Run `wmill refresh prompts` to refresh."
`Your ${fileName} is out of date. Run \`wmill refresh prompts\` to refresh.`
);
}
}
+24 -12
View File
@@ -291,6 +291,10 @@ import Stripe from "stripe";
import { someFunction } from "some-package";
\`\`\`
## Prefer \`//native\` when the runtime allows it
If a script only needs \`fetch\` and the JavaScript standard library — including when it uses \`windmill-client\` — prefer making it a **native** script: add \`//native\` as the first line and write it with the \`write-script-bunnative\` skill. Native scripts run on a lightweight V8 isolate, start faster, and parallelize heavily. \`windmill-client\` works on the native worker (its calls go over \`fetch\`), so needing the Windmill client is **not** a reason to avoid \`//native\`. Use the regular \`bun\` language only when the code (or a dependency) needs Node/Bun runtime APIs — \`node:*\` modules, the filesystem, child processes, or native addons.
## Windmill Client
Import the windmill client for platform interactions:
@@ -299,7 +303,9 @@ Import the windmill client for platform interactions:
import * as wmill from "windmill-client";
\`\`\`
See the SDK documentation for available methods.
**Prefer \`windmill-client\` over raw \`fetch\` for anything that talks to Windmill** — reading resources/variables/states, running scripts and flows, S3 object operations, etc. It handles auth, the workspace, and the base URL for you, so you don't hand-roll URLs or tokens. Reserve \`fetch\` for calling *external* HTTP APIs that aren't Windmill.
The full \`windmill-client\` API reference (every exported function and its signature) is included in this skill below — consult it for the exact method to use instead of guessing or falling back to \`fetch\`.
## Preprocessor Scripts
@@ -1012,7 +1018,9 @@ export async function main(url: string) {
## Windmill Client
\`windmill-client\` is available for Windmill-specific primitives such as the S3 helpers below (\`loadS3File\`, \`loadS3FileStream\`, \`writeS3File\`, \`S3Object\`). Use \`fetch\` for plain HTTP.
\`windmill-client\` works on the native worker (its calls go over \`fetch\`), so use it as the **preferred way to talk to Windmill** — reading resources/variables/states, running scripts and flows, and the S3 helpers below (\`loadS3File\`, \`loadS3FileStream\`, \`writeS3File\`, \`S3Object\`). It handles auth, the workspace, and the base URL for you. Reserve raw \`fetch\` for calling *external* HTTP APIs that aren't Windmill.
The full \`windmill-client\` API reference (every exported function and its signature) is included in this skill below — consult it for the exact method instead of hand-rolling a \`fetch\` against the Windmill API.
## Preprocessor Scripts
@@ -1813,7 +1821,9 @@ Import the windmill client for platform interactions:
import * as wmill from "windmill-client";
\`\`\`
See the SDK documentation for available methods.
**Prefer \`windmill-client\` over raw \`fetch\` for anything that talks to Windmill** — reading resources/variables/states, running scripts and flows, S3 object operations, etc. It handles auth, the workspace, and the base URL for you. Reserve \`fetch\` for calling *external* HTTP APIs that aren't Windmill.
The full \`windmill-client\` API reference (every exported function and its signature) is included in this skill below — consult it for the exact method instead of guessing or falling back to \`fetch\`.
## Preprocessor Scripts
@@ -4494,7 +4504,7 @@ Once the flow has real content, **offer** to open the visual preview as a one-se
## CLI Commands running, previewing, deploying
After writing, tell the user which command fits what they want to do:
After writing, act on the user's intent instead of just listing commands. Run the safe, non-deploying command yourself when it fits (\`wmill flow preview\` — see "After writing — offer to run, don't wait passively" below); only *name* the commands that deploy or rewrite files (\`wmill sync push\`, \`wmill generate-metadata\`) so the user can approve them. The options:
- \`wmill flow preview <flow_path>\` — **default when iterating on a local flow.** Runs the local \`flow.yaml\` against local inline scripts without deploying. Add \`--remote\` to use deployed workspace scripts for PathScript steps instead of local files. Add \`--step <step_id>\` to run only one module in isolation (see "Single-step vs whole-flow preview" below).
- \`wmill flow run <path>\` — runs the flow **already deployed** in the workspace. Use only when the user explicitly wants to test the deployed version, not local edits.
@@ -4960,7 +4970,7 @@ The runnable ID is the filename without extension. For example, \`get_user.ts\`
| C# | \`.cs\` | \`myFunc.cs\` |
| Java | \`.java\` | \`myFunc.java\` |
After creating a runnable, tell the user they can generate lock files by running:
After creating a runnable, offer to generate its lock files as a one-sentence next step (e.g. "Want me to generate the lock files?") and run it yourself once they agree don't just name the command and wait. If the user already asked you to finish/lock the app, run it directly. It writes local lock files (not a deploy), so offer rather than running silently:
\`\`\`bash
wmill generate-metadata
\`\`\`
@@ -5051,15 +5061,16 @@ data:
## CLI Commands
\`wmill app new\` is the exception: you run it yourself, with flags, per the "Creating a Raw App" section above.
Two commands you run yourself, not the user:
- \`wmill app new\` — run it with flags, per the "Creating a Raw App" section above.
- \`wmill generate-metadata\` — generates local lock files; offer it and run it on consent, per "After creating a runnable" above (it writes local lock files, not a deploy).
For everything else, tell the user which command fits their intent and let them run it these touch the workspace or local lock files, and the user should consent each time:
For the rest, tell the user which command fits their intent and let them run it these deploy to the workspace, overwrite local files, or launch a long-running server, so the user should consent each time:
| Command | Description |
|---------|-------------|
| \`wmill app dev\` | Start dev server with live reload (see the \`preview\` skill for the full open-the-app-in-the-IDE-pane procedure). |
| \`wmill app generate-agents\` | Refresh AGENTS.md and DATATABLES.md |
| \`wmill generate-metadata\` | Generate lock files for backend runnables |
| \`wmill sync push\` | Deploy app to Windmill |
| \`wmill sync pull\` | Pull latest from Windmill |
@@ -6456,12 +6467,12 @@ List all queues with their metrics
### refresh
Refresh wmill-managed project files (AGENTS.cli.md, skills, tsconfig.wmill.json)
Refresh wmill-managed project files (AGENTS.wmill.md, skills, tsconfig.wmill.json)
**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: append the @AGENTS.cli.md include to an existing AGENTS.md / CLAUDE.md without prompting. Without it, a non-interactive run leaves an unlinked file untouched.
- \`refresh prompts\` - Refresh AGENTS.wmill.md and managed skills. User-owned AGENTS.md and CLAUDE.md are never overwritten unless you opt in.
- \`--yes\` - Non-interactive: append the @AGENTS.wmill.md include to an existing AGENTS.md / CLAUDE.md without prompting. Without it, a non-interactive run leaves an unlinked file untouched.
- \`refresh tsconfig\` - Refresh the wmill-managed tsconfig.wmill.json (and Deno import map for Deno projects)
- \`--yes\` - Non-interactive: wire an existing custom tsconfig.json/deno.json to the managed file without prompting (a previously-generated config is always migrated automatically).
@@ -6752,7 +6763,8 @@ workspace related commands
- \`--create-workspace-name <workspace_name:string>\` - Specify the workspace name. Ignored if --create is not specified or the workspace already exists. Will default to the workspace id.
- \`--color <color:string>\` - Workspace color (hex code, e.g. #ff0000)
- \`--datatable-behavior <behavior:string>\` - How to handle datatables: skip, schema_only, or schema_and_data (default: interactive prompt)
- \`-y --yes\` - Skip interactive prompts (defaults datatable behavior to 'skip')
- \`--from-branch <branch:string>\` - Non-interactive override for the 'turn my current working branch into the fork' workflow: base the fork on <branch> (its bound workspace is the parent) and rename the current branch onto wm-fork/<branch>/<id>. Usually unneeded — from a working branch \`wmill workspace fork\` offers this interactively; from a base branch it creates a fresh fork branch.
- \`-y --yes\` - Skip interactive prompts (defaults datatable behavior to 'skip'). On a non-base branch, requires --from-branch since the base branch can't be prompted for.
- \`workspace delete-fork <fork_name:string>\` - Delete a forked workspace and git branch
- \`-y --yes\` - Skip confirmation prompt
- \`workspace merge\` - Compare and deploy changes between a fork and its parent workspace
+78 -12
View File
@@ -1,8 +1,11 @@
import { cp, mkdir, readdir, stat, writeFile } from "node:fs/promises";
import { cp, mkdir, readdir, rm, stat, writeFile } from "node:fs/promises";
import { readTextFile } from "../utils/utils.ts";
import { join } from "node:path";
import {
AGENTS_CLI_INCLUDE_LINE,
AGENTS_WMILL_FILENAME,
AGENTS_WMILL_INCLUDE_LINE,
LEGACY_AGENTS_CLI_FILENAME,
LEGACY_AGENTS_CLI_INCLUDE_LINE,
generateAgentsCliMdContent,
generateAgentsMdSkeleton,
} from "./core.ts";
@@ -25,7 +28,7 @@ type ResolvedSkillMetadata = SkillMetadata & {
/**
* 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).
* (`@AGENTS.wmill.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.
@@ -45,13 +48,13 @@ export interface WriteAiGuidanceOptions {
nonDottedPaths?: boolean;
/** Skill source override (testing / source-of-truth bundling). */
skillsSourcePath?: string;
/** AGENTS.cli.md source override (testing). */
/** AGENTS.wmill.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
* `@AGENTS.wmill.md` reference. Callers are expected to prompt the user; if
* omitted, the writer defaults to `append` (non-destructive).
*/
resolveAgentsMdMigration?: () => Promise<AgentsMdMigration>;
@@ -64,6 +67,12 @@ export interface WriteAiGuidanceResult {
claudeCreated: boolean;
claudeMigration: ReconcileOutcome;
skillCount: number;
/**
* True when a legacy `AGENTS.cli.md` was found and removed (its content is
* superseded by `AGENTS.wmill.md`, and any `@AGENTS.cli.md` includes were
* rewritten to `@AGENTS.wmill.md`).
*/
legacyManagedRemoved: boolean;
}
export const WMILL_INIT_AI_SKILLS_SOURCE_ENV = "WMILL_INIT_AI_SKILLS_SOURCE";
@@ -94,7 +103,7 @@ export async function writeAiGuidanceFiles(
? await readSkillMetadataFromDirectory(options.skillsSourcePath)
: getGeneratedSkillMetadata();
// AGENTS.cli.md — always (re)written, this is the managed file.
// AGENTS.wmill.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 =
@@ -105,23 +114,29 @@ export async function writeAiGuidanceFiles(
rawAgentsCliContent,
currentPromptsHash(nonDottedPaths)
);
const agentsCliPath = join(options.targetDir, "AGENTS.cli.md");
const agentsCliPath = join(options.targetDir, AGENTS_WMILL_FILENAME);
await writeFile(agentsCliPath, agentsCliContent, "utf8");
const agentsCliWritten = true;
// Migrate the legacy `AGENTS.cli.md`: rewrite `@AGENTS.cli.md` includes in
// user-owned files to `@AGENTS.wmill.md`, then remove the stale managed
// file. Done before reconciliation so the rewritten include reads as
// "already-linked" rather than triggering a duplicate append.
const legacyManagedRemoved = await migrateLegacyManagedFile(options.targetDir);
// 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
// 1. doesn't exist → create skeleton (which already includes @AGENTS.wmill.md).
// 2. exists and already references @AGENTS.wmill.md → leave alone.
// 3. exists but doesn't reference @AGENTS.wmill.md → ask caller via
// resolveMigration (defaults to append).
const agentsMdResult = await reconcileIncludingFile({
path: join(options.targetDir, "AGENTS.md"),
includeLine: AGENTS_CLI_INCLUDE_LINE,
includeLine: AGENTS_WMILL_INCLUDE_LINE,
skeleton: generateAgentsMdSkeleton(),
resolveMigration,
});
@@ -153,9 +168,60 @@ export async function writeAiGuidanceFiles(
claudeCreated: claudeMdResult.created,
claudeMigration: claudeMdResult.migration,
skillCount: skillMetadata.length,
legacyManagedRemoved,
};
}
/**
* One-time migration from the old managed filename (`AGENTS.cli.md`) to
* `AGENTS.wmill.md`:
*
* 1. Rewrite the `@AGENTS.cli.md` include token → `@AGENTS.wmill.md` in
* AGENTS.md and CLAUDE.md (only when it appears as a standalone
* whitespace-delimited token, so lookalikes like `@AGENTS.cli.md.backup`
* are left intact).
* 2. Delete the stale `AGENTS.cli.md` — its content is fully superseded by
* the freshly written `AGENTS.wmill.md`.
*
* Returns true when a legacy `AGENTS.cli.md` was present and removed.
*/
async function migrateLegacyManagedFile(targetDir: string): Promise<boolean> {
for (const fileName of ["AGENTS.md", "CLAUDE.md"]) {
const filePath = join(targetDir, fileName);
const existing = await readTextFile(filePath).catch(() => null);
if (existing == null) continue;
const rewritten = rewriteIncludeToken(
existing,
LEGACY_AGENTS_CLI_INCLUDE_LINE,
AGENTS_WMILL_INCLUDE_LINE
);
if (rewritten !== existing) {
await writeFile(filePath, rewritten, "utf8");
}
}
const legacyPath = join(targetDir, LEGACY_AGENTS_CLI_FILENAME);
const legacyExists = (await stat(legacyPath).catch(() => null)) != null;
if (legacyExists) {
await rm(legacyPath, { force: true });
}
return legacyExists;
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
/**
* Replace `from` with `to` only where `from` appears as a standalone
* whitespace-delimited token. Line endings and surrounding content are
* preserved (`\s` in the lookahead matches `\r`), so a CRLF file stays CRLF.
*/
function rewriteIncludeToken(content: string, from: string, to: string): string {
const re = new RegExp(`(?<=^|\\s)${escapeRegExp(from)}(?=\\s|$)`, "gm");
return content.replace(re, to);
}
function cacheOnce(
resolver: (() => Promise<AgentsMdMigration>) | undefined
): (() => Promise<AgentsMdMigration>) | undefined {
@@ -212,7 +278,7 @@ function referencesIncludeLine(content: string, includeLine: string): boolean {
// line by itself: our own CLAUDE.md default is `Instructions are in
// @AGENTS.md` (one sentence), and a strict equality check made `wmill
// refresh prompts` re-prompt every run on files wmill itself wrote.
// Skipping comment-bearing lines keeps `<!-- @AGENTS.cli.md -->` from
// Skipping comment-bearing lines keeps `<!-- @AGENTS.wmill.md -->` from
// false-positiving.
for (const line of content.split(/\r?\n/)) {
const trimmed = line.trim();
+1 -1
View File
@@ -302,7 +302,7 @@ async function main() {
return response;
});
// Warn (one line) if AGENTS.cli.md predates this CLI's prompts bundle.
// Warn (one line) if AGENTS.wmill.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)) {
+27
View File
@@ -20,6 +20,33 @@ export function getCurrentGitBranch(): string | null {
}
}
/** Whether a local branch with this exact name exists. */
export function gitBranchExists(branchName: string): boolean {
const r = spawnSync(
"git",
["show-ref", "--verify", "--quiet", `refs/heads/${branchName}`],
{ stdio: "pipe" },
);
return r.status === 0;
}
/**
* Rename the currently checked-out branch (`git branch -m <newName>`). Used by
* `wmill workspace fork --from-branch` to turn an existing working branch into
* the `wm-fork/<base>/<id>` fork branch in place, preserving its commits.
*/
export function renameCurrentGitBranch(newName: string): void {
const r = spawnSync("git", ["branch", "-m", newName], {
encoding: "utf8",
stdio: "pipe",
});
if ((r.status ?? 1) !== 0) {
throw new Error(
`git branch -m ${newName} failed (exit ${r.status}): ${r.stderr ?? ""}`,
);
}
}
export function getOriginalBranchForWorkspaceForks(branchName: string | null): string | null {
if (!branchName || !branchName.startsWith(WM_FORK_PREFIX)) {
return null
+75
View File
@@ -0,0 +1,75 @@
import { describe, expect, test } from "bun:test";
import {
branchToForkId,
validateForkWorkspaceId,
} from "../src/commands/workspace/fork.ts";
describe("branchToForkId — branch name → fork workspace id slug", () => {
test.each<[string, string]>([
["feature-x", "feature-x"],
["my_feature", "my_feature"],
// `/` (common in branch names) must not survive — it would break the
// wm-fork/<base>/<id> branch-name parsing.
["feat/foo", "feat-foo"],
["feature/JIRA-123", "feature-JIRA-123"],
["a/b/c", "a-b-c"],
// Other invalid characters collapse to a single dash.
["hot fix!", "hot-fix"],
["weird@@name", "weird-name"],
// Leading/trailing separators are trimmed.
["/leading", "leading"],
["trailing/", "trailing"],
["--dashes--", "dashes"],
// Degenerate input falls back to a usable id.
["///", "fork"],
["", "fork"],
])("%p → %p", (branch, expected) => {
expect(branchToForkId(branch)).toBe(expected);
});
test("result never contains a slash (would break fork branch parsing)", () => {
for (const branch of ["a/b", "x/y/z", "feat/foo/bar"]) {
expect(branchToForkId(branch)).not.toContain("/");
}
});
test("caps the slug so wm-fork-<slug> stays within the backend's 50-char limit", () => {
const long = "feature/TICKET-1234-" + "a".repeat(80);
const slug = branchToForkId(long);
expect(slug.length).toBeLessThanOrEqual(42);
// The full id the backend validates is `wm-fork-<slug>`.
expect(`wm-fork-${slug}`.length).toBeLessThanOrEqual(50);
// Truncation must not leave a trailing dash.
expect(slug.endsWith("-")).toBe(false);
});
});
describe("validateForkWorkspaceId — mirrors backend validate_fork_workspace_id", () => {
test("accepts a normal slugged id", () => {
expect(() => validateForkWorkspaceId("wm-fork-feature-x")).not.toThrow();
});
test.each<[string, string]>([
["too long (> 50 chars)", "wm-fork-" + "a".repeat(60)],
["ends with '.'", "wm-fork-foo."],
["ends with '.lock'", "wm-fork-foo.lock"],
["contains '..'", "wm-fork-foo..bar"],
["contains '//'", "wm-fork-foo//bar"],
["contains a space", "wm-fork-foo bar"],
["contains a forbidden char", "wm-fork-foo~bar"],
])("rejects: %s", (_label, id) => {
expect(() => validateForkWorkspaceId(id)).toThrow();
});
test("a branchToForkId slug always passes validation (with the prefix)", () => {
for (const branch of [
"feat/foo",
"feature/TICKET-1234-" + "a".repeat(80),
"weird@@name",
"///",
]) {
const id = `wm-fork-${branchToForkId(branch)}`;
expect(() => validateForkWorkspaceId(id)).not.toThrow();
}
});
});
+147 -28
View File
@@ -143,7 +143,7 @@ Copied from source bundle.
});
});
test("AGENTS.cli.md gets the skills reference from copied directory names", async () => {
test("AGENTS.wmill.md gets the skills reference from copied directory names", async () => {
await withTempDir(async (tempDir) => {
const sourceSkillsDir = join(tempDir, "source-skills");
await writeSkill(
@@ -163,7 +163,7 @@ Copied from source bundle.
skillsSourcePath: sourceSkillsDir,
});
const agentsCli = await readFile(join(tempDir, "AGENTS.cli.md"), "utf8");
const agentsCli = await readFile(join(tempDir, "AGENTS.wmill.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/ —
@@ -172,7 +172,7 @@ Copied from source bundle.
});
});
test("AGENTS.cli.md and CLAUDE.md are written even if skills creation fails", async () => {
test("AGENTS.wmill.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");
@@ -181,11 +181,11 @@ Copied from source bundle.
writeAiGuidanceFiles({ targetDir: tempDir })
).rejects.toThrow();
expect(await readFile(join(tempDir, "AGENTS.cli.md"), "utf8")).toContain(
expect(await readFile(join(tempDir, "AGENTS.wmill.md"), "utf8")).toContain(
".agents/skills/"
);
expect(await readFile(join(tempDir, "AGENTS.md"), "utf8")).toContain(
"@AGENTS.cli.md"
"@AGENTS.wmill.md"
);
expect(await readFile(join(tempDir, "CLAUDE.md"), "utf8")).toContain(
"@AGENTS.md"
@@ -195,20 +195,20 @@ Copied from source bundle.
});
describe("writeAiGuidanceFiles — AGENTS.md reconciliation", () => {
test("creates a skeleton AGENTS.md (with @AGENTS.cli.md include) when none exists", async () => {
test("creates a skeleton AGENTS.md (with @AGENTS.wmill.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");
expect(agentsMd).toContain("@AGENTS.wmill.md");
});
});
test("leaves an existing AGENTS.md alone when it already references @AGENTS.cli.md", async () => {
test("leaves an existing AGENTS.md alone when it already references @AGENTS.wmill.md", async () => {
await withTempDir(async (tempDir) => {
const original = "# My AGENTS.md\n\nlocal stuff\n\n@AGENTS.cli.md\n";
const original = "# My AGENTS.md\n\nlocal stuff\n\n@AGENTS.wmill.md\n";
await writeFile(join(tempDir, "AGENTS.md"), original, "utf8");
const result = await writeAiGuidanceFiles({ targetDir: tempDir });
@@ -219,7 +219,7 @@ describe("writeAiGuidanceFiles — AGENTS.md reconciliation", () => {
});
});
test("appends @AGENTS.cli.md when the resolver returns 'append'", async () => {
test("appends @AGENTS.wmill.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");
@@ -233,7 +233,7 @@ describe("writeAiGuidanceFiles — AGENTS.md reconciliation", () => {
const updated = await readFile(join(tempDir, "AGENTS.md"), "utf8");
expect(updated).toStartWith(original);
expect(updated).toContain("@AGENTS.cli.md");
expect(updated).toContain("@AGENTS.wmill.md");
});
});
@@ -251,7 +251,7 @@ describe("writeAiGuidanceFiles — AGENTS.md reconciliation", () => {
const updated = await readFile(join(tempDir, "AGENTS.md"), "utf8");
expect(updated).not.toBe(original);
expect(updated).toContain("@AGENTS.cli.md");
expect(updated).toContain("@AGENTS.wmill.md");
});
});
@@ -281,7 +281,7 @@ describe("writeAiGuidanceFiles — AGENTS.md reconciliation", () => {
const updated = await readFile(join(tempDir, "AGENTS.md"), "utf8");
expect(updated).toStartWith(original);
expect(updated).toContain("@AGENTS.cli.md");
expect(updated).toContain("@AGENTS.wmill.md");
});
});
});
@@ -385,19 +385,78 @@ describe("writeAiGuidanceFiles — CLAUDE.md reconciliation", () => {
});
});
describe("writeAiGuidanceFiles — legacy AGENTS.cli.md migration", () => {
test("removes a legacy AGENTS.cli.md and rewrites the @AGENTS.cli.md include", async () => {
await withTempDir(async (tempDir) => {
// Simulate a project initialized by an older CLI.
await writeFile(
join(tempDir, "AGENTS.cli.md"),
"# old managed file\n",
"utf8"
);
await writeFile(
join(tempDir, "AGENTS.md"),
"# My AGENTS.md\n\nlocal stuff\n\n@AGENTS.cli.md\n",
"utf8"
);
const result = await writeAiGuidanceFiles({ targetDir: tempDir });
// Legacy file is gone; the new managed file is present.
expect(result.legacyManagedRemoved).toBe(true);
await expect(
readFile(join(tempDir, "AGENTS.cli.md"), "utf8")
).rejects.toThrow();
expect(
await readFile(join(tempDir, "AGENTS.wmill.md"), "utf8")
).toContain(".agents/skills/");
// The include was rewritten in place (so it reads as already-linked,
// not a duplicate append).
const agentsMd = await readFile(join(tempDir, "AGENTS.md"), "utf8");
expect(agentsMd).toContain("@AGENTS.wmill.md");
expect(agentsMd).not.toContain("@AGENTS.cli.md");
expect(result.agentsMigration).toBe("already-linked");
});
});
test("legacyManagedRemoved is false when there is no legacy file", async () => {
await withTempDir(async (tempDir) => {
const result = await writeAiGuidanceFiles({ targetDir: tempDir });
expect(result.legacyManagedRemoved).toBe(false);
});
});
test("does not rewrite a @AGENTS.cli.md.backup lookalike token", async () => {
await withTempDir(async (tempDir) => {
await writeFile(
join(tempDir, "AGENTS.md"),
"see @AGENTS.cli.md.backup\n\n@AGENTS.wmill.md\n",
"utf8"
);
await writeAiGuidanceFiles({ targetDir: tempDir });
const agentsMd = await readFile(join(tempDir, "AGENTS.md"), "utf8");
// The standalone backup reference (a different file) is preserved.
expect(agentsMd).toContain("@AGENTS.cli.md.backup");
});
});
});
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"],
["bare line", "@AGENTS.wmill.md"],
["between blank lines", "before\n\n@AGENTS.wmill.md\n\nafter"],
["leading whitespace then include", " @AGENTS.wmill.md\n"],
["CRLF line endings", "line one\r\n@AGENTS.wmill.md\r\nline three"],
// Mid-sentence include: this is how our own CLAUDE.md default looks
// ("Instructions are in @AGENTS.md"). A strict line-equality check made
// `wmill refresh prompts` re-prompt every run on files wmill wrote.
["mid-sentence include", "Instructions are in @AGENTS.cli.md\n"],
["mid-sentence include", "Instructions are in @AGENTS.wmill.md\n"],
// `>` blockquote prefix doesn't disable Claude's `@`-import expansion,
// so we treat it as a reference too.
["blockquoted include", "> @AGENTS.cli.md"],
["blockquoted include", "> @AGENTS.wmill.md"],
])("treats %s as a reference (no append)", async (_label, content) => {
await withTempDir(async (tempDir) => {
await writeFile(join(tempDir, "AGENTS.md"), content, "utf8");
@@ -408,11 +467,11 @@ describe("writeAiGuidanceFiles — referencesAgentsCli (via reconciliation)", ()
});
test.each([
["@AGENTS.cli.md.backup", "@AGENTS.cli.md.backup"],
["@AGENTS.cli.mdx", "@AGENTS.cli.mdx"],
["@AGENTS.wmill.md.backup", "@AGENTS.wmill.md.backup"],
["@AGENTS.wmill.mdx", "@AGENTS.wmill.mdx"],
["@AGENTS-cli-md (lookalike)", "@AGENTS-cli-md"],
["@AGENTS.cli.md without surrounding whitespace", "foo@AGENTS.cli.md"],
["commented-out include", "<!-- @AGENTS.cli.md -->"],
["@AGENTS.wmill.md without surrounding whitespace", "foo@AGENTS.wmill.md"],
["commented-out include", "<!-- @AGENTS.wmill.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");
@@ -426,10 +485,10 @@ describe("writeAiGuidanceFiles — referencesAgentsCli (via reconciliation)", ()
});
describe("prompts freshness — hash marker", () => {
test("AGENTS.cli.md written by writeAiGuidanceFiles carries a hash marker", async () => {
test("AGENTS.wmill.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 agentsCli = await readFile(join(tempDir, "AGENTS.wmill.md"), "utf8");
const hash = extractPromptsHash(agentsCli);
expect(hash).not.toBeNull();
expect(hash).toMatch(/^[0-9a-f]{12}$/);
@@ -441,7 +500,7 @@ describe("prompts freshness — hash marker", () => {
// 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");
const agentsCli = await readFile(join(tempDir, "AGENTS.wmill.md"), "utf8");
expect(extractPromptsHash(agentsCli)).toBe(currentPromptsHash(false));
});
});
@@ -516,9 +575,9 @@ describe("prompts freshness — additional invariants", () => {
test("warnIfPromptsStale writes to stderr (never stdout)", async () => {
await withTempDir(async (tempDir) => {
// Write a tampered AGENTS.cli.md so the freshness check trips.
// Write a tampered AGENTS.wmill.md so the freshness check trips.
await writeFile(
join(tempDir, "AGENTS.cli.md"),
join(tempDir, "AGENTS.wmill.md"),
"# Windmill CLI Agent Instructions\n<!-- wmill-prompts-hash: 000000000000 -->\nbody\n",
"utf8"
);
@@ -555,4 +614,64 @@ describe("prompts freshness — additional invariants", () => {
expect(stdoutJoined).not.toContain("out of date");
});
});
// Back-compat: a legacy AGENTS.cli.md is still hash-checked, and we do NOT
// warn merely because of the old filename — only when it's actually stale.
test("warnIfPromptsStale stays silent for an up-to-date legacy AGENTS.cli.md", async () => {
await withTempDir(async (tempDir) => {
const content = injectPromptsHashMarker(
"# Windmill CLI Agent Instructions\nbody\n",
currentPromptsHash(false)
);
await writeFile(join(tempDir, "AGENTS.cli.md"), content, "utf8");
const stderrWrites: string[] = [];
const originalStderr = process.stderr.write.bind(process.stderr);
// @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.stderr.write = originalStderr;
}
expect(stderrWrites.join("")).toBe("");
});
});
test("warnIfPromptsStale warns (naming the legacy file) for a stale AGENTS.cli.md", async () => {
await withTempDir(async (tempDir) => {
await writeFile(
join(tempDir, "AGENTS.cli.md"),
"# Windmill CLI Agent Instructions\n<!-- wmill-prompts-hash: 000000000000 -->\nbody\n",
"utf8"
);
const stderrWrites: string[] = [];
const originalStderr = process.stderr.write.bind(process.stderr);
// @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.stderr.write = originalStderr;
}
const out = stderrWrites.join("");
expect(out).toContain("out of date");
expect(out).toContain("AGENTS.cli.md");
});
});
});
@@ -430,12 +430,12 @@ List all queues with their metrics
### refresh
Refresh wmill-managed project files (AGENTS.cli.md, skills, tsconfig.wmill.json)
Refresh wmill-managed project files (AGENTS.wmill.md, skills, tsconfig.wmill.json)
**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: append the @AGENTS.cli.md include to an existing AGENTS.md / CLAUDE.md without prompting. Without it, a non-interactive run leaves an unlinked file untouched.
- `refresh prompts` - Refresh AGENTS.wmill.md and managed skills. User-owned AGENTS.md and CLAUDE.md are never overwritten unless you opt in.
- `--yes` - Non-interactive: append the @AGENTS.wmill.md include to an existing AGENTS.md / CLAUDE.md without prompting. Without it, a non-interactive run leaves an unlinked file untouched.
- `refresh tsconfig` - Refresh the wmill-managed tsconfig.wmill.json (and Deno import map for Deno projects)
- `--yes` - Non-interactive: wire an existing custom tsconfig.json/deno.json to the managed file without prompting (a previously-generated config is always migrated automatically).
@@ -726,7 +726,8 @@ workspace related commands
- `--create-workspace-name <workspace_name:string>` - Specify the workspace name. Ignored if --create is not specified or the workspace already exists. Will default to the workspace id.
- `--color <color:string>` - Workspace color (hex code, e.g. #ff0000)
- `--datatable-behavior <behavior:string>` - How to handle datatables: skip, schema_only, or schema_and_data (default: interactive prompt)
- `-y --yes` - Skip interactive prompts (defaults datatable behavior to 'skip')
- `--from-branch <branch:string>` - Non-interactive override for the 'turn my current working branch into the fork' workflow: base the fork on <branch> (its bound workspace is the parent) and rename the current branch onto wm-fork/<branch>/<id>. Usually unneeded — from a working branch `wmill workspace fork` offers this interactively; from a base branch it creates a fresh fork branch.
- `-y --yes` - Skip interactive prompts (defaults datatable behavior to 'skip'). On a non-base branch, requires --from-branch since the base branch can't be prompted for.
- `workspace delete-fork <fork_name:string>` - Delete a forked workspace and git branch
- `-y --yes` - Skip confirmation prompt
- `workspace merge` - Compare and deploy changes between a fork and its parent workspace
+18 -7
View File
@@ -2981,12 +2981,12 @@ List all queues with their metrics
### refresh
Refresh wmill-managed project files (AGENTS.cli.md, skills, tsconfig.wmill.json)
Refresh wmill-managed project files (AGENTS.wmill.md, skills, tsconfig.wmill.json)
**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: append the @AGENTS.cli.md include to an existing AGENTS.md / CLAUDE.md without prompting. Without it, a non-interactive run leaves an unlinked file untouched.
- \`refresh prompts\` - Refresh AGENTS.wmill.md and managed skills. User-owned AGENTS.md and CLAUDE.md are never overwritten unless you opt in.
- \`--yes\` - Non-interactive: append the @AGENTS.wmill.md include to an existing AGENTS.md / CLAUDE.md without prompting. Without it, a non-interactive run leaves an unlinked file untouched.
- \`refresh tsconfig\` - Refresh the wmill-managed tsconfig.wmill.json (and Deno import map for Deno projects)
- \`--yes\` - Non-interactive: wire an existing custom tsconfig.json/deno.json to the managed file without prompting (a previously-generated config is always migrated automatically).
@@ -3277,7 +3277,8 @@ workspace related commands
- \`--create-workspace-name <workspace_name:string>\` - Specify the workspace name. Ignored if --create is not specified or the workspace already exists. Will default to the workspace id.
- \`--color <color:string>\` - Workspace color (hex code, e.g. #ff0000)
- \`--datatable-behavior <behavior:string>\` - How to handle datatables: skip, schema_only, or schema_and_data (default: interactive prompt)
- \`-y --yes\` - Skip interactive prompts (defaults datatable behavior to 'skip')
- \`--from-branch <branch:string>\` - Non-interactive override for the 'turn my current working branch into the fork' workflow: base the fork on <branch> (its bound workspace is the parent) and rename the current branch onto wm-fork/<branch>/<id>. Usually unneeded — from a working branch \`wmill workspace fork\` offers this interactively; from a base branch it creates a fresh fork branch.
- \`-y --yes\` - Skip interactive prompts (defaults datatable behavior to 'skip'). On a non-base branch, requires --from-branch since the base branch can't be prompted for.
- \`workspace delete-fork <fork_name:string>\` - Delete a forked workspace and git branch
- \`-y --yes\` - Skip confirmation prompt
- \`workspace merge\` - Compare and deploy changes between a fork and its parent workspace
@@ -3451,6 +3452,10 @@ import Stripe from "stripe";
import { someFunction } from "some-package";
\`\`\`
## Prefer \`//native\` when the runtime allows it
If a script only needs \`fetch\` and the JavaScript standard library — including when it uses \`windmill-client\` — prefer making it a **native** script: add \`//native\` as the first line and write it with the \`write-script-bunnative\` skill. Native scripts run on a lightweight V8 isolate, start faster, and parallelize heavily. \`windmill-client\` works on the native worker (its calls go over \`fetch\`), so needing the Windmill client is **not** a reason to avoid \`//native\`. Use the regular \`bun\` language only when the code (or a dependency) needs Node/Bun runtime APIs — \`node:*\` modules, the filesystem, child processes, or native addons.
## Windmill Client
Import the windmill client for platform interactions:
@@ -3459,7 +3464,9 @@ Import the windmill client for platform interactions:
import * as wmill from "windmill-client";
\`\`\`
See the SDK documentation for available methods.
**Prefer \`windmill-client\` over raw \`fetch\` for anything that talks to Windmill** — reading resources/variables/states, running scripts and flows, S3 object operations, etc. It handles auth, the workspace, and the base URL for you, so you don't hand-roll URLs or tokens. Reserve \`fetch\` for calling *external* HTTP APIs that aren't Windmill.
The full \`windmill-client\` API reference (every exported function and its signature) is included in this skill below — consult it for the exact method to use instead of guessing or falling back to \`fetch\`.
## Preprocessor Scripts
@@ -3575,7 +3582,9 @@ export async function main(url: string) {
## Windmill Client
\`windmill-client\` is available for Windmill-specific primitives such as the S3 helpers below (\`loadS3File\`, \`loadS3FileStream\`, \`writeS3File\`, \`S3Object\`). Use \`fetch\` for plain HTTP.
\`windmill-client\` works on the native worker (its calls go over \`fetch\`), so use it as the **preferred way to talk to Windmill** — reading resources/variables/states, running scripts and flows, and the S3 helpers below (\`loadS3File\`, \`loadS3FileStream\`, \`writeS3File\`, \`S3Object\`). It handles auth, the workspace, and the base URL for you. Reserve raw \`fetch\` for calling *external* HTTP APIs that aren't Windmill.
The full \`windmill-client\` API reference (every exported function and its signature) is included in this skill below — consult it for the exact method instead of hand-rolling a \`fetch\` against the Windmill API.
## Preprocessor Scripts
@@ -3740,7 +3749,9 @@ Import the windmill client for platform interactions:
import * as wmill from "windmill-client";
\`\`\`
See the SDK documentation for available methods.
**Prefer \`windmill-client\` over raw \`fetch\` for anything that talks to Windmill** — reading resources/variables/states, running scripts and flows, S3 object operations, etc. It handles auth, the workspace, and the base URL for you. Reserve \`fetch\` for calling *external* HTTP APIs that aren't Windmill.
The full \`windmill-client\` API reference (every exported function and its signature) is included in this skill below — consult it for the exact method instead of guessing or falling back to \`fetch\`.
## Preprocessor Scripts
+13 -3
View File
@@ -162,6 +162,10 @@ import Stripe from "stripe";
import { someFunction } from "some-package";
```
## Prefer `//native` when the runtime allows it
If a script only needs `fetch` and the JavaScript standard library — including when it uses `windmill-client` — prefer making it a **native** script: add `//native` as the first line and write it with the `write-script-bunnative` skill. Native scripts run on a lightweight V8 isolate, start faster, and parallelize heavily. `windmill-client` works on the native worker (its calls go over `fetch`), so needing the Windmill client is **not** a reason to avoid `//native`. Use the regular `bun` language only when the code (or a dependency) needs Node/Bun runtime APIs — `node:*` modules, the filesystem, child processes, or native addons.
## Windmill Client
Import the windmill client for platform interactions:
@@ -170,7 +174,9 @@ Import the windmill client for platform interactions:
import * as wmill from "windmill-client";
```
See the SDK documentation for available methods.
**Prefer `windmill-client` over raw `fetch` for anything that talks to Windmill** — reading resources/variables/states, running scripts and flows, S3 object operations, etc. It handles auth, the workspace, and the base URL for you, so you don't hand-roll URLs or tokens. Reserve `fetch` for calling *external* HTTP APIs that aren't Windmill.
The full `windmill-client` API reference (every exported function and its signature) is included in this skill below — consult it for the exact method to use instead of guessing or falling back to `fetch`.
## Preprocessor Scripts
@@ -286,7 +292,9 @@ export async function main(url: string) {
## Windmill Client
`windmill-client` is available for Windmill-specific primitives such as the S3 helpers below (`loadS3File`, `loadS3FileStream`, `writeS3File`, `S3Object`). Use `fetch` for plain HTTP.
`windmill-client` works on the native worker (its calls go over `fetch`), so use it as the **preferred way to talk to Windmill** — reading resources/variables/states, running scripts and flows, and the S3 helpers below (`loadS3File`, `loadS3FileStream`, `writeS3File`, `S3Object`). It handles auth, the workspace, and the base URL for you. Reserve raw `fetch` for calling *external* HTTP APIs that aren't Windmill.
The full `windmill-client` API reference (every exported function and its signature) is included in this skill below — consult it for the exact method instead of hand-rolling a `fetch` against the Windmill API.
## Preprocessor Scripts
@@ -451,7 +459,9 @@ Import the windmill client for platform interactions:
import * as wmill from "windmill-client";
```
See the SDK documentation for available methods.
**Prefer `windmill-client` over raw `fetch` for anything that talks to Windmill** — reading resources/variables/states, running scripts and flows, S3 object operations, etc. It handles auth, the workspace, and the base URL for you. Reserve `fetch` for calling *external* HTTP APIs that aren't Windmill.
The full `windmill-client` API reference (every exported function and its signature) is included in this skill below — consult it for the exact method instead of guessing or falling back to `fetch`.
## Preprocessor Scripts
@@ -435,12 +435,12 @@ List all queues with their metrics
### refresh
Refresh wmill-managed project files (AGENTS.cli.md, skills, tsconfig.wmill.json)
Refresh wmill-managed project files (AGENTS.wmill.md, skills, tsconfig.wmill.json)
**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: append the @AGENTS.cli.md include to an existing AGENTS.md / CLAUDE.md without prompting. Without it, a non-interactive run leaves an unlinked file untouched.
- `refresh prompts` - Refresh AGENTS.wmill.md and managed skills. User-owned AGENTS.md and CLAUDE.md are never overwritten unless you opt in.
- `--yes` - Non-interactive: append the @AGENTS.wmill.md include to an existing AGENTS.md / CLAUDE.md without prompting. Without it, a non-interactive run leaves an unlinked file untouched.
- `refresh tsconfig` - Refresh the wmill-managed tsconfig.wmill.json (and Deno import map for Deno projects)
- `--yes` - Non-interactive: wire an existing custom tsconfig.json/deno.json to the managed file without prompting (a previously-generated config is always migrated automatically).
@@ -731,7 +731,8 @@ workspace related commands
- `--create-workspace-name <workspace_name:string>` - Specify the workspace name. Ignored if --create is not specified or the workspace already exists. Will default to the workspace id.
- `--color <color:string>` - Workspace color (hex code, e.g. #ff0000)
- `--datatable-behavior <behavior:string>` - How to handle datatables: skip, schema_only, or schema_and_data (default: interactive prompt)
- `-y --yes` - Skip interactive prompts (defaults datatable behavior to 'skip')
- `--from-branch <branch:string>` - Non-interactive override for the 'turn my current working branch into the fork' workflow: base the fork on <branch> (its bound workspace is the parent) and rename the current branch onto wm-fork/<branch>/<id>. Usually unneeded — from a working branch `wmill workspace fork` offers this interactively; from a base branch it creates a fresh fork branch.
- `-y --yes` - Skip interactive prompts (defaults datatable behavior to 'skip'). On a non-base branch, requires --from-branch since the base branch can't be prompted for.
- `workspace delete-fork <fork_name:string>` - Delete a forked workspace and git branch
- `-y --yes` - Skip confirmation prompt
- `workspace merge` - Compare and deploy changes between a fork and its parent workspace
@@ -126,7 +126,7 @@ The runnable ID is the filename without extension. For example, `get_user.ts` cr
| C# | `.cs` | `myFunc.cs` |
| Java | `.java` | `myFunc.java` |
After creating a runnable, tell the user they can generate lock files by running:
After creating a runnable, offer to generate its lock files as a one-sentence next step (e.g. "Want me to generate the lock files?") and run it yourself once they agree — don't just name the command and wait. If the user already asked you to finish/lock the app, run it directly. It writes local lock files (not a deploy), so offer rather than running silently:
```bash
wmill generate-metadata
```
@@ -217,15 +217,16 @@ data:
## CLI Commands
`wmill app new` is the exception: you run it yourself, with flags, per the "Creating a Raw App" section above.
Two commands you run yourself, not the user:
- `wmill app new` — run it with flags, per the "Creating a Raw App" section above.
- `wmill generate-metadata` — generates local lock files; offer it and run it on consent, per "After creating a runnable" above (it writes local lock files, not a deploy).
For everything else, tell the user which command fits their intent and let them run it — these touch the workspace or local lock files, and the user should consent each time:
For the rest, tell the user which command fits their intent and let them run it — these deploy to the workspace, overwrite local files, or launch a long-running server, so the user should consent each time:
| Command | Description |
|---------|-------------|
| `wmill app dev` | Start dev server with live reload (see the `preview` skill for the full open-the-app-in-the-IDE-pane procedure). |
| `wmill app generate-agents` | Refresh AGENTS.md and DATATABLES.md |
| `wmill generate-metadata` | Generate lock files for backend runnables |
| `wmill sync push` | Deploy app to Windmill |
| `wmill sync pull` | Pull latest from Windmill |
@@ -44,7 +44,7 @@ Once the flow has real content, **offer** to open the visual preview as a one-se
## CLI Commands — running, previewing, deploying
After writing, tell the user which command fits what they want to do:
After writing, act on the user's intent instead of just listing commands. Run the safe, non-deploying command yourself when it fits (`wmill flow preview` — see "After writing — offer to run, don't wait passively" below); only *name* the commands that deploy or rewrite files (`wmill sync push`, `wmill generate-metadata`) so the user can approve them. The options:
- `wmill flow preview <flow_path>`**default when iterating on a local flow.** Runs the local `flow.yaml` against local inline scripts without deploying. Add `--remote` to use deployed workspace scripts for PathScript steps instead of local files. Add `--step <step_id>` to run only one module in isolation (see "Single-step vs whole-flow preview" below).
- `wmill flow run <path>` — runs the flow **already deployed** in the workspace. Use only when the user explicitly wants to test the deployed version, not local edits.
@@ -78,6 +78,10 @@ import Stripe from "stripe";
import { someFunction } from "some-package";
```
## Prefer `//native` when the runtime allows it
If a script only needs `fetch` and the JavaScript standard library — including when it uses `windmill-client` — prefer making it a **native** script: add `//native` as the first line and write it with the `write-script-bunnative` skill. Native scripts run on a lightweight V8 isolate, start faster, and parallelize heavily. `windmill-client` works on the native worker (its calls go over `fetch`), so needing the Windmill client is **not** a reason to avoid `//native`. Use the regular `bun` language only when the code (or a dependency) needs Node/Bun runtime APIs — `node:*` modules, the filesystem, child processes, or native addons.
## Windmill Client
Import the windmill client for platform interactions:
@@ -86,7 +90,9 @@ Import the windmill client for platform interactions:
import * as wmill from "windmill-client";
```
See the SDK documentation for available methods.
**Prefer `windmill-client` over raw `fetch` for anything that talks to Windmill** — reading resources/variables/states, running scripts and flows, S3 object operations, etc. It handles auth, the workspace, and the base URL for you, so you don't hand-roll URLs or tokens. Reserve `fetch` for calling *external* HTTP APIs that aren't Windmill.
The full `windmill-client` API reference (every exported function and its signature) is included in this skill below — consult it for the exact method to use instead of guessing or falling back to `fetch`.
## Preprocessor Scripts
@@ -87,7 +87,9 @@ export async function main(url: string) {
## Windmill Client
`windmill-client` is available for Windmill-specific primitives such as the S3 helpers below (`loadS3File`, `loadS3FileStream`, `writeS3File`, `S3Object`). Use `fetch` for plain HTTP.
`windmill-client` works on the native worker (its calls go over `fetch`), so use it as the **preferred way to talk to Windmill** — reading resources/variables/states, running scripts and flows, and the S3 helpers below (`loadS3File`, `loadS3FileStream`, `writeS3File`, `S3Object`). It handles auth, the workspace, and the base URL for you. Reserve raw `fetch` for calling *external* HTTP APIs that aren't Windmill.
The full `windmill-client` API reference (every exported function and its signature) is included in this skill below — consult it for the exact method instead of hand-rolling a `fetch` against the Windmill API.
## Preprocessor Scripts
@@ -90,7 +90,9 @@ Import the windmill client for platform interactions:
import * as wmill from "windmill-client";
```
See the SDK documentation for available methods.
**Prefer `windmill-client` over raw `fetch` for anything that talks to Windmill** — reading resources/variables/states, running scripts and flows, S3 object operations, etc. It handles auth, the workspace, and the base URL for you. Reserve `fetch` for calling *external* HTTP APIs that aren't Windmill.
The full `windmill-client` API reference (every exported function and its signature) is included in this skill below — consult it for the exact method instead of guessing or falling back to `fetch`.
## Preprocessor Scripts
+1 -1
View File
@@ -39,7 +39,7 @@ Once the flow has real content, **offer** to open the visual preview as a one-se
## CLI Commands — running, previewing, deploying
After writing, tell the user which command fits what they want to do:
After writing, act on the user's intent instead of just listing commands. Run the safe, non-deploying command yourself when it fits (`wmill flow preview` — see "After writing — offer to run, don't wait passively" below); only *name* the commands that deploy or rewrite files (`wmill sync push`, `wmill generate-metadata`) so the user can approve them. The options:
- `wmill flow preview <flow_path>`**default when iterating on a local flow.** Runs the local `flow.yaml` against local inline scripts without deploying. Add `--remote` to use deployed workspace scripts for PathScript steps instead of local files. Add `--step <step_id>` to run only one module in isolation (see "Single-step vs whole-flow preview" below).
- `wmill flow run <path>` — runs the flow **already deployed** in the workspace. Use only when the user explicitly wants to test the deployed version, not local edits.
+5 -4
View File
@@ -121,7 +121,7 @@ The runnable ID is the filename without extension. For example, `get_user.ts` cr
| C# | `.cs` | `myFunc.cs` |
| Java | `.java` | `myFunc.java` |
After creating a runnable, tell the user they can generate lock files by running:
After creating a runnable, offer to generate its lock files as a one-sentence next step (e.g. "Want me to generate the lock files?") and run it yourself once they agree — don't just name the command and wait. If the user already asked you to finish/lock the app, run it directly. It writes local lock files (not a deploy), so offer rather than running silently:
```bash
wmill generate-metadata
```
@@ -212,15 +212,16 @@ data:
## CLI Commands
`wmill app new` is the exception: you run it yourself, with flags, per the "Creating a Raw App" section above.
Two commands you run yourself, not the user:
- `wmill app new` — run it with flags, per the "Creating a Raw App" section above.
- `wmill generate-metadata` — generates local lock files; offer it and run it on consent, per "After creating a runnable" above (it writes local lock files, not a deploy).
For everything else, tell the user which command fits their intent and let them run it — these touch the workspace or local lock files, and the user should consent each time:
For the rest, tell the user which command fits their intent and let them run it — these deploy to the workspace, overwrite local files, or launch a long-running server, so the user should consent each time:
| Command | Description |
|---------|-------------|
| `wmill app dev` | Start dev server with live reload (see the `preview` skill for the full open-the-app-in-the-IDE-pane procedure). |
| `wmill app generate-agents` | Refresh AGENTS.md and DATATABLES.md |
| `wmill generate-metadata` | Generate lock files for backend runnables |
| `wmill sync push` | Deploy app to Windmill |
| `wmill sync pull` | Pull latest from Windmill |
+4 -4
View File
@@ -1827,7 +1827,7 @@ CONTEXT7_REPO_NAME = "windmill-cli-docs"
def extract_agents_md_template() -> str:
"""Extract the AGENTS.cli.md template string from cli/src/guidance/core.ts.
"""Extract the AGENTS.wmill.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.
@@ -1844,7 +1844,7 @@ def extract_agents_md_template() -> str:
)
if not match:
raise RuntimeError(
f"Could not extract AGENTS.cli.md template from {core_ts_path}"
f"Could not extract AGENTS.wmill.md template from {core_ts_path}"
)
return _unescape_ts_template_literal(match.group(1))
@@ -1866,7 +1866,7 @@ 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.cli.md exactly as `wmill init` would, for the docs repo.
"""Render AGENTS.wmill.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
@@ -2010,7 +2010,7 @@ def generate_context7_repo(
skill_desc_map = build_skill_desc_map(skills)
# AGENTS.md — the managed CLI guidance (what `wmill init` writes as
# AGENTS.cli.md locally). Kept under the `AGENTS.md` filename here to
# AGENTS.wmill.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(
+7 -1
View File
@@ -38,6 +38,10 @@ import Stripe from "stripe";
import { someFunction } from "some-package";
```
## Prefer `//native` when the runtime allows it
If a script only needs `fetch` and the JavaScript standard library — including when it uses `windmill-client` — prefer making it a **native** script: add `//native` as the first line and write it with the `write-script-bunnative` skill. Native scripts run on a lightweight V8 isolate, start faster, and parallelize heavily. `windmill-client` works on the native worker (its calls go over `fetch`), so needing the Windmill client is **not** a reason to avoid `//native`. Use the regular `bun` language only when the code (or a dependency) needs Node/Bun runtime APIs — `node:*` modules, the filesystem, child processes, or native addons.
## Windmill Client
Import the windmill client for platform interactions:
@@ -46,7 +50,9 @@ Import the windmill client for platform interactions:
import * as wmill from "windmill-client";
```
See the SDK documentation for available methods.
**Prefer `windmill-client` over raw `fetch` for anything that talks to Windmill** — reading resources/variables/states, running scripts and flows, S3 object operations, etc. It handles auth, the workspace, and the base URL for you, so you don't hand-roll URLs or tokens. Reserve `fetch` for calling *external* HTTP APIs that aren't Windmill.
The full `windmill-client` API reference (every exported function and its signature) is included in this skill below — consult it for the exact method to use instead of guessing or falling back to `fetch`.
## Preprocessor Scripts
+3 -1
View File
@@ -47,7 +47,9 @@ export async function main(url: string) {
## Windmill Client
`windmill-client` is available for Windmill-specific primitives such as the S3 helpers below (`loadS3File`, `loadS3FileStream`, `writeS3File`, `S3Object`). Use `fetch` for plain HTTP.
`windmill-client` works on the native worker (its calls go over `fetch`), so use it as the **preferred way to talk to Windmill** — reading resources/variables/states, running scripts and flows, and the S3 helpers below (`loadS3File`, `loadS3FileStream`, `writeS3File`, `S3Object`). It handles auth, the workspace, and the base URL for you. Reserve raw `fetch` for calling *external* HTTP APIs that aren't Windmill.
The full `windmill-client` API reference (every exported function and its signature) is included in this skill below — consult it for the exact method instead of hand-rolling a `fetch` against the Windmill API.
## Preprocessor Scripts
+3 -1
View File
@@ -50,7 +50,9 @@ Import the windmill client for platform interactions:
import * as wmill from "windmill-client";
```
See the SDK documentation for available methods.
**Prefer `windmill-client` over raw `fetch` for anything that talks to Windmill** — reading resources/variables/states, running scripts and flows, S3 object operations, etc. It handles auth, the workspace, and the base URL for you. Reserve `fetch` for calling *external* HTTP APIs that aren't Windmill.
The full `windmill-client` API reference (every exported function and its signature) is included in this skill below — consult it for the exact method instead of guessing or falling back to `fetch`.
## Preprocessor Scripts