@eventmodelers/cli 0.0.37 → 0.0.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/RELEASE_NOTES.md +4 -0
  2. package/cli.js +240 -8
  3. package/package.json +3 -2
@@ -0,0 +1,4 @@
1
+ ## v0.0.38
2
+
3
+ ### Features
4
+ - Added a `release-notes` command to print the CLI's release notes (what changed across recent versions) without needing a connected project or credentials.
package/cli.js CHANGED
@@ -281,19 +281,28 @@ function maskSecret(value) {
281
281
  // scripted installs) — the first interface can read ahead and consume lines meant
282
282
  // for later prompts, leaving the next one waiting on a stream that already ended.
283
283
  let sharedRl = null;
284
+ let sharedRlLines = null;
284
285
  function getSharedRl() {
285
286
  if (!sharedRl) {
286
287
  sharedRl = createInterface({ input: process.stdin, output: process.stdout });
288
+ sharedRlLines = sharedRl[Symbol.asyncIterator]();
287
289
  }
288
290
  return sharedRl;
289
291
  }
290
292
 
291
- async function prompt(question) {
292
- return new Promise((resolve) => {
293
- getSharedRl().question(question, (answer) => {
294
- resolve(answer.trim());
295
- });
296
- });
293
+ // Pulls one line from the shared readline's own async iterator rather than calling
294
+ // its `.question()` — `.question()` attaches a one-shot 'line' listener *after* the
295
+ // prompt is issued, but when stdin is piped (a file, `<<<`, scripted/CI input) readline
296
+ // parses and emits 'line' events for an entire buffered chunk synchronously as soon as
297
+ // it arrives. So a second `.question()` call in the same process can miss a line that
298
+ // was already emitted — and dropped, no listener attached yet — before it was even
299
+ // called, hanging forever. Pulling from the iterator instead queues each line until
300
+ // something asks for it, so nothing emitted ahead of time is ever lost between prompts.
301
+ async function prompt(question = '') {
302
+ getSharedRl();
303
+ if (question) process.stdout.write(question);
304
+ const { value, done } = await sharedRlLines.next();
305
+ return (done ? '' : value).trim();
297
306
  }
298
307
 
299
308
  // Reads a pasted block of credentials, which may span one line (minified JSON,
@@ -303,7 +312,7 @@ async function prompt(question) {
303
312
  async function promptPasteBlock() {
304
313
  const lines = [];
305
314
  while (lines.length < 20) {
306
- const line = await new Promise((resolve) => getSharedRl().question('', resolve));
315
+ const line = await prompt();
307
316
  if (line.trim() === '') {
308
317
  if (lines.length > 0) break;
309
318
  continue;
@@ -1352,7 +1361,12 @@ program
1352
1361
  // cleanup commands that are meant to work — and report something useful — whether
1353
1362
  // or not a kit is present, and fetch only needs credentials plus somewhere to write
1354
1363
  // .slices/ (cwd, absent a kit dir — see lib/fetch.js), no kit-specific files.
1355
- const NO_INIT_REQUIRED = new Set(['init', 'init-config', 'stacks', 'status', 'config', 'uninstall', 'fetch']);
1364
+ // activate-context/set-slice-status are the same story minus even the credentials — they
1365
+ // only ever read/write an already-fetched .slices/, and report their own hint (run `fetch`
1366
+ // first) when that's missing. set-slice-status only touches credentials at all for --remote,
1367
+ // which prompts for them itself the same way fetch does. release-notes only reads the CLI's
1368
+ // own bundled RELEASE_NOTES.md, no project state involved at all.
1369
+ const NO_INIT_REQUIRED = new Set(['init', 'init-config', 'stacks', 'status', 'config', 'uninstall', 'fetch', 'activate-context', 'set-slice-status', 'release-notes']);
1356
1370
 
1357
1371
  program.hook('preAction', (_thisCommand, actionCommand) => {
1358
1372
  if (NO_INIT_REQUIRED.has(actionCommand.name())) return;
@@ -1818,6 +1832,212 @@ program
1818
1832
  }
1819
1833
  });
1820
1834
 
1835
+ program
1836
+ .command('activate-context')
1837
+ .description('Choose which fetched context is active — writes .slices/current_context.json, which `run`/bridge/listen treat as sticky and never cross out of on their own')
1838
+ .action(async () => {
1839
+ const cwd = process.cwd();
1840
+ const kitDir = findInstalledKitDir(cwd);
1841
+ // Same modeling-kit exception `fetch` applies — see its action for why.
1842
+ const slicesKitDir = kitDir?.endsWith(MODELING_KIT.kitDirName) ? null : kitDir;
1843
+ const SLICES_DIR = join(slicesKitDir || cwd, '.slices');
1844
+ const hint = ' Run `eventmodelers fetch --context <name>` first to pull a context from the board.';
1845
+
1846
+ // A context is any .slices/ subdirectory fetch/listen wrote an index.json into —
1847
+ // that's the file both of them use as proof a context's slices actually landed.
1848
+ const contextDirs = existsSync(SLICES_DIR)
1849
+ ? readdirSync(SLICES_DIR, { withFileTypes: true })
1850
+ .filter((e) => e.isDirectory() && existsSync(join(SLICES_DIR, e.name, 'index.json')))
1851
+ .map((e) => e.name)
1852
+ : [];
1853
+
1854
+ if (!contextDirs.length) {
1855
+ console.error(`❌ No contexts found in ${relative(cwd, SLICES_DIR)}/.`);
1856
+ console.error(hint);
1857
+ process.exit(1);
1858
+ }
1859
+
1860
+ const currentCtx = readJsonSafe(join(SLICES_DIR, 'current_context.json')).name;
1861
+
1862
+ // context.json's `name` is the human-readable display name; the directory itself
1863
+ // (contextSlug) is what current_context.json must store, since readCurrentContext
1864
+ // (shared/build-kit/lib/ralph.js) joins it straight onto `.slices/<name>/index.json`.
1865
+ const choices = contextDirs.map((dirName) => ({
1866
+ label: readJsonSafe(join(SLICES_DIR, dirName, 'context.json')).name || dirName,
1867
+ value: dirName,
1868
+ }));
1869
+ const defaultIndex = Math.max(0, contextDirs.indexOf(currentCtx));
1870
+
1871
+ const selected = await selectPrompt(
1872
+ `Which context should be active?${currentCtx ? ` (currently: ${choices[defaultIndex].label})` : ''}`,
1873
+ choices,
1874
+ defaultIndex,
1875
+ );
1876
+
1877
+ writeFileSync(join(SLICES_DIR, 'current_context.json'), JSON.stringify({ name: selected }, null, 2));
1878
+ const selectedLabel = choices.find((c) => c.value === selected)?.label || selected;
1879
+ console.log(`✅ Active context set to "${selectedLabel}" → ${relative(cwd, join(SLICES_DIR, 'current_context.json'))}`);
1880
+ });
1881
+
1882
+ // Order/icons/default mirror the board UI's own slice-status picker.
1883
+ const SLICE_STATUSES = [
1884
+ { label: '🌱 Created (default)', value: 'Created' },
1885
+ { label: '✅ Done', value: 'Done' },
1886
+ { label: '👤 Assigned', value: 'Assigned' },
1887
+ { label: '🔄 InProgress', value: 'InProgress' },
1888
+ { label: '🔍 Review', value: 'Review' },
1889
+ { label: '🚫 Blocked', value: 'Blocked' },
1890
+ { label: '📅 Planned', value: 'Planned' },
1891
+ { label: 'ℹ️ Informational', value: 'Informational' },
1892
+ ];
1893
+
1894
+ program
1895
+ .command('set-slice-status')
1896
+ .description('Pick a slice from the active context and change its status — updates .slices/ locally, and the board itself with --remote')
1897
+ .option('--remote', 'Also push the change to the board via the nodes/events API (same effect `update-slice-status` has, see shared/skills/update-slice-status)')
1898
+ .action(async (opts, command) => {
1899
+ const cwd = process.cwd();
1900
+ const kitDir = findInstalledKitDir(cwd);
1901
+ // Same modeling-kit exception `fetch`/`activate-context` apply — see fetch's action for why.
1902
+ const slicesKitDir = kitDir?.endsWith(MODELING_KIT.kitDirName) ? null : kitDir;
1903
+ const SLICES_DIR = join(slicesKitDir || cwd, '.slices');
1904
+ const fetchHint = ' Run `eventmodelers fetch --context <name>` first to pull a context from the board.';
1905
+
1906
+ const currentCtx = readJsonSafe(join(SLICES_DIR, 'current_context.json')).name;
1907
+ if (!currentCtx) {
1908
+ console.error('❌ No active context set.');
1909
+ console.error(existsSync(SLICES_DIR) ? ' Run `eventmodelers activate-context` to pick one.' : fetchHint);
1910
+ process.exit(1);
1911
+ }
1912
+
1913
+ const contextDir = join(SLICES_DIR, currentCtx);
1914
+ const indexPath = join(contextDir, 'index.json');
1915
+ const indexData = readJsonSafe(indexPath);
1916
+ const slices = Array.isArray(indexData.slices) ? indexData.slices : [];
1917
+ if (!slices.length) {
1918
+ console.error(`❌ No slices found for context "${currentCtx}".`);
1919
+ console.error(fetchHint);
1920
+ process.exit(1);
1921
+ }
1922
+
1923
+ const sliceChoices = slices.map((s) => ({ label: `${s.slice || s.id} [${s.status || 'Created'}]`, value: s.id }));
1924
+ const sliceId = await selectPrompt(`Which slice in "${currentCtx}" should change status?`, sliceChoices, 0);
1925
+ const slice = slices.find((s) => s.id === sliceId);
1926
+
1927
+ const statusDefault = Math.max(0, SLICE_STATUSES.findIndex((s) => s.value === (slice.status || 'Created')));
1928
+ const newStatus = await selectPrompt(`New status for "${slice.slice}"? (currently: ${slice.status || 'Created'})`, SLICE_STATUSES, statusDefault);
1929
+
1930
+ if (newStatus === (slice.status || 'Created')) {
1931
+ console.log(`ℹ️ "${slice.slice}" is already ${newStatus} — nothing to change.`);
1932
+ return;
1933
+ }
1934
+
1935
+ // index.json's `definition` is a full copy of the slice (see lib/fetch.js's entry
1936
+ // shape) — keep both copies of `status` in sync so anything reading either stays correct.
1937
+ const previousStatus = slice.status || 'Created';
1938
+ slice.status = newStatus;
1939
+ if (slice.definition) slice.definition.status = newStatus;
1940
+ writeFileSync(indexPath, JSON.stringify(indexData, null, 2));
1941
+
1942
+ if (slice.folder) {
1943
+ const sliceJsonPath = join(contextDir, slice.folder, 'slice.json');
1944
+ if (existsSync(sliceJsonPath)) {
1945
+ const sliceData = readJsonSafe(sliceJsonPath);
1946
+ sliceData.status = newStatus;
1947
+ writeFileSync(sliceJsonPath, JSON.stringify(sliceData, null, 2));
1948
+ }
1949
+ }
1950
+
1951
+ console.log(`✅ "${slice.slice}": ${previousStatus} → ${newStatus} (${relative(cwd, indexPath)})`);
1952
+
1953
+ if (!opts.remote) return;
1954
+
1955
+ // slice.id is the SLICE_BORDER node ID (see shared/skills/update-slice-status/SKILL.md
1956
+ // Step 2) — the same id the board's own nodes/events API expects as nodeId below.
1957
+ const globalOpts = command.optsWithGlobals();
1958
+ const explicitConfig = globalOpts.config;
1959
+ const configPath = explicitConfig ? resolve(cwd, explicitConfig) : join(cwd, '.eventmodelers', 'config.json');
1960
+ const requiredFields = ['organizationId', 'boardId', 'token'];
1961
+ let { config: cfg } = loadEffectiveConfig(cwd, kitDir, explicitConfig);
1962
+
1963
+ async function promptForCredentials() {
1964
+ cfg = await configureCredentials({
1965
+ config: cfg,
1966
+ configPath,
1967
+ targetDir: cwd,
1968
+ requiredFields,
1969
+ boardIdOptional: false,
1970
+ overrides: {},
1971
+ print: globalOpts.print,
1972
+ });
1973
+ if (requiredFields.some((f) => !cfg[f])) {
1974
+ console.error('❌ Still missing token/organizationId/boardId — re-run with --remote once configured.');
1975
+ process.exit(1);
1976
+ }
1977
+ }
1978
+ if (requiredFields.some((f) => !cfg[f])) await promptForCredentials();
1979
+
1980
+ const baseUrl = cfg.baseUrl || DEFAULT_BASE_URL;
1981
+ async function pushRemote() {
1982
+ return fetch(`${baseUrl}/api/org/${cfg.organizationId}/boards/${cfg.boardId}/nodes/events`, {
1983
+ method: 'POST',
1984
+ headers: {
1985
+ 'Content-Type': 'application/json',
1986
+ 'x-token': cfg.token,
1987
+ 'x-board-id': cfg.boardId,
1988
+ 'x-user-id': 'cli-set-slice-status',
1989
+ },
1990
+ body: JSON.stringify([{
1991
+ id: randomUUID(),
1992
+ eventType: 'node:changed',
1993
+ nodeId: slice.id,
1994
+ boardId: cfg.boardId,
1995
+ timestamp: Date.now(),
1996
+ changedAttributes: ['sliceStatus'],
1997
+ meta: { sliceStatus: newStatus },
1998
+ }]),
1999
+ });
2000
+ }
2001
+
2002
+ let res;
2003
+ try {
2004
+ res = await pushRemote();
2005
+ } catch (err) {
2006
+ console.error(`❌ Remote update failed: ${err.message}`);
2007
+ process.exit(1);
2008
+ }
2009
+
2010
+ // Same 401/403 reconfigure-and-retry dance `fetch` does — present-but-wrong
2011
+ // credentials, not missing ones, so clear whichever field is implicated and retry once.
2012
+ if (res.status === 401 || res.status === 403) {
2013
+ console.error(`❌ Remote update: ${res.status === 401 ? 'invalid or expired token' : "token's organization does not match this board"}.`);
2014
+ if (res.status === 403) delete cfg.boardId; else delete cfg.token;
2015
+ await promptForCredentials();
2016
+ try {
2017
+ res = await pushRemote();
2018
+ } catch (err) {
2019
+ console.error(`❌ Remote update failed: ${err.message}`);
2020
+ process.exit(1);
2021
+ }
2022
+ }
2023
+
2024
+ if (!res.ok) {
2025
+ const body = await res.json().catch(() => null);
2026
+ const msg = body?.error || `HTTP ${res.status}`;
2027
+ // The API refuses to move a slice into a status it's already in (a concurrency
2028
+ // guard so two agents/users can't both claim it) — not a real failure, just means
2029
+ // the board had already moved on since the last fetch.
2030
+ if (/already/i.test(msg)) {
2031
+ console.log(`ℹ️ Board already has "${slice.slice}" at ${newStatus} — no remote change needed.`);
2032
+ return;
2033
+ }
2034
+ console.error(`❌ Remote update failed: ${msg}`);
2035
+ process.exit(1);
2036
+ }
2037
+
2038
+ console.log(`✅ Pushed status change to the board (node ${slice.id}).`);
2039
+ });
2040
+
1821
2041
  program
1822
2042
  .command('stacks')
1823
2043
  .description('List available stacks (for `init --stack`)')
@@ -1982,6 +2202,18 @@ program
1982
2202
  }
1983
2203
  });
1984
2204
 
2205
+ program
2206
+ .command('release-notes')
2207
+ .description('Show the CLI release notes (what changed across recent versions)')
2208
+ .action(() => {
2209
+ const notesPath = join(__dirname, 'RELEASE_NOTES.md');
2210
+ if (!existsSync(notesPath)) {
2211
+ console.log('ℹ️ No release notes found.');
2212
+ return;
2213
+ }
2214
+ console.log(readFileSync(notesPath, 'utf8').trimEnd());
2215
+ });
2216
+
1985
2217
  program
1986
2218
  .command('config')
1987
2219
  .description('Print the fully resolved config (merged across the directory hierarchy + EVENTMODELERS_* env vars), with the token masked')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eventmodelers/cli",
3
- "version": "0.0.37",
3
+ "version": "0.0.39",
4
4
  "description": "Eventmodelers CLI — real-time Claude agent + skills for Claude Code, for any stack (Node, Supabase, Axon, Cratis, or modeling-only)",
5
5
  "type": "module",
6
6
  "bin": {
@@ -11,7 +11,8 @@
11
11
  "lib",
12
12
  "shared",
13
13
  "stacks",
14
- "README.md"
14
+ "README.md",
15
+ "RELEASE_NOTES.md"
15
16
  ],
16
17
  "keywords": [
17
18
  "claude",