Start here

The mental model

Workmark has four things:

  • Project — a named directory (wm.ts) with typed metadata.
  • Trait — a named zod schema describing a slice of metadata.
  • Command — a TypeScript file declaring args and a handler.
  • Handler — a function that gets typed args + a context and returns a result.

Commands declare which traits they need. Projects declare which traits they have. The framework matches them, generates CLI args / VS Code forms / MCP tool schemas, and hands your handler fully-typed data. That's the whole idea.

Quick start

Install:

terminal
pnpm add -D @ldlework/workmark

Write a command:

.wm/commands/build.ts
/** Build the project */
import { cmd } from "@ldlework/workmark/define";

export default cmd({
  handler: (_, { sh }) => sh("cargo build"),
});

Run it:

terminal
wm build

That's the simplest case. Projects and traits earn their keep when you have multiple packages or shared config — see below.

Projects

A wm.ts file anywhere in the workspace declares a project. The framework discovers them recursively from the root.

packages/api/wm.ts
import { defineProject } from "@ldlework/workmark/define";

export default defineProject({
  name: "api",
  tags: ["backend"],
  has: { buildable: true, docker: { composeFile: "docker-compose.yml", service: "api" } },
});

has is where a project fulfills traits. tags are free-form labels for human-readable grouping — they don't show up in commands. Use tags for "this is a backend service" documentation; use has for "this project supports the build trait."

The root can also have a wm.ts that exports multiple projects as an array — useful when each package lives in a flat layout:

wm.ts
export default [
  defineProject({ name: "api",  dir: "packages/api",  has: { buildable: true } }),
  defineProject({ name: "web",  dir: "packages/web",  has: { buildable: true } }),
];

Traits

A trait is a named zod schema. Put it in .wm/traits/; the filename doesn't matter — the name field is the identity.

.wm/traits/buildable.ts
import { z } from "zod";
import { defineTrait } from "@ldlework/workmark/define";

/** Projects with a build step. */
export const buildable = defineTrait({
  name: "buildable",
  schema: z.object({
    command: z.string().default("pnpm build"),
    timeout: z.number().default(180_000),
  }),
});

When a project writes has: { buildable: { command: "cargo build" } }, the framework parses that against the schema at load time and stores the typed result.has: { buildable: true } is sugar for "use the defaults."

Traits come in two flavors:

  • Data traits — the schema has fields the handler will read (like buildable.command, docker.composeFile).
  • Marker traits — the schema is empty or all-defaults. Used as a filter: "projects that have the publishable trait."

Commands

A command lives in .wm/commands/. Subdirectories become colon-joined: commands/docker/up.tsdocker:up.

.wm/commands/build.ts
import { cmd } from "@ldlework/workmark/define";
import { buildable } from "../traits/buildable.js";

/** Build one or more packages. */
export default cmd({
  needs: [buildable],
  handler: (_, { traits, sh }) => sh(traits.buildable.command),
});

needs lists required traits. The framework:

  • Filters to projects that fulfill all needed traits.
  • Exposes a project arg as an enum of their names (CLI / form / MCP).
  • Resolves the selection and hands the handler ctx.project + ctx.traits.*, fully typed.

Select modes

How many projects a command runs against:

select: "one"          // exactly one project
select: "one-or-many"  // 1+ projects; handler runs per (default with needs)
select: "all"          // all eligible; no user choice
for: "ghost"           // bound to a specific project; no project arg exposed

Args and flags

Both are Record<string, z.ZodType>. args entries are positional (in declaration order); flags are named --foo. Descriptions come from zod's .describe().

with args and flags
export default cmd({
  needs: [docker],
  args: {
    service: z.string().optional().describe("Service to restart"),
  },
  flags: {
    force: z.boolean().default(false),
  },
  handler: ({ service, force }, { traits, sh }) =>
    sh(`docker compose -f ${traits.docker.composeFile} restart ${service ?? ""}${force ? " --force" : ""}`),
});

Aggregating across projects

With select: "all" (or "one-or-many") you can aggregate results:

