@xdelivered/emberflow 0.2.0 → 0.5.0

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 (116) hide show
  1. package/bin/commands.ts +7 -3
  2. package/bin/init.test.ts +94 -0
  3. package/bin/init.ts +108 -2
  4. package/dist/bin/commands.d.ts +1 -0
  5. package/dist/bin/commands.js +6 -3
  6. package/dist/bin/init.d.ts +4 -0
  7. package/dist/bin/init.js +96 -2
  8. package/dist/server/agents/codexParse.js +31 -0
  9. package/dist/server/agents/detect.d.ts +18 -8
  10. package/dist/server/agents/detect.js +78 -13
  11. package/dist/server/agents/modelRejectionHint.js +1 -1
  12. package/dist/server/agents/prompt.d.ts +15 -1
  13. package/dist/server/agents/prompt.js +59 -37
  14. package/dist/server/agents/runManager.d.ts +23 -2
  15. package/dist/server/agents/runManager.js +36 -8
  16. package/dist/server/client.d.ts +1 -0
  17. package/dist/server/client.js +7 -2
  18. package/dist/server/index.js +208 -16
  19. package/dist/server/nodesPayload.d.ts +14 -1
  20. package/dist/server/nodesPayload.js +15 -2
  21. package/dist/server/projectMode.js +5 -2
  22. package/dist/server/runRegistry.d.ts +52 -1
  23. package/dist/server/runRegistry.js +170 -1
  24. package/dist/server/sourceNav.d.ts +77 -0
  25. package/dist/server/sourceNav.js +503 -0
  26. package/dist/server/subflowRunner.d.ts +48 -1
  27. package/dist/server/subflowRunner.js +34 -7
  28. package/dist/server/updateCheck.d.ts +68 -0
  29. package/dist/server/updateCheck.js +142 -0
  30. package/dist/src/engine/executor.js +4 -3
  31. package/dist/src/engine/registry.d.ts +27 -2
  32. package/dist/src/engine/registry.js +84 -2
  33. package/dist/src/nodes/index.d.ts +8 -2
  34. package/dist/src/nodes/index.js +7 -3
  35. package/dist/src/nodes/login.d.ts +3 -1
  36. package/dist/src/nodes/login.js +2 -2
  37. package/package.json +28 -7
  38. package/server/agentRoute.test.ts +20 -0
  39. package/server/agents/codexParse.test.ts +37 -0
  40. package/server/agents/codexParse.ts +34 -0
  41. package/server/agents/detect.test.ts +26 -7
  42. package/server/agents/detect.ts +82 -14
  43. package/server/agents/modelRejectionHint.ts +2 -1
  44. package/server/agents/prompt.test.ts +128 -0
  45. package/server/agents/prompt.ts +102 -3
  46. package/server/agents/runManager.test.ts +9 -0
  47. package/server/agents/runManager.ts +37 -7
  48. package/server/client.ts +10 -4
  49. package/server/index.ts +213 -15
  50. package/server/nodeRun.test.ts +189 -0
  51. package/server/nodesPayload.test.ts +38 -0
  52. package/server/nodesPayload.ts +28 -3
  53. package/server/projectMode.ts +5 -2
  54. package/server/runRegistry.ts +200 -3
  55. package/server/setupStatus.test.ts +3 -0
  56. package/server/sourceNav.test.ts +382 -0
  57. package/server/sourceNav.ts +644 -0
  58. package/server/sourceNavRoute.test.ts +163 -0
  59. package/server/stepDrill.test.ts +380 -0
  60. package/server/subflowRunner.ts +92 -11
  61. package/server/updateCheck.test.ts +209 -0
  62. package/server/updateCheck.ts +175 -0
  63. package/server/workflowTestRoute.test.ts +1 -1
  64. package/src/App.tsx +82 -2
  65. package/src/components/AgentConsole.tsx +7 -280
  66. package/src/components/AgentStream.test.tsx +88 -0
  67. package/src/components/AgentStream.tsx +303 -0
  68. package/src/components/CreateModal.tsx +43 -9
  69. package/src/components/Dock.tsx +1 -1
  70. package/src/components/EmptyState.test.tsx +49 -0
  71. package/src/components/EmptyState.tsx +61 -0
  72. package/src/components/EnvironmentDialog.tsx +4 -1
  73. package/src/components/EnvironmentPicker.tsx +8 -3
  74. package/src/components/EnvironmentsDialog.tsx +4 -1
  75. package/src/components/InfraPanel.tsx +30 -2
  76. package/src/components/InfrastructureDialog.test.tsx +97 -0
  77. package/src/components/InfrastructureDialog.tsx +111 -0
  78. package/src/components/Inspector.tsx +9 -26
  79. package/src/components/RunbookView.tsx +70 -6
  80. package/src/components/SettingsDialog.tsx +4 -59
  81. package/src/components/Sidebar.tsx +6 -26
  82. package/src/components/SourceNavigator.test.tsx +226 -0
  83. package/src/components/SourceNavigator.tsx +520 -0
  84. package/src/components/StatusBar.tsx +227 -26
  85. package/src/components/Toolbar.tsx +28 -16
  86. package/src/components/UpdateChip.test.tsx +31 -0
  87. package/src/components/WelcomeDialog.test.tsx +384 -13
  88. package/src/components/WelcomeDialog.tsx +996 -177
  89. package/src/engine/executor.ts +4 -3
  90. package/src/engine/registry.sourceRef.test.ts +112 -0
  91. package/src/engine/registry.ts +103 -2
  92. package/src/lib/guidedQuestions.test.ts +224 -0
  93. package/src/lib/guidedQuestions.ts +181 -0
  94. package/src/lib/runbookModel.ts +3 -3
  95. package/src/lib/runbookProjection.test.ts +43 -0
  96. package/src/lib/runbookProjection.ts +26 -6
  97. package/src/nodes/index.ts +10 -3
  98. package/src/nodes/login.ts +5 -2
  99. package/src/store/agentClient.ts +27 -8
  100. package/src/store/apiTree.ts +12 -0
  101. package/src/store/builderStore.test.ts +441 -99
  102. package/src/store/builderStore.ts +385 -314
  103. package/src/store/nodeMeta.ts +10 -2
  104. package/src/store/serverRunner.ts +56 -5
  105. package/src/store/setupClient.ts +7 -2
  106. package/src/store/sourceNavClient.ts +48 -0
  107. package/src/store/updateClient.ts +50 -0
  108. package/studio-dist/assets/index-BbzppDpt.js +65 -0
  109. package/studio-dist/assets/index-CLf6blcA.css +1 -0
  110. package/studio-dist/index.html +2 -2
  111. package/templates/skills/emberflow-basics/SKILL.md +29 -1
  112. package/templates/skills/emberflow-model-process/SKILL.md +92 -9
  113. package/templates/skills/emberflow-new-workflow/SKILL.md +8 -1
  114. package/templates/skills/emberflow-review-workflow/SKILL.md +64 -1
  115. package/studio-dist/assets/index-DNJwW-hM.css +0 -1
  116. package/studio-dist/assets/index-O26dKRjW.js +0 -65
