@rse/ase 0.9.37 → 0.9.39

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
@@ -4,6 +4,7 @@
4
4
  ** Licensed under Apache 2.0 <https://spdx.org/licenses/Apache-2.0>
5
5
  */
6
6
  import fs from "node:fs/promises";
7
+ import os from "node:os";
7
8
  import path from "node:path";
8
9
  import { fileURLToPath } from "node:url";
9
10
  import { execa } from "execa";
@@ -11,6 +12,9 @@ import which from "which";
11
12
  import * as dotenvx from "@dotenvx/dotenvx";
12
13
  import Table from "cli-table3";
13
14
  import chalk from "chalk";
15
+ import writeFileAtomic from "write-file-atomic";
16
+ import { mkdirp } from "mkdirp";
17
+ import JsonAsty from "json-asty";
14
18
  import Version from "./ase-version.js";
15
19
  const toolSpecs = {
16
20
  "claude": { cli: "claude", label: "Anthropic Claude Code CLI", pInstall: "install", pRemove: "uninstall", pUpdate: "update" },
@@ -567,6 +571,226 @@ export default class SetupCommand {
567
571
  }
568
572
  }
569
573
  ];
574
+ /* the default "ase statusline" format lines used when the user does
575
+ not override them with positional arguments to "activate" */
576
+ statuslineFormatDflt = [
577
+ "<blue>%u</blue> <red>%p</red> <black>%T</black> %s",
578
+ "%m %e %t",
579
+ "%P %c"
580
+ ];
581
+ /* resolve the tool settings file for a given installation scope */
582
+ statuslineSettingsFile(tool, scope) {
583
+ if (tool === "copilot") {
584
+ const home = process.env.COPILOT_HOME ?? "";
585
+ const base = home !== "" ? home : path.join(os.homedir(), ".copilot");
586
+ return path.join(base, "settings.json");
587
+ }
588
+ else if (scope === "project")
589
+ return path.join(process.cwd(), ".claude", "settings.json");
590
+ else if (scope === "local")
591
+ return path.join(process.cwd(), ".claude", "settings.local.json");
592
+ else
593
+ return path.join(os.homedir(), ".claude", "settings.json");
594
+ }
595
+ /* reject the statusline operation for tools without a scriptable
596
+ statusline mechanism (only the OpenAI Codex CLI lacks one) */
597
+ requireStatuslineTool(tool) {
598
+ if (tool === "codex")
599
+ throw new Error("statusline configuration is not supported for --tool codex " +
600
+ `(the ${toolSpecs[tool].label} has no scriptable statusline mechanism)`);
601
+ }
602
+ /* reject a non-default --scope for the GitHub Copilot CLI, which has a
603
+ single per-user settings file and thus no scope concept */
604
+ requireStatuslineScope(tool, scope) {
605
+ if (tool === "copilot" && scope !== "user")
606
+ throw new Error("--scope is only supported for --tool claude " +
607
+ `(the ${toolSpecs[tool].label} has a single per-user settings file)`);
608
+ }
609
+ /* build the "ase statusline ..." command string from the activate
610
+ options and the effective format lines */
611
+ statuslineCommand(opts, format) {
612
+ const parts = ["ase", "statusline", "-w", String(opts.width), "-m", String(opts.margin)];
613
+ if (!opts.icons)
614
+ parts.push("--no-icons");
615
+ if (!opts.labels)
616
+ parts.push("--no-labels");
617
+ const lines = format.length > 0 ? format : this.statuslineFormatDflt;
618
+ for (const line of lines)
619
+ parts.push(`'${line.replace(/'/g, "'\\''")}'`);
620
+ return parts.join(" ");
621
+ }
622
+ /* determine whether an existing "statusLine" value object is owned by us */
623
+ statuslineIsOwned(member) {
624
+ const obj = member.query("/ * [ pos() == 2 ]")[0];
625
+ if (obj === undefined)
626
+ return false;
627
+ for (const m of obj.query("/ member")) {
628
+ const key = m.query("/ string [ pos() == 1 ]")[0];
629
+ if (key !== undefined && key.get("value") === "command") {
630
+ const val = m.query("/ string [ pos() == 2 ]")[0];
631
+ const cmd = val !== undefined ? String(val.get("value") ?? "") : "";
632
+ return cmd.startsWith("ase statusline");
633
+ }
634
+ }
635
+ return false;
636
+ }
637
+ /* locate the top-level "statusLine" member node within the root object */
638
+ statuslineFindMember(root) {
639
+ for (const m of root.query("/ member")) {
640
+ const key = m.query("/ string [ pos() == 1 ]")[0];
641
+ if (key !== undefined && key.get("value") === "statusLine")
642
+ return m;
643
+ }
644
+ return undefined;
645
+ }
646
+ /* build the "statusLine" member subtree natively inside the target
647
+ AST, reproducing the exact whitespace tokens json-asty emits for a
648
+ canonically 4-space-indented settings.json */
649
+ statuslineBuildMember(root, command) {
650
+ const strMember = (key, val, epilog) => {
651
+ const k = root.create("string").set({ body: JSON.stringify(key), value: key, epilog: ": " });
652
+ const v = root.create("string").set({ body: JSON.stringify(val), value: val });
653
+ const m = root.create("member");
654
+ if (epilog !== undefined)
655
+ m.set({ epilog });
656
+ return m.add(k, v);
657
+ };
658
+ const numMember = (key, val, epilog) => {
659
+ const k = root.create("string").set({ body: JSON.stringify(key), value: key, epilog: ": " });
660
+ const v = root.create("number").set({ body: String(val), value: val });
661
+ const m = root.create("member");
662
+ if (epilog !== undefined)
663
+ m.set({ epilog });
664
+ return m.add(k, v);
665
+ };
666
+ const obj = root.create("object").set({ prolog: "{\n ", epilog: "\n }\n" });
667
+ obj.add(strMember("type", "command", ",\n "), strMember("command", command, ",\n "), numMember("padding", 0));
668
+ const key = root.create("string").set({
669
+ body: JSON.stringify("statusLine"),
670
+ value: "statusLine",
671
+ epilog: ": "
672
+ });
673
+ return root.create("member").add(key, obj);
674
+ }
675
+ /* normalize the previous-last member so a following ",\n " comma reads cleanly */
676
+ statuslineMakeNonLast(member) {
677
+ const val = member.query("/ * [ pos() == 2 ]")[0];
678
+ if (val !== undefined) {
679
+ const ep = val.get("epilog");
680
+ if (typeof ep === "string" && ep.endsWith("\n"))
681
+ val.set({ epilog: ep.replace(/\n$/, "") });
682
+ }
683
+ member.set({ epilog: ",\n " });
684
+ }
685
+ /* read a settings.json file into a JSON-ASTy AST */
686
+ async statuslineReadAst(file) {
687
+ let text = "";
688
+ try {
689
+ text = await fs.readFile(file, "utf8");
690
+ }
691
+ catch {
692
+ /* missing file: start from an empty object */
693
+ }
694
+ if (text.trim() === "")
695
+ text = "{}";
696
+ return JsonAsty.parse(text);
697
+ }
698
+ /* write a JSON-ASTy AST back to a settings.json file */
699
+ async statuslineWriteAst(file, root) {
700
+ await mkdirp(path.dirname(file));
701
+ const text = JsonAsty.unparse(root);
702
+ writeFileAtomic.sync(file, text, { encoding: "utf8" });
703
+ }
704
+ /* handler for "ase setup statusline activate" */
705
+ async doStatuslineActivate(tool, scope, opts, format) {
706
+ this.requireStatuslineTool(tool);
707
+ this.requireStatuslineScope(tool, scope);
708
+ const file = this.statuslineSettingsFile(tool, scope);
709
+ const command = this.statuslineCommand(opts, format);
710
+ const root = await this.statuslineReadAst(file);
711
+ const existing = this.statuslineFindMember(root);
712
+ if (existing !== undefined) {
713
+ /* preserve a foreign, hand-crafted statusLine: skip and warn */
714
+ if (!this.statuslineIsOwned(existing)) {
715
+ this.log.write("warning", "setup: statusline: activate: a non-ASE \"statusLine\" " +
716
+ `is already present in ${file}: preserving it (skipped)`);
717
+ return 0;
718
+ }
719
+ /* replace the value object in place, preserving its epilog */
720
+ const valNew = this.statuslineBuildMember(root, command).query("/ object")[0];
721
+ const valOld = existing.query("/ * [ pos() == 2 ]")[0];
722
+ valNew.set({ epilog: valOld.get("epilog") });
723
+ existing.del(valOld).add(valNew);
724
+ this.log.write("info", `setup: statusline: activate: updating ASE "statusLine" in ${file}`);
725
+ }
726
+ else {
727
+ /* insert a fresh statusLine member */
728
+ const members = root.query("/ member");
729
+ const member = this.statuslineBuildMember(root, command);
730
+ if (members.length === 0) {
731
+ root.set({ prolog: "{\n ", epilog: "}\n" });
732
+ root.add(member);
733
+ }
734
+ else {
735
+ root.set({ epilog: "}\n" });
736
+ this.statuslineMakeNonLast(members[members.length - 1]);
737
+ root.add(member);
738
+ }
739
+ this.log.write("info", `setup: statusline: activate: adding ASE "statusLine" to ${file}`);
740
+ }
741
+ await this.statuslineWriteAst(file, root);
742
+ return 0;
743
+ }
744
+ /* handler for "ase setup statusline deactivate" */
745
+ async doStatuslineDeactivate(tool, scope) {
746
+ this.requireStatuslineTool(tool);
747
+ this.requireStatuslineScope(tool, scope);
748
+ const file = this.statuslineSettingsFile(tool, scope);
749
+ /* a missing settings file means nothing to remove */
750
+ try {
751
+ await fs.access(file);
752
+ }
753
+ catch {
754
+ this.log.write("info", `setup: statusline: deactivate: no settings file ${file} (skipped)`);
755
+ return 0;
756
+ }
757
+ /* read file */
758
+ const root = await this.statuslineReadAst(file);
759
+ const target = this.statuslineFindMember(root);
760
+ if (target === undefined) {
761
+ this.log.write("info", `setup: statusline: deactivate: no "statusLine" in ${file} (skipped)`);
762
+ return 0;
763
+ }
764
+ /* preserve a foreign, hand-crafted statusLine: skip and warn */
765
+ if (!this.statuslineIsOwned(target)) {
766
+ this.log.write("warning", "setup: statusline: deactivate: a non-ASE \"statusLine\" " +
767
+ `is present in ${file}: preserving it (skipped)`);
768
+ return 0;
769
+ }
770
+ /* remove the member and repair the whitespace at the seam */
771
+ const members = root.query("/ member");
772
+ const wasLast = members[members.length - 1] === target;
773
+ root.del(target);
774
+ if (wasLast) {
775
+ if (members.length > 1) {
776
+ /* promote the new last member: strip its comma epilog and
777
+ restore the pre-"}" newline into the root epilog when the
778
+ new-last value is a scalar (a container value carries the
779
+ newline in its own epilog already) */
780
+ const newLast = members[members.length - 2];
781
+ newLast.set({ epilog: undefined });
782
+ const val = newLast.query("/ * [ pos() == 2 ]")[0];
783
+ const ep = val !== undefined ? val.get("epilog") : undefined;
784
+ if (!(typeof ep === "string" && ep.endsWith("\n")))
785
+ root.set({ epilog: "\n}\n" });
786
+ }
787
+ else
788
+ root.set({ epilog: "}\n" });
789
+ }
790
+ await this.statuslineWriteAst(file, root);
791
+ this.log.write("info", `setup: statusline: deactivate: removing ASE "statusLine" from ${file}`);
792
+ return 0;
793
+ }
570
794
  /* parse and validate the --tool option */
