eidosmd 0.2.0 → 0.3.1

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.
Files changed (47) hide show
  1. package/browser/dist/assets/index-D_Xs1hAc.css +1 -0
  2. package/browser/dist/assets/index-K_EgH2M8.js +46 -0
  3. package/browser/dist/index.html +2 -2
  4. package/dist/src/commands/check.js +9 -3
  5. package/dist/src/commands/configure.js +201 -0
  6. package/dist/src/commands/framework.js +15 -4
  7. package/dist/src/commands/init.js +4 -0
  8. package/dist/src/commands/property.js +125 -0
  9. package/dist/src/commands/setup.js +24 -12
  10. package/dist/src/commands/version.js +2 -2
  11. package/dist/src/core/canvas.js +59 -49
  12. package/dist/src/core/check.js +101 -26
  13. package/dist/src/core/edits.js +1381 -0
  14. package/dist/src/core/framework-markdown.js +9 -3
  15. package/dist/src/core/framework-model.js +43 -8
  16. package/dist/src/core/framework-structured.js +104 -28
  17. package/dist/src/core/frontmatter.js +61 -1
  18. package/dist/src/core/git.js +28 -3
  19. package/dist/src/core/links.js +87 -0
  20. package/dist/src/core/markdown.js +16 -9
  21. package/dist/src/core/migrate.js +91 -15
  22. package/dist/src/core/regions.js +117 -0
  23. package/dist/src/core/scaffold.js +15 -9
  24. package/dist/src/core/seed.js +56 -31
  25. package/dist/src/core/server.js +204 -31
  26. package/dist/src/core/settings.js +63 -12
  27. package/dist/src/core/store.js +73 -17
  28. package/dist/src/core/versions.js +10 -5
  29. package/dist/src/program.js +296 -11
  30. package/instructions/authoring.md +4 -2
  31. package/instructions/configuring.md +27 -11
  32. package/instructions/overview.md +6 -4
  33. package/instructions/validating.md +6 -4
  34. package/package.json +1 -1
  35. package/standard/EIDOS.md +135 -193
  36. package/standard/seeds/README.md +12 -16
  37. package/standard/seeds/book/Framework.yaml +30 -50
  38. package/standard/seeds/book/README.md +2 -1
  39. package/standard/seeds/book/_gitignore +7 -1
  40. package/standard/seeds/research/Framework.yaml +30 -50
  41. package/standard/seeds/research/README.md +2 -1
  42. package/standard/seeds/research/_gitignore +7 -1
  43. package/standard/seeds/software/Framework.yaml +31 -51
  44. package/standard/seeds/software/README.md +2 -1
  45. package/standard/seeds/software/_gitignore +7 -1
  46. package/browser/dist/assets/index-C2NMN_D4.css +0 -1
  47. package/browser/dist/assets/index-C65k1ihb.js +0 -46
@@ -6,7 +6,8 @@
6
6
  // runtime without node:sqlite (Node 20) gets a plain substring scorer with
7
7
  // the same shape of result, so the page behaves the same everywhere.
8
8
  import { createRequire } from 'node:module';
9
- import { watch } from 'node:fs';
9
+ import { readdirSync, statSync, watch } from 'node:fs';
10
+ import path from 'node:path';
10
11
  import { loadRoot } from '../context.js';
11
12
  import { blueprintId, blueprintTitle, propertyString, resolveVariant } from './blueprint.js';
12
13
  const IGNORED = new Set(['.git', 'node_modules', '.DS_Store']);
