@rse/ase 0.0.28 → 0.0.29

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/dst/ase-config.js CHANGED
@@ -11,6 +11,8 @@ import { Document, parseDocument, isMap, isScalar } from "yaml";
11
11
  import { execaSync } from "execa";
12
12
  import * as v from "valibot";
13
13
  import Table from "cli-table3";
14
+ import writeFileAtomic from "write-file-atomic";
15
+ import lockfile from "proper-lockfile";
14
16
  /* classification taxonomy */
15
17
  export const projectClassification = {
16
18
  boxing: ["white", "grey", "black"]
@@ -207,8 +209,9 @@ export class Config {
207
209
  findUpward(start, stop, rel) {
208
210
  let dir = fs.realpathSync(start);
209
211
  const end = fs.realpathSync(stop);
210
- const between = path.relative(end, dir);
211
- const steps = between === "" ? 0 : between.split(path.sep).length;
212
+ const steps = dir.split(path.sep).length - end.split(path.sep).length;
213
+ if (steps < 0)
214
+ return null;
212
215
  for (let i = 0; i <= steps; i++) {
213
216
  const candidate = path.join(dir, rel);
214
217
  if (fs.existsSync(candidate))
@@ -280,6 +283,23 @@ export class Config {
280
283
  this.validateDoc(docs[i].doc, docs[i].filename, perDocMode);
281
284
  }
282
285
  }
286
+ /* acquire a cross-process advisory lock on the target scope's file,
287
+ execute the callback, then release the lock */
288
+ lock(cb) {
289
+ const td = this.docs[this.target];
290
+ if (td.scope.kind === "default")
291
+ throw new Error("internal error: \"default\" scope is not lockable");
292
+ fs.mkdirSync(path.dirname(td.filename), { recursive: true });
293
+ if (!fs.existsSync(td.filename))
294
+ fs.writeFileSync(td.filename, "", "utf8");
295
+ const release = lockfile.lockSync(td.filename);
296
+ try {
297
+ cb();
298
+ }
299
+ finally {
300
+ release();
301
+ }
302
+ }
283
303
  /* write in-memory configuration back to the target scope's file */
284
304
  write() {
285
305
  const td = this.docs[this.target];
@@ -287,7 +307,7 @@ export class Config {
287
307
  throw new Error("internal error: \"default\" scope is not writable");
288
308
  this.validateDoc(td.doc, td.filename, "strict");
289
309
  fs.mkdirSync(path.dirname(td.filename), { recursive: true });
290
- fs.writeFileSync(td.filename, td.doc.toString({ indent: 4 }), "utf8");
310
+ writeFileAtomic.sync(td.filename, td.doc.toString({ indent: 4 }), { encoding: "utf8" });
291
311
  }
292
312
  /* validate a single YAML document against the optional schema */
293
313
  validateDoc(doc, filename, mode = "strict") {
@@ -314,9 +334,9 @@ export class Config {
314
334
  progressed = true;
315
335
  }
316
336
  else
317
- /* root-level issue is structurally unrecoverable: do not wipe
318
- the document, let the next strict validate() surface it */
319
- return;
337
+ /* root-level issue cannot be deleted; skip it and process
338
+ remaining issues so progressed is tracked correctly */
339
+ continue;
320
340
  }
321
341
  if (!progressed)
322
342
  return;
@@ -509,14 +529,16 @@ export default class ConfigCommand {
509
529
  if (preset === undefined)
510
530
  throw new Error(`unknown preset "${type}" (expected: default|vibe|pro|industry)`);
511
531
  const cfg = new Config("config", configSchema, this.log, scope);
512
- cfg.read();
513
- const targetKind = scope[scope.length - 1].kind;
514
- for (const [k, val] of Object.entries(preset)) {
515
- if (!cfg.isWritableOn(k, targetKind))
516
- continue;
517
- cfg.set(k, val);
518
- }
519
- cfg.write();
532
+ cfg.lock(() => {
533
+ cfg.read();
534
+ const targetKind = scope[scope.length - 1].kind;
535
+ for (const [k, val] of Object.entries(preset)) {
536
+ if (!cfg.isWritableOn(k, targetKind))
537
+ continue;
538
+ cfg.set(k, val);
539
+ }
540
+ cfg.write();
541
+ });
520
542
  });
521
543
  /* register CLI sub-command "ase config list" */
522
544
  configCmd
@@ -594,9 +616,11 @@ export default class ConfigCommand {
594
616
  .action((key, value, _opts, cmd) => {
595
617
  const scope = parseScope(cmd.optsWithGlobals().scope);
596
618
  const cfg = new Config("config", configSchema, this.log, scope);
597
- cfg.read();
598
- cfg.set(key, value);
599
- cfg.write();
619
+ cfg.lock(() => {
620
+ cfg.read();
621
+ cfg.set(key, value);
622
+ cfg.write();
623
+ });
600
624
  });
601
625
  }
602
626
  }
package/dst/ase-foo.js ADDED
@@ -0,0 +1,21 @@
1
+ /*
2
+ ** Agentic Software Engineering (ASE)
3
+ ** Copyright (c) 2025-2026 Dr. Ralf S. Engelschall <rse@engelschall.com>
4
+ ** Licensed under GPL 3.0 <https://spdx.org/licenses/GPL-3.0-only>
5
+ */
6
+ /* command-line handling */
7
+ export default class FooCommand {
8
+ log;
9
+ constructor(log) {
10
+ this.log = log;
11
+ }
12
+ /* register commands */
13
+ register(program) {
14
+ program
15
+ .command("foo")
16
+ .description("Print a nice Hello World message")
17
+ .action(() => {
18
+ process.stdout.write("Hello, World!\n");
19
+ });
20
+ }
21
+ }
package/dst/ase-hook.js CHANGED
@@ -7,6 +7,7 @@ import path from "node:path";
7
7
  import fs from "node:fs";
8
8
  import os from "node:os";
9
9
  import { execaSync } from "execa";
10
+ import { quote } from "shell-quote";
10
11
  import Version from "./ase-version.js";
11
12
  import { Config, configSchema, parseScope } from "./ase-config.js";
12
13
  const toolSpecs = {
@@ -95,23 +96,16 @@ export default class HookCommand {
95
96
  /* establish config context (session-scoped only if a valid sessionId is present) */
96
97
  const hasSession = /^[A-Za-z0-9._-]+$/.test(sessionId);
97
98
  const cfg = new Config("config", configSchema, this.log, hasSession ? parseScope(`session:${sessionId}`) : parseScope(undefined));
98
- try {
99
+ cfg.lock(() => {
99
100
  cfg.read();
100
- }
101
- catch (_e) {
102
- /* best-effort: ignore failures */
103
- }
101
+ });
104
102
  /* determine task id (only persist when scoped to a real session) */
105
103
  const taskId = process.env.ASE_TASK_ID ?? "default";
106
- if (hasSession) {
107
- try {
104
+ if (hasSession)
105
+ cfg.lock(() => {
108
106
  cfg.set("agent.task", taskId);
109
107
  cfg.write();
110
- }
111
- catch (_e) {
112
- /* best-effort: ignore failures */
113
- }
114
- }
108
+ });
115
109
  /* determine project id */
116
110
  const cwd = input.cwd ?? process.cwd();
117
111
  let projectDir = cwd;
@@ -139,12 +133,12 @@ export default class HookCommand {
139
133
  (Claude Code only -- Copilot CLI has no equivalent mechanism) */
140
134
  const envFile = tool === "claude" ? (process.env.CLAUDE_ENV_FILE ?? "") : "";
141
135
  if (envFile !== "") {
142
- const script = `export ASE_VERSION="${versionCurrentPlugin}"\n` +
143
- `export ASE_USER_ID="${userId}"\n` +
144
- `export ASE_PROJECT_ID="${projectId}"\n` +
145
- `export ASE_TASK_ID="${taskId}"\n` +
146
- `export ASE_SESSION_ID="${sessionId}"\n` +
147
- `export ASE_HEADLESS="${headless}"\n`;
136
+ const script = `export ASE_VERSION=${quote([versionCurrentPlugin])}\n` +
137
+ `export ASE_USER_ID=${quote([userId])}\n` +
138
+ `export ASE_PROJECT_ID=${quote([projectId])}\n` +
139
+ `export ASE_TASK_ID=${quote([taskId])}\n` +
140
+ `export ASE_SESSION_ID=${quote([sessionId])}\n` +
141
+ `export ASE_HEADLESS=${quote([headless])}\n`;
148
142
  fs.appendFileSync(envFile, script, "utf8");
149
143
  }
150
144
  /* prepend ASE information to constitution markdown */
package/dst/ase-kv.js ADDED
@@ -0,0 +1,136 @@
1
+ /*
2
+ ** Agentic Software Engineering (ASE)
3
+ ** Copyright (c) 2025-2026 Dr. Ralf S. Engelschall <rse@engelschall.com>
4
+ ** Licensed under GPL 3.0 <https://spdx.org/licenses/GPL-3.0-only>
5
+ */
6
+ import { z } from "zod";
7
+ /* reusable functionality: in-memory key/value store living inside the
8
+ "ase service" process; per-project (one service per project) and
9
+ not persisted; intended for sharing information between skills
10
+ across multiple Claude Code instances connected to the same service */
11
+ export class KV {
12
+ /* the actual in-memory store */
13
+ static store = new Map();
14
+ /* maximum allowed key length, to keep memory bounded */
15
+ static KEY_MAX_LEN = 1024;
16
+ /* validate the key to keep it non-empty and bounded in length */
17
+ static validateKey(key) {
18
+ if (typeof key !== "string" || key.length === 0)
19
+ throw new Error("kv: key must be a non-empty string");
20
+ if (key.length > KV.KEY_MAX_LEN)
21
+ throw new Error(`kv: key must be no longer than ${KV.KEY_MAX_LEN} characters`);
22
+ }
23
+ /* test whether a value is stored under the given key */
24
+ static has(key) {
25
+ KV.validateKey(key);
26
+ return KV.store.has(key);
27
+ }
28
+ /* get a value by key; returns undefined if no value is stored */
29
+ static get(key) {
30
+ KV.validateKey(key);
31
+ return KV.store.get(key);
32
+ }
33
+ /* set a value under the given key; overwrites any existing value */
34
+ static set(key, val) {
35
+ KV.validateKey(key);
36
+ KV.store.set(key, val);
37
+ }
38
+ /* delete a value by key; returns true if a value existed */
39
+ static delete(key) {
40
+ KV.validateKey(key);
41
+ return KV.store.delete(key);
42
+ }
43
+ /* clear all keys; returns the number of keys removed */
44
+ static clear() {
45
+ const n = KV.store.size;
46
+ KV.store.clear();
47
+ return n;
48
+ }
49
+ }
50
+ /* MCP registration entry point for in-memory key/value tools */
51
+ export class KVMCP {
52
+ register(mcp) {
53
+ /* key/value get */
54
+ mcp.registerTool("kv_get", {
55
+ title: "ASE key/value get",
56
+ description: "Get a value from the in-memory key/value store by `key`. " +
57
+ "Returns the value as JSON-encoded `text`; returns an empty string if no value is stored.",
58
+ inputSchema: {
59
+ key: z.string()
60
+ .describe("key identifier (non-empty string, up to 1024 characters)")
61
+ }
62
+ }, async (args) => {
63
+ try {
64
+ if (!KV.has(args.key))
65
+ return { content: [{ type: "text", text: "" }] };
66
+ const val = KV.get(args.key);
67
+ const text = JSON.stringify(val);
68
+ return { content: [{ type: "text", text }] };
69
+ }
70
+ catch (err) {
71
+ const message = err instanceof Error ? err.message : String(err);
72
+ return { isError: true, content: [{ type: "text", text: `kv_get: ERROR: ${message}` }] };
73
+ }
74
+ });
75
+ /* key/value set */
76
+ mcp.registerTool("kv_set", {
77
+ title: "ASE key/value set",
78
+ description: "Store a `val` under the given `key` in the in-memory key/value store. " +
79
+ "Overwrites any existing value for the same `key`. " +
80
+ "The value can be any JSON-compatible type (string, number, boolean, null, array, object).",
81
+ inputSchema: {
82
+ key: z.string()
83
+ .describe("key identifier (non-empty string, up to 1024 characters)"),
84
+ val: z.any()
85
+ .describe("arbitrary JSON-compatible value to store under `key`")
86
+ }
87
+ }, async (args) => {
88
+ try {
89
+ KV.set(args.key, args.val);
90
+ return { content: [{ type: "text", text: `kv_set: OK: stored key "${args.key}"` }] };
91
+ }
92
+ catch (err) {
93
+ const message = err instanceof Error ? err.message : String(err);
94
+ return { isError: true, content: [{ type: "text", text: `kv_set: ERROR: ${message}` }] };
95
+ }
96
+ });
97
+ /* key/value clear */
98
+ mcp.registerTool("kv_clear", {
99
+ title: "ASE key/value clear",
100
+ description: "Remove all keys from the in-memory key/value store. " +
101
+ "Returns a status `text` indicating how many keys were removed.",
102
+ inputSchema: {}
103
+ }, async () => {
104
+ try {
105
+ const n = KV.clear();
106
+ return { content: [{ type: "text", text: `kv_clear: OK: removed ${n} key(s)` }] };
107
+ }
108
+ catch (err) {
109
+ const message = err instanceof Error ? err.message : String(err);
110
+ return { isError: true, content: [{ type: "text", text: `kv_clear: ERROR: ${message}` }] };
111
+ }
112
+ });
113
+ /* key/value delete */
114
+ mcp.registerTool("kv_delete", {
115
+ title: "ASE key/value delete",
116
+ description: "Delete a value from the in-memory key/value store by `key`. " +
117
+ "Returns a status `text` indicating whether a value existed and was removed.",
118
+ inputSchema: {
119
+ key: z.string()
120
+ .describe("key identifier (non-empty string, up to 1024 characters)")
121
+ }
122
+ }, async (args) => {
123
+ try {
124
+ const removed = KV.delete(args.key);
125
+ const msg = removed ?
126
+ `kv_delete: OK: removed key "${args.key}"` :
127
+ `kv_delete: WARNING: no key "${args.key}" to remove`;
128
+ return { content: [{ type: "text", text: msg }] };
129
+ }
130
+ catch (err) {
131
+ const message = err instanceof Error ? err.message : String(err);
132
+ return { isError: true, content: [{ type: "text", text: `kv_delete: ERROR: ${message}` }] };
133
+ }
134
+ });
135
+ }
136
+ }
package/dst/ase-mcp.js CHANGED
@@ -68,10 +68,12 @@ export default class MCPCommand {
68
68
  return;
69
69
  bridgeDone = true;
70
70
  closedByUs = true;
71
- await Promise.allSettled([
72
- server.close(),
73
- client?.close()
71
+ const timeout = new Promise((resolve) => setTimeout(resolve, 3000));
72
+ await Promise.race([
73
+ Promise.allSettled([server.close(), client?.close()]),
74
+ timeout
74
75
  ]);
76
+ process.exit(0);
75
77
  };
76
78
  /* (re-)connect the HTTP client to the service */
77
79
  const connectClient = async () => {
@@ -97,11 +99,13 @@ export default class MCPCommand {
97
99
  await next.start();
98
100
  };
99
101
  /* reconnect loop: restart service if needed, then reconnect client */
100
- const reconnect = async (attempt = 0) => {
102
+ const reconnect = async (attempt = 0, done) => {
101
103
  const delay = Math.min(500 * 2 ** attempt, 10000);
102
104
  await new Promise((resolve) => setTimeout(resolve, delay));
103
- if (bridgeDone)
105
+ if (bridgeDone) {
106
+ done?.();
104
107
  return;
108
+ }
105
109
  try {
106
110
  const ctx = await this.ensureService();
107
111
  port = ctx.port;
@@ -110,11 +114,12 @@ export default class MCPCommand {
110
114
  closedByUs = false;
111
115
  await connectClient();
112
116
  this.log.write("info", "mcp: reconnected to service");
117
+ done?.();
113
118
  }
114
119
  catch (_err) {
115
120
  const err = _err instanceof Error ? _err : new Error(String(_err));
116
121
  this.log.write("error", `mcp: reconnect failed: ${err.message}`);
117
- reconnect(attempt + 1).catch(() => { });
122
+ reconnect(attempt + 1, done).catch(() => { });
118
123
  }
119
124
  };
120
125
  /* wire stdio server */
@@ -145,7 +150,7 @@ export default class MCPCommand {
145
150
  if (match !== true) {
146
151
  reconnecting = true;
147
152
  this.log.write("warning", "mcp: health check failed — reconnecting");
148
- reconnect().catch(() => { }).finally(() => { reconnecting = false; });
153
+ reconnect(0, () => { reconnecting = false; }).catch(() => { });
149
154
  }
150
155
  }
151
156
  catch { /* ignore probe errors */ }
@@ -29,9 +29,11 @@ export class Persona {
29
29
  parseScope(`session:${session}`) :
30
30
  parseScope(undefined);
31
31
  const cfg = new Config("config", configSchema, log, scope);
32
- cfg.read();
33
- cfg.set("agent.persona", style);
34
- cfg.write();
32
+ cfg.lock(() => {
33
+ cfg.read();
34
+ cfg.set("agent.persona", style);
35
+ cfg.write();
36
+ });
35
37
  }
36
38
  }
37
39
  /* MCP registration entry point for persona tools */
@@ -47,7 +49,7 @@ export default class PersonaMCP {
47
49
  "If `style` is provided, it sets the persona style, " +
48
50
  "otherwise it returns the current persona `style`. " +
49
51
  "If `session` is provided, the operation is scoped to that session, " +
50
- "otherwise it operates on the broadest scope (user/project cascade). " +
52
+ "otherwise it operates on the strongest/closest scope (user/project cascade). " +
51
53
  "Allowed styles: \"writer\" (decorative, eloquent, explaining), " +
52
54
  "\"engineer\" (brief, factual, accurate), " +
53
55
  "\"telegrapher\" (very brief, factual, abbreviating), " +
@@ -15,12 +15,12 @@ import prettyMs from "pretty-ms";
15
15
  import * as v from "valibot";
16
16
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
17
17
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
18
- import { z } from "zod";
19
- import { DateTime } from "luxon";
20
18
  import { Config, configSchema } from "./ase-config.js";
21
19
  import { DiagramMCP } from "./ase-diagram.js";
22
20
  import { TaskMCP } from "./ase-task.js";
21
+ import { KVMCP } from "./ase-kv.js";
23
22
  import PersonaMCP from "./ase-persona.js";
23
+ import { TimestampMCP } from "./ase-timestamp.js";
24
24
  import pkg from "../package.json" with { type: "json" };
25
25
  /* shared service host */
26
26
  export const SERVICE_HOST = "127.0.0.1";
@@ -87,20 +87,26 @@ export class Service {
87
87
  }
88
88
  /* persist an allocated port into ".ase/service.yaml" */
89
89
  static persistPort(svc, port) {
90
- svc.set("port", port);
91
- svc.write();
90
+ svc.lock(() => {
91
+ svc.read();
92
+ svc.set("port", port);
93
+ svc.write();
94
+ });
92
95
  }
93
96
  /* clear the persisted port and remove ".ase/service.yaml" if it is empty */
94
97
  static clearPort(svc) {
95
- svc.delete("port");
96
- const root = svc.get();
97
- const empty = root === undefined || root === null || (isMap(root) && root.items.length === 0);
98
- if (empty) {
99
- if (fs.existsSync(svc.filename))
100
- fs.rmSync(svc.filename);
101
- }
102
- else
103
- svc.write();
98
+ svc.lock(() => {
99
+ svc.read();
100
+ svc.delete("port");
101
+ const root = svc.get();
102
+ const empty = root === undefined || root === null || (isMap(root) && root.items.length === 0);
103
+ if (empty) {
104
+ if (fs.existsSync(svc.filename))
105
+ fs.rmSync(svc.filename);
106
+ }
107
+ else
108
+ svc.write();
109
+ });
104
110
  }
105
111
  /* spawn the current executable detached as a background service */
106
112
  static spawnDetached(aseDir, port) {
@@ -171,30 +177,6 @@ export class ServiceMCP {
171
177
  content: [{ type: "text", text: JSON.stringify(status) }]
172
178
  };
173
179
  });
174
- mcp.registerTool("timestamp", {
175
- title: "ASE timestamp",
176
- description: "Return the current local date/time formatted via a Luxon format string. " +
177
- "Pass the Luxon format tokens as `format` (default: `yyyy-LL-dd HH:mm`). " +
178
- "Returns the formatted timestamp as `text`.",
179
- inputSchema: {
180
- format: z.string().default("yyyy-LL-dd HH:mm")
181
- .describe("Luxon format tokens (default: `yyyy-LL-dd HH:mm`)")
182
- }
183
- }, async (args) => {
184
- try {
185
- const text = DateTime.now().toFormat(args.format);
186
- return {
187
- content: [{ type: "text", text }]
188
- };
189
- }
190
- catch (err) {
191
- const message = err instanceof Error ? err.message : String(err);
192
- return {
193
- isError: true,
194
- content: [{ type: "text", text: `timestamp: format failed: ${message}` }]
195
- };
196
- }
197
- });
198
180
  }
199
181
  }
200
182
  /* CLI command "ase service" */
@@ -233,8 +215,15 @@ export default class ServiceCommand {
233
215
  /* track start time and last activity */
234
216
  const startTime = Date.now();
235
217
  let lastActivity = Date.now();
218
+ let inFlight = 0;
236
219
  let stopping = false;
237
220
  server.ext("onRequest", (_request, h) => {
221
+ inFlight++;
222
+ lastActivity = Date.now();
223
+ return h.continue;
224
+ });
225
+ server.ext("onPreResponse", (_request, h) => {
226
+ inFlight = Math.max(0, inFlight - 1);
238
227
  lastActivity = Date.now();
239
228
  return h.continue;
240
229
  });
@@ -244,7 +233,9 @@ export default class ServiceCommand {
244
233
  new ServiceMCP({ projectId: ctx.projectId, port: ctx.port, startTime }).register(mcp);
245
234
  new DiagramMCP().register(mcp);
246
235
  new TaskMCP(this.log).register(mcp);
236
+ new KVMCP().register(mcp);
247
237
  new PersonaMCP(this.log).register(mcp);
238
+ new TimestampMCP().register(mcp);
248
239
  return mcp;
249
240
  };
250
241
  /* listen to HTTP/REST endpoints */
@@ -359,6 +350,8 @@ export default class ServiceCommand {
359
350
  setInterval(async () => {
360
351
  if (stopping)
361
352
  return;
353
+ if (inFlight > 0)
354
+ return;
362
355
  if (Date.now() - lastActivity > IDLE_MS) {
363
356
  stopping = true;
364
357
  this.log.write("info", "service: idle timeout reached, stopping");
@@ -394,7 +387,7 @@ export default class ServiceCommand {
394
387
  }
395
388
  }
396
389
  /* bounded retry across the bind/start TOCTOU window: on each attempt
397
- re-allocate, re-persist, re-spawn; early-break on foreign listener */
390
+ re-allocate, re-spawn; early-break on foreign listener */
398
391
  let lastErr = new Error("service failed to start within timeout");
399
392
  for (let attempt = 0; attempt < 3; attempt++) {
400
393
  port = await Service.allocatePort();
@@ -449,12 +442,17 @@ export default class ServiceCommand {
449
442
  ]);
450
443
  if (!exited) {
451
444
  child.kill("SIGKILL");
452
- await exitPromise;
445
+ await Promise.race([
446
+ exitPromise,
447
+ new Promise((resolve) => setTimeout(resolve, 2000))
448
+ ]);
449
+ child.unref();
453
450
  }
454
451
  }
452
+ if (!success)
453
+ Service.clearPort(ctx.svc);
455
454
  }
456
455
  }
457
- Service.clearPort(ctx.svc);
458
456
  throw lastErr;
459
457
  }
460
458
  /* status flow: report whether the service is running */
package/dst/ase-task.js CHANGED
@@ -131,9 +131,11 @@ export class Task {
131
131
  static setId(log, session, id) {
132
132
  const scope = parseScope(`session:${session}`);
133
133
  const cfg = new Config("config", configSchema, log, scope);
134
- cfg.read();
135
- cfg.set("agent.task", id);
136
- cfg.write();
134
+ cfg.lock(() => {
135
+ cfg.read();
136
+ cfg.set("agent.task", id);
137
+ cfg.write();
138
+ });
137
139
  }
138
140
  }
139
141
  /* read all of stdin as a UTF-8 string */
@@ -0,0 +1,36 @@
1
+ /*
2
+ ** Agentic Software Engineering (ASE)
3
+ ** Copyright (c) 2025-2026 Dr. Ralf S. Engelschall <rse@engelschall.com>
4
+ ** Licensed under GPL 3.0 <https://spdx.org/licenses/GPL-3.0-only>
5
+ */
6
+ import { z } from "zod";
7
+ import { DateTime } from "luxon";
8
+ /* MCP registration entry point for timestamp tool */
9
+ export class TimestampMCP {
10
+ register(mcp) {
11
+ mcp.registerTool("timestamp", {
12
+ title: "ASE timestamp",
13
+ description: "Return the current local date/time formatted via a Luxon format string. " +
14
+ "Pass the Luxon format tokens as `format` (default: `yyyy-LL-dd HH:mm`). " +
15
+ "Returns the formatted timestamp as `text`.",
16
+ inputSchema: {
17
+ format: z.string().default("yyyy-LL-dd HH:mm")
18
+ .describe("Luxon format tokens (default: `yyyy-LL-dd HH:mm`)")
19
+ }
20
+ }, async (args) => {
21
+ try {
22
+ const text = DateTime.now().toFormat(args.format);
23
+ return {
24
+ content: [{ type: "text", text }]
25
+ };
26
+ }
27
+ catch (err) {
28
+ const message = err instanceof Error ? err.message : String(err);
29
+ return {
30
+ isError: true,
31
+ content: [{ type: "text", text: `timestamp: format failed: ${message}` }]
32
+ };
33
+ }
34
+ });
35
+ }
36
+ }
package/package.json CHANGED
@@ -6,7 +6,7 @@
6
6
  "homepage": "http://github.com/rse/ase",
7
7
  "repository": { "url": "git+https://github.com/rse/ase.git", "type": "git" },
8
8
  "bugs": { "url": "http://github.com/rse/ase/issues" },
9
- "version": "0.0.28",
9
+ "version": "0.0.29",
10
10
  "license": "GPL-3.0-only",
11
11
  "author": {
12
12
  "name": "Dr. Ralf S. Engelschall",
@@ -33,7 +33,10 @@
33
33
  "@types/node": "25.8.0",
34
34
  "@types/luxon": "3.7.1",
35
35
  "@types/which": "3.0.4",
36
- "@types/update-notifier": "6.0.8"
36
+ "@types/update-notifier": "6.0.8",
37
+ "@types/shell-quote": "1.7.5",
38
+ "@types/proper-lockfile": "4.1.4",
39
+ "@types/write-file-atomic": "4.0.3"
37
40
  },
38
41
  "dependencies": {
39
42
  "commander": "14.0.3",
@@ -51,7 +54,10 @@
51
54
  "@modelcontextprotocol/sdk": "1.29.0",
52
55
  "zod": "4.4.3",
53
56
  "which": "7.0.0",
54
- "update-notifier": "7.3.1"
57
+ "update-notifier": "7.3.1",
58
+ "shell-quote": "1.8.3",
59
+ "proper-lockfile": "4.1.2",
60
+ "write-file-atomic": "4.0.0"
55
61
  },
56
62
  "engines": {
57
63
  "npm": ">=10.0.0",