@eventmodelers/cli 2.0.1 → 2.0.3
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/README.md +27 -9
- package/cli.js +369 -36
- package/lib/build-kits.js +23 -0
- package/lib/scaffold-model.js +71 -0
- package/lib/scaffold.js +224 -0
- package/lib/supervise.js +60 -0
- package/lib/ui-baseline.js +123 -0
- package/package.json +1 -1
- package/shared/build-kit/lib/build-log-share.js +149 -0
- package/shared/build-kit/lib/events-view.js +18 -3
- package/shared/build-kit/lib/ralph.js +27 -16
- package/shared/build-kit/lib/slice-events.js +42 -2
- package/shared/build-kit/lib/slice-info.js +52 -0
- package/shared/build-kit/lib/slice-run.js +2 -2
- package/shared/build-kit/lib/slice-state.js +6 -4
- package/shared/scaffold/LICENSE-em-scaffold +21 -0
- package/shared/scaffold/supabase/generate.mjs +290 -0
- package/shared/scaffold/supabase/template/scripts/create-user.mjs.tmpl +29 -0
- package/shared/scaffold/supabase/template/scripts/dev.mjs.tmpl +145 -0
- package/shared/scaffold/supabase/template/scripts/write-env.mjs.tmpl +44 -0
- package/shared/scaffold/supabase/template/server.ts.tmpl +168 -0
- package/shared/scaffold/supabase/template/src/common/extraEndpoints.ts.tmpl +5 -0
- package/shared/scaffold/supabase/template/src/common/notImplemented.test.ts.tmpl +58 -0
- package/shared/scaffold/supabase/template/src/common/notImplemented.ts.tmpl +29 -0
- package/shared/scaffold/supabase/template/supabase/migrations/_V0__supabase_stubs.sql.tmpl +34 -0
- package/shared/scaffold/supabase/template/web/src/App.tsx.tmpl +38 -0
- package/shared/scaffold/supabase/template/web/src/app/Layout.tsx.tmpl +48 -0
- package/shared/scaffold/supabase/template/web/src/app/NotFound.tsx.tmpl +12 -0
- package/shared/scaffold/supabase/template/web/src/app/SignIn.tsx.tmpl +42 -0
- package/shared/scaffold/supabase/template/web/src/app/TimelinePage.tsx.tmpl +73 -0
- package/shared/scaffold/supabase/template/web/src/app/pages.ts.tmpl +26 -0
- package/shared/scaffold/supabase/template/web/src/app/skeleton.css.tmpl +25 -0
- package/shared/scaffold/supabase/template/web/src/lib/NotBuilt.tsx.tmpl +12 -0
- package/shared/scaffold/supabase/template/web/src/lib/api.ts.tmpl +226 -0
- package/shared/scaffold/supabase/template/web/src/main.tsx.tmpl +13 -0
- package/shared/skills/learn-eventmodelers-api/SKILL.md +4 -4
- package/shared/skills/ui-contract/SKILL.md +6 -6
- package/shared/skills/update-slice-status/SKILL.md +1 -1
- package/stacks/axon/templates/build-kit/CLAUDE.md +4 -3
- package/stacks/axon/templates/build-kit/lib/backend-prompt.md +11 -1
- package/stacks/axon/templates/build-kit/lib/prompt.md +24 -0
- package/stacks/blank/templates/build-kit/CLAUDE.md +4 -3
- package/stacks/blank/templates/build-kit/lib/backend-prompt.md +11 -1
- package/stacks/blank/templates/build-kit/lib/prompt.md +24 -0
- package/stacks/classic/templates/build-kit/CLAUDE.md +6 -5
- package/stacks/classic/templates/build-kit/lib/backend-prompt.md +11 -1
- package/stacks/classic/templates/build-kit/lib/prompt.md +24 -0
- package/stacks/kurrent/templates/build-kit/CLAUDE.md +4 -3
- package/stacks/kurrent/templates/build-kit/lib/backend-prompt.md +11 -1
- package/stacks/kurrent/templates/build-kit/lib/prompt.md +24 -0
- package/stacks/modeling-kit/templates/.claude/skills/analyze-existing-model/SKILL.md +1 -1
- package/stacks/modeling-kit/templates/.claude/skills/prototype/SKILL.md +2 -0
- package/stacks/modeling-kit/templates/.claude/skills/prototype/assets/prototype.mjs +7 -5
- package/stacks/modeling-kit/templates/kit/CLAUDE-STANDALONE.md +1 -1
- package/stacks/modeling-kit/templates/kit/CLAUDE.md +11 -5
- package/stacks/node/templates/build-kit/CLAUDE.md +4 -3
- package/stacks/node/templates/build-kit/lib/backend-prompt.md +11 -1
- package/stacks/node/templates/build-kit/lib/prompt.md +10 -0
- package/stacks/opencqrs/templates/build-kit/CLAUDE.md +4 -3
- package/stacks/opencqrs/templates/build-kit/lib/backend-prompt.md +11 -1
- package/stacks/opencqrs/templates/build-kit/lib/prompt.md +24 -0
- package/stacks/react/templates/build-kit/CLAUDE.md +4 -3
- package/stacks/react/templates/build-kit/lib/AGENT.md +1 -1
- package/stacks/react/templates/build-kit/lib/backend-prompt.md +17 -7
- package/stacks/react/templates/build-kit/lib/kit.json +1 -1
- package/stacks/react/templates/build-kit/lib/prompt.md +39 -2
- package/stacks/supabase/templates/.claude/skills/build-state-view/SKILL.md +15 -0
- package/stacks/supabase/templates/build-kit/CLAUDE.md +4 -3
- package/stacks/supabase/templates/build-kit/lib/backend-prompt.md +11 -1
- package/stacks/supabase/templates/build-kit/lib/prompt.md +10 -0
- package/stacks/supabase-react/templates/.claude/skills/build-state-change/SKILL.md +78 -6
- package/stacks/supabase-react/templates/.claude/skills/build-state-view/SKILL.md +64 -11
- package/stacks/supabase-react/templates/build-kit/CLAUDE.md +5 -4
- package/stacks/supabase-react/templates/build-kit/lib/AGENT.md +1 -1
- package/stacks/supabase-react/templates/build-kit/lib/backend-prompt.md +17 -7
- package/stacks/supabase-react/templates/build-kit/lib/kit.json +1 -1
- package/stacks/supabase-react/templates/build-kit/lib/prompt.md +40 -3
- package/stacks/umadb/templates/build-kit/CLAUDE.md +4 -3
- package/stacks/umadb/templates/build-kit/lib/backend-prompt.md +11 -1
- package/stacks/umadb/templates/build-kit/lib/prompt.md +24 -0
package/cli.js
CHANGED
|
@@ -20,6 +20,10 @@ import { createInterface, emitKeypressEvents, moveCursor, clearScreenDown } from
|
|
|
20
20
|
import { homedir, tmpdir } from 'os';
|
|
21
21
|
import { randomUUID } from 'crypto';
|
|
22
22
|
import { runFetch, FetchAuthError } from './lib/fetch.js';
|
|
23
|
+
import { SCAFFOLD_TYPES, scaffoldTypeNames, missingPrerequisites, runScaffold, builtInTemplates, initTemplates } from './lib/scaffold.js';
|
|
24
|
+
import { supervise } from './lib/supervise.js';
|
|
25
|
+
import { findBuildKitDirs } from './lib/build-kits.js';
|
|
26
|
+
import { buildModel } from './lib/scaffold-model.js';
|
|
23
27
|
import { createModelingLocalAiRunner } from './lib/modeling-local-ai.js';
|
|
24
28
|
import { run as runSpecKittyAdapter } from './lib/adapters/spec-kitty-adapter.js';
|
|
25
29
|
import { latestPublishedVersion, updateHints } from './lib/update-check.js';
|
|
@@ -31,16 +35,18 @@ import { modelingPermissions } from './lib/agent-permissions.js';
|
|
|
31
35
|
// shared/build-kit/lib/adapters/realtime-adapter.js for why it lives there instead.
|
|
32
36
|
import { createRealtimeAdapter, keepSubscribed } from './shared/build-kit/lib/adapters/realtime-adapter.js';
|
|
33
37
|
import { recordAgentStatus } from './shared/build-kit/lib/slice-run.js';
|
|
38
|
+
import { findSliceDir, fingerprint, readBaseline, writeBaseline, compare } from './lib/ui-baseline.js';
|
|
34
39
|
import { defaultAgentName, withInstanceSuffix } from './shared/build-kit/lib/agent-name.js';
|
|
35
40
|
import { acquireAgentId } from './shared/build-kit/lib/agent-lock.js';
|
|
36
41
|
import { readEvents } from './shared/build-kit/lib/slice-events.js';
|
|
37
42
|
import { findPromptEvent, replayRequest } from './shared/build-kit/lib/prompt-replay.js';
|
|
38
43
|
import { prunableEvents, pruneLog, rememberPruneAnswer, shouldOfferPrune } from './shared/build-kit/lib/event-prune.js';
|
|
39
44
|
import { fold } from './shared/build-kit/lib/slice-state.js';
|
|
40
|
-
import { filterEvents, formatEvent, formatPayload, sliceTitles, summarize } from './shared/build-kit/lib/events-view.js';
|
|
45
|
+
import { filterEvents, baselineIds, formatEvent, formatPayload, sliceTitles, summarize } from './shared/build-kit/lib/events-view.js';
|
|
41
46
|
import { stableGlobalAgentId } from './lib/global-agent-id.js';
|
|
42
47
|
import { ensureLearningsGitRules } from './shared/build-kit/lib/ralph.js';
|
|
43
48
|
import { createCallTracker, heldPromptsRequest, journalPromptAction, shouldJournalCall, journalPromptFinished, journalPromptReceived, recoverOwnPrompts } from './shared/build-kit/lib/prompt-journal.js';
|
|
49
|
+
import { INFO_KINDS, infoComment, recordInfo, recordTurnInfos } from './shared/build-kit/lib/slice-info.js';
|
|
44
50
|
import { listLearnings, mergeLearnings, recordLearning, recordTurnLearnings, retractLearning, syncLearnings } from './shared/build-kit/lib/slice-learnings.js';
|
|
45
51
|
|
|
46
52
|
const __filename = fileURLToPath(import.meta.url);
|
|
@@ -463,6 +469,30 @@ function getSharedRl() {
|
|
|
463
469
|
return sharedRl;
|
|
464
470
|
}
|
|
465
471
|
|
|
472
|
+
// --force on a build kit: the credentials step of `init-config` against the project-root config the
|
|
473
|
+
// runner reads. Interactive, so under `run --all` the parent runs it for each kit one after the other —
|
|
474
|
+
// the supervised children have no stdin to ask on. `shared` (another kit's answer) is applied
|
|
475
|
+
// instead of asking, so every kit of one run ends up on the same credentials.
|
|
476
|
+
async function forceBuildKitCredentials(kitDir, cwd, opts, globalOpts, shared = null) {
|
|
477
|
+
const projectDir = dirname(kitDir);
|
|
478
|
+
const configPath = globalOpts.config
|
|
479
|
+
? resolve(projectDir, globalOpts.config)
|
|
480
|
+
: join(projectDir, '.eventmodelers', 'config.json');
|
|
481
|
+
const cfg = await configureCredentials({
|
|
482
|
+
config: loadEffectiveConfig(cwd, kitDir, globalOpts.config).config,
|
|
483
|
+
configPath,
|
|
484
|
+
targetDir: projectDir,
|
|
485
|
+
requiredFields: ['organizationId', 'boardId', 'token'],
|
|
486
|
+
overrides: shared ?? credentialOverridesFromOpts(opts),
|
|
487
|
+
print: false,
|
|
488
|
+
force: !shared,
|
|
489
|
+
});
|
|
490
|
+
ensureMcpRegistered(projectDir, cfg.baseUrl || DEFAULT_BASE_URL);
|
|
491
|
+
ensureEnvToken(projectDir, cfg.token);
|
|
492
|
+
ensureMcpEnabled(projectDir);
|
|
493
|
+
return cfg;
|
|
494
|
+
}
|
|
495
|
+
|
|
466
496
|
// Pulls one line from the shared readline's own async iterator rather than calling
|
|
467
497
|
// its `.question()` — `.question()` attaches a one-shot 'line' listener *after* the
|
|
468
498
|
// prompt is issued, but when stdin is piped (a file, `<<<`, scripted/CI input) readline
|
|
@@ -2652,15 +2682,18 @@ async function runModeling(kitDir, projectDir, { codeDir = null, verbose = false
|
|
|
2652
2682
|
try {
|
|
2653
2683
|
turnPromptId = p.id;
|
|
2654
2684
|
const reply = await runTurn(buildTurn(p));
|
|
2655
|
-
// The agent cannot run `learn` (locked-down Bash), so it states
|
|
2656
|
-
// Best effort: a failed write never takes the prompt queue down.
|
|
2657
|
-
|
|
2658
|
-
|
|
2685
|
+
// The agent cannot run `learn` or `info` (locked-down Bash), so it states them in its reply and they are recorded here.
|
|
2686
|
+
// Best effort: a failed write never takes the prompt queue down. Infos are events stamped with their board, so every
|
|
2687
|
+
// run records them — standalone included; learnings are kept only where `keepsRecord` (they feed AGENTS.md).
|
|
2688
|
+
try {
|
|
2689
|
+
if (keepsRecord) {
|
|
2659
2690
|
const n = recordTurnLearnings({ kitDir, reply, meta: journalMeta });
|
|
2660
2691
|
if (n) log(`recorded ${n} learning${n === 1 ? '' : 's'} from the turn`);
|
|
2661
|
-
} catch (err) {
|
|
2662
|
-
log(`could not record the turn's learnings: ${err.message}`);
|
|
2663
2692
|
}
|
|
2693
|
+
const infos = recordTurnInfos({ kitDir, reply, meta: journalMeta });
|
|
2694
|
+
if (infos) log(`recorded ${infos} info event${infos === 1 ? '' : 's'} from the turn`);
|
|
2695
|
+
} catch (err) {
|
|
2696
|
+
log(`could not record the turn's learnings or infos: ${err.message}`);
|
|
2664
2697
|
}
|
|
2665
2698
|
journal(() => journalPromptFinished({ kitDir, id: p.id, outcome: 'worked', meta: journalMeta }));
|
|
2666
2699
|
} catch (err) {
|
|
@@ -3286,11 +3319,11 @@ program
|
|
|
3286
3319
|
// dir: a modeling run falls back to the global install (see ensureGlobalKit) rather than
|
|
3287
3320
|
// requiring one here, and the build-kit branch reports a better-targeted error of its own
|
|
3288
3321
|
// than this generic gate can.
|
|
3289
|
-
const NO_INIT_REQUIRED = new Set(['init', 'init-config', 'stacks', 'status', 'config', 'uninstall', 'fetch', 'activate-context', 'set-slice-status', 'release-notes', 'run', 'listen', 'events', 'replay', 'bridge']);
|
|
3322
|
+
const NO_INIT_REQUIRED = new Set(['init', 'scaffold', 'init-templates', 'init-config', 'stacks', 'status', 'config', 'uninstall', 'fetch', 'activate-context', 'set-slice-status', 'release-notes', 'run', 'listen', 'events', 'replay', 'bridge']);
|
|
3290
3323
|
|
|
3291
3324
|
// Commands that install or refresh the kit themselves — a "your kit is outdated" hint in
|
|
3292
3325
|
// front of them would only be noise.
|
|
3293
|
-
const NO_KIT_UPDATE_HINT = new Set(['init', 're-init', 'uninstall']);
|
|
3326
|
+
const NO_KIT_UPDATE_HINT = new Set(['init', 'scaffold', 'init-templates', 're-init', 'uninstall']);
|
|
3294
3327
|
|
|
3295
3328
|
// Retired: still recognised, so an old script or muscle memory gets an explanation instead of "unknown option".
|
|
3296
3329
|
// The hidden options and the `bridge` command exist only to say so; they do nothing else.
|
|
@@ -3372,13 +3405,13 @@ const AGENT_NAME_OPTION = [
|
|
|
3372
3405
|
];
|
|
3373
3406
|
|
|
3374
3407
|
// `--handover` is the project policy for how a backend build kit finishes a slice with a UI:
|
|
3375
|
-
// set, it ends STATE_VIEW / STATE_CHANGE slices at `
|
|
3408
|
+
// set, it ends STATE_VIEW / STATE_CHANGE slices at `Waiting` (the UI kit builds the rest and sets
|
|
3376
3409
|
// `Done`); unset, it ends at `Done`, exactly as before. Persisted as `handover: true` in the project's
|
|
3377
3410
|
// config.json — the `ui-contract` skill (Step 4) reads it there. Switch it later with
|
|
3378
3411
|
// `eventmodelers config --handover on|off`.
|
|
3379
3412
|
const HANDOVER_OPTION = [
|
|
3380
3413
|
'--handover',
|
|
3381
|
-
'Backend build kit: finish slices that have a UI at status `
|
|
3414
|
+
'Backend build kit: finish slices that have a UI at status `Waiting` (UI build kit takes over, then sets `Done`) instead of `Done`. Saved as `handover: true` in .eventmodelers/config.json; without it slices end at `Done`. Turn it off later with `eventmodelers config --handover off`.',
|
|
3382
3415
|
];
|
|
3383
3416
|
|
|
3384
3417
|
function identityOverridesFromOpts(opts) {
|
|
@@ -3783,6 +3816,7 @@ credentialFlags(program
|
|
|
3783
3816
|
.option('--agent [name]', `Pick the agent that does the work by name: ${Object.keys(AGENT_PRESETS).join(', ')}. claude is the default runner; any other is a shortcut for --exec with that harness's headless command filled in — plain-text output for --modeling/--standalone/--worker, JSON output (for agent tracing) for a build kit. Bare --agent lists the agents and the exact command each runs, then stops. Mutually exclusive with --exec and --local-ai.`)
|
|
3784
3817
|
.option('--model <id>', 'The model the agent runs on, for this run only: `-m <id>` on the --agent opencode/codex/gemini command (e.g. opencode/big-pickle, gpt-5-codex, gemini-2.5-pro), `--model <id>` for Claude (overriding `model` from config), LOCAL_AI_MODEL for --local-ai. Not with --exec — put the harness\'s own model flag in the command there.')
|
|
3785
3818
|
.option('--exec [command]', 'Fallback for any harness --agent does not cover, or other flags than its preset. Hand each prompt to an external agent command instead of the default Claude runner — for agentic harnesses that bring their own tool loop, e.g. "codex exec --full-auto", "gemini --yolo -p" or "opencode run". One process per prompt; the prompt is appended as a quoted argument and also written to the file named by RALPH_PROMPT_FILE. Bare --exec uses localAi.exec from .eventmodelers/config.json. Claude remains the default when this flag is absent. A build kit runs it via ralph-exec.js; there, with agent tracing on, include the harness\'s JSON output flag (codex exec --json, opencode run --format json, gemini --output-format stream-json) — without it no slice cost is recorded. With --modeling/--standalone/--worker it is how any agent takes board prompts and chats: the loop claims each prompt or chat message and runs the command once for it. Keep the harness in its plain-text output mode there — stdout is the turn\'s answer — the eventmodelers MCP server is registered for it on every run (opencode.json, .gemini/settings.json, or -c flags for codex; any other command logs what to register by hand), authenticating with $EVENTMODELERS_TOKEN from the env the command inherits, and `init-agents --hosts <harness>` installs the skills for a harness that does not read .claude/skills. No subagents: self-directed work is done inline.')
|
|
3819
|
+
.option('--all', 'Run every build kit in this project at once — the one in this directory and any up to two levels below it (a scaffolded app has `.build-kit` at the root and in `web/`, so this works from the app or from the folder holding it) — each from its own directory, output prefixed with the directory, Ctrl+c stops them all. Every other flag is passed on to each run. Not interactive: the kits\' credentials must already be configured (except with --force, which asks once per kit up front, here, before the runs start). With --modeling, the modeling agent runs alongside the build kits. Not with --standalone, --worker, --global, --local or --id (one id cannot belong to several agents).')
|
|
3786
3820
|
.option('--modeling', 'Connect a modeling agent to the board: it takes prompts and chat messages and hands each to an AI — by default one Claude process kept warm across prompts, for low-latency voice/live use; --agent (or --local-ai, or --exec as the manual fallback) picks another. Runs from a modeling-kit install in this directory, or from the global install (~/.eventmodelers/kit) when there is none. Built into the CLI, not a per-project file.')
|
|
3787
3821
|
.option('-f, --force', 'Ask for credentials again before the modeling loop starts, even when a config already has everything required. With a modeling kit in this directory it re-runs the same prompt as `init --modeling`, overwriting the project\'s .eventmodelers/config.json; with the global install it drops the "keep the stored credentials" choice, so a board\'s credentials have to be answered afresh. Applies to build-kit runs too, where it re-asks against the project\'s .eventmodelers/config.json. Cannot be combined with --non-interactive, --print or --local.')
|
|
3788
3822
|
.option('--non-interactive', 'Never ask anything: take the board and credentials that resolve from flags, EVENTMODELERS_* env vars and the config files, and fail with the reason if they are incomplete instead of interviewing for them. A TTY was previously the only signal — right for CI or a supervisor, wrong for a loop started from a terminal, where stdin is a TTY nobody is watching and the run stops on a question. Only affects the modeling loop (--modeling/--standalone/--worker/--global).')
|
|
@@ -3794,8 +3828,9 @@ credentialFlags(program
|
|
|
3794
3828
|
.option('--local', 'Skip platform config/credential lookup entirely and run the local-only loop (no board sync, no realtime agent) — even if .eventmodelers/config.json has credentials (build-kit stacks only)')
|
|
3795
3829
|
.option('--dangerously-skip-permissions', 'Run the modeling agent\'s Claude process with --dangerously-skip-permissions, as before the lockdown: every tool call is approved — any shell command, curl to anywhere, any file the user can read or write, every MCP server the machine configures. Off by default because the agent takes chat messages and snippet clicks from any collaborator on the board; pass it only for a board whose collaborators you trust with your shell. Default Claude runner of --modeling/--standalone/--worker/--global only — not with --agent, --exec or --local-ai, which bring their own permission flags.')
|
|
3796
3830
|
.option('--browser', 'Give the locked-down modeling agent a browser: the Playwright MCP server (npx @playwright/mcp) next to the eventmodelers one, for discover-storyboard. Off by default because a browser can reach any site, which the lockdown otherwise rules out. Default Claude runner only; --dangerously-skip-permissions already loads every MCP server the machine configures.')
|
|
3797
|
-
.option('--handover', 'Backend build kit: for this run only, finish slices that have a UI at `
|
|
3798
|
-
.option('--status-trigger <status>', 'Build kit: the slice status the agent picks work up from, for this run only (e.g. Ready, Planned,
|
|
3831
|
+
.option('--handover', 'Backend build kit: for this run only, finish slices that have a UI at `Waiting` instead of `Done` (same as the project setting `handover: true`, which `init --handover` / `config --handover on` save — this flag writes nothing). Without it the project setting applies; without both, slices end at `Done`.')
|
|
3832
|
+
.option('--status-trigger <status>', 'Build kit: the slice status the agent picks work up from, for this run only (e.g. Ready, Planned, Waiting). Defaults to the kit\'s own pickup status — `Planned` for backend kits, `Handover` for the UI kits. Nothing is written to disk.')
|
|
3833
|
+
.option('--sync', 'Build kit: share this agent\'s event log with the board — events that name a slice are uploaded constantly (POST …/buildlog) for a live dashboard. The platform keeps one iteration per slice, 1000 entries at most. A no-op for --modeling/--standalone, and with --local.')
|
|
3799
3834
|
.option('--verbose', 'Log every tool call\'s full input (commands, skill args, file paths) and assistant reasoning text. Default is condensed, high-level per-step logging only.')
|
|
3800
3835
|
.option('--id <id>', 'Pin the agent id this run identifies itself with on the platform. Without it an agent keeps one id: a project install per project, a --global/--standalone run per board and repo (the directory it is started in) — the same agent on every restart, with its chat, its star on the board and the prompts it holds. If another live process already runs under that id, this one gets a fresh id instead (two agents must not share a heartbeat row). Pass --id when a supervisor already knows the id you want, or for a second agent on purpose; it is used as given, with no such check. Per-run only: nothing is written to disk.')
|
|
3801
3836
|
.option('--name <name>', 'A human-readable name for this agent, sent with every heartbeat so the board shows which agent is live rather than a bare uuid (e.g. "ci-builder", "martins-laptop"). Per-run only, like --id: the persistent name is `agentName` in config.json (set via `init --name` / `init-config --name`), and this overrides it for one run without writing anything.')
|
|
@@ -3803,6 +3838,49 @@ credentialFlags(program
|
|
|
3803
3838
|
.action(async (opts, command) => {
|
|
3804
3839
|
const globalOpts = command.optsWithGlobals();
|
|
3805
3840
|
const cwd = process.cwd();
|
|
3841
|
+
if (opts.all) {
|
|
3842
|
+
const clashing = [['--standalone', opts.standalone], ['--worker', opts.worker], ['--global', opts.global], ['--local', opts.local], ['--id', opts.id !== undefined]]
|
|
3843
|
+
.filter(([, set]) => set).map(([flag]) => flag);
|
|
3844
|
+
if (clashing.length) {
|
|
3845
|
+
console.error(`❌ --all runs build kits and cannot be combined with ${clashing.join(', ')}.`);
|
|
3846
|
+
process.exit(1);
|
|
3847
|
+
}
|
|
3848
|
+
const kitDirNames = [...new Set(Object.values(STACKS).map((st) => st.kitDirName))];
|
|
3849
|
+
const dirs = findBuildKitDirs(cwd, kitDirNames);
|
|
3850
|
+
if (!dirs.length && !opts.modeling) {
|
|
3851
|
+
console.error(`❌ No build kit found in this directory or up to two levels below it (looked for ${kitDirNames.join(', ')}).`);
|
|
3852
|
+
process.exit(1);
|
|
3853
|
+
}
|
|
3854
|
+
// Each kit runs as its own `run` with whatever else was typed; only --all is dropped, and
|
|
3855
|
+
// --modeling too — that one is its own run, below, not something a build kit takes.
|
|
3856
|
+
// --force is the one flag that asks questions, and the children have no terminal to ask on
|
|
3857
|
+
// (the supervisor pipes their stdin, so the first prompt dies with ERR_USE_AFTER_CLOSE). The
|
|
3858
|
+
// credentials step runs here once per kit instead, then --force is not forwarded.
|
|
3859
|
+
if (opts.force) {
|
|
3860
|
+
if (opts.nonInteractive || globalOpts.print) {
|
|
3861
|
+
console.error('❌ --force asks for credentials again, which --non-interactive/--print rule out — pick one.');
|
|
3862
|
+
process.exit(1);
|
|
3863
|
+
}
|
|
3864
|
+
// Asked once, for the kit in this directory (the first one found when there is none), and
|
|
3865
|
+
// handed to the others — the modeling agent reads this directory's config.
|
|
3866
|
+
const rootDir = dirs.find((dir) => dirname(dir) === cwd) ?? dirs[0];
|
|
3867
|
+
console.log(`\n🔑 Credentials for ${relative(cwd, rootDir) || '.'}, shared by all`);
|
|
3868
|
+
const { organizationId, boardId, token, baseUrl } = await forceBuildKitCredentials(rootDir, cwd, opts, globalOpts);
|
|
3869
|
+
for (const dir of dirs.filter((d) => d !== rootDir)) {
|
|
3870
|
+
await forceBuildKitCredentials(dir, cwd, opts, globalOpts, { organizationId, boardId, token, baseUrl });
|
|
3871
|
+
}
|
|
3872
|
+
if (sharedRl) sharedRl.close();
|
|
3873
|
+
console.log('');
|
|
3874
|
+
}
|
|
3875
|
+
const forwarded = process.argv.slice(2).filter((a) => a !== '--all' && a !== '--modeling' && !(opts.force && a === '--force'));
|
|
3876
|
+
const cliJs = join(__dirname, 'cli.js');
|
|
3877
|
+
const specs = dirs.map((dir) => ({ name: relative(cwd, dir) || '.', file: process.execPath, args: [cliJs, ...forwarded], cwd: dir }));
|
|
3878
|
+
// --modeling adds the modeling agent next to the build kits: one process, from this directory
|
|
3879
|
+
// (its modeling kit, or the global install when there is none), like a plain `run --modeling`.
|
|
3880
|
+
if (opts.modeling) specs.push({ name: 'modeling', file: process.execPath, args: [cliJs, ...forwarded, '--modeling'], cwd });
|
|
3881
|
+
console.log(`▶ Running ${dirs.length} build kit(s)${opts.modeling ? ' + the modeling agent' : ''}: ${specs.map((sp) => sp.name).join(', ')} (Ctrl+c stops all)\n`);
|
|
3882
|
+
process.exit(await supervise(specs));
|
|
3883
|
+
}
|
|
3806
3884
|
// Validated up front, before any runner is selected: a cost guard given as garbage
|
|
3807
3885
|
// should fail on the spot, not once the loop is already up — and a cap passed where
|
|
3808
3886
|
// nothing will read it is worth saying out loud rather than ignoring silently.
|
|
@@ -3876,6 +3954,7 @@ credentialFlags(program
|
|
|
3876
3954
|
// no meaning for a build kit, which is scaffolded per project by definition.
|
|
3877
3955
|
if (opts.modeling || opts.standalone || opts.worker || opts.global) {
|
|
3878
3956
|
const picked = opts.modeling ? '--modeling' : opts.standalone ? '--standalone' : opts.worker ? '--worker' : '--global';
|
|
3957
|
+
if (opts.sync) console.log(`ℹ️ --sync shares a build agent's log — it does nothing with ${picked}.`);
|
|
3879
3958
|
// --local-ai and --exec name what does a turn, not a queue, and the modeling loop has its own
|
|
3880
3959
|
// runner for each (lib/modeling-local-ai.js, lib/modeling-exec.js).
|
|
3881
3960
|
const modelingLocalAi = resolveLocalAiTarget(opts);
|
|
@@ -4019,22 +4098,7 @@ credentialFlags(program
|
|
|
4019
4098
|
console.error('❌ --force asks for credentials, which --local (no platform config) never uses — pick one.');
|
|
4020
4099
|
process.exit(1);
|
|
4021
4100
|
}
|
|
4022
|
-
|
|
4023
|
-
const configPath = globalOpts.config
|
|
4024
|
-
? resolve(projectDir, globalOpts.config)
|
|
4025
|
-
: join(projectDir, '.eventmodelers', 'config.json');
|
|
4026
|
-
const cfg = await configureCredentials({
|
|
4027
|
-
config: loadEffectiveConfig(cwd, kitDir, globalOpts.config).config,
|
|
4028
|
-
configPath,
|
|
4029
|
-
targetDir: projectDir,
|
|
4030
|
-
requiredFields: ['organizationId', 'boardId', 'token'],
|
|
4031
|
-
overrides: credentialOverridesFromOpts(opts),
|
|
4032
|
-
print: false,
|
|
4033
|
-
force: true,
|
|
4034
|
-
});
|
|
4035
|
-
ensureMcpRegistered(projectDir, cfg.baseUrl || DEFAULT_BASE_URL);
|
|
4036
|
-
ensureEnvToken(projectDir, cfg.token);
|
|
4037
|
-
ensureMcpEnabled(projectDir);
|
|
4101
|
+
await forceBuildKitCredentials(kitDir, cwd, opts, globalOpts);
|
|
4038
4102
|
}
|
|
4039
4103
|
|
|
4040
4104
|
// ralph-claude.js hands every turn to a `claude` that only discovers MCP servers from
|
|
@@ -4056,7 +4120,7 @@ credentialFlags(program
|
|
|
4056
4120
|
// path with no stream-json parsing to gate. RALPH_LOCAL is read by ralph.js's startRalph to force
|
|
4057
4121
|
// the local-only branch even when .eventmodelers/config.json has valid credentials.
|
|
4058
4122
|
// RALPH_AGENT_ID/RALPH_AGENT_NAME (--id/--name) are read there too.
|
|
4059
|
-
execSync(cmd, { cwd: kitDir, stdio: 'inherit', env: { ...process.env, RALPH_VERBOSE: opts.verbose ? '1' : '', RALPH_LOCAL: opts.local ? '1' : '', RALPH_HANDOVER: opts.handover ? '1' : '', RALPH_STATUS_TRIGGER: opts.statusTrigger ? String(opts.statusTrigger).trim() : '', RALPH_AGENT_ID: identity.agentId ?? '', RALPH_AGENT_NAME: identity.agentName ?? '', ...(typeof localAiTarget === 'string' ? { LOCAL_AI_TARGET: localAiTarget } : {}), ...(typeof opts.exec === 'string' ? { RALPH_EXEC_CMD: opts.exec } : {}) } });
|
|
4123
|
+
execSync(cmd, { cwd: kitDir, stdio: 'inherit', env: { ...process.env, RALPH_VERBOSE: opts.verbose ? '1' : '', RALPH_SYNC: opts.sync ? '1' : '', RALPH_LOCAL: opts.local ? '1' : '', RALPH_HANDOVER: opts.handover ? '1' : '', RALPH_STATUS_TRIGGER: opts.statusTrigger ? String(opts.statusTrigger).trim() : '', RALPH_AGENT_ID: identity.agentId ?? '', RALPH_AGENT_NAME: identity.agentName ?? '', ...(typeof localAiTarget === 'string' ? { LOCAL_AI_TARGET: localAiTarget } : {}), ...(typeof opts.exec === 'string' ? { RALPH_EXEC_CMD: opts.exec } : {}) } });
|
|
4060
4124
|
} catch (err) {
|
|
4061
4125
|
process.exit(err.status || 1);
|
|
4062
4126
|
}
|
|
@@ -4160,6 +4224,162 @@ program
|
|
|
4160
4224
|
}
|
|
4161
4225
|
});
|
|
4162
4226
|
|
|
4227
|
+
program
|
|
4228
|
+
.command('scaffold <target> [args...]')
|
|
4229
|
+
.description('`scaffold add-user <email> <password> [dir]` adds a sign-in user to a scaffolded app (run in its folder, or pass dir). Otherwise: generate a runnable app skeleton from the board — sign-in, navigation with one entry per timeline, a page shell per screen, 501 for every unbuilt endpoint, hot reload — and install the build kits of the chosen --type into it, ready for `run` to add slices. <target> is an empty or new directory, or a skeleton made earlier (which is refreshed from the current model).')
|
|
4230
|
+
.option('--type <name>', `Kind of app to scaffold (${scaffoldTypeNames().join(', ')})`, 'supabase')
|
|
4231
|
+
.option('--no-install', 'Stop after generating the skeleton: no npm install, checks or commit')
|
|
4232
|
+
.option('--no-check', 'Install dependencies but skip the type-check, tests and web build/lint')
|
|
4233
|
+
.option('--templates <dir>', 'Folder of *.tmpl files that replace the built-in skeleton template of the same path (or add files). Default: the target\'s own templates/ folder when it exists, so customisations survive a refresh')
|
|
4234
|
+
.option('--port <name=value>', "Pin one of the type's ports instead of picking a free one, e.g. --port api=3200 (repeatable; the names are listed in the error when one is unknown). A refresh keeps its ports unless pinned here", (v, all) => [...all, v], [])
|
|
4235
|
+
.action(async (target, args, opts, command) => {
|
|
4236
|
+
if (target === 'add-user') {
|
|
4237
|
+
const [email, password, dir] = args;
|
|
4238
|
+
if (!email || !password) {
|
|
4239
|
+
console.error('❌ usage: eventmodelers scaffold add-user <email> <password> [dir]');
|
|
4240
|
+
process.exit(1);
|
|
4241
|
+
}
|
|
4242
|
+
const addUser = SCAFFOLD_TYPES[opts.type]?.addUser;
|
|
4243
|
+
if (!addUser) {
|
|
4244
|
+
console.log(`ℹ️ The "${opts.type}" scaffold has no users — nothing to do.`);
|
|
4245
|
+
return;
|
|
4246
|
+
}
|
|
4247
|
+
const appDir = resolve(process.cwd(), dir ?? '.');
|
|
4248
|
+
if (!existsSync(join(appDir, addUser.requires))) {
|
|
4249
|
+
console.error(`❌ ${appDir} is not a scaffolded "${opts.type}" app (no ${addUser.requires}) — run this in its folder or pass the folder as the last argument.`);
|
|
4250
|
+
process.exit(1);
|
|
4251
|
+
}
|
|
4252
|
+
const [file, ...fileArgs] = addUser.command(email, password);
|
|
4253
|
+
try {
|
|
4254
|
+
execFileSync(file, fileArgs, { cwd: appDir, stdio: 'inherit' });
|
|
4255
|
+
} catch {
|
|
4256
|
+
process.exit(1);
|
|
4257
|
+
}
|
|
4258
|
+
return;
|
|
4259
|
+
}
|
|
4260
|
+
if (args.length) {
|
|
4261
|
+
console.error(`❌ Unexpected argument(s): ${args.join(' ')}`);
|
|
4262
|
+
process.exit(1);
|
|
4263
|
+
}
|
|
4264
|
+
const type = SCAFFOLD_TYPES[opts.type];
|
|
4265
|
+
if (!type) {
|
|
4266
|
+
console.error(`❌ Unknown --type "${opts.type}". Available: ${scaffoldTypeNames().join(', ')}`);
|
|
4267
|
+
process.exit(1);
|
|
4268
|
+
}
|
|
4269
|
+
const missing = missingPrerequisites(type);
|
|
4270
|
+
if (missing.length) {
|
|
4271
|
+
console.error(`❌ Missing on this machine:\n${missing.map((m) => ` - ${m}`).join('\n')}`);
|
|
4272
|
+
process.exit(1);
|
|
4273
|
+
}
|
|
4274
|
+
|
|
4275
|
+
const cwd = process.cwd();
|
|
4276
|
+
const globalOpts = command.optsWithGlobals();
|
|
4277
|
+
let cfg = loadEffectiveConfig(cwd, findInstalledKitDir(cwd), globalOpts.config).config;
|
|
4278
|
+
const explicit = Object.fromEntries(Object.entries(credentialOverridesFromOpts(opts)).filter(([, v]) => v));
|
|
4279
|
+
cfg = { ...cfg, ...explicit };
|
|
4280
|
+
const configPath = globalOpts.config ? resolve(cwd, globalOpts.config) : join(cwd, '.eventmodelers', 'config.json');
|
|
4281
|
+
const requiredFields = ['organizationId', 'boardId', 'token'];
|
|
4282
|
+
const promptForCredentials = async () => {
|
|
4283
|
+
cfg = await configureCredentials({ config: cfg, configPath, targetDir: cwd, requiredFields, boardIdOptional: false, overrides: {}, print: globalOpts.print });
|
|
4284
|
+
if (requiredFields.some((f) => !cfg[f])) {
|
|
4285
|
+
console.error('❌ Still missing token/organizationId/boardId — re-run `eventmodelers scaffold` once configured.');
|
|
4286
|
+
process.exit(1);
|
|
4287
|
+
}
|
|
4288
|
+
};
|
|
4289
|
+
if (requiredFields.some((f) => !cfg[f])) await promptForCredentials();
|
|
4290
|
+
|
|
4291
|
+
const readModel = async () => {
|
|
4292
|
+
const baseUrl = cfg.baseUrl || 'https://api.eventmodelers.ai';
|
|
4293
|
+
const headers = { 'x-token': cfg.token, 'x-user-id': 'cli-scaffold', 'content-type': 'application/json' };
|
|
4294
|
+
const call = async (url, what, init) => {
|
|
4295
|
+
const res = await fetch(url, { headers, ...init }).catch((err) => {
|
|
4296
|
+
console.error(`❌ Request failed (${what}): ${err.message}`);
|
|
4297
|
+
process.exit(1);
|
|
4298
|
+
});
|
|
4299
|
+
if (res.status === 401 || res.status === 403) throw new FetchAuthError(res.status, `${what}: ${res.status === 401 ? 'invalid or expired token' : "token's organization does not match this board"}`);
|
|
4300
|
+
if (!res.ok) {
|
|
4301
|
+
console.error(`❌ ${what}: HTTP ${res.status}`);
|
|
4302
|
+
process.exit(1);
|
|
4303
|
+
}
|
|
4304
|
+
return res.json();
|
|
4305
|
+
};
|
|
4306
|
+
const boardBase = `${baseUrl}/api/org/${cfg.organizationId}/boards/${cfg.boardId}`;
|
|
4307
|
+
const boards = await call(`${baseUrl}/api/boards`, 'boards').catch((err) => { if (err instanceof FetchAuthError) throw err; return []; });
|
|
4308
|
+
const board = (Array.isArray(boards) ? boards : boards.boards ?? []).find((b) => b.id === cfg.boardId);
|
|
4309
|
+
return buildModel({
|
|
4310
|
+
appTitle: board?.name ?? board?.title,
|
|
4311
|
+
api: {
|
|
4312
|
+
get: (path) => call(`${boardBase}${path}`, path.split('?')[0]),
|
|
4313
|
+
post: (path, body) => call(`${boardBase}${path}`, path, { method: 'POST', body: JSON.stringify(body) }),
|
|
4314
|
+
},
|
|
4315
|
+
});
|
|
4316
|
+
};
|
|
4317
|
+
|
|
4318
|
+
console.log(`▶ Reading the model of board ${cfg.boardId}...`);
|
|
4319
|
+
let model;
|
|
4320
|
+
try {
|
|
4321
|
+
model = await readModel();
|
|
4322
|
+
} catch (err) {
|
|
4323
|
+
if (!(err instanceof FetchAuthError)) throw err;
|
|
4324
|
+
console.error(`❌ ${err.message}`);
|
|
4325
|
+
if (err.status === 404) delete cfg.boardId;
|
|
4326
|
+
else delete cfg.token;
|
|
4327
|
+
await promptForCredentials();
|
|
4328
|
+
model = await readModel();
|
|
4329
|
+
}
|
|
4330
|
+
console.log(` ${model.timelines.length} timeline(s), ${model.slices.length} slice(s), ${model.screens.length} screen(s), css: ${model.css}`);
|
|
4331
|
+
|
|
4332
|
+
try {
|
|
4333
|
+
const { root, ports } = await runScaffold({
|
|
4334
|
+
typeName: opts.type,
|
|
4335
|
+
target,
|
|
4336
|
+
cfg,
|
|
4337
|
+
model,
|
|
4338
|
+
cliPath: join(__dirname, 'cli.js'),
|
|
4339
|
+
install: opts.install,
|
|
4340
|
+
check: opts.check,
|
|
4341
|
+
templates: opts.templates ? resolve(cwd, opts.templates) : undefined,
|
|
4342
|
+
ports: Object.fromEntries(opts.port.map((kv) => kv.split(/=(.*)/s).slice(0, 2))),
|
|
4343
|
+
});
|
|
4344
|
+
console.log(`\n✅ Skeleton ready in ${root}`);
|
|
4345
|
+
console.log(` cd ${relative(cwd, root) || '.'} && npm run up # ${Object.entries(ports).map(([n, v]) => `${n} :${v}`).join(', ')}`);
|
|
4346
|
+
console.log(' npx @eventmodelers/cli scaffold add-user <email> <password> # a user to sign in with (no sign-up screen)');
|
|
4347
|
+
console.log(' npx @eventmodelers/cli run --all # then build: every kit in the project, in this terminal');
|
|
4348
|
+
} catch (err) {
|
|
4349
|
+
console.error(`❌ ${err.message}`);
|
|
4350
|
+
process.exit(1);
|
|
4351
|
+
}
|
|
4352
|
+
});
|
|
4353
|
+
|
|
4354
|
+
program
|
|
4355
|
+
.command('init-templates [paths...]')
|
|
4356
|
+
.description('Copy the built-in scaffold templates into ./templates/ (or --dir) so you can edit them: `scaffold` uses a template found there and falls back to the built-in one for everything else. Name paths (a file like web/src/app/Layout.tsx, or a folder like web/src/app) to copy only those — a copied template no longer follows the CLI\'s own updates, so take only what you mean to change. Existing files are kept unless --force.')
|
|
4357
|
+
.option('--type <name>', `Scaffold type whose templates to copy (${scaffoldTypeNames().join(', ')})`, 'supabase')
|
|
4358
|
+
.option('--dir <dir>', 'Where to copy them', 'templates')
|
|
4359
|
+
.option('--list', 'Only list the built-in template paths')
|
|
4360
|
+
.option('-f, --force', 'Overwrite templates already in the folder')
|
|
4361
|
+
.action((paths, opts) => {
|
|
4362
|
+
const type = SCAFFOLD_TYPES[opts.type];
|
|
4363
|
+
if (!type) {
|
|
4364
|
+
console.error(`❌ Unknown --type "${opts.type}". Available: ${scaffoldTypeNames().join(', ')}`);
|
|
4365
|
+
process.exit(1);
|
|
4366
|
+
}
|
|
4367
|
+
if (opts.list) {
|
|
4368
|
+
for (const rel of builtInTemplates(type).keys()) console.log(rel);
|
|
4369
|
+
return;
|
|
4370
|
+
}
|
|
4371
|
+
try {
|
|
4372
|
+
const dest = resolve(process.cwd(), opts.dir);
|
|
4373
|
+
const { copied, kept } = initTemplates(type, dest, { only: paths, force: opts.force });
|
|
4374
|
+
for (const rel of copied) console.log(` ✓ ${relative(process.cwd(), join(dest, `${rel}.tmpl`))}`);
|
|
4375
|
+
if (kept.length) console.log(` ${kept.length} already there, kept (use --force to overwrite)`);
|
|
4376
|
+
console.log(`\n✅ ${copied.length} template(s) copied. Edit them, then run \`eventmodelers scaffold <dir>\` — it uses ${relative(process.cwd(), dest) || '.'}/ when that is the target's templates/ folder, or pass --templates.`);
|
|
4377
|
+
} catch (err) {
|
|
4378
|
+
console.error(`❌ ${err.message}`);
|
|
4379
|
+
process.exit(1);
|
|
4380
|
+
}
|
|
4381
|
+
});
|
|
4382
|
+
|
|
4163
4383
|
program
|
|
4164
4384
|
.command('activate-context')
|
|
4165
4385
|
.description('Choose which fetched context is active — writes .slices/current_context.json, which `run` works through first; once it has no planned slices left, `run` moves on to the next context that does')
|
|
@@ -4217,7 +4437,7 @@ const SLICE_STATUSES = [
|
|
|
4217
4437
|
{ label: '🚫 Blocked', value: 'Blocked' },
|
|
4218
4438
|
{ label: '🧭 Ready', value: 'Ready' },
|
|
4219
4439
|
{ label: '📅 Planned', value: 'Planned' },
|
|
4220
|
-
{ label: '🤝
|
|
4440
|
+
{ label: '🤝 Waiting', value: 'Waiting' },
|
|
4221
4441
|
{ label: 'ℹ️ Informational', value: 'Informational' },
|
|
4222
4442
|
];
|
|
4223
4443
|
|
|
@@ -4394,11 +4614,13 @@ function agentEventMeta(cwd, kitDir, command) {
|
|
|
4394
4614
|
|
|
4395
4615
|
program
|
|
4396
4616
|
.command('slice-status <status>')
|
|
4397
|
-
.description("For a build agent: record the outcome of the slice the run loop is building (Done,
|
|
4617
|
+
.description("For a build agent: record the outcome of the slice the run loop is building (Done, Waiting, Blocked, ...). The loop owns the slice's status on the board and pushes it when the build ends — never edit index.json or call update-slice-status for a loop build.")
|
|
4398
4618
|
.option('--slice <idOrTitle>', 'The slice, when the loop has claimed more than one (default: the one it is building)')
|
|
4399
4619
|
.action((status, opts, command) => {
|
|
4400
4620
|
const cwd = process.cwd();
|
|
4401
|
-
|
|
4621
|
+
// `Handover` is the old name of `Waiting`; older skills and prompts still say it
|
|
4622
|
+
const wanted = status.toLowerCase() === 'handover' ? 'waiting' : status.toLowerCase();
|
|
4623
|
+
const canonical = SLICE_STATUSES.find((s) => s.value.toLowerCase() === wanted)?.value;
|
|
4402
4624
|
if (!canonical) {
|
|
4403
4625
|
console.error(`❌ "${status}" is not a slice status. Valid: ${SLICE_STATUSES.map((s) => s.value).join(', ')}`);
|
|
4404
4626
|
process.exit(1);
|
|
@@ -4414,6 +4636,79 @@ program
|
|
|
4414
4636
|
}
|
|
4415
4637
|
});
|
|
4416
4638
|
|
|
4639
|
+
program
|
|
4640
|
+
.command('ui-baseline <action>')
|
|
4641
|
+
.alias('build-baseline')
|
|
4642
|
+
.description("For a build agent: decide whether a slice's UI needs work. `check` compares the board's hashes of the slice's screen and of every element of the slice and its codeGen.uiPrompts with what was recorded after the last UI build and prints NO_BASELINE, UNCHANGED or CHANGED (UNCHANGED = skip all UI work). `record` stores the current state once the UI is built — commit the resulting ui-baseline.json with the slice.")
|
|
4643
|
+
.option('--slice <idOrTitle>', 'The slice (default: the one the run loop has claimed)')
|
|
4644
|
+
.option('--scope <scope>', 'ui (default): screen, elements and UI prompts · backend: elements only, for a kit that builds no UI', 'ui')
|
|
4645
|
+
.action(async (action, opts, command) => {
|
|
4646
|
+
if (!['ui', 'backend'].includes(opts.scope)) {
|
|
4647
|
+
console.error('❌ --scope must be `ui` or `backend`.');
|
|
4648
|
+
process.exit(1);
|
|
4649
|
+
}
|
|
4650
|
+
if (!['check', 'record'].includes(action)) {
|
|
4651
|
+
console.error('❌ Action must be `check` or `record`.');
|
|
4652
|
+
process.exit(1);
|
|
4653
|
+
}
|
|
4654
|
+
const cwd = process.cwd();
|
|
4655
|
+
const kitDir = requireBuildKitDir(cwd);
|
|
4656
|
+
let wanted = opts.slice;
|
|
4657
|
+
if (!wanted) {
|
|
4658
|
+
const claimed = Object.entries(fold(readEvents(kitDir)).slices).filter(([, s]) => s.claim);
|
|
4659
|
+
if (claimed.length !== 1) {
|
|
4660
|
+
console.error(`❌ ${claimed.length ? `${claimed.length} slices are claimed` : 'No slice is claimed by the run loop'} — name the slice with --slice.`);
|
|
4661
|
+
process.exit(1);
|
|
4662
|
+
}
|
|
4663
|
+
wanted = claimed[0][0];
|
|
4664
|
+
}
|
|
4665
|
+
const found = findSliceDir(kitDir, wanted);
|
|
4666
|
+
if (!found) {
|
|
4667
|
+
console.error(`❌ Slice "${wanted}" not found under ${relative(cwd, join(kitDir, '.slices'))}.`);
|
|
4668
|
+
process.exit(1);
|
|
4669
|
+
}
|
|
4670
|
+
const globalOpts = command.optsWithGlobals();
|
|
4671
|
+
const cfg = loadEffectiveConfig(cwd, kitDir, globalOpts.config).config;
|
|
4672
|
+
const fetchNodes = async (ids) => {
|
|
4673
|
+
if (['organizationId', 'boardId', 'token'].some((f) => !cfg[f])) throw new Error('token/organizationId/boardId are not configured (.eventmodelers/config.json)');
|
|
4674
|
+
const res = await fetch(`${cfg.baseUrl || DEFAULT_BASE_URL}/api/org/${cfg.organizationId}/boards/${cfg.boardId}/nodes/query`, {
|
|
4675
|
+
method: 'POST',
|
|
4676
|
+
headers: { 'Content-Type': 'application/json', 'x-token': cfg.token, 'x-user-id': 'cli-ui-baseline', ...agentHeaders(cfg) },
|
|
4677
|
+
body: JSON.stringify({ ids }),
|
|
4678
|
+
});
|
|
4679
|
+
if (!res.ok) throw new Error(`board returned HTTP ${res.status}`);
|
|
4680
|
+
return res.json();
|
|
4681
|
+
};
|
|
4682
|
+
let fp;
|
|
4683
|
+
try {
|
|
4684
|
+
fp = await fingerprint({ slice: found.slice, fetchNodes, scope: opts.scope });
|
|
4685
|
+
} catch (err) {
|
|
4686
|
+
// Cannot tell — the safe answer is the full check, never a skip.
|
|
4687
|
+
console.log('UNKNOWN');
|
|
4688
|
+
console.log(`Could not read the screen from the board (${err.message}) — do the full screen diff.`);
|
|
4689
|
+
return;
|
|
4690
|
+
}
|
|
4691
|
+
if (action === 'record') {
|
|
4692
|
+
writeBaseline(found.path, fp, opts.scope);
|
|
4693
|
+
console.log(`✅ Recorded the ${opts.scope === 'backend' ? 'build' : 'UI'} baseline for "${found.slice.title}" (${relative(cwd, join(found.path, 'ui-baseline.json'))}) — commit it with the slice.`);
|
|
4694
|
+
return;
|
|
4695
|
+
}
|
|
4696
|
+
const result = compare(readBaseline(found.path), fp, opts.scope);
|
|
4697
|
+
console.log(result.verdict);
|
|
4698
|
+
if (result.verdict === 'UNCHANGED') {
|
|
4699
|
+
console.log('Screen, elements and UI prompts are as of the last UI build — skip all UI work.');
|
|
4700
|
+
return;
|
|
4701
|
+
}
|
|
4702
|
+
if (result.verdict === 'NO_BASELINE') console.log('No UI build recorded for this slice yet — do one full screen diff, apply every UI prompt, then `ui-baseline record`.');
|
|
4703
|
+
const nodeLine = (label, list) => (list.length ? `\n ${label}: ${list.map((n) => `${n.title} (${n.kind === 'screen' ? 'screen' : n.kind.replace(/s$/, '')})`).join(', ')}` : '');
|
|
4704
|
+
const nodeDetail = nodeLine('changed', result.changedNodes) + nodeLine('added', result.addedNodes) + nodeLine('removed', result.removedNodes);
|
|
4705
|
+
if (nodeDetail) console.log(`Since the last recorded build:${nodeDetail}\nWork on exactly these; leave what matches alone.`);
|
|
4706
|
+
if (result.screenChanged && result.verdict === 'CHANGED') console.log('SCREEN_CHANGED: re-fetch the screen node, diff the components against it and adjust the UI.');
|
|
4707
|
+
if (result.elementsChanged && result.verdict === 'CHANGED') console.log('ELEMENTS_CHANGED: an element of the slice (command, event, read model, processor, table, actor, aggregate, specification, ...) changed on the board — diff slice.json against the code field by field.');
|
|
4708
|
+
if (result.promptsChanged) console.log(`PROMPTS_CHANGED: apply ${result.newPrompts.length ? 'these new/changed UI prompts' : 'the UI prompts (they were edited or removed — re-read them)'}:${result.newPrompts.map((p) => `\n - ${p.trim()}`).join('')}`);
|
|
4709
|
+
console.log('When the UI is done, run `ui-baseline record`.');
|
|
4710
|
+
});
|
|
4711
|
+
|
|
4417
4712
|
program
|
|
4418
4713
|
.command('learn [text]')
|
|
4419
4714
|
.description("For a build agent: record one reusable learning (a codebase pattern, a gotcha, a command that works or doesn't, what a failed approach tried and why it failed). .build-kit/AGENTS.md is generated from the recorded learnings — never edit it by hand. --retract withdraws a wrong or obsolete one (ids: `learnings --ids`).")
|
|
@@ -4451,6 +4746,42 @@ program
|
|
|
4451
4746
|
}
|
|
4452
4747
|
});
|
|
4453
4748
|
|
|
4749
|
+
program
|
|
4750
|
+
.command('info <text>')
|
|
4751
|
+
.description("For a build agent: record something that is not a reusable learning — what you cannot change and why (limitation), a decision you took, a note. It is an event in the log and a comment on the slice. Reusable knowledge about the codebase or model is a `learn`, not an info.")
|
|
4752
|
+
.option('--kind <kind>', `${INFO_KINDS.join(' · ')} (default note)`, 'note')
|
|
4753
|
+
.option('--slice <idOrTitle>', 'The slice it is about (default: the one the run loop has claimed)')
|
|
4754
|
+
.option('--tag <tag>', 'Label the info (repeatable)', (tag, tags) => [...tags, tag], [])
|
|
4755
|
+
.action(async (text, opts, command) => {
|
|
4756
|
+
const cwd = process.cwd();
|
|
4757
|
+
const kitDir = requireBuildKitDir(cwd);
|
|
4758
|
+
const meta = agentEventMeta(cwd, kitDir, command);
|
|
4759
|
+
try {
|
|
4760
|
+
let sliceId;
|
|
4761
|
+
if (opts.slice) sliceId = findSliceDir(kitDir, opts.slice)?.slice.id ?? opts.slice;
|
|
4762
|
+
else {
|
|
4763
|
+
const claimed = Object.entries(fold(readEvents(kitDir)).slices).filter(([, s]) => s.claim);
|
|
4764
|
+
if (claimed.length === 1) sliceId = claimed[0][0];
|
|
4765
|
+
}
|
|
4766
|
+
const info = recordInfo({ kitDir, text, kind: opts.kind, slice: sliceId, tags: opts.tag, meta });
|
|
4767
|
+
console.log(`✅ Recorded ${info.kind} (${info.id.slice(0, 8)}).`);
|
|
4768
|
+
if (!sliceId) return console.log('ℹ️ No slice named or claimed — not posted to the board.');
|
|
4769
|
+
// Best effort: the event is the record, the comment is how the board shows it.
|
|
4770
|
+
const cfg = loadEffectiveConfig(cwd, kitDir, command.optsWithGlobals().config).config;
|
|
4771
|
+
if (['organizationId', 'boardId', 'token'].some((f) => !cfg[f])) return console.log('ℹ️ No board credentials — not posted to the board.');
|
|
4772
|
+
const res = await fetch(`${cfg.baseUrl || DEFAULT_BASE_URL}/api/org/${cfg.organizationId}/boards/${cfg.boardId}/nodes/${sliceId}/comments`, {
|
|
4773
|
+
method: 'POST',
|
|
4774
|
+
headers: { 'Content-Type': 'application/json', 'x-token': cfg.token, 'x-user-id': 'cli-info', ...agentHeaders(cfg) },
|
|
4775
|
+
body: JSON.stringify({ text: infoComment(info), type: 'COMMENT', author: 'build-agent' }),
|
|
4776
|
+
signal: AbortSignal.timeout(15_000),
|
|
4777
|
+
});
|
|
4778
|
+
console.log(res.ok ? '✅ Posted as a comment on the slice.' : `⚠️ Board returned HTTP ${res.status} — the info is only in the event log.`);
|
|
4779
|
+
} catch (err) {
|
|
4780
|
+
console.error(`❌ ${err.message}`);
|
|
4781
|
+
process.exit(1);
|
|
4782
|
+
}
|
|
4783
|
+
});
|
|
4784
|
+
|
|
4454
4785
|
program
|
|
4455
4786
|
.command('events')
|
|
4456
4787
|
.description("Decode a kit's event log in the terminal: one line per event (claims, settles, learnings, tasks, prompts …) with the slice's title and the agent that wrote it. Filter it, print the raw events, or print the state the log folds to.")
|
|
@@ -4462,7 +4793,7 @@ program
|
|
|
4462
4793
|
.option('--tag <tag>', 'Only events carrying this tag')
|
|
4463
4794
|
.option('--last <n>', 'Only the last n events (after the other filters)')
|
|
4464
4795
|
.option('--json', 'Print the raw events, one JSON object per line')
|
|
4465
|
-
.option('--verbose', 'Print each event\'s payload under its line — for a tool call, the input it was sent (recorded by runs started with --verbose)')
|
|
4796
|
+
.option('--verbose', 'Print each event\'s payload under its line — for a tool call, the input it was sent (recorded by runs started with --verbose). Also shows the baseline events: the kit\'s first look at each slice (a fresh kit records every slice already on the board), which are hidden by default. `--type board-observed` and `--json` show them too.')
|
|
4466
4797
|
.option('--state', 'Print the state the log folds to (slices, queue, open prompts, learnings) instead of the events')
|
|
4467
4798
|
.option('--prune', 'Delete the events nothing reads any more — the journal of finished prompts — and stop. Slice state, learnings and open prompts are kept; the same prune a run offers at every 1000 events.')
|
|
4468
4799
|
.option('--follow', 'Keep running and print events as they are appended')
|
|
@@ -4510,6 +4841,8 @@ program
|
|
|
4510
4841
|
|
|
4511
4842
|
const follow = opts.follow || opts.watch;
|
|
4512
4843
|
const events = readEvents(kitDir);
|
|
4844
|
+
// The baseline (each slice's first board-observed) is noise unless asked for: --verbose, the raw --json, or the type itself.
|
|
4845
|
+
const hideBaseline = !opts.verbose && !opts.json && !filter.types?.includes('board-observed');
|
|
4513
4846
|
const titles = sliceTitles(events);
|
|
4514
4847
|
if (opts.state) {
|
|
4515
4848
|
if (follow) console.error('ℹ️ --follow/--watch does not apply to --state.');
|
|
@@ -4522,7 +4855,7 @@ program
|
|
|
4522
4855
|
if (!opts.global && !opts.kit && readEvents(globalLog).length) console.log('The global install has a log of its own: `eventmodelers events --global`.');
|
|
4523
4856
|
return;
|
|
4524
4857
|
}
|
|
4525
|
-
print(filterEvents(events, { ...filter, last }, titles), titles);
|
|
4858
|
+
print(filterEvents(events, { ...filter, last, omit: hideBaseline ? baselineIds(events) : undefined }, titles), titles);
|
|
4526
4859
|
if (!follow) return;
|
|
4527
4860
|
|
|
4528
4861
|
// Follow: what has been printed is remembered by event id, and the log is read again twice a second.
|
|
@@ -4531,7 +4864,7 @@ program
|
|
|
4531
4864
|
const all = readEvents(kitDir);
|
|
4532
4865
|
const fresh = all.filter((e) => !seen.has(e.id));
|
|
4533
4866
|
for (const e of fresh) seen.add(e.id);
|
|
4534
|
-
if (fresh.length) print(filterEvents(fresh, filter, sliceTitles(all)), sliceTitles(all));
|
|
4867
|
+
if (fresh.length) print(filterEvents(fresh, { ...filter, omit: hideBaseline ? baselineIds(all) : undefined }, sliceTitles(all)), sliceTitles(all));
|
|
4535
4868
|
}, 500);
|
|
4536
4869
|
});
|
|
4537
4870
|
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Where the build kits of a project live: the root and up to two directory levels below it, so `run --all` finds
|
|
2
|
+
// both kits of a scaffolded app (root and web/) whether it is started in the app or in the folder holding it.
|
|
3
|
+
// Hidden directories and node_modules are not searched.
|
|
4
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
|
|
7
|
+
const MAX_DEPTH = 2;
|
|
8
|
+
|
|
9
|
+
export function findBuildKitDirs(root, kitDirNames) {
|
|
10
|
+
const has = (dir) => kitDirNames.some((name) => existsSync(join(dir, name)));
|
|
11
|
+
const found = [];
|
|
12
|
+
const visit = (dir, depth) => {
|
|
13
|
+
if (has(dir)) found.push(dir);
|
|
14
|
+
if (depth === MAX_DEPTH) return;
|
|
15
|
+
const children = readdirSync(dir, { withFileTypes: true })
|
|
16
|
+
.filter((e) => e.isDirectory() && !e.name.startsWith('.') && e.name !== 'node_modules')
|
|
17
|
+
.map((e) => join(dir, e.name))
|
|
18
|
+
.sort();
|
|
19
|
+
for (const child of children) visit(child, depth + 1);
|
|
20
|
+
};
|
|
21
|
+
visit(root, 0);
|
|
22
|
+
return found;
|
|
23
|
+
}
|