571
795
  parseTool(value) {
572
796
  if (value !== "claude" && value !== "copilot" && value !== "codex")
@@ -683,5 +907,42 @@ export default class SetupCommand {
683
907
  .action(async (servers, opts) => {
684
908
  process.exit(await this.doMcp("deactivate", this.parseTool(opts.tool), servers ?? "all", this.parseScope(opts.scope)));
685
909
  });
910
+ /* parser for the non-negative integer "ase setup statusline" options */
911
+ const parseNonNeg = (name) => (value) => {
912
+ const n = Number.parseInt(value, 10);
913
+ if (!Number.isFinite(n) || n < 0)
914
+ throw new Error(`invalid --${name} value: "${value}" (expected a non-negative integer)`);
915
+ return n;
916
+ };
917
+ /* register CLI sub-command "ase setup statusline" */
918
+ const statuslineCmd = setupCmd
919
+ .command("statusline")
920
+ .description("activate or deactivate the ASE statusline for a tool")
921
+ .action(() => {
922
+ statuslineCmd.outputHelp();
923
+ process.exit(1);
924
+ });
925
+ /* register CLI sub-command "ase setup statusline activate" */
926
+ statuslineCmd
927
+ .command("activate [format...]")
928
+ .description("activate the ASE statusline (optionally with custom format lines)")
929
+ .option("-t, --tool <tool>", "target tool (\"claude\" or \"copilot\"; \"codex\" is unsupported)", toolDflt)
930
+ .option("-s, --scope <scope>", "target scope (\"user\", \"project\", or \"local\")", "user")
931
+ .option("-w, --width <n>", "force terminal width to <n> characters (0 = auto-detect via /dev/tty)", parseNonNeg("width"), 0)
932
+ .option("-m, --margin <n>", "reduce maximum used terminal width by <n> characters on each side", parseNonNeg("margin"), 2)
933
+ .option("--no-icons", "disable icons in placeholder rendering")
934
+ .option("--no-labels", "disable labels in front of bold values")
935
+ .action(async (format, opts) => {
936
+ process.exit(await this.doStatuslineActivate(this.parseTool(opts.tool), this.parseScope(opts.scope), { width: opts.width, margin: opts.margin, icons: opts.icons, labels: opts.labels }, format ?? []));
937
+ });
938
+ /* register CLI sub-command "ase setup statusline deactivate" */
939
+ statuslineCmd
940
+ .command("deactivate")
941
+ .description("deactivate the ASE statusline")
942
+ .option("-t, --tool <tool>", "target tool (\"claude\" or \"copilot\"; \"codex\" is unsupported)", toolDflt)
943
+ .option("-s, --scope <scope>", "target scope (\"user\", \"project\", or \"local\")", "user")
944
+ .action(async (opts) => {
945
+ process.exit(await this.doStatuslineDeactivate(this.parseTool(opts.tool), this.parseScope(opts.scope)));
946
+ });
686
947
  }
687
948
  }
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.9.37",
9
+ "version": "0.9.39",
10
10
  "license": "Apache-2.0",
