@ldlework/workmark 1.3.1 → 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.
@@ -3,24 +3,28 @@ export class Project {
3
3
  name;
4
4
  dir;
5
5
  tags;
6
- capabilities;
6
+ description;
7
+ /** Parsed (defaulted) trait data, keyed by trait name. */
8
+ traitData;
7
9
  paths;
8
- constructor(def, wsDir) {
10
+ constructor(def, wsDir, traitData) {
9
11
  this.name = def.name;
10
12
  this.dir = def.dir ? join(wsDir, def.dir) : wsDir;
11
- this.tags = Object.freeze(def.tags ?? []);
12
- this.capabilities = Object.freeze(def.capabilities ?? {});
13
- this.paths = Object.freeze(def.paths ?? {});
13
+ this.tags = Object.freeze([...(def.tags ?? [])]);
14
+ this.description = def.description;
15
+ this.traitData = Object.freeze(traitData);
16
+ this.paths = Object.freeze({ ...(def.paths ?? {}) });
14
17
  }
15
- has(capability) {
16
- return capability in this.capabilities;
18
+ hasTrait(trait) {
19
+ const name = typeof trait === "string" ? trait : trait.name;
20
+ return name in this.traitData;
17
21
  }
18
- capability(name) {
19
- const cap = this.capabilities[name];
20
- if (cap === undefined) {
21
- throw new Error(`Project "${this.name}" does not have capability "${name}"`);
22
+ trait(trait) {
23
+ const data = this.traitData[trait.name];
24
+ if (data === undefined) {
25
+ throw new Error(`Project "${this.name}" does not fulfill trait "${trait.name}"`);
22
26
  }
23
- return cap;
27
+ return data;
24
28
  }
25
29
  path(name) {
26
30
  const rel = this.paths[name];
@@ -30,3 +34,32 @@ export class Project {
30
34
  return join(this.dir, rel);
31
35
  }
32
36
  }
37
+ /** Validate a project's `has` against the registry, returning parsed data. */
38
+ export function validateHas(def, registry, sourceFile) {
39
+ const out = {};
40
+ const has = def.has ?? {};
41
+ for (const [traitName, raw] of Object.entries(has)) {
42
+ const trait = registry.get(traitName);
43
+ if (!trait) {
44
+ const known = registry.all().map((t) => t.name).join(", ") || "(none)";
45
+ throw new Error(`Project "${def.name}" declares has.${traitName}, but no such trait is defined\n at ${sourceFile}\n known traits: ${known}`);
46
+ }
47
+ // Marker-trait sugar: `has: { x: true }` parses the trait with `{}`, which
48
+ // triggers per-field defaults. Zod 4's outer `.default({})` returns `{}`
49
+ // for undefined input — useful but NOT deeply defaulted — so we pass `{}`
50
+ // explicitly to get nested defaults.
51
+ const input = raw === true ? {} : raw;
52
+ const result = trait.schema.safeParse(input);
53
+ if (!result.success) {
54
+ const issues = result.error.issues
55
+ .map((i) => ` ${i.path.join(".") || "(root)"}: ${i.message}`)
56
+ .join("\n");
57
+ const hint = raw === true
58
+ ? `\n hint: "true" is sugar for defaults; trait "${traitName}" has no top-level .default(). Provide an object literal.`
59
+ : "";
60
+ throw new Error(`Project "${def.name}" has.${traitName} failed validation:\n${issues}\n at ${sourceFile}${hint}`);
61
+ }
62
+ out[traitName] = result.data;
63
+ }
64
+ return out;
65
+ }
@@ -0,0 +1,15 @@
1
+ import type { Trait } from "./types.js";
2
+ /** Per-load trait registry. A fresh instance is created for each `loadWorkspace`
3
+ * call so reloads don't trip duplicate-name checks against prior state. */
4
+ export declare class TraitRegistry {
5
+ private readonly traits;
6
+ private readonly sources;
7
+ register(trait: Trait, sourceFile?: string): void;
8
+ get(name: string): Trait | undefined;
9
+ require(name: string): Trait;
10
+ has(name: string): boolean;
11
+ all(): readonly Trait[];
12
+ }
13
+ export declare function installRegistry(reg: TraitRegistry): void;
14
+ export declare function uninstallRegistry(): void;
15
+ export declare function registerAmbient(trait: Trait, sourceFile?: string): void;
@@ -0,0 +1,64 @@
1
+ /** Per-load trait registry. A fresh instance is created for each `loadWorkspace`
2
+ * call so reloads don't trip duplicate-name checks against prior state. */
3
+ export class TraitRegistry {
4
+ traits = new Map();
5
+ sources = new Map();
6
+ register(trait, sourceFile) {
7
+ const existing = this.traits.get(trait.name);
8
+ const prevSource = this.sources.get(trait.name);
9
+ if (existing) {
10
+ // Same source file re-executed (jiti moduleCache:false) is idempotent.
11
+ // Different source files with the same name is an error.
12
+ if (sourceFile && prevSource && sourceFile !== prevSource) {
13
+ throw new Error(`Trait "${trait.name}" defined twice:\n ${prevSource}\n ${sourceFile}`);
14
+ }
15
+ // Keep the first-registered trait object as canonical.
16
+ return;
17
+ }
18
+ this.traits.set(trait.name, trait);
19
+ if (sourceFile)
20
+ this.sources.set(trait.name, sourceFile);
21
+ }
22
+ get(name) {
23
+ return this.traits.get(name);
24
+ }
25
+ require(name) {
26
+ const t = this.traits.get(name);
27
+ if (!t) {
28
+ const known = [...this.traits.keys()].join(", ") || "(none)";
29
+ throw new Error(`Unknown trait "${name}". Known: ${known}`);
30
+ }
31
+ return t;
32
+ }
33
+ has(name) {
34
+ return this.traits.has(name);
35
+ }
36
+ all() {
37
+ return [...this.traits.values()];
38
+ }
39
+ }
40
+ /** Ambient registry used by `defineTrait` at module-import time.
41
+ *
42
+ * Stored on `globalThis` so it survives the pnpm+jiti module-duplication problem:
43
+ * the CLI's workmark module and the jiti-loaded trait file's workmark module are
44
+ * distinct instances, but both see the same globalThis slot.
45
+ *
46
+ * The loader installs a fresh registry, imports trait files (which call
47
+ * defineTrait → registerAmbient → reads this slot), then uninstalls. */
48
+ const GLOBAL_KEY = Symbol.for("workmark.ambientRegistry");
49
+ function slot() {
50
+ return globalThis;
51
+ }
52
+ export function installRegistry(reg) {
53
+ slot()[GLOBAL_KEY] = reg;
54
+ }
55
+ export function uninstallRegistry() {
56
+ delete slot()[GLOBAL_KEY];
57
+ }
58
+ export function registerAmbient(trait, sourceFile) {
59
+ const reg = slot()[GLOBAL_KEY];
60
+ if (!reg) {
61
+ throw new Error(`defineTrait called outside of a workspace load. Traits must live in .wm/traits/*.ts files.`);
62
+ }
63
+ reg.register(trait, sourceFile);
64
+ }
@@ -1,69 +1,120 @@
1
1
  import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
2
2
  import type { z } from "zod";
3
- /** An input schema: either a Zod schema (converted automatically) or raw JSON Schema. */
4
- export type InputSchema = z.ZodType | Record<string, unknown>;
5
- /** A record of named Zod schemas or raw JSON Schema property objects. */
6
- export type SchemaFields = Record<string, z.ZodType | Record<string, unknown>>;
3
+ /** A named, schema-backed contract that projects fulfill and commands require.
4
+ *
5
+ * Identity is `name`. Created via `defineTrait`; the returned object carries a
6
+ * phantom type so `cmd({ needs: [docker] })` can infer trait-data types into
7
+ * the handler's `ctx.traits`. */
8
+ export interface Trait<N extends string = string, T = unknown> {
9
+ readonly name: N;
10
+ readonly schema: z.ZodType;
11
+ readonly description?: string;
12
+ /** Phantom for inference. Never accessed at runtime. */
13
+ readonly __brand?: readonly ["trait", N, T];
14
+ }
15
+ /** A field schema: a zod type or raw JSON Schema object. */
16
+ export type SchemaField = z.ZodType | Record<string, unknown>;
17
+ /** A record of named field schemas. */
18
+ export type SchemaFields = Record<string, SchemaField>;
7
19
  export interface ProjectDef {
8
20
  /** Unique identifier. */
9
21
  name: string;
10
22
  /** Project directory relative to the wm.ts location. Defaults to ".". */
11
23
  dir?: string;
12
- /** Arbitrary string tags for grouping/filtering. */
24
+ /** Free-form labels. Filter only — no handler injection. */
13
25
  tags?: string[];
14
26
  /** Named directories, resolved relative to the project dir. */
15
27
  paths?: Record<string, string>;
16
- /** Capabilities this project supports. `true` = use convention, object = custom config. */
17
- capabilities?: Record<string, true | Record<string, unknown>>;
28
+ /** Trait fulfillments. Keys are trait names; values are validated against
29
+ * the trait schema at load time. */
30
+ has?: Record<string, unknown>;
31
+ /** Optional human-readable description. */
32
+ description?: string;
18
33
  }
19
34
  export interface IProject {
20
35
  readonly name: string;
21
36
  readonly dir: string;
22
37
  readonly tags: readonly string[];
23
- readonly capabilities: Readonly<Record<string, true | Record<string, unknown>>>;
24
- has(capability: string): boolean;
25
- capability<T = true | Record<string, unknown>>(name: string): T;
38
+ readonly description?: string;
39
+ /** Check whether this project fulfills a trait (by object or name). */
40
+ hasTrait(trait: Trait | string): boolean;
41
+ /** Get the parsed trait data. Throws if the project does not fulfill it. */
42
+ trait<T>(trait: Trait<string, T>): T;
43
+ /** Resolve a named path relative to this project's directory. */
26
44
  path(name: string): string;
27
45
  }
28
46
  export interface IWorkspace {
29
47
  readonly root: string;
30
48
  readonly projects: readonly IProject[];
31
49
  get(name: string): IProject;
32
- withCapability(cap: string): IProject[];
50
+ withTrait(trait: Trait | string): IProject[];
33
51
  withTag(tag: string): IProject[];
34
52
  }
35
- export type ToolHandler = (args: Record<string, unknown>) => Promise<CallToolResult>;
36
- export interface StaticCommandDef {
37
- name: string;
38
- label: string;
39
- description: string;
40
- /** Positional arguments (order = key order). */
41
- args?: SchemaFields;
42
- /** Named flags (--key value). */
43
- flags?: SchemaFields;
44
- handler: ToolHandler;
53
+ /** How many projects a command runs against. */
54
+ export type SelectMode = "one" | "one-or-many" | "all";
55
+ /** Framework-standard context passed to every handler. */
56
+ export interface BaseCtx {
57
+ workspace: IWorkspace;
58
+ /** Run a shell command (or sequence) in the resolved cwd. An array is executed
59
+ * sequentially; fails fast; output is concatenated. */
60
+ sh: (cmd: string | readonly string[], opts?: {
61
+ timeout?: number;
62
+ env?: Record<string, string>;
63
+ }) => Promise<CallToolResult>;
64
+ /** Run a shell command with explicit options (cwd, timeout, env). */
65
+ exec: (cmd: string, opts: {
66
+ cwd: string;
67
+ timeout?: number;
68
+ env?: Record<string, string>;
69
+ }) => Promise<CallToolResult>;
70
+ ok: (data: unknown) => CallToolResult;
71
+ fail: (error: unknown) => CallToolResult;
72
+ /** Invoke another command by name with wire-shape args. */
73
+ invoke: (name: string, args: Record<string, unknown>) => Promise<CallToolResult>;
45
74
  }
46
- export interface DynamicCommandDef {
47
- name: string;
48
- label: string;
49
- description: string;
50
- factory: (workspace: IWorkspace) => {
51
- args?: SchemaFields;
52
- flags?: SchemaFields;
53
- handler: ToolHandler;
54
- };
75
+ /** Context for commands with `needs`. Adds `project` and `traits`. */
76
+ export interface NeedsCtx<Traits extends Record<string, unknown>> extends BaseCtx {
77
+ project: IProject;
78
+ traits: Traits;
55
79
  }
56
- export type CommandDef = StaticCommandDef | DynamicCommandDef;
80
+ export type HandlerReturn = CallToolResult;
81
+ /** A resolved, runnable command — what the framework holds after load. */
57
82
  export interface ResolvedCommand {
58
83
  name: string;
59
84
  label: string;
60
85
  group: string;
61
86
  description: string;
62
- /** Merged JSON Schema from args + flags. */
63
87
  inputSchema: Record<string, unknown>;
64
- /** Positional arg names in order. */
65
88
  positional: string[];
66
- handler: ToolHandler;
67
- /** Absolute path to the command source file. */
89
+ handler: ResolvedHandler;
68
90
  sourceFile?: string;
91
+ select: SelectMode;
92
+ /** Names of traits this command requires. Empty array = no needs. */
93
+ needs: string[];
94
+ }
95
+ export type ResolvedHandler = (args: Record<string, unknown>) => Promise<CallToolResult>;
96
+ /** Per-project result shape for multi-target commands. */
97
+ export interface ProjectResult {
98
+ project: string;
99
+ ok: boolean;
100
+ output?: string;
101
+ error?: string;
102
+ }
103
+ /** Optional reducer for custom multi-target aggregation. */
104
+ export type ReduceFn = (results: ProjectResult[]) => HandlerReturn | Promise<HandlerReturn>;
105
+ export interface RunOptions {
106
+ order?: "parallel" | "serial";
107
+ concurrency?: number;
108
+ stopOnFailure?: boolean;
109
+ reduce?: ReduceFn;
110
+ }
111
+ /** Internal marker stamped on schemas produced by `fromWorkspace`. */
112
+ export declare const FROM_WORKSPACE: unique symbol;
113
+ /** Marker for invocation-time (args-dependent) schemas — resolved per-call. */
114
+ export declare const FROM_ARGS: unique symbol;
115
+ export interface FromWorkspaceResolver {
116
+ (ws: IWorkspace): z.ZodType;
117
+ }
118
+ export interface FromArgsResolver {
119
+ (ws: IWorkspace, args: Record<string, unknown>): z.ZodType;
69
120
  }
package/dist/lib/types.js CHANGED
@@ -1 +1,5 @@
1
- export {};
1
+ // ---- fromWorkspace marker ----------------------------------------------
2
+ /** Internal marker stamped on schemas produced by `fromWorkspace`. */
3
+ export const FROM_WORKSPACE = Symbol.for("workmark.fromWorkspace");
4
+ /** Marker for invocation-time (args-dependent) schemas — resolved per-call. */
5
+ export const FROM_ARGS = Symbol.for("workmark.fromArgs");
@@ -1,11 +1,14 @@
1
- import type { IProject, IWorkspace } from "./types.js";
1
+ import type { IProject, IWorkspace, Trait } from "./types.js";
2
+ import { TraitRegistry } from "./registry.js";
2
3
  export declare class Workspace implements IWorkspace {
3
4
  readonly root: string;
4
5
  readonly projects: readonly IProject[];
6
+ /** Trait registry populated during load. Exposed for command-load phase. */
7
+ readonly traits: TraitRegistry;
5
8
  private readonly byName;
6
- constructor(root: string, projects: IProject[]);
9
+ constructor(root: string, projects: IProject[], traits: TraitRegistry);
7
10
  get(name: string): IProject;
8
- withCapability(cap: string): IProject[];
11
+ withTrait(trait: Trait | string): IProject[];
9
12
  withTag(tag: string): IProject[];
10
13
  }
11
14
  export declare function loadWorkspace(from?: string): Promise<Workspace>;
@@ -2,17 +2,20 @@ import { dirname, join, relative } from "node:path";
2
2
  import { existsSync, readFileSync, readdirSync } from "node:fs";
3
3
  import { createJiti } from "jiti";
4
4
  import ignore from "ignore";
5
- import { Project } from "./project.js";
5
+ import { Project, validateHas } from "./project.js";
6
+ import { TraitRegistry, installRegistry, uninstallRegistry } from "./registry.js";
6
7
  import { jitiOptions } from "./jiti-options.js";
7
8
  export class Workspace {
8
9
  root;
9
10
  projects;
11
+ /** Trait registry populated during load. Exposed for command-load phase. */
12
+ traits;
10
13
  byName;
11
- constructor(root, projects) {
14
+ constructor(root, projects, traits) {
12
15
  this.root = root;
13
- this.projects = Object.freeze(projects);
16
+ this.projects = Object.freeze([...projects]);
17
+ this.traits = traits;
14
18
  this.byName = new Map(projects.map((p) => [p.name, p]));
15
- // Validate unique names
16
19
  if (this.byName.size !== projects.length) {
17
20
  const seen = new Set();
18
21
  for (const p of projects) {
@@ -28,14 +31,15 @@ export class Workspace {
28
31
  throw new Error(`Unknown project: "${name}"`);
29
32
  return p;
30
33
  }
31
- withCapability(cap) {
32
- return this.projects.filter((p) => p.has(cap));
34
+ withTrait(trait) {
35
+ const name = typeof trait === "string" ? trait : trait.name;
36
+ return this.projects.filter((p) => p.hasTrait(name));
33
37
  }
34
38
  withTag(tag) {
35
39
  return this.projects.filter((p) => p.tags.includes(tag));
36
40
  }
37
41
  }
38
- /** Build an Ignore instance from .gitignore (if present) + hardcoded defaults. */
42
+ // ---- Discovery ---------------------------------------------------------
39
43
  function loadIgnore(root) {
40
44
  const ig = ignore().add(["node_modules", "dist"]);
41
45
  const gitignorePath = join(root, ".gitignore");
@@ -44,7 +48,6 @@ function loadIgnore(root) {
44
48
  }
45
49
  return ig;
46
50
  }
47
- /** Recursively find all wm.ts files, respecting .gitignore rules. */
48
51
  function findWmFiles(root) {
49
52
  const ig = loadIgnore(root);
50
53
  const results = [];
@@ -63,47 +66,81 @@ function findWmFiles(root) {
63
66
  walk(root);
64
67
  return results;
65
68
  }
66
- /** Find the workspace root by walking up from cwd looking for .wm/. */
69
+ function findTraitFiles(root) {
70
+ const traitsDir = join(root, ".wm", "traits");
71
+ if (!existsSync(traitsDir))
72
+ return [];
73
+ const out = [];
74
+ function walk(dir) {
75
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
76
+ const full = join(dir, entry.name);
77
+ if (entry.isDirectory())
78
+ walk(full);
79
+ else if (entry.name.endsWith(".ts") && !entry.name.endsWith(".d.ts")) {
80
+ out.push(full);
81
+ }
82
+ }
83
+ }
84
+ walk(traitsDir);
85
+ return out;
86
+ }
67
87
  function findRoot(from) {
68
88
  let dir = from;
69
89
  while (true) {
70
- if (existsSync(join(dir, ".wm"))) {
90
+ if (existsSync(join(dir, ".wm")))
71
91
  return dir;
72
- }
73
92
  const parent = dirname(dir);
74
93
  if (parent === dir)
75
94
  throw new Error("Could not find workspace root (.wm/ directory)");
76
95
  dir = parent;
77
96
  }
78
97
  }
79
- export async function loadWorkspace(from) {
80
- const root = from ?? process.env.WORKSPACE_ROOT ?? findRoot(process.cwd());
81
- const jiti = createJiti(root, jitiOptions());
82
- // Recursively find all wm.ts files
83
- const wmFiles = findWmFiles(root);
84
- // Import each and build Project instances
98
+ // ---- Load --------------------------------------------------------------
99
+ async function loadTraits(root, jiti) {
100
+ const registry = new TraitRegistry();
101
+ installRegistry(registry);
102
+ for (const file of findTraitFiles(root)) {
103
+ try {
104
+ await jiti.import(file);
105
+ }
106
+ catch (err) {
107
+ uninstallRegistry();
108
+ throw new Error(`Failed to import trait file ${file}: ${err.message}`);
109
+ }
110
+ }
111
+ // Registry stays installed — command loading re-executes trait files
112
+ // under jiti moduleCache:false, and those defineTrait calls need it.
113
+ return registry;
114
+ }
115
+ async function loadProjects(root, jiti, registry) {
85
116
  const projects = [];
86
- for (const wmFile of wmFiles) {
117
+ for (const wmFile of findWmFiles(root)) {
87
118
  const dir = dirname(wmFile);
88
119
  let exported;
89
120
  try {
90
121
  exported = await jiti.import(wmFile);
91
122
  }
92
123
  catch (err) {
93
- // Log import failures helps debug bad wm.ts files
94
- console.error(`[workspace] Skipping ${wmFile}: ${err.message}`);
95
- continue;
124
+ throw new Error(`Failed to import ${wmFile}: ${err.message}`);
96
125
  }
97
- // jiti may return { default: ... } when interopDefault doesn't fully unwrap
98
126
  if (exported && typeof exported === "object" && "default" in exported) {
99
127
  exported = exported.default;
100
128
  }
101
129
  const items = Array.isArray(exported) ? exported : [exported];
102
130
  for (const item of items) {
103
131
  if (item && typeof item === "object" && typeof item.name === "string") {
104
- projects.push(new Project(item, dir));
132
+ const def = item;
133
+ const traitData = validateHas(def, registry, wmFile);
134
+ projects.push(new Project(def, dir, traitData));
105
135
  }
106
136
  }
107
137
  }
108
- return new Workspace(root, projects);
138
+ return projects;
139
+ }
140
+ export async function loadWorkspace(from) {
141
+ const root = from ?? process.env.WORKSPACE_ROOT ?? findRoot(process.cwd());
142
+ const jiti = createJiti(root, jitiOptions());
143
+ const registry = await loadTraits(root, jiti);
144
+ const projects = await loadProjects(root, jiti, registry);
145
+ return new Workspace(root, projects, registry);
109
146
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ldlework/workmark",
3
- "version": "1.3.1",
3
+ "version": "1.4.1",
4
4
  "type": "module",
5
5
  "main": "dist/lib/load.js",
6
6
  "bin": {
package/src/cli.ts CHANGED
@@ -114,7 +114,13 @@ async function main(): Promise<void> {
114
114
  }
115
115
 
116
116
  const args = parseArgs(rest, cmd.positional, cmd.inputSchema);
117
- const result = await cmd.handler(args);
117
+ let result;
118
+ try {
119
+ result = await cmd.handler(args);
120
+ } catch (e) {
121
+ process.stderr.write((e instanceof Error ? e.message : String(e)) + "\n");
122
+ process.exit(1);
123
+ }
118
124
 
119
125
  for (const content of result.content) {
120
126
  if (content.type === "text") {
@@ -127,6 +133,6 @@ async function main(): Promise<void> {
127
133
  }
128
134
 
129
135
  main().catch((err) => {
130
- console.error("Fatal:", err);
136
+ process.stderr.write((err instanceof Error ? err.message : String(err)) + "\n");
131
137
  process.exit(1);
132
138
  });