package/bin/commands.ts CHANGED
@@ -14,6 +14,7 @@ export interface ParsedArgs {
14
14
  noSkills?: boolean;
15
15
  scope?: 'global' | 'repo';
16
16
  noLaunch?: boolean;
17
+ noGit?: boolean;
17
18
  mock?: boolean;
18
19
  js?: boolean;
19
20
  ts?: boolean;
@@ -42,6 +43,7 @@ export function parseArgs(argv: string[]): ParsedArgs {
42
43
  else if (a === '--global') out.scope = 'global';
43
44
  else if (a === '--local') out.scope = 'repo';
44
45
  else if (a === '--no-launch') out.noLaunch = true;
46
+ else if (a === '--no-git') out.noGit = true;
45
47
  else if (a === '--mock') out.mock = true;
46
48
  else if (a === '--js') out.js = true;
47
49
  else if (a === '--ts') out.ts = true;
@@ -243,17 +245,19 @@ export async function runCommand(p: ParsedArgs, ctx: RuntimeContext = defaultCtx
243
245
  skills?: false | { scope: 'repo' | 'global'; home: string };
244
246
  packageRoot?: string;
245
247
  language?: 'javascript' | 'typescript';
248
+ git?: boolean;
246
249
  }
247
250
  ) => Promise<number>;
248
251
  tsxResolvable: (cwd: string) => boolean;
249
252
  };
250
253
  const scope = p.scope ?? (await promptScope());
251
254
  const language = p.js ? 'javascript' : p.ts ? 'typescript' : await promptLanguage();
255
+ const git = !p.noGit;
252
256
  const code = await mod.runInit(
253
257
  process.cwd(),
254
258
  p.noSkills
255
- ? { skills: false, packageRoot: pkgRoot, language }
256
- : { skills: { scope, home: homedir() }, packageRoot: pkgRoot, language }
259
+ ? { skills: false, packageRoot: pkgRoot, language, git }
260
+ : { skills: { scope, home: homedir() }, packageRoot: pkgRoot, language, git }
257
261
  );
258
262
  if (code !== 0) return code;
259
263
 
@@ -297,7 +301,7 @@ export async function runCommand(p: ParsedArgs, ctx: RuntimeContext = defaultCtx
297
301
  }
298
302
  default:
299
303
  console.log(
300
- 'Usage: emberflow <dev|serve|mcp|init|run|test|doctor|list-nodes|node-schema|list-workflows|get-workflow|list-environments|login-environment|set-environment-auth|serving|validate|publish|save|create|delete|rename|samples> [--port N] [--project DIR] [--scenario NAME] [--no-skills] [--global|--local] [--no-launch] [--mock] [--js|--ts]\n' +
304
+ 'Usage: emberflow <dev|serve|mcp|init|run|test|doctor|list-nodes|node-schema|list-workflows|get-workflow|list-environments|login-environment|set-environment-auth|serving|validate|publish|save|create|delete|rename|samples> [--port N] [--project DIR] [--scenario NAME] [--no-skills] [--global|--local] [--no-launch] [--no-git] [--mock] [--js|--ts]\n' +
301
305
  ' test [opId] [--environment NAME] [--json] Run scenario expectations in-process (no runner) — exit 0/1/2\n' +
302
306
  ' doctor [opId] [--fix] Diagnose operation(s) in-process (no runner); --fix seeds param defaults — exit 0/1/2'
303
307
  );
