A workspace command framework

Every workspace command.
One source of truth.

Define each operation once in TypeScript. Run it from the terminal, click it in a VS Code dashboard, or let an AI agent invoke it as a tool.

$ pnpm add @ldlework/workmark
view on GitHub →

The problem

Every workspace collects a graveyard.

scripts/deploy.shthe one Ben wrote
package.json scriptshalf aliases, half lies
Makefileonly Alice remembers
docs/ONBOARDING.mddon't actually run step 4
bin/rebuild-assets.tsusage: ???
notion page from 2023out of date, unclear how

Each tool has its own conventions. None of them show up in your editor. None of them are discoverable by an AI assistant. And every new hire learns them by asking in Slack.

The idea

One file. Three surfaces.

Write a command once in TypeScript. Workmark renders it as a CLI, a VS Code form, and an AI-invocable tool — from the same definition, fully typed.

.wm/commands/build.ts
/** Build one or more packages. */
import { cmd } from "@ldlework/workmark/define";
import { buildable } from "../traits/buildable.js";
export default cmd({
needs: [buildable],
handler: (_, { traits, sh }) =>
sh(traits.buildable.command),
});
terminal
$ wm build api web
--- api ---
compiled in 1.2s
--- web ---
compiled in 0.9s
VS Code
projectapi, web
AI agent
> use_tool("build",
{ project: ["api"] }
)
→ compiled in 1.2s

Why it works

Three things, for the price of one.

Typed end-to-end

Traits carry zod schemas. Handlers destructure typed data from ctx. The CLI args, VS Code form fields, and MCP tool input schemas are all generated from the same declarations — no casts, no drift.

AI-native

Every command is an MCP tool. AI assistants discover and invoke your workspace the same way you do, with the same validated inputs. No server to run, no schema to maintain twice.

Zero SaaS

It's files in your repo. No accounts, no dashboards-as-a-service, no login. Works offline, commits to git, ships with your code.

For monorepos

One command, many projects.

Declare a trait once with a zod schema. Projects fulfill it with typed data. Commands ask for the trait and get per-project data, typed, in the handler.

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

export const docker = defineTrait({
  name: "docker",
  schema: z.object({
    composeFile: z.string(),
    service: z.string(),
  }),
});
sites/api/wm.ts
defineProject({
  name: "api",
  has: {
    docker: {
      composeFile: "docker-compose.yml",
      service: "api",
    },
  },
});
sites/web/wm.ts
defineProject({
  name: "web",
  has: {
    docker: {
      composeFile: "docker-compose.yml",
      service: "web",
    },
  },
});

Now one command, and the project enum in CLI, VS Code, and the MCP tool schema is whichever projects fulfill docker.

.wm/commands/up.ts
export default cmd({
  needs: [docker],
  handler: (_, { traits, sh }) =>
    sh(`docker compose -f ${traits.docker.composeFile} up -d ${traits.docker.service}`),
});
terminal
wm up api
wm up api web        # both in parallel
wm up --help         # project: [api, web]

Get started

Three steps.

  1. 1

    Install

    pnpm add -D @ldlework/workmark
  2. 2

    Write a command

    .wm/commands/build.ts
    /** Build the project */
    import { cmd } from "@ldlework/workmark/define";
    
    export default cmd({
      handler: (_, { sh }) => sh("cargo build"),
    });
  3. 3

    Run it

    wm build              # CLI
    # or click it in the VS Code dashboard
    # or let Claude call it as an MCP tool