@ldlework/workmark 1.0.0

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/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@ldlework/workmark",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "main": "dist/lib/load.js",
6
+ "bin": {
7
+ "ws": "dist/cli.js"
8
+ },
9
+ "engines": {
10
+ "node": ">=18"
11
+ },
12
+ "exports": {
13
+ ".": "./dist/lib/load.js",
14
+ "./types": "./dist/lib/types.js",
15
+ "./helpers": "./dist/lib/helpers.js",
16
+ "./workspace": "./dist/lib/workspace.js",
17
+ "./define": "./dist/lib/define.js",
18
+ "./project": "./dist/lib/project.js"
19
+ },
20
+ "scripts": {
21
+ "build": "tsc",
22
+ "dev": "tsc --watch",
23
+ "typecheck": "tsc --noEmit"
24
+ },
25
+ "dependencies": {
26
+ "@modelcontextprotocol/sdk": "^1.27.1",
27
+ "ignore": "^7.0.5",
28
+ "jiti": "^2.6.1",
29
+ "zod": "^4.3.6"
30
+ },
31
+ "devDependencies": {
32
+ "@types/node": "^22.0.0",
33
+ "typescript": "^5.7.0"
34
+ }
35
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,172 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { loadCommands } from "./lib/load.js";
4
+ import { loadWorkspace } from "./lib/workspace.js";
5
+ import type { ResolvedCommand } from "./lib/types.js";
6
+
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Arg parsing
10
+ // ---------------------------------------------------------------------------
11
+
12
+ function parseValue(raw: string): unknown {
13
+ if (raw === "true") return true;
14
+ if (raw === "false") return false;
15
+ const n = Number(raw);
16
+ if (!Number.isNaN(n) && raw !== "") return n;
17
+ return raw;
18
+ }
19
+
20
+ function parseArgs(
21
+ argv: string[],
22
+ positionalNames: string[],
23
+ ): Record<string, unknown> {
24
+ const args: Record<string, unknown> = {};
25
+ let posIdx = 0;
26
+
27
+ for (let i = 0; i < argv.length; i++) {
28
+ const token = argv[i];
29
+
30
+ if (token.startsWith("--")) {
31
+ const key = token.slice(2);
32
+ const next = argv[i + 1];
33
+ if (next === undefined || next.startsWith("--")) {
34
+ args[key] = true;
35
+ } else {
36
+ args[key] = parseValue(next);
37
+ i++;
38
+ }
39
+ } else if (posIdx < positionalNames.length) {
40
+ args[positionalNames[posIdx]] = parseValue(token);
41
+ posIdx++;
42
+ }
43
+ }
44
+
45
+ return args;
46
+ }
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // Help
50
+ // ---------------------------------------------------------------------------
51
+
52
+ function printHelp(commands: ResolvedCommand[]): void {
53
+ console.log("Usage: ws <command> [args...]\n");
54
+
55
+ // Group by group name
56
+ const groups = new Map<string, ResolvedCommand[]>();
57
+ for (const cmd of commands) {
58
+ const list = groups.get(cmd.group) ?? [];
59
+ list.push(cmd);
60
+ groups.set(cmd.group, list);
61
+ }
62
+
63
+ const maxCmd = Math.max(...commands.map((c) => {
64
+ const pos = c.positional.map((p) => `<${p}>`).join(" ");
65
+ return (c.name + " " + pos).trim().length;
66
+ }));
67
+
68
+ // Print ungrouped commands first (top-level)
69
+ const ungrouped = groups.get("");
70
+ if (ungrouped) {
71
+ for (const cmd of ungrouped) {
72
+ const pos = cmd.positional.map((p) => `<${p}>`).join(" ");
73
+ const left = (cmd.name + " " + pos).trim();
74
+ console.log(` ${left.padEnd(maxCmd + 4)} ${cmd.description}`);
75
+ }
76
+ console.log();
77
+ }
78
+
79
+ // Then grouped commands
80
+ for (const [group, cmds] of groups) {
81
+ if (group === "") continue;
82
+ console.log(` ${group}:`);
83
+ for (const cmd of cmds) {
84
+ const pos = cmd.positional.map((p) => `<${p}>`).join(" ");
85
+ const left = (cmd.name + " " + pos).trim();
86
+ console.log(` ${left.padEnd(maxCmd + 4)} ${cmd.description}`);
87
+ }
88
+ console.log();
89
+ }
90
+ }
91
+
92
+ function printCommandHelp(cmd: ResolvedCommand): void {
93
+ const pos = cmd.positional.map((p) => `<${p}>`).join(" ");
94
+ console.log(`Usage: ws ${cmd.name} ${pos}\n`);
95
+ console.log(cmd.description);
96
+
97
+ const schema = cmd.inputSchema as {
98
+ properties?: Record<string, { type?: string; description?: string; enum?: string[]; default?: unknown }>;
99
+ required?: string[];
100
+ };
101
+ const props = schema.properties;
102
+ if (!props || Object.keys(props).length === 0) return;
103
+
104
+ const positionalSet = new Set(cmd.positional);
105
+ const required = new Set(schema.required ?? []);
106
+
107
+ console.log("\nArguments:");
108
+ for (const [name, prop] of Object.entries(props)) {
109
+ const isPositional = positionalSet.has(name);
110
+ const isReq = required.has(name);
111
+ const flag = isPositional ? ` ${name}` : ` --${name}`;
112
+ const parts: string[] = [];
113
+ if (prop.enum) parts.push(`[${prop.enum.join("|")}]`);
114
+ if (prop.type) parts.push(`(${prop.type})`);
115
+ if (prop.default !== undefined) parts.push(`default: ${prop.default}`);
116
+ if (isReq && !isPositional) parts.push("required");
117
+ const desc = prop.description ?? "";
118
+ console.log(`${flag.padEnd(20)} ${desc}${parts.length ? " " + parts.join(", ") : ""}`);
119
+ }
120
+ }
121
+
122
+ // ---------------------------------------------------------------------------
123
+ // Main
124
+ // ---------------------------------------------------------------------------
125
+
126
+ async function main(): Promise<void> {
127
+ const [command, ...rest] = process.argv.slice(2);
128
+
129
+ const workspace = await loadWorkspace();
130
+ const commands = await loadCommands(workspace);
131
+
132
+ if (!command || command === "--help" || command === "-h") {
133
+ printHelp(commands);
134
+ return;
135
+ }
136
+
137
+ // Build lookup: command name → ResolvedCommand
138
+ const cmdMap = new Map<string, ResolvedCommand>();
139
+ for (const cmd of commands) {
140
+ cmdMap.set(cmd.name, cmd);
141
+ }
142
+
143
+ const cmd = cmdMap.get(command);
144
+ if (!cmd) {
145
+ console.error(`Unknown command: ${command}`);
146
+ console.error(`Run 'ws --help' for available commands.`);
147
+ process.exit(1);
148
+ }
149
+
150
+ // Per-command help
151
+ if (rest[0] === "--help" || rest[0] === "-h") {
152
+ printCommandHelp(cmd);
153
+ return;
154
+ }
155
+
156
+ const args = parseArgs(rest, cmd.positional);
157
+ const result = await cmd.handler(args);
158
+
159
+ for (const content of result.content) {
160
+ if (content.type === "text") {
161
+ const stream = result.isError ? process.stderr : process.stdout;
162
+ stream.write(content.text + "\n");
163
+ }
164
+ }
165
+
166
+ if (result.isError) process.exit(1);
167
+ }
168
+
169
+ main().catch((err) => {
170
+ console.error("Fatal:", err);
171
+ process.exit(1);
172
+ });
package/src/index.ts ADDED
@@ -0,0 +1,62 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
4
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
+ import {
6
+ CallToolRequestSchema,
7
+ ListToolsRequestSchema,
8
+ ErrorCode,
9
+ McpError,
10
+ } from "@modelcontextprotocol/sdk/types.js";
11
+ import { loadCommands } from "./lib/load.js";
12
+ import { loadWorkspace } from "./lib/workspace.js";
13
+
14
+ async function main(): Promise<void> {
15
+ const server = new Server(
16
+ { name: "workmark", version: "1.0.0" },
17
+ { capabilities: { tools: {} } },
18
+ );
19
+
20
+ const workspace = await loadWorkspace();
21
+ const commands = await loadCommands(workspace);
22
+ const handlers = new Map(commands.map((c) => [c.name, c.handler]));
23
+
24
+ console.error(`Loaded ${commands.length} commands from ${workspace.projects.length} projects`);
25
+
26
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
27
+ tools: commands.map(({ name, description, inputSchema }) => ({
28
+ name,
29
+ description,
30
+ inputSchema,
31
+ })),
32
+ }));
33
+
34
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
35
+ const handler = handlers.get(request.params.name);
36
+ if (!handler) {
37
+ throw new McpError(
38
+ ErrorCode.MethodNotFound,
39
+ `Unknown tool: ${request.params.name}`,
40
+ );
41
+ }
42
+ return handler(request.params.arguments ?? {});
43
+ });
44
+
45
+ server.onerror = (error) => console.error("[MCP Error]", error);
46
+
47
+ const transport = new StdioServerTransport();
48
+ await server.connect(transport);
49
+ console.error("workmark MCP server running on stdio");
50
+
51
+ const cleanup = async () => {
52
+ await server.close();
53
+ process.exit(0);
54
+ };
55
+ process.on("SIGINT", cleanup);
56
+ process.on("SIGTERM", cleanup);
57
+ }
58
+
59
+ main().catch((err) => {
60
+ console.error("Fatal:", err);
61
+ process.exit(1);
62
+ });
@@ -0,0 +1,6 @@
1
+ import type { ProjectDef } from "./types.js";
2
+
3
+ /** Define a project. Identity function for type safety. */
4
+ export function defineProject(def: ProjectDef): ProjectDef {
5
+ return def;
6
+ }
@@ -0,0 +1,65 @@
1
+ import { execSync, exec as cpExec } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
4
+
5
+
6
+ // --- Response helpers ---
7
+
8
+ export function ok(data: unknown): CallToolResult {
9
+ return {
10
+ content: [
11
+ {
12
+ type: "text",
13
+ text: typeof data === "string" ? data : JSON.stringify(data, null, 2),
14
+ },
15
+ ],
16
+ };
17
+ }
18
+
19
+ export function fail(error: unknown): CallToolResult {
20
+ let msg: string;
21
+ if (error instanceof Error) {
22
+ const { stderr, stdout } = error as unknown as Record<string, unknown>;
23
+ msg =
24
+ (typeof stderr === "string" && stderr) ||
25
+ (typeof stdout === "string" && stdout) ||
26
+ error.message;
27
+ } else {
28
+ msg = String(error);
29
+ }
30
+ return { content: [{ type: "text", text: msg }], isError: true };
31
+ }
32
+
33
+ // --- Shell execution ---
34
+
35
+ export interface ExecOptions {
36
+ cwd: string;
37
+ timeout?: number; // ms, default 120_000
38
+ }
39
+
40
+ export function exec(command: string, opts: ExecOptions): string {
41
+ const { cwd, timeout = 120_000 } = opts;
42
+ return execSync(command, {
43
+ cwd,
44
+ timeout,
45
+ encoding: "utf-8",
46
+ stdio: ["ignore", "pipe", "pipe"],
47
+ maxBuffer: 1024 * 1024 * 10,
48
+ });
49
+ }
50
+
51
+ const execPromise = promisify(cpExec);
52
+
53
+ export async function execAsync(
54
+ command: string,
55
+ opts: ExecOptions,
56
+ ): Promise<string> {
57
+ const { cwd, timeout = 120_000 } = opts;
58
+ const { stdout } = await execPromise(command, {
59
+ cwd,
60
+ timeout,
61
+ encoding: "utf-8",
62
+ maxBuffer: 1024 * 1024 * 10,
63
+ });
64
+ return stdout;
65
+ }
@@ -0,0 +1,29 @@
1
+ import { dirname, join } from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+ import type { JitiOptions } from "jiti";
4
+
5
+ // Resolve the workmark package root from this file's location (src/lib/ or dist/lib/)
6
+ const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
7
+ const SRC_LIB = join(PKG_ROOT, "src", "lib");
8
+
9
+ /**
10
+ * Build jiti options that let ws.ts / command files import from
11
+ * `@ldlework/workmark/*` and from workmark's own dependencies (zod, etc.)
12
+ * regardless of how the package is installed or linked.
13
+ */
14
+ export function jitiOptions(opts?: Partial<JitiOptions>): JitiOptions {
15
+ return {
16
+ interopDefault: true,
17
+ moduleCache: false,
18
+ alias: {
19
+ "@ldlework/workmark/types": join(SRC_LIB, "types.ts"),
20
+ "@ldlework/workmark/helpers": join(SRC_LIB, "helpers.ts"),
21
+ "@ldlework/workmark/define": join(SRC_LIB, "define.ts"),
22
+ "@ldlework/workmark/workspace": join(SRC_LIB, "workspace.ts"),
23
+ "@ldlework/workmark/project": join(SRC_LIB, "project.ts"),
24
+ "@ldlework/workmark": join(SRC_LIB, "load.ts"),
25
+ },
26
+ nativeModules: ["zod", "@modelcontextprotocol/sdk"],
27
+ ...opts,
28
+ };
29
+ }
@@ -0,0 +1,122 @@
1
+ import { existsSync, readdirSync, statSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { createJiti } from "jiti";
4
+ import { z } from "zod";
5
+ import type { CommandDef, IWorkspace, ResolvedCommand, SchemaFields, StaticCommandDef } from "./types.js";
6
+ import { jitiOptions } from "./jiti-options.js";
7
+
8
+ /** Title-case a folder name: "docker" → "Docker" */
9
+ function titleCase(s: string): string {
10
+ return s.charAt(0).toUpperCase() + s.slice(1);
11
+ }
12
+
13
+ function isStatic(def: CommandDef): def is StaticCommandDef {
14
+ return "handler" in def;
15
+ }
16
+
17
+ /** Convert a single field (Zod or raw JSON Schema property) to a JSON Schema property. */
18
+ function fieldToJsonSchema(field: z.ZodType | Record<string, unknown>): Record<string, unknown> {
19
+ if (field instanceof z.ZodType) {
20
+ return z.toJSONSchema(field) as Record<string, unknown>;
21
+ }
22
+ return field;
23
+ }
24
+
25
+ /** Merge args + flags into a single JSON Schema object and return positional names. */
26
+ function mergeSchemas(
27
+ args?: SchemaFields,
28
+ flags?: SchemaFields,
29
+ ): { inputSchema: Record<string, unknown>; positional: string[] } {
30
+ const properties: Record<string, unknown> = {};
31
+ const required: string[] = [];
32
+ const positional: string[] = [];
33
+
34
+ // Args are positional, in key order
35
+ if (args) {
36
+ for (const [name, field] of Object.entries(args)) {
37
+ const schema = fieldToJsonSchema(field);
38
+ properties[name] = schema;
39
+ positional.push(name);
40
+ // If no default and not explicitly optional, it's required
41
+ if (!("default" in schema)) {
42
+ required.push(name);
43
+ }
44
+ }
45
+ }
46
+
47
+ // Flags are named (--key value)
48
+ if (flags) {
49
+ for (const [name, field] of Object.entries(flags)) {
50
+ const schema = fieldToJsonSchema(field);
51
+ properties[name] = schema;
52
+ if (!("default" in schema)) {
53
+ required.push(name);
54
+ }
55
+ }
56
+ }
57
+
58
+ const inputSchema: Record<string, unknown> = {
59
+ type: "object",
60
+ properties,
61
+ };
62
+ if (required.length > 0) {
63
+ inputSchema.required = required;
64
+ }
65
+
66
+ return { inputSchema, positional };
67
+ }
68
+
69
+ function resolve(def: CommandDef, workspace: IWorkspace, group: string, sourceFile: string): ResolvedCommand {
70
+ let args: SchemaFields | undefined;
71
+ let flags: SchemaFields | undefined;
72
+ let handler;
73
+
74
+ if (isStatic(def)) {
75
+ args = def.args;
76
+ flags = def.flags;
77
+ handler = def.handler;
78
+ } else {
79
+ const result = def.factory(workspace);
80
+ args = result.args;
81
+ flags = result.flags;
82
+ handler = result.handler;
83
+ }
84
+
85
+ const { inputSchema, positional } = mergeSchemas(args, flags);
86
+ return { name: def.name, label: def.label, group, description: def.description, inputSchema, positional, handler, sourceFile };
87
+ }
88
+
89
+ export async function loadCommands(workspace: IWorkspace): Promise<ResolvedCommand[]> {
90
+ const commandsDir = join(workspace.root, ".ws", "commands");
91
+ if (!existsSync(commandsDir)) return [];
92
+
93
+ const jiti = createJiti(commandsDir, jitiOptions());
94
+ const commands: ResolvedCommand[] = [];
95
+
96
+ // Ungrouped (root-level) commands first
97
+ for (const entry of readdirSync(commandsDir)) {
98
+ const entryPath = join(commandsDir, entry);
99
+ const stat = statSync(entryPath);
100
+ if (!stat.isDirectory() && entry.endsWith(".ts") && !entry.endsWith(".d.ts")) {
101
+ const mod = await jiti.import(entryPath) as { default: CommandDef };
102
+ commands.push(resolve(mod.default, workspace, "", entryPath));
103
+ }
104
+ }
105
+
106
+ // Grouped commands (subdirectories)
107
+ for (const entry of readdirSync(commandsDir)) {
108
+ const entryPath = join(commandsDir, entry);
109
+ const stat = statSync(entryPath);
110
+ if (stat.isDirectory()) {
111
+ const group = titleCase(entry);
112
+ for (const file of readdirSync(entryPath)) {
113
+ if (!file.endsWith(".ts") || file.endsWith(".d.ts")) continue;
114
+ const filePath = join(entryPath, file);
115
+ const mod = await jiti.import(filePath) as { default: CommandDef };
116
+ commands.push(resolve(mod.default, workspace, group, filePath));
117
+ }
118
+ }
119
+ }
120
+
121
+ return commands;
122
+ }
@@ -0,0 +1,39 @@
1
+ import { join } from "node:path";
2
+ import type { IProject, ProjectDef } from "./types.js";
3
+
4
+ export class Project implements IProject {
5
+ readonly name: string;
6
+ readonly dir: string;
7
+ readonly tags: readonly string[];
8
+ readonly capabilities: Readonly<Record<string, true | Record<string, unknown>>>;
9
+
10
+ private readonly paths: Readonly<Record<string, string>>;
11
+
12
+ constructor(def: ProjectDef, wsDir: string) {
13
+ this.name = def.name;
14
+ this.dir = def.dir ? join(wsDir, def.dir) : wsDir;
15
+ this.tags = Object.freeze(def.tags ?? []);
16
+ this.capabilities = Object.freeze(def.capabilities ?? {});
17
+ this.paths = Object.freeze(def.paths ?? {});
18
+ }
19
+
20
+ has(capability: string): boolean {
21
+ return capability in this.capabilities;
22
+ }
23
+
24
+ capability<T = true | Record<string, unknown>>(name: string): T {
25
+ const cap = this.capabilities[name];
26
+ if (cap === undefined) {
27
+ throw new Error(`Project "${this.name}" does not have capability "${name}"`);
28
+ }
29
+ return cap as T;
30
+ }
31
+
32
+ path(name: string): string {
33
+ const rel = this.paths[name];
34
+ if (rel === undefined) {
35
+ throw new Error(`Project "${this.name}" does not have path "${name}"`);
36
+ }
37
+ return join(this.dir, rel);
38
+ }
39
+ }
@@ -0,0 +1,89 @@
1
+ import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
2
+ import type { z } from "zod";
3
+
4
+ /** An input schema: either a Zod schema (converted automatically) or raw JSON Schema. */
5
+ export type InputSchema = z.ZodType | Record<string, unknown>;
6
+
7
+ /** A record of named Zod schemas or raw JSON Schema property objects. */
8
+ export type SchemaFields = Record<string, z.ZodType | Record<string, unknown>>;
9
+
10
+ // ---- Project definitions (used in ws.ts files) ----
11
+
12
+ export interface ProjectDef {
13
+ /** Unique identifier. */
14
+ name: string;
15
+ /** Project directory relative to the ws.ts location. Defaults to ".". */
16
+ dir?: string;
17
+ /** Arbitrary string tags for grouping/filtering. */
18
+ tags?: string[];
19
+ /** Named directories, resolved relative to the project dir. */
20
+ paths?: Record<string, string>;
21
+ /** Capabilities this project supports. `true` = use convention, object = custom config. */
22
+ capabilities?: Record<string, true | Record<string, unknown>>;
23
+ }
24
+
25
+ // ---- Runtime project/workspace interfaces ----
26
+
27
+ export interface IProject {
28
+ readonly name: string;
29
+ readonly dir: string;
30
+ readonly tags: readonly string[];
31
+ readonly capabilities: Readonly<Record<string, true | Record<string, unknown>>>;
32
+
33
+ has(capability: string): boolean;
34
+ capability<T = true | Record<string, unknown>>(name: string): T;
35
+ path(name: string): string;
36
+ }
37
+
38
+ export interface IWorkspace {
39
+ readonly root: string;
40
+ readonly projects: readonly IProject[];
41
+
42
+ get(name: string): IProject;
43
+ withCapability(cap: string): IProject[];
44
+ withTag(tag: string): IProject[];
45
+ }
46
+
47
+ // ---- Command definitions ----
48
+
49
+ export type ToolHandler = (
50
+ args: Record<string, unknown>,
51
+ ) => Promise<CallToolResult>;
52
+
53
+ export interface StaticCommandDef {
54
+ name: string;
55
+ label: string;
56
+ description: string;
57
+ /** Positional arguments (order = key order). */
58
+ args?: SchemaFields;
59
+ /** Named flags (--key value). */
60
+ flags?: SchemaFields;
61
+ handler: ToolHandler;
62
+ }
63
+
64
+ export interface DynamicCommandDef {
65
+ name: string;
66
+ label: string;
67
+ description: string;
68
+ factory: (workspace: IWorkspace) => {
69
+ args?: SchemaFields;
70
+ flags?: SchemaFields;
71
+ handler: ToolHandler;
72
+ };
73
+ }
74
+
75
+ export type CommandDef = StaticCommandDef | DynamicCommandDef;
76
+
77
+ export interface ResolvedCommand {
78
+ name: string;
79
+ label: string;
80
+ group: string;
81
+ description: string;
82
+ /** Merged JSON Schema from args + flags. */
83
+ inputSchema: Record<string, unknown>;
84
+ /** Positional arg names in order. */
85
+ positional: string[];
86
+ handler: ToolHandler;
87
+ /** Absolute path to the command source file. */
88
+ sourceFile?: string;
89
+ }