diff --git a/cli/src/commands/refresh/prompts.ts b/cli/src/commands/refresh/prompts.ts index 863ab4f9ff..0828342202 100644 --- a/cli/src/commands/refresh/prompts.ts +++ b/cli/src/commands/refresh/prompts.ts @@ -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 { } 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); diff --git a/cli/src/commands/refresh/refresh.ts b/cli/src/commands/refresh/refresh.ts index d1fea23af2..34b29663e3 100644 --- a/cli/src/commands/refresh/refresh.ts +++ b/cli/src/commands/refresh/refresh.ts @@ -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); diff --git a/cli/src/commands/refresh/tsconfig.ts b/cli/src/commands/refresh/tsconfig.ts index 10d9993d50..167b030cc3 100644 --- a/cli/src/commands/refresh/tsconfig.ts +++ b/cli/src/commands/refresh/tsconfig.ts @@ -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). diff --git a/cli/src/commands/workspace/fork.ts b/cli/src/commands/workspace/fork.ts index bf8d5e404e..5742d9ae68 100644 --- a/cli/src/commands/workspace/fork.ts +++ b/cli/src/commands/workspace/fork.ts @@ -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 , 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//). + // 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//). 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 ` instead). + */ +async function resolveWorkingBranchBase( + config: Awaited>, + opts: { yes?: boolean }, + currentBranch: string, +): Promise { + 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 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 .`, + ); + } + 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-`) 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//` 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-` 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>, +): string[] { + const workspaces = config.workspaces; + const branches = new Set(); + 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, +}; diff --git a/cli/src/commands/workspace/workspace.ts b/cli/src/commands/workspace/workspace.ts index 2ff71fda27..5628213b75 100644 --- a/cli/src/commands/workspace/workspace.ts +++ b/cli/src/commands/workspace/workspace.ts @@ -812,7 +812,11 @@ const command = new Command() "--datatable-behavior ", "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 ", + "Non-interactive override for the 'turn my current working branch into the fork' workflow: base the fork on (its bound workspace is the parent) and rename the current branch onto wm-fork//. 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") diff --git a/cli/src/guidance/core.ts b/cli/src/guidance/core.ts index cb409ef34f..25890e1923 100644 --- a/cli/src/guidance/core.ts +++ b/cli/src/guidance/core.ts @@ -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//\`. 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//\` 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//\` 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 \` 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. diff --git a/cli/src/guidance/freshness.ts b/cli/src/guidance/freshness.ts index 5104f7c0e6..02590b7235 100644 --- a/cli/src/guidance/freshness.ts +++ b/cli/src/guidance/freshness.ts @@ -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.` ); } } diff --git a/cli/src/guidance/skills.gen.ts b/cli/src/guidance/skills.gen.ts index 0cdd001b63..c1c882993e 100644 --- a/cli/src/guidance/skills.gen.ts +++ b/cli/src/guidance/skills.gen.ts @@ -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 \` — **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 \` to run only one module in isolation (see "Single-step vs whole-flow preview" below). - \`wmill flow run \` — 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 \` - Specify the workspace name. Ignored if --create is not specified or the workspace already exists. Will default to the workspace id. - \`--color \` - Workspace color (hex code, e.g. #ff0000) - \`--datatable-behavior \` - 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 \` - Non-interactive override for the 'turn my current working branch into the fork' workflow: base the fork on (its bound workspace is the parent) and rename the current branch onto wm-fork//. 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 \` - 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 diff --git a/cli/src/guidance/writer.ts b/cli/src/guidance/writer.ts index 033519bf8e..d1ae4e5fe9 100644 --- a/cli/src/guidance/writer.ts +++ b/cli/src/guidance/writer.ts @@ -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; @@ -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 { + 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) | undefined ): (() => Promise) | 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 `` from + // Skipping comment-bearing lines keeps `` from // false-positiving. for (const line of content.split(/\r?\n/)) { const trimmed = line.trim(); diff --git a/cli/src/main.ts b/cli/src/main.ts index c9e8d557c4..efa7df963b 100755 --- a/cli/src/main.ts +++ b/cli/src/main.ts @@ -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)) { diff --git a/cli/src/utils/git.ts b/cli/src/utils/git.ts index 0d2e8cb8a8..be899b0a55 100644 --- a/cli/src/utils/git.ts +++ b/cli/src/utils/git.ts @@ -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 `). Used by + * `wmill workspace fork --from-branch` to turn an existing working branch into + * the `wm-fork//` 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 diff --git a/cli/test/fork_branch_id_unit.test.ts b/cli/test/fork_branch_id_unit.test.ts new file mode 100644 index 0000000000..a1555d22fe --- /dev/null +++ b/cli/test/fork_branch_id_unit.test.ts @@ -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// 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- 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-`. + 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(); + } + }); +}); diff --git a/cli/test/guidance_writer_unit.test.ts b/cli/test/guidance_writer_unit.test.ts index cbeb494d1c..4ee068c83c 100644 --- a/cli/test/guidance_writer_unit.test.ts +++ b/cli/test/guidance_writer_unit.test.ts @@ -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.wmill.md without surrounding whitespace", "foo@AGENTS.wmill.md"], + ["commented-out include", ""], ])("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\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\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"); + }); + }); }); diff --git a/system_prompts/auto-generated/cli/cli-commands.md b/system_prompts/auto-generated/cli/cli-commands.md index 19e1144c95..86c43bedac 100644 --- a/system_prompts/auto-generated/cli/cli-commands.md +++ b/system_prompts/auto-generated/cli/cli-commands.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 ` - Specify the workspace name. Ignored if --create is not specified or the workspace already exists. Will default to the workspace id. - `--color ` - Workspace color (hex code, e.g. #ff0000) - `--datatable-behavior ` - 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 ` - Non-interactive override for the 'turn my current working branch into the fork' workflow: base the fork on (its bound workspace is the parent) and rename the current branch onto wm-fork//. 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 ` - 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 diff --git a/system_prompts/auto-generated/prompts.ts b/system_prompts/auto-generated/prompts.ts index 7c5bb531bb..f22caeaa4c 100644 --- a/system_prompts/auto-generated/prompts.ts +++ b/system_prompts/auto-generated/prompts.ts @@ -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 \` - Specify the workspace name. Ignored if --create is not specified or the workspace already exists. Will default to the workspace id. - \`--color \` - Workspace color (hex code, e.g. #ff0000) - \`--datatable-behavior \` - 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 \` - Non-interactive override for the 'turn my current working branch into the fork' workflow: base the fork on (its bound workspace is the parent) and rename the current branch onto wm-fork//. 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 \` - 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 diff --git a/system_prompts/auto-generated/script.md b/system_prompts/auto-generated/script.md index 7515b2bec4..6559077104 100644 --- a/system_prompts/auto-generated/script.md +++ b/system_prompts/auto-generated/script.md @@ -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 diff --git a/system_prompts/auto-generated/skills/cli-commands/SKILL.md b/system_prompts/auto-generated/skills/cli-commands/SKILL.md index dc2ab710ed..13d423dad7 100644 --- a/system_prompts/auto-generated/skills/cli-commands/SKILL.md +++ b/system_prompts/auto-generated/skills/cli-commands/SKILL.md @@ -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 ` - Specify the workspace name. Ignored if --create is not specified or the workspace already exists. Will default to the workspace id. - `--color ` - Workspace color (hex code, e.g. #ff0000) - `--datatable-behavior ` - 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 ` - Non-interactive override for the 'turn my current working branch into the fork' workflow: base the fork on (its bound workspace is the parent) and rename the current branch onto wm-fork//. 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 ` - 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 diff --git a/system_prompts/auto-generated/skills/raw-app/SKILL.md b/system_prompts/auto-generated/skills/raw-app/SKILL.md index 937a18e1c9..ad89af6159 100644 --- a/system_prompts/auto-generated/skills/raw-app/SKILL.md +++ b/system_prompts/auto-generated/skills/raw-app/SKILL.md @@ -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 | diff --git a/system_prompts/auto-generated/skills/write-flow/SKILL.md b/system_prompts/auto-generated/skills/write-flow/SKILL.md index f1bfc6b6e0..f71bdbf3fa 100644 --- a/system_prompts/auto-generated/skills/write-flow/SKILL.md +++ b/system_prompts/auto-generated/skills/write-flow/SKILL.md @@ -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 ` — **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 ` to run only one module in isolation (see "Single-step vs whole-flow preview" below). - `wmill flow run ` — runs the flow **already deployed** in the workspace. Use only when the user explicitly wants to test the deployed version, not local edits. diff --git a/system_prompts/auto-generated/skills/write-script-bun/SKILL.md b/system_prompts/auto-generated/skills/write-script-bun/SKILL.md index 5e80f99795..799eab6682 100644 --- a/system_prompts/auto-generated/skills/write-script-bun/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-bun/SKILL.md @@ -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 diff --git a/system_prompts/auto-generated/skills/write-script-bunnative/SKILL.md b/system_prompts/auto-generated/skills/write-script-bunnative/SKILL.md index 3b790959f2..a6cb64ff4b 100644 --- a/system_prompts/auto-generated/skills/write-script-bunnative/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-bunnative/SKILL.md @@ -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 diff --git a/system_prompts/auto-generated/skills/write-script-deno/SKILL.md b/system_prompts/auto-generated/skills/write-script-deno/SKILL.md index 179392981f..1d04831125 100644 --- a/system_prompts/auto-generated/skills/write-script-deno/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-deno/SKILL.md @@ -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 diff --git a/system_prompts/base/flow-cli.md b/system_prompts/base/flow-cli.md index b783b22526..e2c553580f 100644 --- a/system_prompts/base/flow-cli.md +++ b/system_prompts/base/flow-cli.md @@ -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 ` — **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 ` to run only one module in isolation (see "Single-step vs whole-flow preview" below). - `wmill flow run ` — runs the flow **already deployed** in the workspace. Use only when the user explicitly wants to test the deployed version, not local edits. diff --git a/system_prompts/base/raw-app-cli.md b/system_prompts/base/raw-app-cli.md index e7e8fe9881..70e6d8135f 100644 --- a/system_prompts/base/raw-app-cli.md +++ b/system_prompts/base/raw-app-cli.md @@ -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 | diff --git a/system_prompts/generate.py b/system_prompts/generate.py index 783dcc1ce3..e13ff56715 100644 --- a/system_prompts/generate.py +++ b/system_prompts/generate.py @@ -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( diff --git a/system_prompts/languages/bun.md b/system_prompts/languages/bun.md index 5d5abdc4b8..c505b6cf97 100644 --- a/system_prompts/languages/bun.md +++ b/system_prompts/languages/bun.md @@ -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 diff --git a/system_prompts/languages/bunnative.md b/system_prompts/languages/bunnative.md index 9daa8a9a46..cdcbcc6908 100644 --- a/system_prompts/languages/bunnative.md +++ b/system_prompts/languages/bunnative.md @@ -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 diff --git a/system_prompts/languages/deno.md b/system_prompts/languages/deno.md index adf5677a6f..c84e5e772a 100644 --- a/system_prompts/languages/deno.md +++ b/system_prompts/languages/deno.md @@ -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