@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 +562 -108
- package/dst/ase-log.js +69 -0
- package/dst/ase-service.js +365 -198
- package/dst/ase.1 +27 -10
- package/dst/ase.js +29 -12
- package/package.json +8 -4
- package/README.md +0 -42
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
|
|
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
|
-
|
|
206
|
+
/* private state */
|
|
207
|
+
name;
|
|
208
|
+
scope;
|
|
22
209
|
schema;
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
/*
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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.
|
|
64
|
-
|
|
65
|
-
|
|
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
|
|
68
|
-
|
|
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,
|
|
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 ${
|
|
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
|
-
|
|
375
|
+
this.log.write("warning", `invalid entry in ${filename}: ${dotPath ? `${dotPath}: ` : ""}${i.message}`);
|
|
87
376
|
if (segs.length > 0) {
|
|
88
|
-
|
|
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
|
-
/*
|
|
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
|
-
|
|
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
|
-
/*
|
|
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 =
|
|
108
|
-
if (!isMap(node))
|
|
109
|
-
|
|
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.
|
|
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
|
-
/*
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
.
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
+
}
|