@rse/ase 0.0.7 → 0.0.9

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
@@ -4,33 +4,278 @@
4
4
  ** Licensed under GPL 3.0 <https://spdx.org/licenses/GPL-3.0-only>
5
5
  */
6
6
  import path from "node:path";
7
+ import os from "node:os";
7
8
  import fs from "node:fs";
9
+ import readline from "node:readline/promises";
8
10
  import { Document, parseDocument, isMap, isScalar } from "yaml";
9
11
  import { execaSync } from "execa";
10
12
  import * as v from "valibot";
11
13
  import Table from "cli-table3";
14
+ /* classification taxonomy */
15
+ export const projectClassification = {
16
+ source: {
17
+ ambition: ["artist", "craftsman", "engineer"],
18
+ boxing: ["white", "grey", "black"],
19
+ size: ["small", "medium", "large"],
20
+ structure: ["bare", "library", "framework"]
21
+ },
22
+ process: {
23
+ actors: ["person", "team", "crew"],
24
+ control: ["human", "hitl", "agent"],
25
+ drive: ["spec", "code", "test"]
26
+ },
27
+ result: {
28
+ target: ["prototype", "mvp", "product"]
29
+ }
30
+ };
31
+ /* agent classification taxonomy */
32
+ export const agentClassification = {
33
+ persona: {
34
+ style: ["writer", "engineer", "telegrapher", "caveman"],
35
+ creativity: ["none", "lite", "full"]
36
+ },
37
+ process: {
38
+ autonomy: ["assistant", "hotl", "agent"]
39
+ }
40
+ };
41
+ /* classification presets */
42
+ export const projectClassificationPresets = {
43
+ vibe: {
44
+ "project.id": "example",
45
+ "project.name": "Example Project",
46
+ "project.source.ambition": "engineer",
47
+ "project.source.boxing": "black",
48
+ "project.source.size": "small",
49
+ "project.source.structure": "bare",
50
+ "project.process.actors": "person",
51
+ "project.process.control": "agent",
52
+ "project.process.drive": "spec",
53
+ "project.result.target": "prototype",
54
+ "agent.persona.style": "writer",
55
+ "agent.persona.creativity": "full",
56
+ "agent.process.autonomy": "agent",
57
+ },
58
+ pro: {
59
+ "project.id": "example",
60
+ "project.name": "Example Project",
61
+ "project.source.ambition": "artist",
62
+ "project.source.boxing": "white",
63
+ "project.source.size": "medium",
64
+ "project.source.structure": "framework",
65
+ "project.process.actors": "person",
66
+ "project.process.control": "human",
67
+ "project.process.drive": "code",
68
+ "project.result.target": "product",
69
+ "agent.persona.style": "engineer",
70
+ "agent.persona.creativity": "none",
71
+ "agent.process.autonomy": "assistant",
72
+ },
73
+ default: {
74
+ "project.id": "example",
75
+ "project.name": "Example Project",
76
+ "project.source.ambition": "artist",
77
+ "project.source.boxing": "white",
78
+ "project.source.size": "medium",
79
+ "project.source.structure": "framework",
80
+ "project.process.actors": "person",
81
+ "project.process.control": "human",
82
+ "project.process.drive": "code",
83
+ "project.result.target": "product",
84
+ "agent.persona.style": "engineer",
85
+ "agent.persona.creativity": "none",
86
+ "agent.process.autonomy": "assistant",
87
+ },
88
+ industry: {
89
+ "project.id": "example",
90
+ "project.name": "Example Project",
91
+ "project.source.ambition": "craftsman",
92
+ "project.source.boxing": "grey",
93
+ "project.source.size": "large",
94
+ "project.source.structure": "framework",
95
+ "project.process.actors": "crew",
96
+ "project.process.control": "hitl",
97
+ "project.process.drive": "code",
98
+ "project.result.target": "mvp",
99
+ "agent.persona.style": "engineer",
100
+ "agent.persona.creativity": "none",
101
+ "agent.process.autonomy": "hotl",
102
+ }
103
+ };
104
+ /* canonical ordering rank of a scope kind */
105
+ const scopeRank = (kind) => ({ default: -1, user: 0, project: 1, task: 2, session: 3 })[kind];
106
+ /* parse a single scope term */
107
+ const parseScopeTerm = (value) => {
108
+ if (value === "user")
109
+ return { kind: "user" };
110
+ else if (value === "project")
111
+ return { kind: "project" };
112
+ const m = /^(session|task):([A-Za-z0-9._-]+)$/.exec(value);
113
+ if (m !== null)
114
+ return { kind: m[1], id: m[2] };
115
+ throw new Error(`invalid --scope term "${value}" ` +
116
+ "(expected: \"user\", \"project\", \"task:<id>\", or \"session:<id>\")");
117
+ };
118
+ /* detect whether a project context exists, i.e. either we are inside
119
+ a Git working tree or a ".ase" directory is present at or above cwd */
120
+ const hasProjectContext = () => {
121
+ try {
122
+ const result = execaSync("git", ["rev-parse", "--show-toplevel"], { stderr: "ignore" });
123
+ if (result.stdout.trim() !== "")
124
+ return true;
125
+ }
126
+ catch {
127
+ /* not inside a Git working tree */
128
+ }
129
+ let dir = fs.realpathSync(process.cwd());
130
+ for (;;) {
131
+ if (fs.existsSync(path.join(dir, ".ase")))
132
+ return true;
133
+ const parent = path.dirname(dir);
134
+ if (parent === dir)
135
+ return false;
136
+ dir = parent;
137
+ }
138
+ };
139
+ /* parse a raw "--scope" option value into a canonical Scope chain;
140
+ accepts a comma-separated list of terms in any order. The "user"
141
+ term is always implicitly added at the bottom of the chain; the
142
+ "project" term is implicitly added only when a project context
143
+ exists (Git repository or ".ase" directory at or above cwd), and
144
+ an explicit "project" term requires that same context */
145
+ const parseScope = (value) => {
146
+ const projectActive = hasProjectContext();
147
+ const input = (value === undefined || value === "") ?
148
+ (projectActive ? "project" : "user") :
149
+ value.trim();
150
+ if (input === "")
151
+ throw new Error("invalid --scope: value must not be empty");
152
+ const terms = input.split(",").map((s) => parseScopeTerm(s.trim()));
153
+ const seen = new Set();
154
+ for (const t of terms) {
155
+ if (seen.has(t.kind))
156
+ throw new Error(`invalid --scope: duplicate term of kind "${t.kind}"`);
157
+ seen.add(t.kind);
158
+ }
159
+ if (seen.has("project") && !projectActive)
160
+ throw new Error("invalid --scope: \"project\" requires a project context " +
161
+ "(a Git repository or a \".ase\" directory at or above the current directory)");
162
+ if (!seen.has("project") && projectActive)
163
+ terms.unshift({ kind: "project" });
164
+ if (!seen.has("user"))
165
+ terms.unshift({ kind: "user" });
166
+ terms.sort((a, b) => scopeRank(a.kind) - scopeRank(b.kind));
167
+ terms.unshift({ kind: "default" });
168
+ return terms;
169
+ };
12
170
  /* schema for ".ase/config.yaml" */