package/bin/init.test.ts CHANGED
@@ -1,10 +1,21 @@
1
1
  // bin/init.test.ts
2
+ import { execFileSync } from 'node:child_process';
2
3
  import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
3
4
  import { tmpdir } from 'node:os';
4
5
  import { join } from 'node:path';
5
6
  import { afterEach, describe, expect, it, vi } from 'vitest';
6
7
  import { runInit, tsxResolvable } from './init';
7
8
 
9
+ /** Run git in `dir`, returning trimmed stdout. Pins an identity so commits work
10
+ * on a machine/CI with no global git config. */
11
+ function git(dir: string, args: string[]): string {
12
+ return execFileSync(
13
+ 'git',
14
+ ['-c', 'user.name=Test', '-c', 'user.email=test@example.com', ...args],
15
+ { cwd: dir, encoding: 'utf8' },
16
+ ).trim();
17
+ }
18
+
8
19
  const dirs: string[] = [];
9
20
  function scratch(): string { const d = mkdtempSync(join(tmpdir(), 'ef-init-')); dirs.push(d); return d; }
10
21
  afterEach(() => { for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }); });
@@ -152,6 +163,89 @@ describe('runInit', () => {
152
163
  });
153
164
  });
154
165
 
166
+ describe('runInit git bootstrap', () => {
167
+ it('git-inits the folder and makes the load-bearing initial commit', async () => {
168
+ const d = scratch();
169
+ await runInit(d, { skills: false });
170
+ expect(existsSync(join(d, '.git'))).toBe(true);
171
+ // Exactly one commit, the scaffold commit — the snapshot/diff/revert safety
172
+ // net needs a real HEAD to diff against (an unborn HEAD can't be reverted).
173
+ expect(git(d, ['rev-list', '--count', 'HEAD'])).toBe('1');
174
+ expect(git(d, ['log', '--oneline'])).toContain('chore: emberflow init');
175
+ });
176
+
177
+ it('commits ONLY init-scaffolded files, never the user’s pre-existing code', async () => {
178
+ const d = scratch();
179
+ // A fresh dir may already hold the user's own uncommitted app code.
180
+ writeFileSync(join(d, 'app.js'), 'console.log("mine");\n');
181
+ await runInit(d, { skills: false });
182
+
183
+ const tracked = git(d, ['ls-tree', '-r', 'HEAD', '--name-only']).split('\n');
184
+ expect(tracked).toContain('emberflow.config.mjs');
185
+ expect(tracked).toContain('emberflow/apis/default/hello.json');
186
+ expect(tracked).toContain('.gitignore');
187
+ // The user's file was NOT swept into the commit (no `git add -A`).
188
+ expect(tracked).not.toContain('app.js');
189
+ // It's still there, just untracked.
190
+ expect(git(d, ['status', '--porcelain'])).toContain('?? app.js');
191
+ });
192
+
193
+ it('--no-git (git: false) skips repo creation entirely', async () => {
194
+ const d = scratch();
195
+ await runInit(d, { skills: false, git: false });
196
+ expect(existsSync(join(d, '.git'))).toBe(false);
197
+ });
198
+
199
+ it('leaves a pre-existing repo untouched — no nested repo, no extra commit', async () => {
200
+ const d = scratch();
201
+ git(d, ['init']);
202
+ writeFileSync(join(d, 'README.md'), '# mine\n');
203
+ git(d, ['add', '-A']);
204
+ git(d, ['commit', '-m', 'user commit']);
205
+ const before = git(d, ['rev-list', '--count', 'HEAD']);
206
+
207
+ await runInit(d, { skills: false });
208
+
209
+ // `d` is still the repo root — init did not nest a new repo under it
210
+ // (--show-cdup is empty only when cwd IS the toplevel).
211
+ expect(git(d, ['rev-parse', '--show-cdup'])).toBe('');
212
+ expect(existsSync(join(d, 'emberflow', 'apis', 'default'))).toBe(true);
213
+ // init added no commit of its own.
214
+ expect(git(d, ['rev-list', '--count', 'HEAD'])).toBe(before);
215
+ // The scaffold is present but uncommitted — the user commits on their terms.
216
+ expect(git(d, ['status', '--porcelain'])).toContain('emberflow.config.mjs');
217
+ });
218
+
219
+ it('re-running init on an already-scaffolded, non-git dir makes an empty anchor commit (no unborn HEAD)', async () => {
220
+ const d = scratch();
221
+ // Pre-scaffold everything init would normally write, as if a prior
222
+ // `emberflow init --no-git` (or a non-interactive run) already wrote it
223
+ // and the directory is still not a git repo — exactly what the
224
+ // checklist's copyable skills command produces.
225
+ await runInit(d, { skills: false, git: false });
226
+ expect(existsSync(join(d, '.git'))).toBe(false);
227
+
228
+ await runInit(d, { skills: false });
229
+
230
+ expect(existsSync(join(d, '.git'))).toBe(true);
231
+ // HEAD must resolve — an unborn HEAD (git init with zero commits) makes
232
+ // snapshot.head null downstream, which the agent snapshot/diff/revert
233
+ // safety net treats as "unrevertable".
234
+ expect(() => git(d, ['rev-parse', 'HEAD'])).not.toThrow();
235
+ expect(git(d, ['rev-list', '--count', 'HEAD'])).toBe('1');
236
+ expect(git(d, ['log', '--oneline'])).toContain('chore: emberflow init (anchor)');
237
+ });
238
+
239
+ it('folds repo-scope skill files into the same init commit as the rest of the scaffold', async () => {
240
+ const d = scratch();
241
+ await runInit(d, { skills: { scope: 'repo', home: join(d, '.home-unused') } });
242
+
243
+ const tracked = git(d, ['ls-tree', '-r', 'HEAD', '--name-only']).split('\n');
244
+ expect(tracked).toContain('emberflow.config.mjs');
245
+ expect(tracked).toContain('.claude/skills/emberflow-basics/SKILL.md');
246
+ });
247
+ });
248
+
155
249
  describe('tsxResolvable', () => {
156
250
  it('returns true when tsx resolves from a project (this repo has it as a devDependency)', () => {
157
251
  expect(tsxResolvable(process.cwd())).toBe(true);
package/bin/init.ts CHANGED
@@ -1,7 +1,8 @@
1
+ import { execFileSync } from 'node:child_process';
1
2
  import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync, copyFileSync } from 'node:fs';
2
3
  import { createRequire } from 'node:module';
3
4
  import { homedir } from 'node:os';
4
- import { dirname, join } from 'node:path';
5
+ import { dirname, join, relative } from 'node:path';
5
6
  import { fileURLToPath } from 'node:url';
6
7
  import { detectHarnesses, resolveSkillDirs } from './skillTargets';
7
8
 
@@ -148,6 +149,87 @@ function writeFileIfAbsent(path: string, content: string, log: string[]): void {
148
149
  log.push(path);
149
150
  }
150
151
 
152
+ /** Same notion as server/agents/gitScope.ts's isGitRepo: is `cwd` inside a git
153
+ * work tree? A plain rev-parse probe — we only need the yes/no. */
154
+ function isInsideGitRepo(cwd: string): boolean {
155
+ try {
156
+ return (
157
+ execFileSync('git', ['rev-parse', '--is-inside-work-tree'], {
158
+ cwd,
159
+ encoding: 'utf8',
160
+ stdio: ['ignore', 'pipe', 'ignore'],
161
+ }).trim() === 'true'
162
+ );
163
+ } catch {
164
+ return false;
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Every agent feature is gated on git: AgentRunManager snapshots the tree with
170
+ * git so changes can be reviewed and reverted (server/agents/runManager.ts).
171
+ * Nothing else creates the repo, so a freshly-`init`ed folder that isn't already
172
+ * inside a repo gets one here, with an initial commit of ONLY the files init
173
+ * scaffolded — never `git add -A`, since a fresh dir may already hold the user's
174
+ * own uncommitted app code.
175
+ *
176
+ * The initial commit is load-bearing, not cosmetic: gitScope restores tracked
177
+ * files by checking them out of the snapshot commit, and an unborn HEAD (a repo
178
+ * with zero commits) leaves `snapshot.head` null — so agent edits to any
179
+ * pre-existing tracked file could not be reverted. The commit gives the safety
180
+ * net something to diff/revert against.
181
+ *
182
+ * Never nests a repo or commits when already inside one. Returns a one-line
183
+ * human summary of what happened, or null when nothing was done.
184
+ */
185
+ function initGitRepo(cwd: string, scaffolded: string[]): string | null {
186
+ if (isInsideGitRepo(cwd)) return null; // pre-existing repo — never nest, never commit
187
+
188
+ const git = (args: string[]): void => {
189
+ execFileSync('git', args, { cwd, stdio: ['ignore', 'ignore', 'pipe'] });
190
+ };
191
+
192
+ git(['init']);
193
+
194
+ // Commit ONLY init's own scaffold, by explicit relative path — not `-A`.
195
+ const paths = [
196
+ ...new Set(
197
+ scaffolded
198
+ .map((p) => relative(cwd, p.replace(/\/+$/, '')))
199
+ .filter((p) => p.length > 0 && existsSync(join(cwd, p))),
200
+ ),
201
+ ];
202
+ if (paths.length === 0) {
203
+ // No scaffolded files to commit — most likely a re-run of init on a
204
+ // directory it already scaffolded (e.g. the checklist's copyable skills
205
+ // command, run non-interactively, against an existing project). `git init`
206
+ // alone leaves HEAD unborn, which breaks the agent snapshot safety net
207
+ // (gitScope reads snapshot.head as null and treats all later edits as
208
+ // unrevertable) while the git checklist row reports green. An empty anchor
209
+ // commit gives HEAD a real target with zero files tracked.
210
+ const commitEmpty = (extra: string[]): void =>
211
+ git([...extra, 'commit', '--allow-empty', '-m', 'chore: emberflow init (anchor)']);
212
+ try {
213
+ commitEmpty([]);
214
+ } catch {
215
+ commitEmpty(['-c', 'user.name=Emberflow', '-c', 'user.email=emberflow@localhost']);
216
+ }
217
+ return 'initialized a git repository with an empty anchor commit (no scaffolded files to commit)';
218
+ }
219
+
220
+ git(['add', '--', ...paths]);
221
+ const commit = (extra: string[]): void => git([...extra, 'commit', '-m', 'chore: emberflow init']);
222
+ try {
223
+ commit([]);
224
+ } catch {
225
+ // No user.name/user.email configured (fresh machine, CI) — the commit is
226
+ // load-bearing for the agent snapshot safety net, so fall back to a one-off
227
+ // identity for THIS commit only rather than fail init.
228
+ commit(['-c', 'user.name=Emberflow', '-c', 'user.email=emberflow@localhost']);
229
+ }
230
+ return 'initialized a git repository and committed the scaffold (chore: emberflow init)';
231
+ }
232
+
151
233
  export async function runInit(
152
234
  cwd: string,
153
235
  opts?: {
@@ -159,6 +241,10 @@ export async function runInit(
159
241
  /** Which language to scaffold the config/nodes in. Defaults to javascript
160
242
  * (matches the non-TTY prompt default in bin/commands.ts). */
161
243
  language?: 'javascript' | 'typescript';
244
+ /** Create a git repo + initial commit when the target isn't already inside
245
+ * one (agent features need it — see initGitRepo). Defaults to true; the
246
+ * `--no-git` flag threads through as false. */
247
+ git?: boolean;
162
248
  }
163
249
  ): Promise<number> {
164
250
  const written: string[] = [];
@@ -265,6 +351,10 @@ export async function runInit(
265
351
  console.log(`[emberflow] created ${path}`);
266
352
  }
267
353
 
354
+ // Skills are copied BEFORE the git init/commit below so repo-scope skill
355
+ // files (e.g. .claude/skills/emberflow-basics/SKILL.md) land in the same
356
+ // scaffold commit instead of being left untracked.
357
+ let skillsWritten: string[] = [];
268
358
  const skillsOpt = opts?.skills === undefined ? { scope: 'repo' as const, home: homedir() } : opts.skills;
269
359
  if (skillsOpt !== false) {
270
360
  const pkgRoot = opts?.packageRoot ?? join(dirname(fileURLToPath(import.meta.url)), '..');
@@ -272,7 +362,6 @@ export async function runInit(
272
362
  if (existsSync(skillsSrc)) {
273
363
  const presence = detectHarnesses(cwd, skillsOpt.home);
274
364
  const skillDirs = resolveSkillDirs(presence, skillsOpt.scope, cwd, skillsOpt.home);
275
- const skillsWritten: string[] = [];
276
365
  for (const dir of skillDirs) {
277
366
  copySkillsRecursive(skillsSrc, dir, skillsWritten);
278
367
  }
@@ -293,5 +382,22 @@ export async function runInit(
293
382
  console.log(JSON.stringify(MCP_SNIPPET, null, 2));
294
383
  }
295
384
 
385
+ // Agent features (snapshot/diff/revert) require git — create the repo + an
386
+ // initial commit of just the scaffold unless the user opted out or is already
387
+ // inside a repo. Best-effort: a git failure must never fail `init` itself.
388
+ if (opts?.git !== false) {
389
+ try {
390
+ // A `--global` skills install writes into the user's homedir, not the
391
+ // project — only fold in skill paths that actually live under cwd.
392
+ const repoScopeSkills = skillsWritten.filter((p) => !relative(cwd, p).startsWith('..'));
393
+ const summary = initGitRepo(cwd, [...written, ...repoScopeSkills]);
394
+ if (summary) console.log(`[emberflow] ${summary}`);
395
+ } catch (err) {
396
+ console.warn(
397
+ `[emberflow] could not initialize git (agent features need a repo — run \`git init && git add -A && git commit -m "initial"\`): ${err instanceof Error ? err.message : String(err)}`,
398
+ );
399
+ }
400
+ }
401
+
296
402
  return 0;
297
403
  }
@@ -6,6 +6,7 @@ export interface ParsedArgs {
6
6
  noSkills?: boolean;
7
7
  scope?: 'global' | 'repo';
8
8
  noLaunch?: boolean;
9
+ noGit?: boolean;
9
10
  mock?: boolean;
10
11
  js?: boolean;
11
12
  ts?: boolean;
@@ -24,6 +24,8 @@ export function parseArgs(argv) {
24
24
  out.scope = 'repo';
25
25
  else if (a === '--no-launch')
26
26
  out.noLaunch = true;
27
+ else if (a === '--no-git')
28
+ out.noGit = true;
27
29
  else if (a === '--mock')
28
30
  out.mock = true;
29
31
  else if (a === '--js')
@@ -203,9 +205,10 @@ export async function runCommand(p, ctx = defaultCtx) {
203
205
  const mod = (await import('./init.js'));
204
206
  const scope = p.scope ?? (await promptScope());
205
207
  const language = p.js ? 'javascript' : p.ts ? 'typescript' : await promptLanguage();
208
+ const git = !p.noGit;
206
209
  const code = await mod.runInit(process.cwd(), p.noSkills
207
- ? { skills: false, packageRoot: pkgRoot, language }
208
- : { skills: { scope, home: homedir() }, packageRoot: pkgRoot, language });
210
+ ? { skills: false, packageRoot: pkgRoot, language, git }
211
+ : { skills: { scope, home: homedir() }, packageRoot: pkgRoot, language, git });
209
212
  if (code !== 0)
210
213
  return code;
211
214
  console.log('');
@@ -244,7 +247,7 @@ export async function runCommand(p, ctx = defaultCtx) {
244
247
  }
245
248
  }
246
249
  default:
247
- console.log('Usage: emberflow <dev|serve|mcp|init|run|test|doctor|list-nodes|node-schema|list-workflows|get-workflow|list-environments|login-environment|set-environment-auth|serving|validate|publish|save|create|delete|rename|samples> [--port N] [--project DIR] [--scenario NAME] [--no-skills] [--global|--local] [--no-launch] [--mock] [--js|--ts]\n' +
250
+ console.log('Usage: emberflow <dev|serve|mcp|init|run|test|doctor|list-nodes|node-schema|list-workflows|get-workflow|list-environments|login-environment|set-environment-auth|serving|validate|publish|save|create|delete|rename|samples> [--port N] [--project DIR] [--scenario NAME] [--no-skills] [--global|--local] [--no-launch] [--no-git] [--mock] [--js|--ts]\n' +
248
251
  ' test [opId] [--environment NAME] [--json] Run scenario expectations in-process (no runner) — exit 0/1/2\n' +
249
252
  ' doctor [opId] [--fix] Diagnose operation(s) in-process (no runner); --fix seeds param defaults — exit 0/1/2');
250
253
  return 0;
@@ -14,4 +14,8 @@ export declare function runInit(cwd: string, opts?: {
14
14
  /** Which language to scaffold the config/nodes in. Defaults to javascript
15
15
  * (matches the non-TTY prompt default in bin/commands.ts). */
16
16
  language?: 'javascript' | 'typescript';
17
+ /** Create a git repo + initial commit when the target isn't already inside
18
+ * one (agent features need it — see initGitRepo). Defaults to true; the
19
+ * `--no-git` flag threads through as false. */
20
+ git?: boolean;
17
21
  }): Promise<number>;
package/dist/bin/init.js CHANGED
@@ -1,7 +1,8 @@
1
+ import { execFileSync } from 'node:child_process';
1
2
  import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync, copyFileSync } from 'node:fs';
2
3
  import { createRequire } from 'node:module';
3
4
  import { homedir } from 'node:os';
4
- import { dirname, join } from 'node:path';
5
+ import { dirname, join, relative } from 'node:path';
5
6
  import { fileURLToPath } from 'node:url';
6
7
  import { detectHarnesses, resolveSkillDirs } from './skillTargets.js';
7
8
  const MCP_SNIPPET = {
@@ -133,6 +134,80 @@ function writeFileIfAbsent(path, content, log) {
133
134
  writeFileSync(path, content);
134
135
  log.push(path);
135
136
  }
137
+ /** Same notion as server/agents/gitScope.ts's isGitRepo: is `cwd` inside a git
138
+ * work tree? A plain rev-parse probe — we only need the yes/no. */
139
+ function isInsideGitRepo(cwd) {
140
+ try {
141
+ return (execFileSync('git', ['rev-parse', '--is-inside-work-tree'], {
142
+ cwd,
143
+ encoding: 'utf8',
144
+ stdio: ['ignore', 'pipe', 'ignore'],
145
+ }).trim() === 'true');
146
+ }
147
+ catch {
148
+ return false;
149
+ }
150
+ }
151
+ /**
152
+ * Every agent feature is gated on git: AgentRunManager snapshots the tree with
153
+ * git so changes can be reviewed and reverted (server/agents/runManager.ts).
154
+ * Nothing else creates the repo, so a freshly-`init`ed folder that isn't already
155
+ * inside a repo gets one here, with an initial commit of ONLY the files init
156
+ * scaffolded — never `git add -A`, since a fresh dir may already hold the user's
157
+ * own uncommitted app code.
158
+ *
159
+ * The initial commit is load-bearing, not cosmetic: gitScope restores tracked
160
+ * files by checking them out of the snapshot commit, and an unborn HEAD (a repo
161
+ * with zero commits) leaves `snapshot.head` null — so agent edits to any
162
+ * pre-existing tracked file could not be reverted. The commit gives the safety
163
+ * net something to diff/revert against.
164
+ *
165
+ * Never nests a repo or commits when already inside one. Returns a one-line
166
+ * human summary of what happened, or null when nothing was done.
167
+ */
168
+ function initGitRepo(cwd, scaffolded) {
169
+ if (isInsideGitRepo(cwd))
170
+ return null; // pre-existing repo — never nest, never commit
171
+ const git = (args) => {
172
+ execFileSync('git', args, { cwd, stdio: ['ignore', 'ignore', 'pipe'] });
173
+ };
174
+ git(['init']);
175
+ // Commit ONLY init's own scaffold, by explicit relative path — not `-A`.
176
+ const paths = [
177
+ ...new Set(scaffolded
178
+ .map((p) => relative(cwd, p.replace(/\/+$/, '')))
179
+ .filter((p) => p.length > 0 && existsSync(join(cwd, p)))),
180
+ ];
181
+ if (paths.length === 0) {
182
+ // No scaffolded files to commit — most likely a re-run of init on a
183
+ // directory it already scaffolded (e.g. the checklist's copyable skills
184
+ // command, run non-interactively, against an existing project). `git init`
185
+ // alone leaves HEAD unborn, which breaks the agent snapshot safety net
186
+ // (gitScope reads snapshot.head as null and treats all later edits as
187
+ // unrevertable) while the git checklist row reports green. An empty anchor
188
+ // commit gives HEAD a real target with zero files tracked.
189
+ const commitEmpty = (extra) => git([...extra, 'commit', '--allow-empty', '-m', 'chore: emberflow init (anchor)']);
190
+ try {
191
+ commitEmpty([]);
192
+ }
193
+ catch {
194
+ commitEmpty(['-c', 'user.name=Emberflow', '-c', 'user.email=emberflow@localhost']);
195
+ }
196
+ return 'initialized a git repository with an empty anchor commit (no scaffolded files to commit)';
197
+ }
198
+ git(['add', '--', ...paths]);
199
+ const commit = (extra) => git([...extra, 'commit', '-m', 'chore: emberflow init']);
200
+ try {
201
+ commit([]);
202
+ }
203
+ catch {
204
+ // No user.name/user.email configured (fresh machine, CI) — the commit is
205
+ // load-bearing for the agent snapshot safety net, so fall back to a one-off
206
+ // identity for THIS commit only rather than fail init.
207
+ commit(['-c', 'user.name=Emberflow', '-c', 'user.email=emberflow@localhost']);
208
+ }
209
+ return 'initialized a git repository and committed the scaffold (chore: emberflow init)';
210
+ }
136
211
  export async function runInit(cwd, opts) {
137
212
  const written = [];
138
213
  const language = opts?.language ?? 'javascript';
@@ -216,6 +291,10 @@ export async function runInit(cwd, opts) {
216
291
  for (const path of written) {
217
292
  console.log(`[emberflow] created ${path}`);
218
293
  }
294
+ // Skills are copied BEFORE the git init/commit below so repo-scope skill
295
+ // files (e.g. .claude/skills/emberflow-basics/SKILL.md) land in the same
296
+ // scaffold commit instead of being left untracked.
297
+ let skillsWritten = [];
219
298
  const skillsOpt = opts?.skills === undefined ? { scope: 'repo', home: homedir() } : opts.skills;
220
299
  if (skillsOpt !== false) {
221
300
  const pkgRoot = opts?.packageRoot ?? join(dirname(fileURLToPath(import.meta.url)), '..');
@@ -223,7 +302,6 @@ export async function runInit(cwd, opts) {
223
302
  if (existsSync(skillsSrc)) {
224
303
  const presence = detectHarnesses(cwd, skillsOpt.home);
225
304
  const skillDirs = resolveSkillDirs(presence, skillsOpt.scope, cwd, skillsOpt.home);
226
- const skillsWritten = [];
227
305
  for (const dir of skillDirs) {
228
306
  copySkillsRecursive(skillsSrc, dir, skillsWritten);
229
307
  }
@@ -237,5 +315,21 @@ export async function runInit(cwd, opts) {
237
315
  console.log(" - Other agents: add it to your agent's MCP server config (name/location varies by tool).");
238
316
  console.log(JSON.stringify(MCP_SNIPPET, null, 2));
239
317
  }
318
+ // Agent features (snapshot/diff/revert) require git — create the repo + an
319
+ // initial commit of just the scaffold unless the user opted out or is already
320
+ // inside a repo. Best-effort: a git failure must never fail `init` itself.
321
+ if (opts?.git !== false) {
322
+ try {
323
+ // A `--global` skills install writes into the user's homedir, not the
324
+ // project — only fold in skill paths that actually live under cwd.
325
+ const repoScopeSkills = skillsWritten.filter((p) => !relative(cwd, p).startsWith('..'));
326
+ const summary = initGitRepo(cwd, [...written, ...repoScopeSkills]);
327
+ if (summary)
328
+ console.log(`[emberflow] ${summary}`);
329
+ }
330
+ catch (err) {
331
+ console.warn(`[emberflow] could not initialize git (agent features need a repo — run \`git init && git add -A && git commit -m "initial"\`): ${err instanceof Error ? err.message : String(err)}`);
332
+ }
333
+ }
240
334
  return 0;
241
335
  }
@@ -1,3 +1,4 @@
1
+ import { modelRejectionHint } from './modelRejectionHint.js';
1
2
  /** Map a codex mcp_tool_call item → an 'mcp' AgentEvent, tolerating shape drift. */
2
3
  function mcpEventFrom(item) {
3
4
  const server = item.server ?? item.invocation?.server ?? 'mcp';
@@ -6,6 +7,23 @@ function mcpEventFrom(item) {
6
7
  const output = raw === undefined ? undefined : typeof raw === 'string' ? raw : JSON.stringify(raw, null, 2);
7
8
  return { type: 'mcp', mcpServer: server, mcpTool: tool, mcpStatus: item.status, ...(output !== undefined ? { output } : {}) };
8
9
  }
10
+ /**
11
+ * Codex wraps API failures as JSON-in-a-string: `turn.failed`'s error.message
12
+ * (and top-level `error`'s message) is often itself a serialized
13
+ * `{"type":"error","status":400,"error":{"message":"…"}}` payload. Dig out the
14
+ * innermost human-readable message, falling back to the raw string.
15
+ */
16
+ function extractCodexErrorMessage(raw) {
17
+ if (!raw)
18
+ return 'codex turn failed';
19
+ try {
20
+ const inner = JSON.parse(raw);
21
+ return inner.error?.message ?? inner.message ?? raw;
22
+ }
23
+ catch {
24
+ return raw;
25
+ }
26
+ }
9
27
  export function parseCodexLine(line) {
10
28
  const trimmed = line.trim();
11
29
  if (!trimmed)
@@ -68,6 +86,19 @@ export function parseCodexLine(line) {
68
86
  }
69
87
  case 'turn.completed':
70
88
  return { type: 'done', usage: parsed.usage };
89
+ // The turn itself failed (e.g. the API rejected the request) — this IS the
90
+ // run failing, unlike an `error` ITEM above. Codex exits 1 after emitting
91
+ // it; without handling it here the adapter can only synthesize a generic
92
+ // "codex exited with code 1" and the real cause stays buried.
93
+ case 'turn.failed': {
94
+ const text = extractCodexErrorMessage(parsed.error?.message);
95
+ const hint = modelRejectionHint('codex', text);
96
+ return { type: 'error', text: hint ? `${text} (${hint})` : text };
97
+ }
98
+ // Top-level `error` lines precede turn.failed with the same payload —
99
+ // surface as a visible diagnostic, let turn.failed be the terminal event.
100
+ case 'error':
101
+ return { type: 'message', text: `⚠ ${extractCodexErrorMessage(parsed.message)}` };
71
102
  default:
72
103
  return null;
73
104
  }
@@ -2,22 +2,32 @@ import type { AgentKind } from './types.js';
2
2
  export interface DetectedAgent {
3
3
  kind: AgentKind;
4
4
  version: string | null;
5
+ /** The concrete binary the newest version was found at — what spawns should use. */
6
+ bin: string;
5
7
  }
6
8
  /**
7
- * Real PATH probe: runs `<bin> --version` and parses the first semver-ish
8
- * token out of stdout. Returns `undefined` when the binary isn't present/
9
- * runnable (spawn error, timeout, or nonzero exit); `null` version when it
10
- * ran but produced no parseable version string.
9
+ * Real probe: runs `<bin> --version` and parses the first semver-ish token out
10
+ * of stdout. Returns `undefined` when the binary isn't present/runnable (spawn
11
+ * error, timeout, or nonzero exit); `null` version when it ran but produced no
12
+ * parseable version string.
11
13
  */
12
14
  export declare function probe(bin: string): {
13
15
  version: string | null;
14
16
  } | undefined;
17
+ /** Numeric segment compare; a parseable version always beats null.
18
+ * Shared: agent-CLI detection here, and the package update check
19
+ * (server/updateCheck.ts) both rank semver-ish strings with it. */
20
+ export declare function newer(a: string | null, b: string | null): boolean;
15
21
  /**
16
- * Detects which coding-agent CLIs are available on PATH, and the version
17
- * each reports (null when present but unparseable). `probeBin` is
18
- * injectable so tests can stub PATH presence/version without shelling out
19
- * for real binaries.
22
+ * Detects which coding-agent CLIs are available probing PATH plus the known
23
+ * install locations for each kind and keeps the NEWEST version found per
24
+ * kind, with the binary path it came from. `probeBin` is injectable so tests
25
+ * can stub presence/version per location without shelling out.
20
26
  */
21
27
  export declare function detectAgents(probeBin?: (bin: string) => {
22
28
  version: string | null;
23
29
  } | undefined): DetectedAgent[];
30
+ /** Cached detection — the newest binary per kind, refreshed every 30s. */
31
+ export declare function detectAgentsCached(): DetectedAgent[];
32
+ /** The concrete binary path spawns should use for `kind` — the newest found. */
33
+ export declare function resolveAgentBin(kind: AgentKind): string | undefined;