@rse/ase 1.0.4 → 1.0.6

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-setup.js CHANGED
@@ -10,12 +10,12 @@ import { fileURLToPath } from "node:url";
10
10
  import { execa } from "execa";
11
11
  import which from "which";
12
12
  import * as dotenvx from "@dotenvx/dotenvx";
13
- import Table from "cli-table3";
14
13
  import chalk from "chalk";
15
14
  import writeFileAtomic from "write-file-atomic";
16
15
  import { mkdirp } from "mkdirp";
17
16
  import JsonAsty from "json-asty";
18
17
  import Version from "./ase-version.js";
18
+ import { renderTable } from "./ase-table.js";
19
19
  const toolSpecs = {
20
20
  "claude": { cli: "claude", label: "Anthropic Claude Code CLI", pInstall: "install", pRemove: "uninstall", pUpdate: "update" },
21
21
  "copilot": { cli: "copilot", label: "GitHub Copilot CLI", pInstall: "install", pRemove: "uninstall", pUpdate: "update" },
@@ -143,6 +143,9 @@ export default class SetupCommand {
143
143
  const scopeArgs = tool === "claude" && scope !== "user" ? ["--scope", scope] : [];
144
144
  await this.run(spec.cli, ["plugin", "marketplace", "add", source, ...scopeArgs]);
145
145
  await this.run(spec.cli, ["plugin", spec.pInstall, "ase@ase", ...scopeArgs], { retries: 3 });
146
+ /* select the plugin-shipped ASE output style (Anthropic Claude Code CLI only) */
147
+ if (tool === "claude")
148
+ await this.outputStyleActivate(`install${dev ? "[dev]" : ""}`, scope);
146
149
  return 0;
147
150
  }
148
151
  /* handler for "ase setup update" (both tools) */
@@ -197,6 +200,9 @@ export default class SetupCommand {
197
200
  await this.run(spec.cli, ["plugin", spec.pInstall, "ase@ase", ...scopeArgs], { retries: 3 });
198
201
  }
199
202
  }
203
+ /* select the plugin-shipped ASE output style (Anthropic Claude Code CLI only) */
204
+ if (tool === "claude")
205
+ await this.outputStyleActivate(`update${dev ? "[dev]" : ""}`, scope);
200
206
  return 0;
201
207
  }
202
208
  /* handler for "ase setup enable|disable" (both tools) */