11
11
  "author": {
12
12
  "name": "Dr. Ralf S. Engelschall",
@@ -54,6 +54,7 @@
54
54
  "pretty-ms": "9.3.0",
55
55
  "luxon": "3.7.2",
56
56
  "@modelcontextprotocol/sdk": "1.29.0",
57
+ "json-asty": "1.3.2",
57
58
  "zod": "4.4.3",
58
59
  "which": "7.0.0",
59
60
  "update-notifier": "7.3.1",
@@ -65,6 +66,9 @@
65
66
  "ofetch": "1.5.1",
66
67
  "picomatch": "4.0.5"
67
68
  },
69
+ "allowScripts": {
70
+ "es5-ext": true
71
+ },
68
72
  "engines": {
69
73
  "npm": ">=10.0.0",
70
74
  "node": ">=22.0.0"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ase",
3
- "version": "0.9.37",
3
+ "version": "0.9.39",
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": "0.9.37",
3
+ "version": "0.9.39",
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": "0.9.37",
3
+ "version": "0.9.39",
4
4
  "description": "Agentic Software Engineering (ASE)",
5
5
  "keywords": [ "agentic", "software", "engineering" ],
6
6
  "homepage": "https://ase.tools",
@@ -56,12 +56,14 @@ following procedure:
56
56
  If this line exists, parse it according to the format `<label/>: <description/>`.
57
57
  Set <n/> to <n/> + 1 (increment entry count).
58
58
  Set <label-key/> to `<ase-tpl-key digit="<n/>"/>`.
59
- Set <label-text/> to `<ase-tpl-pad width="<width/>" text="<label/>:"/>`.
59
+ Set <label-text/> to `**<label/>:**`.
60
+ Set <label-pad/> to `<ase-tpl-pad width="<width/>" text="<label/>:"/>` with
61
+ the leading `<label/>:` text removed, i.e. only the trailing padding spaces.
60
62
  Append an entry to <text/>:
61
63
 
62
64
  <text>
63
65
  <text/>
64
- <ase-tpl-boxline><label-key/> ▶ **<label-text/>** <description/></ase-tpl-boxline>
66
+ <ase-tpl-boxline><label-key/> ▶ <label-text/><label-pad/> <description/></ase-tpl-boxline>
65
67
  </text>
66
68
 
67
69
  <if condition="<keys/> is empty">
@@ -286,6 +286,15 @@ Artifact Boxing Transparency
286
286
  Template Patterns
287
287
  -----------------
288
288
 
289
+ - *IMPORTANT*: In every `<template/>` below you *MUST* reproduce all
290
+ box-drawing and decorative glyphs *verbatim*, character-for-character,
291
+ exactly as written. This especially includes the corner and edge
292
+ characters `╭`, `╰`, and `│`, the shoulder character `━`, the bar
293
+ character `─`, and the trailing dotted character `┈`. You *MUST* *NOT*
294
+ substitute visually similar ASCII fallbacks (e.g. `+`, `|`, `-`) or
295
+ other Unicode look-alikes, and you *MUST* *NOT* drop, add, or reorder
296
+ any of these glyphs.
297
+
289
298
  - When `<ase-tpl-head/>` (no title attribute) should be expanded, use:
290
299
 
291
300
  <template>
@@ -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.9.37",
9
+ "version": "0.9.39",
10
10
  "license": "Apache-2.0",
11
11
  "author": {
12
12
  "name": "Dr. Ralf S. Engelschall",