@ldlework/workmark 1.3.0 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,263 @@
1
+ # @ldlework/workmark
2
+
3
+ Most workspaces accumulate a graveyard of shell scripts, Makefiles, npm scripts, and "just run this" tribal knowledge. Workmark lets you define these workspace operations in TypeScript and run them from:
4
+
5
+ - **CLI** — the `wm` command, with auto-generated help and argument parsing
6
+ - **VS Code** — a dashboard extension with auto-generated forms for every command/parameter
7
+ - **AI Agents** — a built-in MCP server so any MCP client can discover and run your commands
8
+
9
+ ## Quick start
10
+
11
+ ### Install
12
+
13
+ ```bash
14
+ pnpm add @ldlework/workmark
15
+ ```
16
+
17
+ ### Write a command
18
+
19
+ Create commands in `.wm/commands/`. Subdirectories become groups in the CLI, dashboard, and MCP server. The filename becomes the command name, a leading JSDoc becomes the description, and a string return is executed as a shell command in the workspace root.
20
+
21
+ ```ts
22
+ // .wm/commands/art/sprites.ts
23
+ /** Pack sprite sheets from raw assets */
24
+ import { cmd } from "@ldlework/workmark/define";
25
+ import { z } from "zod";
26
+
27
+ export default cmd({
28
+ args: {
29
+ target: z.enum(["all", "characters", "terrain", "ui"]).default("all"),
30
+ },
31
+ flags: {
32
+ watch: z.boolean().default(false),
33
+ },
34
+ handler: ({ target, watch }) =>
35
+ `./tools/pack-sprites.sh ${target}${watch ? " --watch" : ""}`,
36
+ });
37
+ ```
38
+
39
+ For a bare shell alias:
40
+
41
+ ```ts
42
+ // .wm/commands/build.ts
43
+ /** Build the project */
44
+ import { cmd } from "@ldlework/workmark/define";
45
+ export default cmd({ handler: () => "cargo build" });
46
+ ```
47
+
48
+ ### Run it
49
+
50
+ ```bash
51
+ wm sprites # pack all sprite sheets
52
+ wm sprites characters --watch # pack characters, rebuild on change
53
+ wm --help # list all commands
54
+ wm sprites --help # per-command help
55
+ ```
56
+
57
+ ## Projects
58
+
59
+ If your workspace contains multiple packages or services, you can define **projects** so that commands can discover and operate on them. Drop a `wm.ts` in any project directory:
60
+
61
+ ```ts
62
+ // packages/api/wm.ts
63
+ import { defineProject } from "@ldlework/workmark/define";
64
+
65
+ export default defineProject({
66
+ name: "api",
67
+ tags: ["backend"],
68
+ });
69
+ ```
70
+
71
+ ```ts
72
+ // packages/web/wm.ts
73
+ import { defineProject } from "@ldlework/workmark/define";
74
+
75
+ export default defineProject({
76
+ name: "web",
77
+ tags: ["frontend"],
78
+ });
79
+ ```
80
+
81
+ Workmark recursively discovers all `wm.ts` files from the workspace root (respecting `.gitignore`). Projects are available to commands via the workspace object — query them by name with `workspace.get("api")`, by tag with `workspace.withTag("backend")`, or by capability with `workspace.withCapability("deploy")`.
82
+
83
+ ### Capabilities
84
+
85
+ Capabilities are structured metadata attached to projects. A project declares what it supports, and commands filter by it — this keeps project-specific config out of your command files. For example, marking a project with `deploy: true` advertises that it has the `scripts/deploy.sh` script that the `deploy` command expects.
86
+
87
+ ```ts
88
+ // packages/api/wm.ts
89
+ export default defineProject({
90
+ name: "api",
91
+ tags: ["backend"],
92
+ capabilities: { deploy: true },
93
+ });
94
+ ```
95
+
96
+ ```ts
97
+ // packages/web/wm.ts
98
+ export default defineProject({
99
+ name: "web",
100
+ tags: ["frontend"],
101
+ capabilities: { deploy: true },
102
+ });
103
+ ```
104
+
105
+ `workspace.withCapability("deploy")` returns both projects. Capabilities can also carry structured data:
106
+
107
+ ```ts
108
+ export default defineProject({
109
+ name: "api",
110
+ capabilities: {
111
+ deploy: true,
112
+ build: { targets: ["esm", "cjs"] },
113
+ },
114
+ });
115
+ ```
116
+
117
+ Commands read this with `project.capability<{ targets: string[] }>("build")`. This pattern works for anything — build targets, test runners, linting rules.
118
+
119
+ ### Dynamic commands
120
+
121
+ A **dynamic command** receives the workspace and builds its schema from project metadata. Here, the `deploy` command finds all projects with the `deploy` capability and populates its argument from their names:
122
+
123
+ ```ts
124
+ // .wm/commands/deploy.ts
125
+ /** Deploy a project */
126
+ import { z } from "zod";
127
+ import type { DynamicCommandDef } from "@ldlework/workmark/types";
128
+
129
+ export default {
130
+ factory: (workspace) => {
131
+ const names = workspace.withCapability("deploy").map((p) => p.name);
132
+ return {
133
+ args: { project: z.enum(names as [string, ...string[]]) },
134
+ handler: ({ project }) => {
135
+ const p = workspace.get(project as string);
136
+ return { content: [{ type: "text", text: `deploying ${p.name}` }] };
137
+ // or: return `./scripts/deploy.sh` (with cwd resolved to workspace root by default)
138
+ },
139
+ };
140
+ },
141
+ } satisfies DynamicCommandDef;
142
+ ```
143
+
144
+ ```bash
145
+ wm deploy api
146
+ wm deploy web
147
+ ```
148
+
149
+ The CLI help, VS Code dropdowns, and MCP tool schema all show exactly the valid project names — no impossible combinations, no runtime validation needed.
150
+
151
+ ## Features
152
+
153
+ ### CLI
154
+
155
+ The `wm` binary auto-discovers your commands and generates help text, argument parsing, and type coercion.
156
+
157
+ ```
158
+ $ wm --help
159
+
160
+ Usage: wm <command> [args...]
161
+
162
+ Commands:
163
+ sprites Pack sprite sheets from raw assets
164
+ deploy Deploy a project to an environment
165
+ db:migrate Run database migrations
166
+
167
+ Run wm <command> --help for details on a specific command.
168
+ ```
169
+
170
+ Arguments support positional args and named flags:
171
+
172
+ ```bash
173
+ wm sprites characters # positional (from args)
174
+ wm sprites characters --watch # positional + flag
175
+ wm deploy api # dynamic command with project arg
176
+ wm deploy --project api # everything works as flags too
177
+ ```
178
+
179
+ ### VS Code dashboard
180
+
181
+ Install the [`workmark-vsc`](https://marketplace.visualstudio.com/items?itemName=ldlework.workmark-vsc) extension. It adds a **Workspace** panel to the activity bar showing all your commands grouped by category with auto-generated forms:
182
+
183
+ - Enum fields become dropdowns
184
+ - Booleans become checkboxes
185
+ - Numbers get validated inputs with min/max
186
+ - Required fields are enforced before execution
187
+ - Double-click any command to jump to its source file
188
+
189
+ Commands run in the integrated terminal, so you get full color output and interactivity.
190
+
191
+ ### MCP server
192
+
193
+ Workmark includes a built-in [Model Context Protocol](https://modelcontextprotocol.io) server. Every command you define is automatically exposed as an MCP tool, which means AI assistants like Claude can discover and invoke your workspace commands.
194
+
195
+ ```jsonc
196
+ // claude_desktop_config.json or .mcp.json
197
+ {
198
+ "mcpServers": {
199
+ "workspace": {
200
+ "command": "node",
201
+ "args": ["./node_modules/@ldlework/workmark/dist/index.js"]
202
+ }
203
+ }
204
+ }
205
+ ```
206
+
207
+ Once connected, your assistant can run `wm deploy api` the same way you do — with full schema validation and typed responses.
208
+
209
+ ## Project structure
210
+
211
+ ```
212
+ your-workspace/
213
+ ├── .wm/
214
+ │ └── commands/
215
+ │ ├── deploy.ts # Root-level command
216
+ │ ├── art/
217
+ │ │ └── sprites.ts # Grouped under "Art"
218
+ │ └── db/
219
+ │ └── migrate.ts # Grouped under "Db"
220
+ ├── packages/
221
+ │ ├── api/
222
+ │ │ └── wm.ts # Project definition
223
+ │ └── web/
224
+ │ └── wm.ts
225
+ ```
226
+
227
+ - **`wm.ts`** — project definitions, discovered recursively
228
+ - **`.wm/commands/**/*.ts`** — command files, grouped by directory name
229
+
230
+ ## API reference
231
+
232
+ ### Defining
233
+
234
+ ```ts
235
+ import { defineProject } from "@ldlework/workmark/define";
236
+ ```
237
+
238
+ ### Helpers
239
+
240
+ ```ts
241
+ import { ok, fail, exec, execAsync } from "@ldlework/workmark/helpers";
242
+
243
+ ok(data) // Wrap data in a success CallToolResult
244
+ fail(error) // Wrap error in an error CallToolResult
245
+ exec(cmd, { cwd }) // Synchronous shell exec, returns CallToolResult
246
+ execAsync(cmd, { cwd }) // Async shell exec, returns Promise<CallToolResult>
247
+ execRaw(cmd, { cwd }) // Synchronous shell exec, returns string (throws on error)
248
+ execAsyncRaw(cmd, { cwd }) // Async shell exec, returns Promise<string> (throws on error)
249
+ ```
250
+
251
+ ### Loading
252
+
253
+ ```ts
254
+ import { loadWorkspace } from "@ldlework/workmark/workspace";
255
+ import { loadCommands } from "@ldlework/workmark";
256
+
257
+ const workspace = await loadWorkspace();
258
+ const commands = await loadCommands(workspace);
259
+ ```
260
+
261
+ ## License
262
+
263
+ MIT
package/dist/cli.js CHANGED
@@ -97,7 +97,14 @@ async function main() {
97
97
  return;
98
98
  }
99
99
  const args = parseArgs(rest, cmd.positional, cmd.inputSchema);
100
- const result = await cmd.handler(args);
100
+ let result;
101
+ try {
102
+ result = await cmd.handler(args);
103
+ }
104
+ catch (e) {
105
+ process.stderr.write((e instanceof Error ? e.message : String(e)) + "\n");
106
+ process.exit(1);
107
+ }
101
108
  for (const content of result.content) {
102
109
  if (content.type === "text") {
103
110
  const stream = result.isError ? process.stderr : process.stdout;
@@ -108,6 +115,6 @@ async function main() {
108
115
  process.exit(1);
109
116
  }
110
117
  main().catch((err) => {
111
- console.error("Fatal:", err);
118
+ process.stderr.write((err instanceof Error ? err.message : String(err)) + "\n");
112
119
  process.exit(1);
113
120
  });
@@ -1,3 +1,72 @@
1
- import type { ProjectDef } from "./types.js";
2
- /** Define a project. Identity function for type safety. */
1
+ import { z } from "zod";
2
+ import type { BaseCtx, FromArgsResolver, FromWorkspaceResolver, HandlerReturn, NeedsCtx, ProjectDef, ReduceFn, RunOptions, SchemaFields, SelectMode, Trait } from "./types.js";
3
+ /** Declare a trait: a named schema that projects fulfill and commands require. */
4
+ export declare function defineTrait<N extends string, S extends z.ZodType>(def: {
5
+ name: N;
6
+ schema: S;
7
+ description?: string;
8
+ }): Trait<N, z.output<S>>;
3
9
  export declare function defineProject(def: ProjectDef): ProjectDef;
10
+ type TraitsOf<N extends readonly Trait<string, unknown>[]> = {
11
+ [K in N[number] as K["name"]]: K extends Trait<string, infer T> ? T : never;
12
+ };
13
+ type InferField<F> = F extends z.ZodType ? z.output<F> : unknown;
14
+ type InferFields<R extends SchemaFields | undefined> = R extends SchemaFields ? {
15
+ [K in keyof R]: InferField<R[K]>;
16
+ } : Record<string, never>;
17
+ type CtxFor<N extends readonly Trait<string, unknown>[]> = N extends readonly [] ? BaseCtx : NeedsCtx<TraitsOf<N>>;
18
+ export interface StaticCommandDef {
19
+ needs?: readonly Trait[];
20
+ select?: SelectMode;
21
+ for?: string;
22
+ run?: RunOptions;
23
+ args?: SchemaFields;
24
+ flags?: SchemaFields;
25
+ meta?: {
26
+ name?: string;
27
+ label?: string;
28
+ description?: string;
29
+ };
30
+ handler: (args: Record<string, unknown>, ctx: BaseCtx | NeedsCtx<Record<string, unknown>>) => HandlerReturn | Promise<HandlerReturn>;
31
+ }
32
+ /** Declare a command. `needs`, `args`, and `flags` are inferred for handler
33
+ * typing; the framework manages the auto-generated `project` arg separately.
34
+ *
35
+ * `for`: bind the command to a named project. No project arg is exposed on any
36
+ * surface; `ctx.project` is the bound project. Validated at load.
37
+ */
38
+ export declare function cmd<const N extends readonly Trait<string, unknown>[] = readonly [], const A extends SchemaFields = Record<string, never>, const F extends SchemaFields = Record<string, never>>(def: {
39
+ needs?: N;
40
+ select?: SelectMode;
41
+ for?: string;
42
+ run?: RunOptions;
43
+ args?: A;
44
+ flags?: F;
45
+ meta?: {
46
+ name?: string;
47
+ label?: string;
48
+ description?: string;
49
+ };
50
+ handler: (args: InferFields<A> & InferFields<F>, ctx: CtxFor<N>) => HandlerReturn | Promise<HandlerReturn>;
51
+ }): StaticCommandDef;
52
+ export declare const defineCommand: typeof cmd;
53
+ /** Declare a schema that depends on workspace state. The framework resolves
54
+ * the marker during command load and substitutes the returned zod schema. */
55
+ export declare function fromWorkspace(resolver: FromWorkspaceResolver): z.ZodType;
56
+ /** Declare a schema whose shape depends on other args supplied at invocation.
57
+ * The framework resolves this per-call, AFTER other args are parsed. The
58
+ * field's load-time JSON Schema is permissive (type-agnostic) — validation
59
+ * runs at invocation with the resolved schema. */
60
+ export declare function fromArgs(resolver: FromArgsResolver): z.ZodType;
61
+ /** Shortcut: enum of project names fulfilling a trait. */
62
+ export declare function projectsOf(trait: Trait): z.ZodType;
63
+ /** Build a schema whose shape depends on a specific project's trait data.
64
+ *
65
+ * Usage: `traitField(docker, d => z.enum(d.buildable)).forProject("ghost")`.
66
+ */
67
+ export declare function traitField<T extends Trait<string, unknown>>(trait: T, selector: (data: unknown) => z.ZodType): TraitFieldBuilder;
68
+ export interface TraitFieldBuilder {
69
+ forProject(name: string): z.ZodType;
70
+ fromArg(argName: string): z.ZodType;
71
+ }
72
+ export type { SelectMode, RunOptions, ReduceFn };
@@ -1,4 +1,90 @@
1
- /** Define a project. Identity function for type safety. */
1
+ import { z } from "zod";
2
+ import { FROM_ARGS, FROM_WORKSPACE } from "./types.js";
3
+ import { registerAmbient } from "./registry.js";
4
+ // ---- defineTrait -------------------------------------------------------
5
+ /** Declare a trait: a named schema that projects fulfill and commands require. */
6
+ export function defineTrait(def) {
7
+ const trait = {
8
+ name: def.name,
9
+ schema: def.schema,
10
+ description: def.description,
11
+ };
12
+ // Register into ambient registry (installed by the loader during trait import).
13
+ registerAmbient(trait);
14
+ return trait;
15
+ }
16
+ // ---- defineProject -----------------------------------------------------
2
17
  export function defineProject(def) {
3
18
  return def;
4
19
  }
20
+ /** Declare a command. `needs`, `args`, and `flags` are inferred for handler
21
+ * typing; the framework manages the auto-generated `project` arg separately.
22
+ *
23
+ * `for`: bind the command to a named project. No project arg is exposed on any
24
+ * surface; `ctx.project` is the bound project. Validated at load.
25
+ */
26
+ export function cmd(def) {
27
+ return def;
28
+ }
29
+ export const defineCommand = cmd;
30
+ // ---- fromWorkspace ------------------------------------------------------
31
+ /** Declare a schema that depends on workspace state. The framework resolves
32
+ * the marker during command load and substitutes the returned zod schema. */
33
+ export function fromWorkspace(resolver) {
34
+ const marker = z.lazy(() => {
35
+ throw new Error("fromWorkspace marker used at runtime — the framework should have replaced it at load time");
36
+ });
37
+ marker[FROM_WORKSPACE] = resolver;
38
+ return marker;
39
+ }
40
+ /** Declare a schema whose shape depends on other args supplied at invocation.
41
+ * The framework resolves this per-call, AFTER other args are parsed. The
42
+ * field's load-time JSON Schema is permissive (type-agnostic) — validation
43
+ * runs at invocation with the resolved schema. */
44
+ export function fromArgs(resolver) {
45
+ const marker = z.any();
46
+ marker[FROM_ARGS] = resolver;
47
+ return marker;
48
+ }
49
+ /** Shortcut: enum of project names fulfilling a trait. */
50
+ export function projectsOf(trait) {
51
+ return fromWorkspace((ws) => {
52
+ const names = ws.withTrait(trait).map((p) => p.name);
53
+ if (names.length === 0) {
54
+ throw new Error(`No projects fulfill trait "${trait.name}"`);
55
+ }
56
+ return z.enum(names);
57
+ });
58
+ }
59
+ /** Build a schema whose shape depends on a specific project's trait data.
60
+ *
61
+ * Usage: `traitField(docker, d => z.enum(d.buildable)).forProject("ghost")`.
62
+ */
63
+ export function traitField(trait, selector) {
64
+ return {
65
+ forProject(name) {
66
+ return fromWorkspace((ws) => {
67
+ const project = ws.get(name);
68
+ if (!project.hasTrait(trait)) {
69
+ throw new Error(`traitField(${trait.name}).forProject("${name}") — project does not fulfill trait "${trait.name}"`);
70
+ }
71
+ const data = project.trait(trait);
72
+ return selector(data);
73
+ });
74
+ },
75
+ fromArg(argName) {
76
+ return fromArgs((ws, args) => {
77
+ const target = args[argName];
78
+ if (typeof target !== "string") {
79
+ throw new Error(`traitField(${trait.name}).fromArg("${argName}") — expected arg "${argName}" to be a project name string, got ${typeof target}`);
80
+ }
81
+ const project = ws.get(target);
82
+ if (!project.hasTrait(trait)) {
83
+ throw new Error(`traitField(${trait.name}).fromArg("${argName}") — project "${target}" does not fulfill trait "${trait.name}"`);
84
+ }
85
+ const data = project.trait(trait);
86
+ return selector(data);
87
+ });
88
+ },
89
+ };
90
+ }
@@ -4,8 +4,11 @@ export declare function fail(error: unknown): CallToolResult;
4
4
  export interface ExecOptions {
5
5
  cwd: string;
6
6
  timeout?: number;
7
+ env?: Record<string, string>;
7
8
  }
8
9
  export declare function execRaw(command: string, opts: ExecOptions): string;
9
10
  export declare function exec(command: string, opts: ExecOptions): CallToolResult;
10
11
  export declare function execAsyncRaw(command: string, opts: ExecOptions): Promise<string>;
12
+ /** Run a sequence of shell commands. Fails fast; concatenates outputs. */
13
+ export declare function shSeq(commands: readonly string[], opts: ExecOptions): Promise<CallToolResult>;
11
14
  export declare function execAsync(command: string, opts: ExecOptions): Promise<CallToolResult>;
@@ -26,13 +26,14 @@ export function fail(error) {
26
26
  return { content: [{ type: "text", text: msg }], isError: true };
27
27
  }
28
28
  export function execRaw(command, opts) {
29
- const { cwd, timeout = 120_000 } = opts;
29
+ const { cwd, timeout = 120_000, env } = opts;
30
30
  return execSync(command, {
31
31
  cwd,
32
32
  timeout,
33
33
  encoding: "utf-8",
34
34
  stdio: ["ignore", "pipe", "pipe"],
35
35
  maxBuffer: 1024 * 1024 * 10,
36
+ env: env ? { ...process.env, ...env } : process.env,
36
37
  });
37
38
  }
38
39
  export function exec(command, opts) {
@@ -45,15 +46,32 @@ export function exec(command, opts) {
45
46
  }
46
47
  const execPromise = promisify(cpExec);
47
48
  export async function execAsyncRaw(command, opts) {
48
- const { cwd, timeout = 120_000 } = opts;
49
+ const { cwd, timeout = 120_000, env } = opts;
49
50
  const { stdout } = await execPromise(command, {
50
51
  cwd,
51
52
  timeout,
52
53
  encoding: "utf-8",
53
54
  maxBuffer: 1024 * 1024 * 10,
55
+ env: env ? { ...process.env, ...env } : process.env,
54
56
  });
55
57
  return stdout;
56
58
  }
59
+ /** Run a sequence of shell commands. Fails fast; concatenates outputs. */
60
+ export async function shSeq(commands, opts) {
61
+ const outputs = [];
62
+ for (const cmd of commands) {
63
+ try {
64
+ outputs.push(await execAsyncRaw(cmd, opts));
65
+ }
66
+ catch (e) {
67
+ const prior = outputs.join("\n");
68
+ const err = e instanceof Error ? e : null;
69
+ const errText = err?.stderr || err?.stdout || (err?.message ?? String(e));
70
+ return fail(prior ? `${prior}\n${errText}` : errText);
71
+ }
72
+ }
73
+ return ok(outputs.join("\n"));
74
+ }
57
75
  export async function execAsync(command, opts) {
58
76
  try {
59
77
  return ok(await execAsyncRaw(command, opts));
@@ -1,2 +1,3 @@
1
- import type { IWorkspace, ResolvedCommand } from "./types.js";
2
- export declare function loadCommands(workspace: IWorkspace): Promise<ResolvedCommand[]>;
1
+ import type { ResolvedCommand } from "./types.js";
2
+ import type { Workspace } from "./workspace.js";
3
+ export declare function loadCommands(workspace: Workspace): Promise<ResolvedCommand[]>;