@@ -227,6 +233,9 @@ export default class SetupCommand {
227
233
  this.log.write("info", `setup: uninstall${dev ? "[dev]" : ""}: ` +
228
234
  "stopping potentially running ASE service");
229
235
  await this.run("ase", ["service", "stop"], { quiet: true, ignoreError: "ASE service not running" });
236
+ /* deselect the plugin-shipped ASE output style (Anthropic Claude Code CLI only) */
237
+ if (tool === "claude")
238
+ await this.outputStyleDeactivate(`uninstall${dev ? "[dev]" : ""}`, scope);
230
239
  /* uninstall ASE plugin */
231
240
  this.log.write("info", `setup: uninstall${dev ? "[dev]" : ""}: ` +
232
241
  `uninstalling ASE ${spec.label} plugin (origin: ${dev ? "local" : "remote/bundled"})`);
@@ -245,21 +254,15 @@ export default class SetupCommand {
245
254
  const spec = toolSpecs[tool];
246
255
  await this.ensureTool(spec.cli);
247
256
  this.log.write("info", `setup: status: probing ASE registrations for ${spec.label}`);
248
- const table = new Table({
249
- head: ["KIND", "ID", "SCOPE", "STATUS"],
250
- colWidths: [13, 34, 10, 16],
251
- wordWrap: true,
252
- chars: { "mid": "", "left-mid": "", "mid-mid": "", "right-mid": "" },
253
- style: { head: ["blue"] }
254
- });
257
+ const rows = [];
255
258
  /* report the ASE plugin registrations, with an absent plugin
256
259
  still rendered as an explicit row instead of being omitted */
257
260
  const plugins = await this.pluginStatus(tool);
258
261
  if (plugins.length === 0)
259
- table.push(["PLUGIN", chalk.bold("ase@ase"), "(n/a)", "not installed"]);
262
+ rows.push(["PLUGIN", chalk.bold("ase@ase"), "(n/a)", "not installed"]);
260
263
  else
261
264
  for (const plugin of plugins)
262
- table.push(["PLUGIN", chalk.bold("ase@ase"), plugin.scope, plugin.status]);
265
+ rows.push(["PLUGIN", chalk.bold("ase@ase"), plugin.scope, plugin.status]);
263
266
  /* report the registered MCP servers, probed concurrently and
264
267
  silently skipping every server which is not registered at all */
265
268
  const scopes = await Promise.all(this.mcpServers.map((handle) => this.mcpScope(tool, handle.server)));
@@ -267,12 +270,15 @@ export default class SetupCommand {
267
270
  const scope = scopes[i];
268
271
  if (scope === undefined)
269
272
  continue;
270
- table.push(["MCP", chalk.bold(this.mcpServers[i].server), scope, "registered"]);
273
+ rows.push(["MCP", chalk.bold(this.mcpServers[i].server), scope, "registered"]);
271
274
  }
275
+ /* report the ASE output style selections */
276
+ for (const outputStyle of await this.outputStyleStatus(tool))
277
+ rows.push(["OUTPUTSTYLE", chalk.bold(outputStyle.file), outputStyle.scope, outputStyle.status]);
272
278
  /* report the ASE statusline registrations */
273
279
  for (const statusline of await this.statuslineStatus(tool))
274
- table.push(["STATUSLINE", chalk.bold(statusline.file), statusline.scope, statusline.status]);
275
- process.stdout.write(`${table.toString()}\n`);
280
+ rows.push(["STATUSLINE", chalk.bold(statusline.file), statusline.scope, statusline.status]);
281
+ process.stdout.write(renderTable(["KIND", "ID", "SCOPE", "STATUS"], rows));
276
282
  return 0;
277
283
  }
278
284
  /* probe the ASE plugin registrations by scraping the human-readable
@@ -323,15 +329,9 @@ export default class SetupCommand {
323
329
  }
324
330
  /* handler for "ase setup mcp list" */
325
331
  async doMcpList() {
326
- const table = new Table({
327
- head: ["ID", "NAME", "VERS", "MCP", "KEY", "SKILLS"],
328
- colWidths: [16, 16, 8, 21, 17, 20],
329
- wordWrap: true,
330
- chars: { "mid": "", "left-mid": "", "mid-mid": "", "right-mid": "" },
331
- style: { head: ["blue"] }
332
- });
332
+ const rows = [];
333
333
  for (const handle of this.mcpServers)
334
- table.push([
334
+ rows.push([
335
335
  chalk.bold(handle.id),
336
336
  handle.name,
337
337
  handle.version ?? "(unknown)",
@@ -339,7 +339,7 @@ export default class SetupCommand {
339
339
  handle.env.join(", "),
340
340
  handle.skills.join(", ")
341
341
  ]);
342
- process.stdout.write(`${table.toString()}\n`);
342
+ process.stdout.write(renderTable(["ID", "NAME", "VERS", "MCP", "KEY", "SKILLS"], rows));
343
343
  return 0;
344
344
  }
345
345
  /* handler for "ase setup mcp activate|deactivate [<servers>]" */
@@ -672,7 +672,7 @@ export default class SetupCommand {
672
672
  "%P %h %B %c"
673
673
  ];
674
674
  /* resolve the tool settings file for a given installation scope */
675
- statuslineSettingsFile(tool, scope) {
675
+ settingsFile(tool, scope) {
676
676
  if (tool === "copilot") {
677
677
  const home = process.env.COPILOT_HOME ?? "";
678
678
  const base = home !== "" ? home : path.join(os.homedir(), ".copilot");
@@ -728,11 +728,11 @@ export default class SetupCommand {
728
728
  }
729
729
  return false;
730
730
  }
731
- /* locate the top-level "statusLine" member node within the root object */
732
- statuslineFindMember(root) {
731
+ /* locate a top-level member node by key within the root object */
732
+ settingsFindMember(root, name) {
733
733
  for (const m of root.query("/ member")) {
734
734
  const key = m.query("/ string [ pos() == 1 ]")[0];
735
- if (key !== undefined && key.get("value") === "statusLine")
735
+ if (key !== undefined && key.get("value") === name)
736
736
  return m;
737
737
  }
738
738
  return undefined;
@@ -761,7 +761,7 @@ export default class SetupCommand {
761
761
  return root.create("member").add(key, obj);
762
762
  }
763
763
  /* normalize the previous-last member so a following ",\n " comma reads cleanly */
764
- statuslineMakeNonLast(member) {
764
+ settingsMakeNonLast(member) {
765
765
  const val = member.query("/ * [ pos() == 2 ]")[0];
766
766
  if (val !== undefined) {
767
767
  const ep = val.get("epilog");
@@ -770,8 +770,42 @@ export default class SetupCommand {
770
770
  }
771
771
  member.set({ epilog: ",\n " });
772
772
  }
773
+ /* append a member to the root object, repairing the whitespace at
774
+ the seam: a scalar value carries no pre-"}" newline of its own
775
+ (a container value carries it in its own epilog already), so the
776
+ root epilog has to supply it */
777
+ settingsAddMember(root, member, scalar) {
778
+ const members = root.query("/ member");
779
+ if (members.length === 0)
780
+ root.set({ prolog: "{\n " });
781
+ else
782
+ this.settingsMakeNonLast(members[members.length - 1]);
783
+ root.set({ epilog: scalar ? "\n}\n" : "}\n" });
784
+ root.add(member);
785
+ }
786
+ /* remove a member from the root object, repairing the whitespace at the seam */
787
+ settingsRemoveMember(root, target) {
788
+ const members = root.query("/ member");
789
+ const wasLast = members[members.length - 1] === target;
790
+ root.del(target);
791
+ if (wasLast) {
792
+ if (members.length > 1) {
793
+ /* promote the new last member: strip its comma epilog and
794
+ restore the pre-"}" newline into the root epilog when the
795
+ new-last value is a scalar (a container value carries the
796
+ newline in its own epilog already) */
797
+ const newLast = members[members.length - 2];
798
+ newLast.set({ epilog: undefined });
799
+ const val = newLast.query("/ * [ pos() == 2 ]")[0];
800
+ const ep = val !== undefined ? val.get("epilog") : undefined;
801
+ root.set({ epilog: (typeof ep === "string" && ep.endsWith("\n")) ? "}\n" : "\n}\n" });
802
+ }
803
+ else
804
+ root.set({ epilog: "}\n" });
805
+ }
806
+ }
773
807
  /* read a settings.json file into a JSON-ASTy AST */
774
- async statuslineReadAst(file) {
808
+ async settingsReadAst(file) {
775
809
  let text = "";
776
810
  try {
777
811
  text = await fs.readFile(file, "utf8");
@@ -784,7 +818,7 @@ export default class SetupCommand {
784
818
  return JsonAsty.parse(text);
785
819
  }
786
820
  /* write a JSON-ASTy AST back to a settings.json file */
787
- async statuslineWriteAst(file, root) {
821
+ async settingsWriteAst(file, root) {
788
822
  await mkdirp(path.dirname(file));
789
823
  const text = JsonAsty.unparse(root);
790
824
  await writeFileAtomic(file, text, { encoding: "utf8" });
@@ -793,10 +827,10 @@ export default class SetupCommand {
793
827
  async doStatuslineActivate(tool, scope, opts, format) {
794
828
  this.requireStatuslineTool(tool);
795
829
  this.requireStatuslineScope(tool, scope);
796
- const file = this.statuslineSettingsFile(tool, scope);
830
+ const file = this.settingsFile(tool, scope);
797
831
  const command = this.statuslineCommand(tool, opts, format);
798
- const root = await this.statuslineReadAst(file);
799
- const existing = this.statuslineFindMember(root);
832
+ const root = await this.settingsReadAst(file);
833
+ const existing = this.settingsFindMember(root, "statusLine");
800
834
  if (existing !== undefined) {
801
835
  /* preserve a foreign, hand-crafted statusLine: skip and warn */
802
836
  if (!this.statuslineIsOwned(existing)) {
@@ -813,27 +847,17 @@ export default class SetupCommand {
813
847
  }
814
848
  else {
815
849
  /* insert a fresh statusLine member */
816
- const members = root.query("/ member");
817
- const member = this.statuslineBuildMember(root, command);
818
- if (members.length === 0) {
819
- root.set({ prolog: "{\n ", epilog: "}\n" });
820
- root.add(member);
821
- }
822
- else {
823
- root.set({ epilog: "}\n" });
824
- this.statuslineMakeNonLast(members[members.length - 1]);
825
- root.add(member);
826
- }
850
+ this.settingsAddMember(root, this.statuslineBuildMember(root, command), false);
827
851
  this.log.write("info", `setup: statusline: activate: adding ASE "statusLine" to ${file}`);
828
852
  }
829
- await this.statuslineWriteAst(file, root);
853
+ await this.settingsWriteAst(file, root);
830
854
  return 0;
831
855
  }
832
856
  /* handler for "ase setup statusline deactivate" */
833
857
  async doStatuslineDeactivate(tool, scope) {
834
858
  this.requireStatuslineTool(tool);
835
859
  this.requireStatuslineScope(tool, scope);
836
- const file = this.statuslineSettingsFile(tool, scope);
860
+ const file = this.settingsFile(tool, scope);
837
861
  /* a missing settings file means nothing to remove */
838
862
  try {
839
863
  await fs.access(file);
@@ -843,8 +867,8 @@ export default class SetupCommand {
843
867
  return 0;
844
868
  }
845
869
  /* read file */
846
- const root = await this.statuslineReadAst(file);
847
- const target = this.statuslineFindMember(root);
870
+ const root = await this.settingsReadAst(file);
871
+ const target = this.settingsFindMember(root, "statusLine");
848
872
  if (target === undefined) {
849
873
  this.log.write("info", `setup: statusline: deactivate: no "statusLine" in ${file} (skipped)`);
850
874
  return 0;
@@ -856,26 +880,8 @@ export default class SetupCommand {
856
880
  return 0;
857
881
  }
858
882
  /* remove the member and repair the whitespace at the seam */
859
- const members = root.query("/ member");
860
- const wasLast = members[members.length - 1] === target;
861
- root.del(target);
862
- if (wasLast) {
863
- if (members.length > 1) {
864
- /* promote the new last member: strip its comma epilog and
865
- restore the pre-"}" newline into the root epilog when the
866
- new-last value is a scalar (a container value carries the
867
- newline in its own epilog already) */
868
- const newLast = members[members.length - 2];
869
- newLast.set({ epilog: undefined });
870
- const val = newLast.query("/ * [ pos() == 2 ]")[0];
871
- const ep = val !== undefined ? val.get("epilog") : undefined;
872
- if (!(typeof ep === "string" && ep.endsWith("\n")))
873
- root.set({ epilog: "\n}\n" });
874
- }
875
- else
876
- root.set({ epilog: "}\n" });
877
- }
878
- await this.statuslineWriteAst(file, root);
883
+ this.settingsRemoveMember(root, target);
884
+ await this.settingsWriteAst(file, root);
879
885
  this.log.write("info", `setup: statusline: deactivate: removing ASE "statusLine" from ${file}`);
880
886
  return 0;
881
887
  }
@@ -889,16 +895,16 @@ export default class SetupCommand {
889
895
  const scopes = tool === "claude" ? ["user", "project", "local"] : ["user"];
890
896
  const entries = [];
891
897
  for (const scope of scopes) {
892
- const file = this.statuslineSettingsFile(tool, scope);
898
+ const file = this.settingsFile(tool, scope);
893
899
  let root;
894
900
  try {
895
- root = await this.statuslineReadAst(file);
901
+ root = await this.settingsReadAst(file);
896
902
  }
897
903
  catch {
898
904
  /* an unparsable settings file carries no usable state */
899
905
  continue;
900
906
  }
901
- const member = this.statuslineFindMember(root);
907
+ const member = this.settingsFindMember(root, "statusLine");
902
908
  if (member === undefined)
903
909
  continue;
904
910
  entries.push({
@@ -909,6 +915,90 @@ export default class SetupCommand {
909
915
  }
910
916
  return entries;
911
917
  }
918
+ /* determine whether an existing "outputStyle" value is owned by us */
919
+ outputStyleIsOwned(member) {
920
+ const val = member.query("/ string [ pos() == 2 ]")[0];
921
+ return val !== undefined && val.get("value") === "ase:ase-terse";
922
+ }
923
+ /* build the "outputStyle" member subtree natively inside the target AST */
924
+ outputStyleBuildMember(root) {
925
+ const key = root.create("string").set({ body: JSON.stringify("outputStyle"), value: "outputStyle", epilog: ": " });
926
+ const val = root.create("string").set({ body: JSON.stringify("ase:ase-terse"), value: "ase:ase-terse" });
927
+ return root.create("member").add(key, val);
928
+ }
929
+ /* activate the plugin-shipped ASE output style by selecting it in
930
+ the settings file of the scope (Anthropic Claude Code CLI only:
931
+ the other tools have no output style concept, so the session-start
932
+ hook injects the style into their session context instead) */
933
+ async outputStyleActivate(action, scope) {
934
+ const file = this.settingsFile("claude", scope);
935
+ const root = await this.settingsReadAst(file);
936
+ const existing = this.settingsFindMember(root, "outputStyle");
937
+ if (existing !== undefined) {
938
+ /* preserve a foreign, hand-selected outputStyle: skip and warn */
939
+ if (!this.outputStyleIsOwned(existing))
940
+ this.log.write("warning", `setup: ${action}: a non-ASE "outputStyle" ` +
941
+ `is already selected in ${file}: preserving it (skipped)`);
942
+ return;
943
+ }
944
+ this.settingsAddMember(root, this.outputStyleBuildMember(root), true);
945
+ await this.settingsWriteAst(file, root);
946
+ this.log.write("info", `setup: ${action}: selecting ASE "outputStyle" in ${file}`);
947
+ }
948
+ /* deactivate the plugin-shipped ASE output style by removing its
949
+ selection from the settings file of the scope (Anthropic Claude Code CLI only) */
950
+ async outputStyleDeactivate(action, scope) {
951
+ const file = this.settingsFile("claude", scope);
952
+ /* a missing settings file means nothing to remove */
953
+ try {
954
+ await fs.access(file);
955
+ }
956
+ catch {
957
+ return;
958
+ }
959
+ const root = await this.settingsReadAst(file);
960
+ const target = this.settingsFindMember(root, "outputStyle");
961
+ if (target === undefined)
962
+ return;
963
+ /* preserve a foreign, hand-selected outputStyle: skip and warn */
964
+ if (!this.outputStyleIsOwned(target)) {
965
+ this.log.write("warning", `setup: ${action}: a non-ASE "outputStyle" ` +
966
+ `is selected in ${file}: preserving it (skipped)`);
967
+ return;
968
+ }
969
+ this.settingsRemoveMember(root, target);
970
+ await this.settingsWriteAst(file, root);
971
+ this.log.write("info", `setup: ${action}: deselecting ASE "outputStyle" in ${file}`);
972
+ }
973
+ /* probe the output style selections by inspecting the tool
974
+ settings files of all installation scopes (Anthropic Claude Code CLI only) */
975
+ async outputStyleStatus(tool) {
976
+ if (tool !== "claude")
977
+ return [];
978
+ const home = os.homedir();
979
+ const scopes = ["user", "project", "local"];
980
+ const entries = [];
981
+ for (const scope of scopes) {
982
+ const file = this.settingsFile(tool, scope);
983
+ let root;
984
+ try {
985
+ root = await this.settingsReadAst(file);
986
+ }
987
+ catch {
988
+ /* an unparsable settings file carries no usable state */
989
+ continue;
990
+ }
991
+ const member = this.settingsFindMember(root, "outputStyle");
992
+ if (member === undefined)
993
+ continue;
994
+ entries.push({
995
+ file: file.startsWith(home + path.sep) ? `~${file.slice(home.length)}` : file,
996
+ scope,
997
+ status: this.outputStyleIsOwned(member) ? "selected" : "foreign"
998
+ });
999
+ }
1000
+ return entries;
1001
+ }
912
1002
  /* parse and validate the --tool option */
913
1003
  parseTool(value) {
914
1004
  if (value !== "claude" && value !== "copilot" && value !== "codex")
@@ -997,7 +1087,7 @@ export default class SetupCommand {
997
1087
  /* register CLI sub-command "ase setup status" */
998
1088
  setupCmd
999
1089
  .command("status")
1000
- .description("report the ASE plugin, MCP server, and statusline registrations for a tool")
1090
+ .description("report the ASE plugin, MCP server, output style, and statusline registrations for a tool")
1001
1091
  .option("-t, --tool <tool>", "target tool (\"claude\", \"copilot\", or \"codex\")", toolDflt)
1002
1092
  .action(async (opts) => {
1003
1093
  process.exit(await this.doStatus(this.parseTool(opts.tool)));
package/dst/ase-spec.js CHANGED
@@ -18,18 +18,22 @@ import { writeStdout } from "./ase-stdio.js";
18
18
  project specification, located via the "project.artifact.spec.basedir"
19
19
  and "project.artifact.spec.schema" configuration */
20
20
  export class Spec {
21
- /* resolve the YAML schema configuration files: the configured
22
- "project.artifact.spec.schema" relative to the project root, or
23
- the bundled standard "ase-format-specbook.yaml" plugin meta file
24
- if unset or empty */
21
+ /* resolve the YAML schema configuration files: the whitespace-separated
22
+ entries of the configured "project.artifact.spec.schema", merged in
23
+ the given order, where each entry is either the literal "std" for the
24
+ bundled standard "ase-format-specbook.yaml" plugin meta file or a file
25
+ path relative to the project root, and an unset or empty value means "std" */
25
26
  static configFiles(log) {
26
27
  const cfg = new Config("config", configSchema, log);
27
28
  cfg.read();
28
29
  const val = cfg.get("project.artifact.spec.schema");
29
- const file = val === undefined ? "" : String(isScalar(val) ? val.value : val);
30
- if (file === "")
31
- return [Meta.resolve("ase-format-specbook.yaml")];
32
- return [path.resolve(Task.projectRoot(), file)];
30
+ const list = val === undefined ? "" : String(isScalar(val) ? val.value : val);
31
+ const entries = list.split(/\s+/).filter((entry) => entry !== "");
32
+ if (entries.length === 0)
33
+ entries.push("std");
34
+ return entries.map((entry) => entry === "std" ?
35
+ Meta.resolve("ase-format-specbook.yaml") :
36
+ path.resolve(Task.projectRoot(), entry));
33
37
  }
34
38
  /* the ASE log level each SpecBook verbosity level maps onto: the
35
39
  "none" level carries the messages a bare SpecBook CLI run always
@@ -80,12 +84,19 @@ export class Spec {
80
84
  return file;
81
85
  return rel.replace(/\\/g, "/");
82
86
  }
87
+ /* every specification command honors the Git exclude rules, so that
88
+ SpecBook resolves the very same artifact files as "ase artifact
89
+ list --kind spec": both consult ".gitignore", so a specification
90
+ the project does not track is invisible to both instead of only
91
+ to one of them */
92
+ static gitignore = true;
83
93
  /* lint the specification Markdown files below the "spec" artifact
84
94
  base directory against the schema configuration */
85
95
  static async lint(log) {
86
96
  const result = await Spec.unmarked(Spec.api(log).lint({
87
97
  config: Spec.configFiles(log),
88
- basedir: Artifact.basedir(log, "spec")
98
+ basedir: Artifact.basedir(log, "spec"),
99
+ gitignore: Spec.gitignore
89
100
  }));
90
101
  return result.diagnostics.map((d) => ({ ...d, file: Spec.relativize(d.file) }));
91
102
  }
@@ -120,6 +131,7 @@ export class Spec {
120
131
  return Spec.unmarked(Spec.api(log, notices).export({
121
132
  config: Spec.configFiles(log),
122
133
  basedir: Artifact.basedir(log, "spec"),
134
+ gitignore: Spec.gitignore,
123
135
  formats
124
136
  }));
125
137
  }
@@ -131,6 +143,7 @@ export class Spec {
131
143
  return Spec.unmarked(Spec.api(log).watch({
132
144
  config: Spec.configFiles(log),
133
145
  basedir: Artifact.basedir(log, "spec"),
146
+ gitignore: Spec.gitignore,
134
147
  formats,
135
148
  outputs,
136
149
  onExport
@@ -143,6 +156,7 @@ export class Spec {
143
156
  return Spec.unmarked(Spec.api(log).preview({
144
157
  config: Spec.configFiles(log),
145
158
  basedir: Artifact.basedir(log, "spec"),
159
+ gitignore: Spec.gitignore,
146
160
  addr,
147
161
  port
148
162
  }));
@@ -246,7 +260,9 @@ export class SpecMCP {
246
260
  title: "ASE SpecBook lint",
247
261
  description: "Lint the SpecBook specification Markdown files of the project (located via the " +
248
262
  "`project.artifact.spec.basedir` configuration) against the SpecBook YAML schema " +
249
- "configuration (`project.artifact.spec.schema`, defaulting to the bundled `ase-format-specbook.yaml`). " +
263
+ "configuration (`project.artifact.spec.schema`, a whitespace-separated list of schema files " +
264
+ "merged in order, with `std` naming the bundled standard `ase-format-specbook.yaml`, " +
265
+ "which is also the default). " +
250
266
  "Returns a `diagnostics` array of `{ file, line, column, severity, message }` objects (with " +
251
267
  "project-relative `file` and a `severity` of `error` or `warning`), rendered as bullet " +
252
268
  "points in `text`. With `verbose`, " +
@@ -0,0 +1,80 @@
1
+ /*
2
+ ** Agentic Software Engineering (ASE)
3
+ ** Copyright (c) 2025-2026 Dr. Ralf S. Engelschall <rse@engelschall.com>
4
+ ** Licensed under Apache 2.0 <https://spdx.org/licenses/Apache-2.0>
5
+ */
6
+ import Table from "cli-table3";
7
+ import { Diagram } from "./ase-diagram.js";
8
+ /* assumed terminal width in case the output is not a terminal at all */
9
+ const termWidthFallback = 80;
10
+ /* content width a column is at most shrunk down to, unless its own
11
+ content or its column head already is narrower anyway */
12
+ const colWidthMinimum = 8;
13
+ /* measure the visible width of a cell content, ignoring ANSI escape
14
+ sequences and honoring the longest line of a multi-line content */
15
+ const cellWidth = (text) => text.split("\n").reduce((width, line) =>
16
+ /* eslint-disable-next-line no-control-regex */
17
+ Math.max(width, line.replace(/\x1b\[[0-9;]*[A-Za-z]/g, "").length), 0);
18
+ /* check whether a cell content carries a single token which does not
19
+ fit the column and hence can be wrapped on character boundaries only */
20
+ const hasOverlongToken = (text, width) => text.split(/\s+/).some((token) => cellWidth(token) > width);
21
+ /* hard-break all over-long tokens of a cell content, so the content as
22
+ a whole can still be wrapped on its regular word boundaries; tokens
23
+ carrying ANSI escape sequences are left alone, as breaking them
24
+ would tear apart their escape sequences */
25
+ const breakOverlongTokens = (text, width) => text.split(/(\s+)/).map((token) => {
26
+ if (cellWidth(token) <= width || /\s/.test(token) || token.includes("\x1b"))
27
+ return token;
28
+ const chunks = [];
29
+ for (let i = 0; i < token.length; i += width)
30
+ chunks.push(token.slice(i, i + width));
31
+ return chunks.join("\n");
32
+ }).join("");
33
+ /* reduce the column content widths until their sum fits the available
34
+ budget, by binary-searching the largest common upper cap which still
35
+ fits and which never cuts a column below its individual minimum */
36
+ const capWidths = (natural, minimum, budget) => {
37
+ const capped = (cap) => natural.map((width, i) => Math.max(Math.min(width, cap), Math.min(width, minimum[i])));
38
+ const total = (widths) => widths.reduce((sum, width) => sum + width, 0);
39
+ let lo = 1;
40
+ let hi = Math.max(...natural, 1);
41
+ while (lo < hi) {
42
+ const mid = Math.floor((lo + hi + 1) / 2);
43
+ if (total(capped(mid)) <= budget)
44
+ lo = mid;
45
+ else
46
+ hi = mid - 1;
47
+ }
48
+ return capped(lo);
49
+ };
50
+ /* render a table which never exceeds the terminal width, by wrapping
51
+ over-wide cell contents over multiple lines instead */
52
+ export const renderTable = (head, rows) => {
53
+ const cols = head.length;
54
+ /* determine the natural, still unwrapped content width per column */
55
+ const natural = head.map((text, i) => rows.reduce((width, row) => Math.max(width, cellWidth(row[i] ?? "")), cellWidth(text)));
56
+ /* shrink the columns down to the width still remaining after the
57
+ border characters and the padding spaces of all columns */
58
+ const term = Diagram.detectTermWidth() || termWidthFallback;
59
+ const budget = Math.max(term - (3 * cols + 1), cols);
60
+ const minimum = head.map((text) => Math.max(cellWidth(text), colWidthMinimum));
61
+ const widths = capWidths(natural, minimum, budget);
62
+ /* render the table, falling back to wrapping on character
63
+ boundaries in just those cells where a token still overflows */
64
+ const table = new Table({
65
+ head,
66
+ colWidths: widths.map((width) => width + 2),
67
+ wordWrap: true,
68
+ chars: { "mid": "", "left-mid": "", "mid-mid": "", "right-mid": "" },
69
+ style: { head: ["blue"] }
70
+ });
71
+ for (const row of rows)
72
+ table.push(row.map((text, i) => {
73
+ const content = breakOverlongTokens(text, widths[i]);
74
+ return {
75
+ content,
76
+ wrapOnWordBoundary: !hasOverlongToken(content, widths[i])
77
+ };
78
+ }));
79
+ return `${table.toString()}\n`;
80
+ };
package/package.json CHANGED
@@ -6,7 +6,7 @@
6
6
  "homepage": "https://ase.tools",
7
7
  "repository": { "url": "git+https://github.com/rse/ase.git", "type": "git" },
8
8
  "bugs": { "url": "https://github.com/rse/ase/issues" },
9
- "version": "1.0.4",
9
+ "version": "1.0.6",
10
10
  "license": "Apache-2.0",
11
11
  "author": {
12
12
  "name": "Dr. Ralf S. Engelschall",
@@ -26,11 +26,11 @@
26
26
  "globals": "17.12.0",
27
27
  "typescript": "6.0.3",
28
28
 
29
- "@rse/stx": "1.1.6",
29
+ "@rse/stx": "1.1.7",
30
30
  "nodemon": "3.1.14",
31
31
  "shx": "0.4.0",
32
32
 
33
- "@types/node": "26.5.0",
33
+ "@types/node": "26.5.1",
34
34
  "@types/luxon": "3.7.5",
35
35
  "@types/which": "3.0.4",
36
36
  "@types/update-notifier": "6.0.8",
@@ -42,8 +42,8 @@
42
42
  },
43
43
  "dependencies": {
44
44
  "commander": "15.0.0",
45
- "@dotenvx/dotenvx": "2.23.0",
46
- "yaml": "2.9.0",
45
+ "@dotenvx/dotenvx": "2.24.1",
46
+ "yaml": "2.9.1",
47
47
  "valibot": "1.5.0",
48
48
  "execa": "10.0.1",
49
49
  "mkdirp": "3.0.1",
@@ -55,7 +55,7 @@
55
55
  "luxon": "3.7.2",
56
56
  "@modelcontextprotocol/sdk": "1.30.0",
57
57
  "json-asty": "1.3.4",
58
- "zod": "4.6.1",
58
+ "zod": "4.6.5",
59
59
  "which": "7.0.0",
60
60
  "update-notifier": "7.3.1",
61
61
  "shell-quote": "1.10.0",
@@ -66,7 +66,7 @@
66
66
  "ofetch": "1.5.1",
67
67
  "picomatch": "4.0.7",
68
68
  "lru-cache": "11.5.2",
69
- "@rse/specbook": "1.2.9",
69
+ "@rse/specbook": "1.2.13",
70
70
  "source-code-error": "1.2.0",
71
71
  "pure-uuid": "2.0.0"
72
72
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ase",
3
- "version": "1.0.4",
3
+ "version": "1.0.6",
4
4
  "description": "Agentic Software Engineering (ASE)",
5
5
  "keywords": [ "agentic", "software", "engineering" ],
6
6
  "homepage": "https://ase.tools",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ase",
3
- "version": "1.0.4",
3
+ "version": "1.0.6",
4
4
  "description": "Agentic Software Engineering (ASE)",
5
5
  "keywords": [ "agentic", "software", "engineering" ],
6
6
  "homepage": "https://ase.tools",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ase",
3
- "version": "1.0.4",
3
+ "version": "1.0.6",
4
4
  "description": "Agentic Software Engineering (ASE)",
5
5
  "keywords": [ "agentic", "software", "engineering" ],
6
6
  "homepage": "https://ase.tools",