export default cmd({
  needs: [buildable],
  select: "all",
  run: {
    reduce: (results) => {
      const failed = results.filter(r => !r.ok);
      return failed.length === 0
        ? ok(`${results.length} built`)
        : fail(`${failed.length} failed`);
    },
  },
  handler: (_, { traits, sh }) => sh(traits.buildable.command),
});

Handlers

A handler takes (args, ctx) and returns a CallToolResult.

args is your declared args + flags, fully typed from the zod schemas.ctx is workmark-provided — it's where project, traits, and helpers live.

ctx.project     // the resolved Project (when needs is set)
ctx.traits      // { [traitName]: typed data } (when needs is set)
ctx.workspace   // the full Workspace
ctx.sh(cmd)     // shell exec in the resolved cwd; returns CallToolResult
ctx.sh([a, b])  // sequence: fail-fast, concatenate output
ctx.exec(cmd, { cwd, timeout, env })  // explicit options
ctx.ok(data)    // wrap data as a success result
ctx.fail(err)   // wrap as an error result
ctx.invoke(name, args)  // call another command

Working directory

ctx.sh resolves cwd automatically:

  • With needs → each iteration's project.dir.
  • Without needs → the workspace root.
  • Override per-command via cwd: "project" | "workspace" | (ctx) => absolutePath.

Composition

Handlers can invoke other commands by name. The framework detects cycles and returns a clean error.

handler: async (_, { invoke, fail }) => {
  const check = await invoke("check", {});
  if (check.isError) return fail("check failed — aborting");
  return invoke("build", { project: ["api", "web"] });
}

Running

CLI

terminal
wm --help                         # list all commands
wm build --help                   # per-command help
wm build api                      # one project
wm build api web                  # two projects
wm docker:up api --service=db     # nested group, with a flag

VS Code dashboard

Install the workmark-vsc extension. The Workspace panel shows every command with an auto-generated form: enums become dropdowns, booleans become checkboxes, required fields are enforced, and the command runs in the integrated terminal. The extension bundles workmark itself, so the dashboard works in any workspace with nothing to install.

MCP

Workmark ships a built-in MCP server. Every command is an MCP tool; input schemas are JSON Schema derived from your zod declarations. Point your client at the binary:

.mcp.json
{
  "mcpServers": {
    "workspace": {
      "command": "node",
      "args": ["./node_modules/@ldlework/workmark/dist/index.js"]
    }
  }
}

AI assistants see your commands with the same validated inputs you see. No separate server to run.

Reference

Imports

import {
  cmd,              // declare a command
  defineProject,    // declare a project
  defineTrait,      // declare a trait
  projectsOf,       // enum of projects fulfilling a trait (for use in args/flags)
  traitField,       // per-project-data enums (.forProject / .fromArg)
  fromWorkspace,    // custom workspace-aware schema
  fromArgs,         // custom invocation-time schema
} from "@ldlework/workmark/define";

import { ok, fail, exec, execAsync } from "@ldlework/workmark/helpers";
import type { Trait } from "@ldlework/workmark/types";

Project structure

your-workspace/
├── .wm/
│   ├── traits/
│   │   └── buildable.ts       # trait definitions
│   └── commands/
│       ├── build.ts           # wm build
│       └── docker/
│           ├── up.ts          # wm docker:up
│           └── down.ts        # wm docker:down
├── packages/
│   ├── api/
│   │   └── wm.ts              # project definition
│   └── web/
│       └── wm.ts
├── wm.ts                      # optional: root project(s)
└── package.json

Command options

cmd({
  needs?: Trait[],                            // required traits
  select?: "one" | "one-or-many" | "all",     // default: "one-or-many" when needs present
  for?: string,                               // bind to a specific project
  args?: Record<string, z.ZodType>,           // positional
  flags?: Record<string, z.ZodType>,          // named
  cwd?: "project" | "workspace" | ((ctx) => string),
  run?: {
    order?: "parallel" | "serial",
    concurrency?: number,
    stopOnFailure?: boolean,
    reduce?: (results) => CallToolResult,
  },
  meta?: { name?: string; label?: string; description?: string },
  handler: (args, ctx) => CallToolResult | Promise<CallToolResult>,
});