@@ -192,7 +193,10 @@ export class Store {
192
193
  dirty = true;
193
194
  search_;
194
195
  watcher = null;
196
+ poller = null;
195
197
  pending = null;
198
+ // the root-relative paths that changed since the last event went out
199
+ changed = new Set();
196
200
  listeners = new Set();
197
201
  constructor(options) {
198
202
  this.root = options.root;
@@ -221,7 +225,8 @@ export class Store {
221
225
  this.context();
222
226
  return this.search_.search(query, limit);
223
227
  }
224
- // Called after the disk settles, for the page to refetch what it shows.
228
+ // Called after the disk settles, with the root-relative paths that
229
+ // changed, so a view can tell whether the file it holds is one of them.
225
230
  onChange(listener) {
226
231
  this.listeners.add(listener);
227
232
  return () => this.listeners.delete(listener);
@@ -229,31 +234,82 @@ export class Store {
229
234
  close() {
230
235
  this.watcher?.close();
231
236
  this.watcher = null;
237
+ if (this.poller)
238
+ clearInterval(this.poller);
239
+ this.poller = null;
232
240
  if (this.pending)
233
241
  clearTimeout(this.pending);
234
242
  this.search_.close();
235
243
  }
244
+ noticed(name) {
245
+ if (name.split(/[\\/]/).some((part) => IGNORED.has(part)))
246
+ return;
247
+ this.dirty = true;
248
+ if (name !== '')
249
+ this.changed.add(name.split(path.sep).join('/'));
250
+ if (this.pending)
251
+ clearTimeout(this.pending);
252
+ this.pending = setTimeout(() => {
253
+ this.pending = null;
254
+ const paths = [...this.changed].sort();
255
+ this.changed.clear();
256
+ for (const listener of this.listeners)
257
+ listener(paths);
258
+ }, 150);
259
+ }
260
+ // Recursive watching where the platform has it (macOS, Windows, recent
261
+ // Linux); elsewhere a poll of the tree's mtimes every two seconds does the
262
+ // same job, so the page behaves the same on every machine.
236
263
  startWatching() {
237
264
  try {
238
- this.watcher = watch(this.root, { recursive: true }, (_event, filename) => {
239
- const name = typeof filename === 'string' ? filename : '';
240
- if (name.split(/[\\/]/).some((part) => IGNORED.has(part)))
241
- return;
242
- this.dirty = true;
243
- if (this.pending)
244
- clearTimeout(this.pending);
245
- this.pending = setTimeout(() => {
246
- this.pending = null;
247
- for (const listener of this.listeners)
248
- listener();
249
- }, 150);
250
- });
265
+ this.watcher = watch(this.root, { recursive: true }, (_event, filename) => this.noticed(typeof filename === 'string' ? filename : ''));
251
266
  this.watcher.unref();
252
267
  }
253
268
  catch {
254
- // No recursive watching here: reads still see every write made through
255
- // this server, and a change made elsewhere shows on the next reload.
256
269
  this.watcher = null;
270
+ let last = snapshotTree(this.root);
271
+ this.poller = setInterval(() => {
272
+ const next = snapshotTree(this.root);
273
+ for (const [file, stamp] of next)
274
+ if (last.get(file) !== stamp)
275
+ this.noticed(file);
276
+ for (const file of last.keys())
277
+ if (!next.has(file))
278
+ this.noticed(file);
279
+ last = next;
280
+ }, 2000);
281
+ this.poller.unref();
257
282
  }
258
283
  }
259
284
  }
285
+ // Every file under the root with its mtime, root-relative, for the poll.
286
+ function snapshotTree(root) {
287
+ const out = new Map();
288
+ const walk = (dir) => {
289
+ let entries = [];
290
+ try {
291
+ entries = readdirSync(dir);
292
+ }
293
+ catch {
294
+ return;
295
+ }
296
+ for (const entry of entries) {
297
+ if (IGNORED.has(entry))
298
+ continue;
299
+ const full = path.join(dir, entry);
300
+ let stat;
301
+ try {
302
+ stat = statSync(full);
303
+ }
304
+ catch {
305
+ continue;
306
+ }
307
+ if (stat.isDirectory())
308
+ walk(full);
309
+ else
310
+ out.set(path.relative(root, full).split(path.sep).join('/'), stat.mtimeMs);
311
+ }
312
+ };
313
+ walk(root);
314
+ return out;
315
+ }
@@ -3,14 +3,19 @@
3
3
  // plugin's folder, and since 5.0.0 keeps no `versions` key of its own). A
4
4
  // version is a name the owner chose and a commit that already exists in the
5
5
  // repository the root lives in; the commit is the snapshot, nothing is copied.
6
- // A tag, when wanted, is `blueprints/<version>`. Nothing here proposes a
7
- // version: it is written only when asked, and it needs git.
6
+ // A tag, when wanted, is the root's tag prefix (settings.yaml
7
+ // `versions.tag_prefix`, `blueprints/` unless changed) before the version.
8
+ // Nothing here proposes a version: it is written only when asked, and it
9
+ // needs git.
8
10
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
9
11
  import path from 'node:path';
10
12
  import { parse, stringify } from 'yaml';
11
13
  import { FRAMEWORK_DIR, TOOL_KEY } from './framework-model.js';
12
14
  import { gitResolve, gitTag, gitTagExists } from './git.js';
13
- export const TAG_PREFIX = 'blueprints/';
15
+ import { DEFAULT_TAG_PREFIX, readSettings } from './settings.js';
16
+ export const TAG_PREFIX = DEFAULT_TAG_PREFIX;
17
+ // What a tag on this root starts with, as its settings say.
18
+ export const tagPrefix = (root) => readSettings(root).shared.versions.tag_prefix;
14
19
  export class VersionError extends Error {
15
20
  constructor(message) {
16
21
  super(message);
@@ -18,7 +23,7 @@ export class VersionError extends Error {
18
23
  }
19
24
  }
20
25
  export const versionsFile = (root) => path.join(root, FRAMEWORK_DIR, 'plugins', TOOL_KEY, 'versions.yaml');
21
- const GUIDANCE = "# The root's own versions, kept by the eidos CLI (`eidos version`): snapshots taken on purpose, newest first.\n# version is the root's own number, commit the sha that is the snapshot, tag blueprints/<version> when one was made.\n";
26
+ const GUIDANCE = "# The root's own versions, kept by the eidos CLI (`eidos version`): snapshots taken on purpose, newest first.\n# version is the root's own number, commit the sha that is the snapshot, tag <prefix><version> when one was made.\n";
22
27
  // Newest first, as recorded; none when the file is absent.
23
28
  export function readVersions(root) {
24
29
  const file = versionsFile(root);
@@ -62,7 +67,7 @@ export function recordVersion(root, options) {
62
67
  throw new VersionError(`'${ref}' is not a commit in the repository this root lives in; a version names a commit that exists`);
63
68
  }
64
69
  const short = sha.slice(0, 12);
65
- const tag = options.tag ? `${TAG_PREFIX}${version}` : null;
70
+ const tag = options.tag ? `${options.tagPrefix ?? tagPrefix(root)}${version}` : null;
66
71
  let tagged = false;
67
72
  if (tag) {
68
73
  if (gitTagExists(root, tag)) {
@@ -15,7 +15,10 @@ import { runInstructions, runStandard } from './commands/instructions.js';
15
15
  import { runList } from './commands/list.js';
16
16
  import { runNew } from './commands/new.js';
17
17
  import { runSeeds } from './commands/seeds.js';
18
- import { offerGit, runSetup } from './commands/setup.js';
18
+ import { offerSetup, runSetup } from './commands/setup.js';
19
+ import { runPlan, showCollection, showFolder, showProperty, showRole } from './commands/configure.js';
20
+ import { runPropertyGet, runPropertySet, runPropertyUnset } from './commands/property.js';
21
+ import { planCollectionAdd, planDocAdd, planDocRemove, planDocRename, planDocSet, planCollectionRemove, planCollectionRename, planFolderAdd, planFolderRemove, planFolderRename, planFolderSet, planGroupAdd, planGroupRemove, planGroupRename, planPropertyAdd, planPropertyRemap, planPropertyRemove, planPropertyRename, planPropertySet, planRoleAdd, planRoleRename, planRoleRemove, planTermAdd, planTermRemove, planTermSet, planVariantAdd, planVariantRemove, planVariantRename, planVariantSetDefault } from './core/edits.js';
19
22
  import { runShow } from './commands/show.js';
20
23
  import { runVersionList, runVersionRecord } from './commands/version.js';
21
24
  import { runRoles, runWhoami } from './commands/whoami.js';
@@ -37,6 +40,11 @@ function run(action) {
37
40
  process.exitCode = EXIT_USAGE;
38
41
  }
39
42
  }
43
+ // `--options A,B,C` as the list it names, in the order typed.
44
+ const splitValues = (given) => given
45
+ .split(',')
46
+ .map((entry) => entry.trim())
47
+ .filter((entry) => entry !== '');
40
48
  export function buildProgram() {
41
49
  const program = new Command();
42
50
  // --root is accepted before or after the command name.
@@ -63,10 +71,13 @@ Start with \`eidos instructions\` for the workflow, or \`eidos seeds\` and \`eid
63
71
  .option('--product <name>', "fills the README's {{Product}} placeholder")
64
72
  .option('--no-framing', 'leave out the seed\'s framing collection (the docs about the whole product, recommended for every product)')
65
73
  .option('--date <YYYY-MM-DD>', 'the date written into the scaffolded frames', today())
74
+ .option('--strict', 'a warning fails `eidos check` in this root (kept in settings.yaml, with the root)')
75
+ .option('--no-strict', 'only an error fails `eidos check` in this root')
66
76
  .option('--dry-run', 'print every write and touch nothing', false)
67
77
  .option('--json', 'machine-readable result', false)
68
78
  .addHelpText('after', `
69
79
  Writes no prose: the README one-liner, each group's description, and every frame's summary and body stay the owner's.
80
+ In a terminal, asks the two settings a framework owner decides once and commits with the root: whether the browser reads git, and whether a warning fails the check (--strict / --no-strict answers it without the question).
70
81
 
71
82
  Examples:
72
83
  eidos init
@@ -76,9 +87,9 @@ Examples:
76
87
  Writes the seed's Framework.yaml into the root with its guidance as comments, the naming, the starting groups, and the collection names set in it.`)
77
88
  .action(async (root, options) => {
78
89
  run(() => runInit(root, options));
79
- // In a terminal, the one setting worth asking for right away; the rest is `eidos setup`.
80
- if (process.exitCode === 0 && !options.json && !options.dryRun)
81
- await offerGit(path.resolve(root ?? 'Blueprints'));
90
+ // The owner's two settings: a flag answers, a terminal asks, the rest is `eidos setup`.
91
+ if (process.exitCode === 0 && !options.dryRun)
92
+ await offerSetup(path.resolve(root ?? 'Blueprints'), { strict: options.strict });
82
93
  });
83
94
  program
84
95
  .command('seeds')
@@ -88,14 +99,15 @@ Writes the seed's Framework.yaml into the root with its guidance as comments, th
88
99
  run(() => runSeeds(options));
89
100
  });
90
101
  withRoot(program.command('framework'))
91
- .description("show the root's framework: version, naming, top-level docs, collections (variants, groups), the Properties table, and the Vocabulary")
102
+ .description("show the root's framework: version, naming, top-level docs, folders (a collection's variants and groups), the Properties table, and the Vocabulary")
92
103
  .option('--json', 'the framework as JSON: the document form plus root, file, and format', false)
93
104
  .option('--as <format>', 'print the framework as the yaml document, normalized, with this CLI\'s guidance as comments')
94
105
  .addHelpText('after', `
95
106
  Read this before assuming any collection, variant, section, or property name; the framework declares its own.
96
107
  The document is .eidos/Framework.yaml. A root still on the 4.x Framework.md is moved by \`eidos migrate\` first.
97
108
 
98
- Output (--json): { root, file, format, eidos_version, naming, top_level[], collections[{ name, description, variants[{ name, template, description, default }], grouping{ label, property, groups[] } }], properties{ core[], custom[{ name, type, applies_to, meaning, <tool>… }], tools{ <tool>: [...] } }, vocabulary[{ term, means, not[], see? }] }
109
+ Output (--json): { root, file, format, eidos_version, naming, top_level[], folders[{ name, type: collection | assets | other, description, variants[{ name, template, description, default }], grouping{ label, property, groups[] } }], properties{ core[], custom[{ name, type, applies_to, meaning, <tool>… }], tools{ <tool>: [...] } }, vocabulary[{ term, means, not[], see? }] }
110
+ A folder's variants and grouping are a collection's; an assets or other folder carries only its name, type, and description.
99
111
  A key past the standard's four on a property entry is a tool's, named for the tool (this CLI's is eidosmd, carrying a canvas hint); properties.tools.<tool> is a block of properties a tool declared and alone writes.`)
100
112
  .action((options) => {
101
113
  run(() => runFramework(loadRoot(globals(options), process.cwd()), options));
@@ -147,10 +159,12 @@ Examples:
147
159
  withRoot(program.command('check'))
148
160
  .argument('[blueprint...]', 'only these blueprints (paths); default: the whole root')
149
161
  .description("validate the root against its own framework: the framework document and templates, every blueprint's frontmatter and body, the layout, and the indexes")
150
- .option('--strict', 'warnings fail too', false)
151
- .option('--json', '{ ok, root, eidos_version, naming, blueprints, errors, warnings, findings[{ level, code, path, message }] }', false)
162
+ .option('--strict', 'warnings fail too, for this run (the root\'s own setting is settings.yaml check.strict)')
163
+ .option('--no-strict', 'warnings do not fail, for this run')
164
+ .option('--json', '{ ok, strict, root, eidos_version, naming, blueprints, errors, warnings, findings[{ level, code, path, message }] }', false)
152
165
  .addHelpText('after', `
153
- Errors are wrong on any reading (unparseable frontmatter, a missing or duplicate id, an undeclared variant, a broken link). Warnings are gaps the standard says to surface, never refuse (a missing property or section, a stale index, a version gap). Exit 1 on an error; with --strict, on a warning too.
166
+ Errors are wrong on any reading (unparseable frontmatter, a missing or duplicate id, an undeclared variant, a broken link). Warnings are gaps the standard says to surface, never refuse (a missing property or section, a stale index, a version gap). Exit 1 on an error; on a warning too when the root is strict.
167
+ Strictness is the framework owner's decision for the root, asked by \`eidos init\` and set by \`eidos setup --strict on|off\`, kept in .eidos/plugins/eidosmd/settings.yaml so CI and every machine agree; --strict and --no-strict override one run.
154
168
 
155
169
  Examples:
156
170
  eidos check
@@ -236,9 +250,9 @@ A snapshot of the root as a whole is a version: \`eidos version record\`.`);
236
250
  });
237
251
  withRoot(version.command('record'))
238
252
  .argument('<version>', "the root's own number, e.g. 1.0.0 (not the product's release version)")
239
- .description('record a snapshot: a row naming a commit that exists; --tag also tags it blueprints/<version>')
253
+ .description('record a snapshot: a row naming a commit that exists; --tag also tags it <prefix><version>')
240
254
  .option('--commit <ref>', 'the commit that is the snapshot (a sha, HEAD, a tag); HEAD when absent')
241
- .option('--tag', 'create the tag blueprints/<version> on that commit', false)
255
+ .option('--tag', 'create the tag <prefix><version> on that commit (the prefix is settings.yaml versions.tag_prefix, blueprints/ by default)', false)
242
256
  .option('--json', 'machine-readable result', false)
243
257
  .action((given, options) => {
244
258
  run(() => runVersionRecord(loadRoot(globals(options)), given, options));
@@ -254,6 +268,7 @@ The commit is the snapshot (git holds every blueprint as it was), nothing is cop
254
268
  .option('--json', 'machine-readable result', false)
255
269
  .addHelpText('after', `
256
270
  The 4.x → 5.0.0 hop: _eidos/ becomes .eidos/, shapes/ becomes templates/, the framework document takes the 5.0.0 keys (schema → properties, flavors → variants, shape → template), a root that kept Framework.md gets Framework.yaml in its place (the index inside it; Framework.md and each collection's index.md removed), every blueprint's flavor becomes variant, and eidos_version is set.
271
+ From 5.0.0 on nothing moves on disk: the version is bumped, the core block is rewritten, and .eidos/.gitignore gains the one line every tool's personal file needs (plugins/*/local.yaml).
257
272
  A template that opens with frontmatter loses the block; one named off its unit (<unit>.<variant>.md) is reported for you to rename. Run eidos check afterwards.`)
258
273
  .action((options) => {
259
274
  run(() => runMigrate(options));
@@ -261,6 +276,7 @@ A template that opens with frontmatter loses the block; one named off its unit (
261
276
  withRoot(program.command('setup'))
262
277
  .description("the CLI's own settings for this root: whether git is read, the people who work here, and who this machine acts as")
263
278
  .option('--git <on|off>', 'read git in the browser: history, authors, versions')
279
+ .option('--strict <on|off>', 'a warning fails `eidos check` in this root, the way an error does; the framework owner\'s decision, kept with the root')
264
280
  .option('--add-user <name>', 'add (or update) a user by display name (repeatable)', collect, [])
265
281
  .option('--alias <alias>', 'the @mention handle of the user being added, no spaces')
266
282
  .option('--role <name>', 'the role of the user being added, from `eidos roles`')
@@ -289,6 +305,275 @@ Examples:
289
305
  }
290
306
  }
291
307
  });
308
+ // ---- configure:<noun>: the framework edited from the shell, each a plan shown before it runs
309
+ const runAsync = async (action) => {
310
+ try {
311
+ process.exitCode = await action();
312
+ }
313
+ catch (cause) {
314
+ if (cause instanceof CliError) {
315
+ printError(`error: ${cause.message}`);
316
+ process.exitCode = cause.exitCode;
317
+ return;
318
+ }
319
+ printError(`error: ${cause instanceof Error ? cause.message : String(cause)}`);
320
+ process.exitCode = EXIT_USAGE;
321
+ }
322
+ };
323
+ const planned = (command) => withRoot(command)
324
+ .option('--dry-run', 'print the plan and write nothing', false)
325
+ .option('-y, --yes', 'proceed without asking', false)
326
+ .option('--force', 'proceed through what needs forcing: values or files that will be gone, a conflict the plan names', false)
327
+ .option('--json', 'the plan, its conflicts, and once applied every result, as one object', false);
328
+ const PLAN_HELP = `
329
+ The plan is printed whole, then the command asks in a terminal; --dry-run prints it and stops, --yes proceeds, --force proceeds where values or files would be gone. Without a terminal and without a switch the command exits 2 and names the switch.
330
+ After any change the index is rewritten and the check runs; a command never leaves the root with a finding it caused, unless --preserve, the owner's choice, says so.`;
331
+ const configure = (noun, description) => program.command(`configure:${noun}`).description(description).addHelpText('after', PLAN_HELP);
332
+ const configureProperty = configure('property', "the Properties table's custom block: add, show, rename, set, remap, or remove a property, every blueprint it touches following");
333
+ planned(configureProperty.command('add'))
334
+ .argument('<name>', 'the frontmatter key: letters, digits, _ and -')
335
+ .requiredOption('--type <type>', 'Text | List | Number | Checkbox | Date | "Date & time"')
336
+ .option('--applies-to <all|collection,...>', 'all, or a comma-separated list of collections', 'all')
337
+ .option('--required', 'generated into every blueprint it applies to, blank, and noted when missing', false)
338
+ .option('--options <value,...>', 'the closed set a Text value is one of, or a List\'s elements are, comma-separated in the order they run; absent, any value is valid')
339
+ .option('--meaning <text>', 'one line: what it holds and why', '')
340
+ .description('declare a custom property; required, it is backfilled blank into the blueprints it applies to')
341
+ .action((name, options) => runAsync(() => runPlan(loadRoot(globals(options)), (edit) => planPropertyAdd(edit, { name, type: options.type, appliesTo: options.appliesTo === 'all' ? 'all' : options.appliesTo.split(',').map((entry) => entry.trim()).filter((entry) => entry !== ''), required: options.required, meaning: options.meaning, options: options.options === undefined ? null : splitValues(options.options) }), options)));
342
+ withRoot(configureProperty.command('show'))
343
+ .argument('[name]', 'one property; none lists every block')
344
+ .option('--json', 'machine-readable result', false)
345
+ .description('print a property as the table declares it')
346
+ .action((name, options) => run(() => showProperty(loadRoot(globals(options)), name, options.json)));
347
+ planned(configureProperty.command('rename'))
348
+ .argument('<name>', 'the property')
349
+ .argument('<new>', 'its new key')
350
+ .description('rename a custom property: the entry, the key in every blueprint, a grouping that names it, and this CLI\'s settings')
351
+ .action((name, to, options) => runAsync(() => runPlan(loadRoot(globals(options)), (edit) => planPropertyRename(edit, name, to), options)));
352
+ planned(configureProperty.command('set'))
353
+ .argument('<name>', 'the property')
354
+ .option('--type <type>', 'a new type')
355
+ .option('--applies-to <all|collection,...>', 'a new scope; a blueprint now out of scope loses the key unless --preserve')
356
+ .option('--required', 'make it required: backfilled blank where missing')
357
+ .option('--optional', 'make it optional')
358
+ .option('--options <value,...>', 'the closed set of values, comma-separated in order; narrowing a list surfaces every blueprint value off it and needs --force')
359
+ .option('--open', 'drop the options: any value is valid again')
360
+ .option('--meaning <text>', 'a new meaning')
361
+ .option('--preserve', 'leave keys in blueprints now out of scope, for check to report', false)
362
+ .description("change a custom property's type, scope, required, options, or meaning")
363
+ .action((name, options) => {
364
+ if (options.required && options.optional)
365
+ return runAsync(async () => Promise.reject(new CliError('--required and --optional exclude each other')));
366
+ if (options.options !== undefined && options.open)
367
+ return runAsync(async () => Promise.reject(new CliError('--options and --open exclude each other')));
368
+ return runAsync(() => runPlan(loadRoot(globals(options)), (edit) => planPropertySet(edit, name, {
369
+ ...(options.type !== undefined ? { type: options.type } : {}),
370
+ ...(options.appliesTo !== undefined ? { appliesTo: options.appliesTo === 'all' ? 'all' : options.appliesTo.split(',').map((entry) => entry.trim()).filter((entry) => entry !== '') } : {}),
371
+ ...(options.required ? { required: true } : options.optional ? { required: false } : {}),
372
+ ...(options.options !== undefined ? { options: splitValues(options.options) } : options.open ? { options: null } : {}),
373
+ ...(options.meaning !== undefined ? { meaning: options.meaning } : {}),
374
+ }, { preserve: options.preserve }), options));
375
+ });
376
+ planned(configureProperty.command('remap'))
377
+ .argument('<name>', 'the property')
378
+ .argument('<old=new...>', 'each value to rewrite, and what to')
379
+ .description('rewrite a value in every blueprint where it equals old, and the canvas style keyed by it')
380
+ .action((name, pairs, options) => runAsync(() => runPlan(loadRoot(globals(options)), (edit) => planPropertyRemap(edit, name, pairs.map((pair) => {
381
+ const eq = pair.indexOf('=');
382
+ if (eq <= 0)
383
+ throw new CliError(`expected old=new, got '${pair}'`);
384
+ return [pair.slice(0, eq), pair.slice(eq + 1)];
385
+ })), options)));
386
+ planned(configureProperty.command('remove'))
387
+ .argument('<name>', 'the property')
388
+ .option('--preserve', 'leave the key in every blueprint, for check to report as undeclared', false)
389
+ .description('retire a custom property: the entry and, unless --preserve, the key in every blueprint, its values shown first')
390
+ .action((name, options) => runAsync(() => runPlan(loadRoot(globals(options)), (edit) => planPropertyRemove(edit, name, { preserve: options.preserve }), options)));
391
+ const configureCollection = configure('collection', 'the collections: add, show, rename, or remove one, its folder, templates, properties, and links following');
392
+ planned(configureCollection.command('add'))
393
+ .argument('<name>', 'the collection, which is its folder')
394
+ .requiredOption('--unit <unit>', 'the word for one of its blueprints (spec, chapter, decision); its templates are named for it')
395
+ .option('--description <text>', 'one line', '')
396
+ .option('--grouping <label>', 'group its blueprints one level deep under this label (Domains, Products)')
397
+ .description("declare a collection with one default variant, its folder, and that variant's template (a title and an Intent section)")
398
+ .action((name, options) => runAsync(() => runPlan(loadRoot(globals(options)), (edit) => planCollectionAdd(edit, { name, unit: options.unit, description: options.description, grouping: options.grouping ?? null }), options)));
399
+ withRoot(configureCollection.command('show'))
400
+ .argument('[name]', 'one collection; none lists them all')
401
+ .option('--json', 'machine-readable result', false)
402
+ .description('print a collection as the framework declares it')
403
+ .action((name, options) => run(() => showCollection(loadRoot(globals(options)), name, options.json)));
404
+ planned(configureCollection.command('rename'))
405
+ .argument('<name>', 'the collection')
406
+ .argument('[new]', 'its new name; omit to rename only the unit')
407
+ .option('--unit <unit>', 'a new unit: the template files are renamed for it')
408
+ .description('rename a collection: the folder, the entry, every applies_to that names it, and every link in the root that crosses the folder')
409
+ .action((name, to, options) => runAsync(() => runPlan(loadRoot(globals(options)), (edit) => planCollectionRename(edit, name, to ?? name, options.unit ?? null), options)));
410
+ planned(configureCollection.command('remove'))
411
+ .argument('<name>', 'the collection')
412
+ .option('--preserve', 'leave the folder and its blueprints on disk, for check to report as undeclared', false)
413
+ .description('remove a collection: the entry, its templates, the properties scoped only to it, and, unless --preserve, its folder and blueprints')
414
+ .action((name, options) => runAsync(() => runPlan(loadRoot(globals(options)), (edit) => planCollectionRemove(edit, name, { preserve: options.preserve }), options)));
415
+ const folderType = (given) => {
416
+ if (given === 'assets' || given === 'other')
417
+ return given;
418
+ throw new CliError('--type takes assets or other; a collection is declared with configure:collection add');
419
+ };
420
+ const configureFolder = configure('folder', 'the folders at the root that are not collections (assets, other): add, show, rename, set, or remove one, its folder and the links into it following');
421
+ planned(configureFolder.command('add'))
422
+ .argument('<name>', 'the folder at the root, in the naming convention')
423
+ .requiredOption('--type <type>', 'assets (files that are not markdown) or other (whatever the description says)')
424
+ .option('--description <text>', 'one line: what the folder holds', '')
425
+ .description('declare a folder and create it')
426
+ .action((name, options) => runAsync(() => runPlan(loadRoot(globals(options)), (edit) => planFolderAdd(edit, { name, type: folderType(options.type), description: options.description }), options)));
427
+ withRoot(configureFolder.command('show'))
428
+ .argument('[name]', 'one folder; none lists every folder at the root with its type')
429
+ .option('--json', 'machine-readable result', false)
430
+ .description('print a folder as the framework declares it')
431
+ .action((name, options) => run(() => showFolder(loadRoot(globals(options)), name, options.json)));
432
+ planned(configureFolder.command('rename'))
433
+ .argument('<name>', 'the folder')
434
+ .argument('<new>', 'its new name')
435
+ .description('rename a folder: the entry, the folder on disk, and every link in the root into it')
436
+ .action((name, to, options) => runAsync(() => runPlan(loadRoot(globals(options)), (edit) => planFolderRename(edit, name, to), options)));
437
+ planned(configureFolder.command('set'))
438
+ .argument('<name>', 'the folder')
439
+ .option('--type <type>', 'assets or other')
440
+ .option('--description <text>', 'a new description')
441
+ .description("change a folder's type or description")
442
+ .action((name, options) => runAsync(() => runPlan(loadRoot(globals(options)), (edit) => planFolderSet(edit, name, { ...(options.type !== undefined ? { type: folderType(options.type) } : {}), ...(options.description !== undefined ? { description: options.description } : {}) }), options)));
443
+ planned(configureFolder.command('remove'))
444
+ .argument('<name>', 'the folder')
445
+ .option('--preserve', 'leave the folder and its files on disk, for check to report as undeclared', false)
446
+ .description('remove a folder: the entry and, unless --preserve, the folder and every file in it, each listed first')
447
+ .action((name, options) => runAsync(() => runPlan(loadRoot(globals(options)), (edit) => planFolderRemove(edit, name, { preserve: options.preserve }), options)));
448
+ const configureGroup = configure('group', "a collection's groups: add, rename, or remove one, its sub-folder, the grouping values, and the links following");
449
+ planned(configureGroup.command('add'))
450
+ .argument('<collection>', 'the collection, which must declare a grouping')
451
+ .argument('<name>', 'the group, which is its sub-folder')
452
+ .option('--description <text>', 'one line', '')
453
+ .description('declare a group and create its sub-folder')
454
+ .action((collection, name, options) => runAsync(() => runPlan(loadRoot(globals(options)), (edit) => planGroupAdd(edit, collection, name, options.description), options)));
455
+ planned(configureGroup.command('rename'))
456
+ .argument('<collection>', 'the collection')
457
+ .argument('<name>', 'the group')
458
+ .argument('<new>', 'its new name')
459
+ .description('rename a group: the sub-folder, the entry, the grouping value in every blueprint inside, and every link in the root into or out of it')
460
+ .action((collection, name, to, options) => runAsync(() => runPlan(loadRoot(globals(options)), (edit) => planGroupRename(edit, collection, name, to), options)));
461
+ planned(configureGroup.command('remove'))
462
+ .argument('<collection>', 'the collection')
463
+ .argument('<name>', 'the group')
464
+ .option('--preserve', 'leave the sub-folder and its blueprints on disk, for check to report as undeclared', false)
465
+ .description('remove a group: the entry and, unless --preserve, its sub-folder and blueprints')
466
+ .action((collection, name, options) => runAsync(() => runPlan(loadRoot(globals(options)), (edit) => planGroupRemove(edit, collection, name, { preserve: options.preserve }), options)));
467
+ const configureVariant = configure('variant', "a collection's variants: add, rename, set the default, or remove one, its template and the blueprints on it following");
468
+ planned(configureVariant.command('add'))
469
+ .argument('<collection>', 'the collection')
470
+ .argument('<name>', 'the variant (full, micro, api)')
471
+ .option('--description <text>', 'one line', '')
472
+ .option('--from <variant>', "copy this variant's template; default: the collection's default variant")
473
+ .description('declare a variant and write its template')
474
+ .action((collection, name, options) => runAsync(() => runPlan(loadRoot(globals(options)), (edit) => planVariantAdd(edit, collection, name, options.description, options.from ?? null), options)));
475
+ planned(configureVariant.command('rename'))
476
+ .argument('<collection>', 'the collection')
477
+ .argument('<name>', 'the variant')
478
+ .argument('<new>', 'its new name')
479
+ .description('rename a variant: the entry, its template file, and the variant value in every blueprint on it')
480
+ .action((collection, name, to, options) => runAsync(() => runPlan(loadRoot(globals(options)), (edit) => planVariantRename(edit, collection, name, to), options)));
481
+ planned(configureVariant.command('set-default'))
482
+ .argument('<collection>', 'the collection')
483
+ .argument('<name>', 'the variant that becomes the default')
484
+ .description("make a variant the collection's default")
485
+ .action((collection, name, options) => runAsync(() => runPlan(loadRoot(globals(options)), (edit) => planVariantSetDefault(edit, collection, name), options)));
486
+ planned(configureVariant.command('remove'))
487
+ .argument('<collection>', 'the collection')
488
+ .argument('<name>', 'the variant')
489
+ .description('remove a variant and its template; refused while a blueprint names it unless --force, which clears their variant')
490
+ .action((collection, name, options) => runAsync(() => runPlan(loadRoot(globals(options)), (edit) => planVariantRemove(edit, collection, name, { force: options.force }), options)));
491
+ const configureTerm = configure('term', "the Vocabulary: add, set, or remove a term");
492
+ planned(configureTerm.command('add'))
493
+ .argument('<term>', 'the word, as prose uses it')
494
+ .requiredOption('--means <text>', 'one line: what it means')
495
+ .option('--not <clause>', 'a near-miss, opening with the word and saying why it differs (repeatable)', collect, [])
496
+ .option('--see <path>', 'the blueprint that defines it in full, relative to .eidos/')
497
+ .description('declare a term')
498
+ .action((term, options) => runAsync(() => runPlan(loadRoot(globals(options)), (edit) => planTermAdd(edit, { term, means: options.means, not: options.not, see: options.see ?? null }), options)));
499
+ planned(configureTerm.command('set'))
500
+ .argument('<term>', 'the term')
501
+ .option('--term <word>', 'a new spelling')
502
+ .option('--means <text>', 'a new meaning')
503
+ .option('--not <clause>', 'the near-misses, replacing the list (repeatable)', collect, [])
504
+ .option('--see <path>', "where it is defined in full; '' clears it")
505
+ .description("change a term's word, meaning, near-misses, or see")
506
+ .action((term, options) => runAsync(() => runPlan(loadRoot(globals(options)), (edit) => planTermSet(edit, term, { ...(options.term !== undefined ? { term: options.term } : {}), ...(options.means !== undefined ? { means: options.means } : {}), ...(options.not.length > 0 ? { not: options.not } : {}), ...(options.see !== undefined ? { see: options.see } : {}) }), options)));
507
+ planned(configureTerm.command('remove'))
508
+ .argument('<term>', 'the term')
509
+ .description('remove a term')
510
+ .action((term, options) => runAsync(() => runPlan(loadRoot(globals(options)), (edit) => planTermRemove(edit, term), options)));
511
+ const configureDoc = configure('doc', "the top-level docs under top_level: add, rename, set, or remove one, its file at the root following its title");
512
+ planned(configureDoc.command('add'))
513
+ .argument('<title>', 'the title; the file is named for it in the naming convention')
514
+ .option('--description <text>', 'one line: what the document is', '')
515
+ .description('declare a top-level doc and write its file when there is none')
516
+ .action((title, options) => runAsync(() => runPlan(loadRoot(globals(options)), (edit) => planDocAdd(edit, title, options.description), options)));
517
+ planned(configureDoc.command('rename'))
518
+ .argument('<doc>', 'the title or the file')
519
+ .argument('<title>', 'its new title; the file is renamed to match and every link follows (the same title brings a drifted file in line)')
520
+ .description('retitle a top-level doc and rename its file')
521
+ .action((ref, title, options) => runAsync(() => runPlan(loadRoot(globals(options)), (edit) => planDocRename(edit, ref, title), options)));
522
+ planned(configureDoc.command('set'))
523
+ .argument('<doc>', 'the title or the file')
524
+ .option('--description <text>', 'a new description')
525
+ .description("change a top-level doc's description")
526
+ .action((ref, options) => runAsync(() => runPlan(loadRoot(globals(options)), (edit) => planDocSet(edit, ref, { ...(options.description !== undefined ? { description: options.description } : {}) }), options)));
527
+ planned(configureDoc.command('remove'))
528
+ .argument('<doc>', 'the title or the file')
529
+ .option('--preserve', 'leave the file at the root, undeclared', false)
530
+ .description('remove a top-level doc: the entry, and the file unless --preserve')
531
+ .action((ref, options) => runAsync(() => runPlan(loadRoot(globals(options)), (edit) => planDocRemove(edit, ref, { preserve: options.preserve }), options)));
532
+ const configureRole = configure('role', 'the roles under .eidos/roles/: add, show, rename, or remove one');
533
+ planned(configureRole.command('add'))
534
+ .argument('<name>', 'the role, lowercase words joined by hyphens')
535
+ .option('--from <role>', 'copy this role\'s file; default: a bare skeleton')
536
+ .description('write a role file')
537
+ .action((name, options) => runAsync(() => runPlan(loadRoot(globals(options)), (edit) => planRoleAdd(edit, name, options.from ?? null), options)));
538
+ withRoot(configureRole.command('show'))
539
+ .argument('[name]', 'one role, printed whole; none lists them')
540
+ .option('--json', 'machine-readable result', false)
541
+ .description('print a role')
542
+ .action((name, options) => run(() => showRole(loadRoot(globals(options)), name, options.json)));
543
+ planned(configureRole.command('rename'))
544
+ .argument('<name>', 'the role')
545
+ .argument('<new>', 'its new name, lowercase words joined by hyphens; the file, me.md, and the users who hold it follow')
546
+ .description('rename a role: the file moves, and every reference to it follows')
547
+ .action((name, to, options) => runAsync(() => runPlan(loadRoot(globals(options)), (edit) => planRoleRename(edit, name, to), options)));
548
+ planned(configureRole.command('remove'))
549
+ .argument('<name>', 'the role')
550
+ .description('remove a role file; refused while me.md names it unless --force')
551
+ .action((name, options) => runAsync(() => runPlan(loadRoot(globals(options)), (edit) => planRoleRemove(edit, name, { force: options.force }), options)));
552
+ // ---- property: one blueprint's frontmatter
553
+ const property = program.command('property').description("one blueprint's frontmatter: get, set, or unset a property, typed by the Properties table, so nobody opens a file to flip a value");
554
+ withRoot(property.command('get'))
555
+ .argument('<blueprint>', '@<id>, a path, or a filename')
556
+ .argument('[property]', 'one property; none prints them all')
557
+ .option('--json', 'machine-readable result', false)
558
+ .description("print a blueprint's property, or all of them")
559
+ .action((ref, key, options) => run(() => runPropertyGet(loadRoot(globals(options)), ref, key, options)));
560
+ withRoot(property.command('set'))
561
+ .argument('<blueprint>', '@<id>, a path, or a filename')
562
+ .argument('<property>', 'the property, as the Properties table declares it for the collection')
563
+ .argument('<value>', 'the value; a List is comma-separated, a Checkbox true or false, a Date YYYY-MM-DD, one of the options where the property declares them')
564
+ .option('--force', 'write a property the table does not declare for this collection, a tool\'s own, or a value off its options', false)
565
+ .option('--dry-run', 'print the frontmatter as it would be, writing nothing', false)
566
+ .option('--json', 'machine-readable result', false)
567
+ .description('set one property, coerced to its declared type, with the root\'s on-save rules applied')
568
+ .action((ref, key, value, options) => run(() => runPropertySet(loadRoot(globals(options)), ref, key, value, options)));
569
+ withRoot(property.command('unset'))
570
+ .argument('<blueprint>', '@<id>, a path, or a filename')
571
+ .argument('<property>', 'the property to remove from the file')
572
+ .option('--dry-run', 'print the frontmatter as it would be, writing nothing', false)
573
+ .option('--json', 'machine-readable result', false)
574
+ .description('remove one property from a blueprint')
575
+ .action((ref, key, options) => run(() => runPropertyUnset(loadRoot(globals(options)), ref, key, options)));
576
+ property.addHelpText('after', '\nExamples:\n eidos property get @login\n eidos property set @login status Done\n eidos property set specs/identity/login.md tags auth,security\n eidos property unset @login depends_on');
292
577
  withRoot(program.command('browser'))
293
578
  .description('open the root in a local web page: browse and read blueprints, create one, edit one, run the check, rebuild the index')
294
579
  .option('-p, --port <port>', 'the port to serve on; the next free one is used if it is busy', (value) => Number.parseInt(value, 10), DEFAULT_PORT)
@@ -12,7 +12,7 @@ Run `eidos framework`. Decide with the owner which collection the blueprint belo
12
12
  eidos new <collection> "<Title>" [--variant <variant>] [--group <group>] [--summary "<one line>"]
13
13
  ```
14
14
 
15
- `new` generates the frontmatter from the properties that apply to the collection, renders the body from the variant's template with its guidance kept, names the file in the framework's convention, and puts a permanent `id` inside (a kebab-case slug by default; any stable, unique form is allowed with `--id`). Set a property at creation with `--set key=value`. Use `--dry-run` to see the file before writing it, and `--json` to get its path.
15
+ `new` generates the frontmatter from the required properties that apply to the collection, adding an optional one only when it is given a value (`--set`, `--summary`), renders the body from the variant's template with its guidance kept, names the file in the framework's convention, and puts a permanent `id` inside (a kebab-case slug by default; any stable, unique form is allowed with `--id`). Set a property at creation with `--set key=value`. Use `--dry-run` to see the file before writing it, and `--json` to get its path.
16
16
 
17
17
  Write the `summary` at creation when you can: one plain line saying what the blueprint is, so the collection index lists it the moment it exists.
18
18
 
@@ -29,7 +29,9 @@ Open the file. The template's sections are in order, each with an italic prompt
29
29
  - Reference other blueprints with markdown links, never bare names, in prose and in properties alike: `[Session Management](../identity/session-management.md)`. If the target has no blueprint yet, name it plainly rather than fabricating a link.
30
30
  - Delete each italic prompt as its section is filled.
31
31
 
32
- Frontmatter stays what `new` generated. Fill values; do not add keys the Properties table does not declare (`eidos instructions configuring` is how a property is added). Leave a property blank rather than guessing it.
32
+ Frontmatter stays what `new` generated. Set a value with the command, never by opening the file: `eidos property set @<id> <property> <value>` (a List comma-separated, a Checkbox true or false, a Date YYYY-MM-DD; `eidos property get @<id>` reads, `unset` removes). It is typed by the Properties table, applies the root's on-save rules, and refuses a key the table does not declare for the collection. Where a property declares `options` (`eidos framework` lists them, in order), offer the owner that list and set one of them, exactly as declared, case included; the command refuses a value off the list unless `--force`, and `check` reports one it finds. Leave a required property blank rather than guessing it; an optional one absent is not a gap.
33
+
34
+ A span between `<!-- <tool>:<region> <args> -->` and `<!-- /<tool>:<region> -->`, each marker alone on its line, is a region that tool owns: leave its contents alone, and when you edit the file around it, carry it across as found. `eidosmd` is this CLI's name; a region under it is the CLI's.
33
35
 
34
36
  ### 4. Check and index
35
37