13
171
  export const configSchema = v.nullish(v.strictObject({
14
172
  project: v.optional(v.strictObject({
15
- id: v.optional(v.pipe(v.string(), v.minLength(1)))
173
+ id: v.optional(v.pipe(v.string(), v.minLength(1))),
174
+ name: v.optional(v.pipe(v.string(), v.minLength(1))),
175
+ source: v.optional(v.strictObject({
176
+ ambition: v.optional(v.picklist(projectClassification.source.ambition)),
177
+ boxing: v.optional(v.picklist(projectClassification.source.boxing)),
178
+ size: v.optional(v.picklist(projectClassification.source.size)),
179
+ structure: v.optional(v.picklist(projectClassification.source.structure))
180
+ })),
181
+ process: v.optional(v.strictObject({
182
+ actors: v.optional(v.picklist(projectClassification.process.actors)),
183
+ control: v.optional(v.picklist(projectClassification.process.control)),
184
+ drive: v.optional(v.picklist(projectClassification.process.drive))
185
+ })),
186
+ result: v.optional(v.strictObject({
187
+ target: v.optional(v.picklist(projectClassification.result.target))
188
+ }))
189
+ })),
190
+ agent: v.optional(v.strictObject({
191
+ persona: v.optional(v.strictObject({
192
+ style: v.optional(v.picklist(agentClassification.persona.style)),
193
+ creativity: v.optional(v.picklist(agentClassification.persona.creativity))
194
+ })),
195
+ process: v.optional(v.strictObject({
196
+ autonomy: v.optional(v.picklist(agentClassification.process.autonomy))
197
+ }))
16
198
  }))
17
199
  }));
