@eventmodelers/cli 0.0.37 → 0.0.38

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 (2) hide show
  1. package/cli.js +227 -8
  2. package/package.json +1 -1
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,11 @@ 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.
1368
+ const NO_INIT_REQUIRED = new Set(['init', 'init-config', 'stacks', 'status', 'config', 'uninstall', 'fetch', 'activate-context', 'set-slice-status']);
1356
1369
 
1357
1370
  program.hook('preAction', (_thisCommand, actionCommand) => {
1358
1371
  if (NO_INIT_REQUIRED.has(actionCommand.name())) return;
@@ -1818,6 +1831,212 @@ program
1818
1831
  }
1819
1832
  });
1820
1833
 
1834
+ program
1835
+ .command('activate-context')
1836
+ .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')
1837
+ .action(async () => {
1838
+ const cwd = process.cwd();
1839
+ const kitDir = findInstalledKitDir(cwd);
1840
+ // Same modeling-kit exception `fetch` applies — see its action for why.
1841
+ const slicesKitDir = kitDir?.endsWith(MODELING_KIT.kitDirName) ? null : kitDir;
1842
+ const SLICES_DIR = join(slicesKitDir || cwd, '.slices');
1843
+ const hint = ' Run `eventmodelers fetch --context <name>` first to pull a context from the board.';
1844
+
1845
+ // A context is any .slices/ subdirectory fetch/listen wrote an index.json into —
1846
+ // that's the file both of them use as proof a context's slices actually landed.
1847
+ const contextDirs = existsSync(SLICES_DIR)
1848
+ ? readdirSync(SLICES_DIR, { withFileTypes: true })
1849
+ .filter((e) => e.isDirectory() && existsSync(join(SLICES_DIR, e.name, 'index.json')))
1850
+ .map((e) => e.name)
1851
+ : [];
1852
+
1853
+ if (!contextDirs.length) {
1854
+ console.error(`❌ No contexts found in ${relative(cwd, SLICES_DIR)}/.`);
1855
+ console.error(hint);
1856
+ process.exit(1);
1857
+ }
1858
+
1859
+ const currentCtx = readJsonSafe(join(SLICES_DIR, 'current_context.json')).name;
1860
+
1861
+ // context.json's `name` is the human-readable display name; the directory itself
1862
+ // (contextSlug) is what current_context.json must store, since readCurrentContext
1863
+ // (shared/build-kit/lib/ralph.js) joins it straight onto `.slices/<name>/index.json`.
1864
+ const choices = contextDirs.map((dirName) => ({
1865
+ label: readJsonSafe(join(SLICES_DIR, dirName, 'context.json')).name || dirName,
1866
+ value: dirName,
1867
+ }));
1868
+ const defaultIndex = Math.max(0, contextDirs.indexOf(currentCtx));
1869
+
1870
+ const selected = await selectPrompt(
1871
+ `Which context should be active?${currentCtx ? ` (currently: ${choices[defaultIndex].label})` : ''}`,
1872
+ choices,
1873
+ defaultIndex,
1874
+ );
1875
+
1876
+ writeFileSync(join(SLICES_DIR, 'current_context.json'), JSON.stringify({ name: selected }, null, 2));
1877
+ const selectedLabel = choices.find((c) => c.value === selected)?.label || selected;
1878
+ console.log(`✅ Active context set to "${selectedLabel}" → ${relative(cwd, join(SLICES_DIR, 'current_context.json'))}`);
1879
+ });
1880
+
1881
+ // Order/icons/default mirror the board UI's own slice-status picker.
1882
+ const SLICE_STATUSES = [
1883
+ { label: '🌱 Created (default)', value: 'Created' },
1884
+ { label: '✅ Done', value: 'Done' },
1885
+ { label: '👤 Assigned', value: 'Assigned' },
1886
+ { label: '🔄 InProgress', value: 'InProgress' },
1887
+ { label: '🔍 Review', value: 'Review' },
1888
+ { label: '🚫 Blocked', value: 'Blocked' },
1889
+ { label: '📅 Planned', value: 'Planned' },
1890
+ { label: 'ℹ️ Informational', value: 'Informational' },
1891
+ ];
1892
+
1893
+ program
1894
+ .command('set-slice-status')
1895
+ .description('Pick a slice from the active context and change its status — updates .slices/ locally, and the board itself with --remote')
1896
+ .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)')
1897
+ .action(async (opts, command) => {
1898
+ const cwd = process.cwd();
1899
+ const kitDir = findInstalledKitDir(cwd);
1900
+ // Same modeling-kit exception `fetch`/`activate-context` apply — see fetch's action for why.
1901
+ const slicesKitDir = kitDir?.endsWith(MODELING_KIT.kitDirName) ? null : kitDir;
1902
+ const SLICES_DIR = join(slicesKitDir || cwd, '.slices');
1903
+ const fetchHint = ' Run `eventmodelers fetch --context <name>` first to pull a context from the board.';
1904
+
1905
+ const currentCtx = readJsonSafe(join(SLICES_DIR, 'current_context.json')).name;
1906
+ if (!currentCtx) {
1907
+ console.error('❌ No active context set.');
1908
+ console.error(existsSync(SLICES_DIR) ? ' Run `eventmodelers activate-context` to pick one.' : fetchHint);
1909
+ process.exit(1);
1910
+ }
1911
+
1912
+ const contextDir = join(SLICES_DIR, currentCtx);
1913
+ const indexPath = join(contextDir, 'index.json');
1914
+ const indexData = readJsonSafe(indexPath);
1915
+ const slices = Array.isArray(indexData.slices) ? indexData.slices : [];
1916
+ if (!slices.length) {
1917
+ console.error(`❌ No slices found for context "${currentCtx}".`);
1918
+ console.error(fetchHint);
1919
+ process.exit(1);
1920
+ }
1921
+
1922
+ const sliceChoices = slices.map((s) => ({ label: `${s.slice || s.id} [${s.status || 'Created'}]`, value: s.id }));
1923
+ const sliceId = await selectPrompt(`Which slice in "${currentCtx}" should change status?`, sliceChoices, 0);
1924
+ const slice = slices.find((s) => s.id === sliceId);
1925
+
1926
+ const statusDefault = Math.max(0, SLICE_STATUSES.findIndex((s) => s.value === (slice.status || 'Created')));
1927
+ const newStatus = await selectPrompt(`New status for "${slice.slice}"? (currently: ${slice.status || 'Created'})`, SLICE_STATUSES, statusDefault);
1928
+
1929
+ if (newStatus === (slice.status || 'Created')) {
1930
+ console.log(`ℹ️ "${slice.slice}" is already ${newStatus} — nothing to change.`);
1931
+ return;
1932
+ }
1933
+
1934
+ // index.json's `definition` is a full copy of the slice (see lib/fetch.js's entry
1935
+ // shape) — keep both copies of `status` in sync so anything reading either stays correct.
1936
+ const previousStatus = slice.status || 'Created';
1937
+ slice.status = newStatus;
1938
+ if (slice.definition) slice.definition.status = newStatus;
1939
+ writeFileSync(indexPath, JSON.stringify(indexData, null, 2));
1940
+
1941
+ if (slice.folder) {
1942
+ const sliceJsonPath = join(contextDir, slice.folder, 'slice.json');
1943
+ if (existsSync(sliceJsonPath)) {
1944
+ const sliceData = readJsonSafe(sliceJsonPath);
1945
+ sliceData.status = newStatus;
1946
+ writeFileSync(sliceJsonPath, JSON.stringify(sliceData, null, 2));
1947
+ }
1948
+ }
1949
+
1950
+ console.log(`✅ "${slice.slice}": ${previousStatus} → ${newStatus} (${relative(cwd, indexPath)})`);
1951
+
1952
+ if (!opts.remote) return;
1953
+
1954
+ // slice.id is the SLICE_BORDER node ID (see shared/skills/update-slice-status/SKILL.md
1955
+ // Step 2) — the same id the board's own nodes/events API expects as nodeId below.
1956
+ const globalOpts = command.optsWithGlobals();
1957
+ const explicitConfig = globalOpts.config;
1958
+ const configPath = explicitConfig ? resolve(cwd, explicitConfig) : join(cwd, '.eventmodelers', 'config.json');
1959
+ const requiredFields = ['organizationId', 'boardId', 'token'];
1960
+ let { config: cfg } = loadEffectiveConfig(cwd, kitDir, explicitConfig);
1961
+
1962
+ async function promptForCredentials() {
1963
+ cfg = await configureCredentials({
1964
+ config: cfg,
1965
+ configPath,
1966
+ targetDir: cwd,
1967
+ requiredFields,
1968
+ boardIdOptional: false,
1969
+ overrides: {},
1970
+ print: globalOpts.print,
1971
+ });
1972
+ if (requiredFields.some((f) => !cfg[f])) {
1973
+ console.error('❌ Still missing token/organizationId/boardId — re-run with --remote once configured.');
1974
+ process.exit(1);
1975
+ }
1976
+ }
1977
+ if (requiredFields.some((f) => !cfg[f])) await promptForCredentials();
1978
+
1979
+ const baseUrl = cfg.baseUrl || DEFAULT_BASE_URL;
1980
+ async function pushRemote() {
1981
+ return fetch(`${baseUrl}/api/org/${cfg.organizationId}/boards/${cfg.boardId}/nodes/events`, {
1982
+ method: 'POST',
1983
+ headers: {
1984
+ 'Content-Type': 'application/json',
1985
+ 'x-token': cfg.token,
1986
+ 'x-board-id': cfg.boardId,
1987
+ 'x-user-id': 'cli-set-slice-status',
1988
+ },
1989
+ body: JSON.stringify([{
1990
+ id: randomUUID(),
1991
+ eventType: 'node:changed',
1992
+ nodeId: slice.id,
1993
+ boardId: cfg.boardId,
1994
+ timestamp: Date.now(),
1995
+ changedAttributes: ['sliceStatus'],
1996
+ meta: { sliceStatus: newStatus },
1997
+ }]),
1998
+ });
1999
+ }
2000
+
2001
+ let res;
2002
+ try {
2003
+ res = await pushRemote();
2004
+ } catch (err) {
2005
+ console.error(`❌ Remote update failed: ${err.message}`);
2006
+ process.exit(1);
2007
+ }
2008
+
2009
+ // Same 401/403 reconfigure-and-retry dance `fetch` does — present-but-wrong
2010
+ // credentials, not missing ones, so clear whichever field is implicated and retry once.
2011
+ if (res.status === 401 || res.status === 403) {
2012
+ console.error(`❌ Remote update: ${res.status === 401 ? 'invalid or expired token' : "token's organization does not match this board"}.`);
2013
+ if (res.status === 403) delete cfg.boardId; else delete cfg.token;
2014
+ await promptForCredentials();
2015
+ try {
2016
+ res = await pushRemote();
2017
+ } catch (err) {
2018
+ console.error(`❌ Remote update failed: ${err.message}`);
2019
+ process.exit(1);
2020
+ }
2021
+ }
2022
+
2023
+ if (!res.ok) {
2024
+ const body = await res.json().catch(() => null);
2025
+ const msg = body?.error || `HTTP ${res.status}`;
2026
+ // The API refuses to move a slice into a status it's already in (a concurrency
2027
+ // guard so two agents/users can't both claim it) — not a real failure, just means
2028
+ // the board had already moved on since the last fetch.
2029
+ if (/already/i.test(msg)) {
2030
+ console.log(`ℹ️ Board already has "${slice.slice}" at ${newStatus} — no remote change needed.`);
2031
+ return;
2032
+ }
2033
+ console.error(`❌ Remote update failed: ${msg}`);
2034
+ process.exit(1);
2035
+ }
2036
+
2037
+ console.log(`✅ Pushed status change to the board (node ${slice.id}).`);
2038
+ });
2039
+
1821
2040
  program
1822
2041
  .command('stacks')
1823
2042
  .description('List available stacks (for `init --stack`)')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eventmodelers/cli",
3
- "version": "0.0.37",
3
+ "version": "0.0.38",
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": {