18
- /* encapsulate read/write access to a project-local ".ase/<name>.yaml" file */
200
+ /* encapsulate read/write access to a stack of "<name>.yaml" configuration files,
201
+ each associated with a scope term; reads cascade along user < project < task < session,
202
+ writes are confined to the target (strongest) scope term */
19
203
  export class Config {
204
+ /* public state */
20
205
  filename;
21
- doc;
206
+ /* private state */
207
+ name;
208
+ scope;
22
209
  schema;
23
- constructor(name, schema) {
24
- const rel = path.join(".ase", `${name}.yaml`);
25
- const found = this.findUpward(process.cwd(), rel);
26
- this.filename = found ?? path.join(this.gitToplevel() ?? process.cwd(), rel);
27
- this.doc = new Document();
210
+ log;
211
+ docs;
212
+ target;
213
+ /* creation */
214
+ constructor(name, schema, log, scope = [{ kind: "project" }]) {
215
+ if (scope.length === 0)
216
+ throw new Error("invalid scope: chain must not be empty");
217
+ this.name = name;
218
+ this.scope = scope[0].kind === "default" ? scope : [{ kind: "default" }, ...scope];
28
219
  this.schema = schema ?? null;
220
+ this.log = log;
221
+ const tgt = this.scope[this.scope.length - 1];
222
+ this.filename = this.resolveFilename(name, tgt);
223
+ this.docs = [{ scope: tgt, filename: this.filename, doc: new Document() }];
224
+ this.target = 0;
29
225
  }
30
- /* upward-walk on filesystem for a file path relative to a start directory */
31
- findUpward(start, rel) {
32
- let dir = start;
33
- for (;;) {
226
+ /* render a scope term as a short textual label */
227
+ static scopeLabel(term) {
228
+ if (term.kind === "default" || term.kind === "user" || term.kind === "project")
229
+ return term.kind;
230
+ return `${term.kind}:${term.id}`;
231
+ }
232
+ /* resolve the per-OS user-scope base directory */
233
+ userConfigDir() {
234
+ if (process.platform === "darwin")
235
+ /* macOS */
236
+ return path.join(os.homedir(), "Library", "Application Support", "ase");
237
+ else if (process.platform === "win32")
238
+ /* Windows */
239
+ return path.join(process.env.APPDATA ?? os.homedir(), "ase");
240
+ else {
241
+ /* Linux */
242
+ const xdg = process.env.XDG_CONFIG_HOME;
243
+ const base = xdg !== undefined && xdg !== "" ? xdg : path.join(os.homedir(), ".config");
244
+ return path.join(base, "ase");
245
+ }
246
+ }
247
+ /* resolve the configuration filename based on the selected scope term */
248
+ resolveFilename(name, term) {
249
+ if (term.kind === "default")
250
+ throw new Error("internal error: \"default\" scope has no filename");
251
+ if (term.kind === "user")
252
+ return path.join(this.userConfigDir(), `${name}.yaml`);
253
+ else if (term.kind === "project") {
254
+ const rel = path.join(".ase", `${name}.yaml`);
255
+ const cwd = process.cwd();
256
+ const top = this.gitToplevel();
257
+ const found = top !== null ?
258
+ this.findUpward(cwd, top, rel) :
259
+ (fs.existsSync(path.join(cwd, rel)) ? path.join(cwd, rel) : null);
260
+ return found ?? path.join(top ?? cwd, rel);
261
+ }
262
+ else {
263
+ const sub = term.kind === "session" ? "sessions" : "tasks";
264
+ const top = this.gitToplevel();
265
+ if (top !== null)
266
+ return path.join(top, ".ase", sub, term.id, `${name}.yaml`);
267
+ else
268
+ return path.join(this.userConfigDir(), sub, term.id, `${name}.yaml`);
269
+ }
270
+ }
271
+ /* upward-walk on filesystem for a file path relative to a start directory,
272
+ bounded above (inclusive) by a stop directory */
273
+ findUpward(start, stop, rel) {
274
+ let dir = fs.realpathSync(start);
275
+ const end = fs.realpathSync(stop);
276
+ const between = path.relative(end, dir);
277
+ const steps = between === "" ? 0 : between.split(path.sep).length;
278
+ for (let i = 0; i <= steps; i++) {
34
279
  const candidate = path.join(dir, rel);
35
280
  if (fs.existsSync(candidate))
36
281
  return candidate;
@@ -39,6 +284,7 @@ export class Config {
39
284
  return null;
40
285
  dir = parent;
41
286
  }
287
+ return null;
42
288
  }
43
289
  /* determine the Git top-level directory, if inside a Git repository */
44
290
  gitToplevel() {
@@ -52,24 +298,67 @@ export class Config {
52
298
  return null;
53
299
  }
54
300
  }
55
- /* read configuration file into memory */
56
- read() {
57
- const text = fs.existsSync(this.filename) ? fs.readFileSync(this.filename, "utf8") : "";
58
- this.doc = parseDocument(text);
59
- this.validate("lenient");
301
+ /* read the full scope chain into memory; the requested mode applies
302
+ to the target scope only, inherited scopes are always lenient */
303
+ read(mode = "lenient") {
304
+ const chain = this.scope;
305
+ const docs = [];
306
+ for (let i = 0; i < chain.length; i++) {
307
+ const sc = chain[i];
308
+ if (sc.kind === "default") {
309
+ const doc = new Document();
310
+ doc.contents = doc.createNode({});
311
+ const preset = projectClassificationPresets.default;
312
+ for (const [k, val] of Object.entries(preset)) {
313
+ const segments = k.split(".");
314
+ for (let j = 1; j < segments.length; j++) {
315
+ const prefix = segments.slice(0, j);
316
+ const node = doc.getIn(prefix, true);
317
+ if (node === undefined)
318
+ doc.setIn(prefix, doc.createNode({}));
319
+ }
320
+ doc.setIn(segments, doc.createNode(val));
321
+ }
322
+ docs.push({ scope: sc, filename: "", doc });
323
+ continue;
324
+ }
325
+ const filename = this.resolveFilename(this.name, sc);
326
+ const isTarget = (i === chain.length - 1);
327
+ const perDocMode = isTarget ? mode : "lenient";
328
+ const text = fs.existsSync(filename) ? fs.readFileSync(filename, "utf8") : "";
329
+ let doc = parseDocument(text);
330
+ if (doc.errors.length > 0) {
331
+ const msg = `invalid YAML in ${filename}: ${doc.errors[0].message}`;
332
+ if (perDocMode === "strict")
333
+ throw new Error(msg);
334
+ this.log.write("warning", msg);
335
+ doc = new Document();
336
+ }
337
+ docs.push({ scope: sc, filename, doc });
338
+ }
339
+ this.docs = docs;
340
+ this.target = docs.length - 1;
341
+ for (let i = 0; i < docs.length; i++) {
342
+ const isTarget = (i === this.target);
343
+ const perDocMode = isTarget ? mode : "lenient";
344
+ this.validateDoc(docs[i].doc, docs[i].filename, perDocMode);
345
+ }
60
346
  }
61
- /* write in-memory configuration back to file */
347
+ /* write in-memory configuration back to the target scope's file */
62
348
  write() {
63
- this.validate("strict");
64
- fs.mkdirSync(path.dirname(this.filename), { recursive: true });
65
- fs.writeFileSync(this.filename, this.doc.toString({ indent: 4 }), "utf8");
349
+ const td = this.docs[this.target];
350
+ if (td.scope.kind === "default")
351
+ throw new Error("internal error: \"default\" scope is not writable");
352
+ this.validateDoc(td.doc, td.filename, "strict");
353
+ fs.mkdirSync(path.dirname(td.filename), { recursive: true });
354
+ fs.writeFileSync(td.filename, td.doc.toString({ indent: 4 }), "utf8");
66
355
  }
67
- /* validate in-memory configuration against the optional schema */
68
- validate(mode = "strict") {
356
+ /* validate a single YAML document against the optional schema */
357
+ validateDoc(doc, filename, mode = "strict") {
69
358
  if (this.schema === null)
70
359
  return;
71
360
  for (;;) {
72
- const result = v.safeParse(this.schema, this.doc.toJS());
361
+ const result = v.safeParse(this.schema, doc.toJS());
73
362
  if (result.success)
74
363
  return;
75
364
  if (mode === "strict") {
@@ -77,112 +366,277 @@ export class Config {
77
366
  const dotPath = (i.path ?? []).map((p) => String(p.key)).join(".");
78
367
  return dotPath ? `${dotPath}: ${i.message}` : i.message;
79
368
  }).join("; ");
80
- throw new Error(`invalid configuration in ${this.filename}: ${issues}`);
369
+ throw new Error(`invalid configuration in ${filename}: ${issues}`);
81
370
  }
82
371
  let progressed = false;
83
372
  for (const i of result.issues) {
84
373
  const segs = (i.path ?? []).map((p) => String(p.key));
85
374
  const dotPath = segs.join(".");
86
- process.stderr.write(`ase: warning: invalid entry in ${this.filename}: ${dotPath ? `${dotPath}: ` : ""}${i.message}\n`);
375
+ this.log.write("warning", `invalid entry in ${filename}: ${dotPath ? `${dotPath}: ` : ""}${i.message}`);
87
376
  if (segs.length > 0) {
88
- this.doc.deleteIn(segs);
377
+ doc.deleteIn(segs);
89
378
  progressed = true;
90
379
  }
380
+ else
381
+ /* root-level issue is structurally unrecoverable: do not wipe
382
+ the document, let the next strict validate() surface it */
383
+ return;
91
384
  }
92
385
  if (!progressed)
93
386
  return;
94
387
  }
95
388
  }
96
- /* retrieve a value at a dotted key, or the root contents if no key given */
389
+ /* enumerate all full dotted leaf paths from the attached valibot schema */
390
+ schemaLeafPaths() {
391
+ const unwrap = (s) => {
392
+ while (s !== undefined && s !== null && (s.type === "optional" || s.type === "nullish"
393
+ || s.type === "nullable" || s.type === "undefinedable"))
394
+ s = s.wrapped;
395
+ return s;
396
+ };
397
+ const walk = (s, prefix) => {
398
+ const u = unwrap(s);
399
+ if (u !== undefined && u !== null
400
+ && (u.type === "object" || u.type === "strict_object" || u.type === "loose_object")
401
+ && u.entries !== undefined) {
402
+ const paths = [];
403
+ for (const [k, sub] of Object.entries(u.entries))
404
+ paths.push(...walk(sub, [...prefix, k]));
405
+ return paths;
406
+ }
407
+ return [prefix];
408
+ };
409
+ return walk(this.schema, []);
410
+ }
411
+ /* resolve a (possibly trailing-segment) dotted key to its full schema path */
412
+ resolveKey(key) {
413
+ if (this.schema === null)
414
+ return key;
415
+ const segs = key.split(".");
416
+ const matches = this.schemaLeafPaths().filter((p) => {
417
+ if (p.length < segs.length)
418
+ return false;
419
+ for (let i = 0; i < segs.length; i++)
420
+ if (p[p.length - segs.length + i] !== segs[i])
421
+ return false;
422
+ return true;
423
+ });
424
+ if (matches.length === 0)
425
+ return key;
426
+ if (matches.length > 1)
427
+ throw new Error(`ambiguous key "${key}" matches: ${matches.map((m) => m.join(".")).join(", ")}`);
428
+ return matches[0].join(".");
429
+ }
430
+ /* retrieve the effective value at a dotted key (strongest scope wins),
431
+ or the target scope's root contents if no key is given */
97
432
  get(key) {
98
433
  if (key === undefined)
99
- return this.doc.contents;
100
- return this.doc.getIn(key.split("."));
434
+ return this.docs[this.target].doc.contents;
435
+ const segs = this.resolveKey(key).split(".");
436
+ for (let i = this.docs.length - 1; i >= 0; i--) {
437
+ const v = this.docs[i].doc.getIn(segs);
438
+ if (v !== undefined)
439
+ return v;
440
+ }
441
+ return undefined;
101
442
  }
102
- /* set a value at a dotted key, creating intermediate maps as needed */
443
+ /* retrieve the effective value together with the scope it came from */
444
+ getWithOrigin(key) {
445
+ const segs = this.resolveKey(key).split(".");
446
+ for (let i = this.docs.length - 1; i >= 0; i--) {
447
+ const v = this.docs[i].doc.getIn(segs);
448
+ if (v !== undefined)
449
+ return { value: v, scope: this.docs[i].scope };
450
+ }
451
+ return undefined;
452
+ }
453
+ /* enumerate the effective leaf entries across the full scope chain;
454
+ each returned entry identifies the originating scope */
455
+ entries() {
456
+ const keys = new Set();
457
+ const walk = (node, prefix) => {
458
+ if (isMap(node))
459
+ for (const item of node.items) {
460
+ const k = [...prefix, String(item.key)];
461
+ if (isMap(item.value))
462
+ walk(item.value, k);
463
+ else if (isScalar(item.value))
464
+ keys.add(k.join("."));
465
+ else
466
+ throw new Error(`key "${k.join(".")}" has unsupported node type`);
467
+ }
468
+ };
469
+ for (const d of this.docs)
470
+ walk(d.doc.contents, []);
471
+ const result = [];
472
+ for (const k of keys) {
473
+ const segs = k.split(".");
474
+ for (let i = this.docs.length - 1; i >= 0; i--) {
475
+ const v = this.docs[i].doc.getIn(segs);
476
+ if (v !== undefined) {
477
+ result.push({ key: k, value: v, scope: this.docs[i].scope });
478
+ break;
479
+ }
480
+ }
481
+ }
482
+ result.sort((a, b) => a.key.localeCompare(b.key));
483
+ return result;
484
+ }
485
+ /* set a value at a dotted key in the target scope, creating intermediate maps as needed */
103
486
  set(key, value) {
104
- const segments = key.split(".");
487
+ const segments = this.resolveKey(key).split(".");
488
+ const td = this.docs[this.target];
489
+ const next = td.doc.clone();
105
490
  for (let i = 1; i < segments.length; i++) {
106
491
  const prefix = segments.slice(0, i);
107
- const node = this.doc.getIn(prefix, true);
108
- if (!isMap(node))
109
- this.doc.setIn(prefix, this.doc.createNode({}));
492
+ const node = next.getIn(prefix, true);
493
+ if (node !== undefined && !isMap(node))
494
+ throw new Error(`cannot set "${key}": intermediate path "${prefix.join(".")}" is not a map`);
495
+ if (node === undefined)
496
+ next.setIn(prefix, next.createNode({}));
497
+ }
498
+ next.setIn(segments, value);
499
+ const saved = td.doc;
500
+ td.doc = next;
501
+ try {
502
+ this.validateDoc(td.doc, td.filename, "strict");
503
+ }
504
+ catch (err) {
505
+ td.doc = saved;
506
+ throw err;
110
507
  }
111
- this.doc.setIn(segments, value);
112
- this.validate("strict");
113
508
  }
114
- /* delete a value at a dotted key */
509
+ /* delete a value at a dotted key from the target scope */
115
510
  delete(key) {
116
- this.doc.deleteIn(key.split("."));
511
+ const td = this.docs[this.target];
512
+ const next = td.doc.clone();
513
+ next.deleteIn(this.resolveKey(key).split("."));
514
+ const saved = td.doc;
515
+ td.doc = next;
516
+ try {
517
+ this.validateDoc(td.doc, td.filename, "strict");
518
+ }
519
+ catch (err) {
520
+ td.doc = saved;
521
+ throw err;
522
+ }
117
523
  }
118
524
  }
119
- /* register CLI command "ase config" */
120
- const registerConfigCommand = (program) => {
121
- const configCmd = program
122
- .command("config")
123
- .description("Manage ASE configuration")
124
- .action((_opts, cmd) => {
125
- cmd.help();
126
- });
127
- /* register CLI sub-command "ase config get" */
128
- configCmd
129
- .command("get")
130
- .description("Print the value at a dotted configuration key")
131
- .argument("<key>", "Configuration key (dotted path)")
132
- .action((key) => {
133
- const cfg = new Config("config", configSchema);
134
- cfg.read();
135
- const v = cfg.get(key);
136
- if (isMap(v))
137
- throw new Error(`key "${key}" is not a leaf key`);
138
- console.log(isScalar(v) ? v.value : v);
139
- });
140
- /* register CLI sub-command "ase config set" */
141
- configCmd
142
- .command("set")
143
- .description("Set the value at a dotted configuration key")
144
- .argument("<key>", "Configuration key (dotted path)")
145
- .argument("<value>", "Configuration value")
146
- .action((key, value) => {
147
- const cfg = new Config("config", configSchema);
148
- cfg.read();
149
- console.log(`${key}: ${value}`);
150
- cfg.set(key, value);
151
- cfg.write();
152
- });
153
- /* register CLI sub-command "ase config list" */
154
- configCmd
155
- .command("list")
156
- .description("List all configured values as flat dotted keys")
157
- .action(() => {
158
- const cfg = new Config("config", configSchema);
159
- cfg.read();
160
- const table = new Table({ head: ["key", "value"] });
161
- const list = (node, prefix) => {
162
- if (isMap(node))
163
- for (const item of node.items) {
164
- const k = prefix ? `${prefix}.${item.key}` : String(item.key);
165
- if (isMap(item.value))
166
- list(item.value, k);
167
- else
168
- table.push([k, String(isScalar(item.value) ? item.value.value : item.value)]);
525
+ /* CLI command "ase config" */
526
+ export default class ConfigCommand {
527
+ log;
528
+ constructor(log) {
529
+ this.log = log;
530
+ }
531
+ /* register commands */
532
+ register(program) {
533
+ /* register CLI top-level command "ase config" */
534
+ const configCmd = program
535
+ .command("config")
536
+ .option("--scope <scope>", "configuration scope chain: comma-separated list of \"user\", \"project\", " +
537
+ "\"task:<id>\", and/or \"session:<id>\" terms (e.g. \"task:N,session:M\"); " +
538
+ "\"user\" is always implicitly included and \"project\" is implicitly " +
539
+ "included whenever a project context (Git repo or upward \".ase\" directory) exists")
540
+ .description("manage ASE configuration")
541
+ .action((_opts, cmd) => {
542
+ cmd.outputHelp();
543
+ process.exit(1);
544
+ });
545
+ /* register CLI sub-command "ase config init" */
546
+ configCmd
547
+ .command("init")
548
+ .description("initialize configuration with preset values (default|vibe|pro|industry)")
549
+ .argument("<type>", "Preset type (default|vibe|pro|industry)")
550
+ .action((type, _opts, cmd) => {
551
+ const scope = parseScope(cmd.optsWithGlobals().scope);
552
+ const preset = projectClassificationPresets[type];
553
+ if (preset === undefined)
554
+ throw new Error(`unknown preset "${type}" (expected: default|vibe|pro|industry)`);
555
+ const cfg = new Config("config", configSchema, this.log, scope);
556
+ cfg.read();
557
+ for (const [k, val] of Object.entries(preset))
558
+ cfg.set(k, val);
559
+ cfg.write();
560
+ });
561
+ /* register CLI sub-command "ase config list" */
562
+ configCmd
563
+ .command("list")
564
+ .description("list all configured values as flat dotted keys")
565
+ .action((_opts, cmd) => {
566
+ const scope = parseScope(cmd.optsWithGlobals().scope);
567
+ const cfg = new Config("config", configSchema, this.log, scope);
568
+ cfg.read();
569
+ const table = new Table({
570
+ head: ["KEY", "VALUE", "SCOPE"],
571
+ chars: { "mid": "", "left-mid": "", "mid-mid": "", "right-mid": "" },
572
+ style: { head: ["blue"] }
573
+ });
574
+ for (const e of cfg.entries()) {
575
+ const v = isScalar(e.value) ? e.value.value : e.value;
576
+ table.push([e.key, String(v), Config.scopeLabel(e.scope)]);
577
+ }
578
+ process.stdout.write(`${table.toString()}\n`);
579
+ });
580
+ /* register CLI sub-command "ase config edit" */
581
+ configCmd
582
+ .command("edit")
583
+ .description("edit configuration file with $EDITOR")
584
+ .action(async (_opts, cmd) => {
585
+ const scope = parseScope(cmd.optsWithGlobals().scope);
586
+ const editor = process.env.EDITOR ?? process.env.VISUAL ?? "vi";
587
+ const cfg = new Config("config", configSchema, this.log, scope);
588
+ fs.mkdirSync(path.dirname(cfg.filename), { recursive: true });
589
+ if (!fs.existsSync(cfg.filename))
590
+ fs.writeFileSync(cfg.filename, "", "utf8");
591
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
592
+ try {
593
+ for (;;) {
594
+ execaSync(editor, [cfg.filename], { stdio: "inherit" });
595
+ try {
596
+ cfg.read("strict");
597
+ break;
598
+ }
599
+ catch (err) {
600
+ const msg = err instanceof Error ? err.message : String(err);
601
+ this.log.write("error", msg);
602
+ const ans = (await rl.question("re-edit? [Y/n] ")).trim().toLowerCase();
603
+ if (ans === "n" || ans === "no")
604
+ throw err;
605
+ }
169
606
  }
170
- };
171
- list(cfg.get(), "");
172
- console.log(table.toString());
173
- });
174
- /* register CLI sub-command "ase config edit" */
175
- configCmd
176
- .command("edit")
177
- .description("Edit configuration file with $EDITOR")
178
- .action(() => {
179
- const editor = process.env.EDITOR ?? process.env.VISUAL ?? "vi";
180
- const cfg = new Config("config", configSchema);
181
- fs.mkdirSync(path.dirname(cfg.filename), { recursive: true });
182
- if (!fs.existsSync(cfg.filename))
183
- fs.writeFileSync(cfg.filename, "", "utf8");
184
- execaSync(editor, [cfg.filename], { stdio: "inherit" });
185
- cfg.read();
186
- });
187
- };
188
- export default registerConfigCommand;
607
+ }
608
+ finally {
609
+ rl.close();
610
+ }
611
+ });
612
+ /* register CLI sub-command "ase config get" */
613
+ configCmd
614
+ .command("get")
615
+ .description("print the value at a dotted configuration key")
616
+ .argument("<key>", "configuration key (dotted path)")
617
+ .action((key, _opts, cmd) => {
618
+ const scope = parseScope(cmd.optsWithGlobals().scope);
619
+ const cfg = new Config("config", configSchema, this.log, scope);
620
+ cfg.read();
621
+ const val = cfg.get(key);
622
+ if (val === undefined)
623
+ throw new Error(`key "${key}" is not set`);
624
+ if (isMap(val))
625
+ throw new Error(`key "${key}" is not a leaf key`);
626
+ process.stdout.write(`${isScalar(val) ? val.value : val}\n`);
627
+ });
628
+ /* register CLI sub-command "ase config set" */
629
+ configCmd
630
+ .command("set")
631
+ .description("set the value at a dotted configuration key")
632
+ .argument("<key>", "configuration key (dotted path)")
633
+ .argument("<value>", "configuration value")
634
+ .action((key, value, _opts, cmd) => {
635
+ const scope = parseScope(cmd.optsWithGlobals().scope);
636
+ const cfg = new Config("config", configSchema, this.log, scope);
637
+ cfg.read();
638
+ cfg.set(key, value);
639
+ cfg.write();
640
+ });
641
+ }
642
+ }