@algolia/wizard 0.21.0-rc.111.195 → 0.21.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 (3) hide show
  1. package/README.md +14 -0
  2. package/dist/main.js +454 -330
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -12,8 +12,22 @@ npx @algolia/wizard
12
12
 
13
13
  # run a specific workflow by id
14
14
  npx @algolia/wizard <workflow-id>
15
+
16
+ # see all options
17
+ npx @algolia/wizard --help
15
18
  ```
16
19
 
20
+ ### Options
21
+
22
+ | Flag | Effect |
23
+ | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
24
+ | `--seed <step-id>` | Start the workflow at the step with this id (e.g. `--seed ingestion`), with the earlier steps pre-filled with test data. Pass with no value to print the step ids. |
25
+ | `--no-telemetry` | Send no telemetry or analytics for this run. |
26
+ | `--reset-on-run` | Wipe this project's wizard state (run state, AI-changes consent, worktrees) before starting, so the run behaves like a first-ever run. Credentials are untouched. |
27
+ | `-h`, `--help` | Print usage. |
28
+
29
+ `--seed` pre-fills the earlier steps with fabricated data so a single step can be exercised without running the whole workflow — useful for testing a step, not for a real implementation. It replaces any in-progress run for that workflow and pre-grants the AI-changes consent. See [Starting mid-workflow](CONTRIBUTING.md#starting-mid-workflow---seed).
30
+
17
31
  On first run, Wizard checks whether you're signed in to the Algolia CLI. If you aren't, it runs `algolia auth login --non-interactive` — the browser opens for sign-in, and no prompts land in the wizard's terminal. It then asks which Algolia application to work in (skipping the question when the account has only one) and makes it current with `algolia application select`.
18
32
 
19
33
  You'll also be asked once to consent to AI-authored changes to the repository.
package/dist/main.js CHANGED
@@ -251,7 +251,6 @@ var useWizard = create((set, get) => ({
251
251
  _noticeTimer: null,
252
252
  cliOutput: [],
253
253
  targetIndex: null,
254
- writtenFiles: [],
255
254
  logs: [],
256
255
  error: null,
257
256
  inputReq: null,
@@ -345,8 +344,6 @@ var useWizard = create((set, get) => ({
345
344
  })),
346
345
  clearCliOutput: () => set({ cliOutput: [] }),
347
346
  setTargetIndex: (index) => set({ targetIndex: index }),
348
- recordWrittenFile: (path) => set((s) => ({ writtenFiles: [...s.writtenFiles, path] })),
349
- clearWrittenFiles: () => set({ writtenFiles: [] }),
350
347
  logStart: (kind, name, input) => {
351
348
  const id = nanoid();
352
349
  set((s) => ({
@@ -394,7 +391,6 @@ var useWizard = create((set, get) => ({
394
391
  notices: [],
395
392
  cliOutput: [],
396
393
  targetIndex: null,
397
- writtenFiles: [],
398
394
  logs: [],
399
395
  error: null,
400
396
  inputReq: null,
@@ -1204,7 +1200,7 @@ var accessItems = [
1204
1200
  {
1205
1201
  tag: "WRITE",
1206
1202
  title: "Code changes",
1207
- description: "creates & edits files (search UI, config) directly in your branch."
1203
+ description: "creates & edits files (search UI, config) in a throwaway git worktree \u2014 your checkout is never touched."
1208
1204
  },
1209
1205
  {
1210
1206
  tag: "EXEC",
@@ -1219,7 +1215,7 @@ var accessItems = [
1219
1215
  {
1220
1216
  tag: "KEY",
1221
1217
  title: "Credentials",
1222
- description: "writes your Algolia app id and a search-only key (safe to expose) to .env in your project."
1218
+ description: "writes your Algolia app id and a search-only key (safe to expose) to .env in the worktree."
1223
1219
  }
1224
1220
  ];
1225
1221
  var neverItems = [
@@ -2500,7 +2496,6 @@ function writeFileTool(ctx) {
2500
2496
  }
2501
2497
  await mkdir3(dirname4(resolved2.target), { recursive: true });
2502
2498
  await writeFile3(resolved2.target, content, "utf8");
2503
- useWizard.getState().recordWrittenFile(resolved2.target);
2504
2499
  return `Wrote to ${filePath}`;
2505
2500
  } catch (err) {
2506
2501
  return `Error writing ${filePath}: ${err.message}`;
@@ -3268,12 +3263,20 @@ var RECORD_MODEL = "claude-haiku-4-5";
3268
3263
  var MAX_RECORDS = 100;
3269
3264
  var BATCH_SIZE = 10;
3270
3265
  var MAX_BATCH_ATTEMPTS = 3;
3271
- var anthropic = createAnthropic({
3272
- apiKey: process.env.PROVIDER_API_KEY ?? ""
3273
- });
3274
- function generateRecordTool(ctx) {
3266
+ function defaultCreateModel() {
3267
+ const token = getAuthToken();
3268
+ if (!token) {
3269
+ throw new Error("Not authenticated: no user token available");
3270
+ }
3271
+ return createAnthropic({
3272
+ apiKey: token,
3273
+ baseURL: PROXY_BASE_URL,
3274
+ fetch: proxyFetch
3275
+ });
3276
+ }
3277
+ function generateRecordTool(ctx, createModel = defaultCreateModel) {
3275
3278
  return tool10({
3276
- description: "Generate realistic sample records for an entity and write them to a JSON file. Provide the entity name and its attributes; this tool asks a model to invent varied, realistic values, each with a unique objectID, and returns the file path to read them from at runtime. Do not invent the record values or objectIDs yourself, and do not inline the returned records into the script \u2014 call this tool and read the file it writes.",
3279
+ description: "Generate realistic sample records for an entity and write them to a JSON file in the worktree. Provide the entity name and its attributes; this tool asks a model to invent varied, realistic values, each with a unique objectID, and returns the file path to read them from at runtime. Do not invent the record values or objectIDs yourself, and do not inline the returned records into the script \u2014 call this tool and read the file it writes.",
3277
3280
  inputSchema: z17.object({
3278
3281
  entityName: z17.string().describe("Name of the entity to generate records for."),
3279
3282
  attributes: z17.array(z17.string()).describe("Attribute names each record must contain."),
@@ -3283,6 +3286,7 @@ function generateRecordTool(ctx) {
3283
3286
  execute: async ({ entityName, attributes, count, hint }) => {
3284
3287
  logger.info({ entityName, count }, "called generateRecord tool");
3285
3288
  try {
3289
+ const anthropic = createModel();
3286
3290
  const value = z17.union([z17.string(), z17.number(), z17.boolean(), z17.null()]);
3287
3291
  const recordSchema = z17.object(
3288
3292
  Object.fromEntries(attributes.map((attr) => [attr, value]))
@@ -3301,10 +3305,6 @@ function generateRecordTool(ctx) {
3301
3305
  prompt: [
3302
3306
  `Generate ${batchCount} realistic, varied sample records for the "${entityName}" entity.`,
3303
3307
  hint ? `Context: ${hint}` : "",
3304
- // Batches run concurrently with identical prompts, so each call
3305
- // would otherwise drift to the same high-probability values and
3306
- // collide across batches. This per-batch seed pushes each call
3307
- // into a different region of the output space.
3308
3308
  `Variety seed: ${nanoid2()}. Use it to diversify values.`
3309
3309
  ].filter(Boolean).join("\n")
3310
3310
  });
@@ -3337,8 +3337,15 @@ function generateRecordTool(ctx) {
3337
3337
  return `Refused: ${resolved2.target} is outside the repo root (${ctx.root}).`;
3338
3338
  }
3339
3339
  await mkdir5(dirname6(resolved2.target), { recursive: true });
3340
- await writeFile6(resolved2.target, JSON.stringify(records, null, 2), "utf8");
3341
- logger.info({ entityName, count: records.length, relPath }, "generateRecord wrote records to disk");
3340
+ await writeFile6(
3341
+ resolved2.target,
3342
+ JSON.stringify(records, null, 2),
3343
+ "utf8"
3344
+ );
3345
+ logger.info(
3346
+ { entityName, count: records.length, relPath },
3347
+ "generateRecord wrote records to disk"
3348
+ );
3342
3349
  return {
3343
3350
  filePath: relPath,
3344
3351
  count: records.length,
@@ -3432,7 +3439,7 @@ async function runAgent(req) {
3432
3439
  if (!token) {
3433
3440
  throw new Error("Not authenticated: no user token available");
3434
3441
  }
3435
- const anthropic2 = createAnthropic2({
3442
+ const anthropic = createAnthropic2({
3436
3443
  apiKey: token,
3437
3444
  baseURL: PROXY_BASE_URL,
3438
3445
  fetch: proxyFetch
@@ -3448,7 +3455,7 @@ async function runAgent(req) {
3448
3455
  "Call notifyUser whenever you start a new phase of work or your focus shifts (e.g. moving from reading code to writing files) \u2014 a short, plain-language, high-level update. Do not call it for every tool use."
3449
3456
  ];
3450
3457
  const agent = new ToolLoopAgent({
3451
- model: anthropic2(MODEL_BY_SIZE[req.modelSize ?? "medium"]),
3458
+ model: anthropic(MODEL_BY_SIZE[req.modelSize ?? "medium"]),
3452
3459
  // Cache tools + system on the last system block. Tools render before
3453
3460
  // system, so one breakpoint here caches both, reused on every loop turn
3454
3461
  // after the first.
@@ -3635,7 +3642,7 @@ async function runAnalysis(mode, extraInstructions = []) {
3635
3642
  // package.json
3636
3643
  var package_default = {
3637
3644
  name: "@algolia/wizard",
3638
- version: "0.21.0-rc.111.195",
3645
+ version: "0.21.0",
3639
3646
  description: "Magically implement Algolia functionality in your codebase",
3640
3647
  type: "module",
3641
3648
  engines: {
@@ -4002,10 +4009,11 @@ ${JSON.stringify(s.output, null, 2)}`
4002
4009
  function formatReviewSummary(result) {
4003
4010
  const nextStepLines = result.nextSteps.map((step) => {
4004
4011
  const isIngestCommand = step.includes(".algolia-wizard/ingest.sh");
4012
+ const isWorktreeCommand = step.includes("/worktrees/");
4005
4013
  return {
4006
4014
  text: `\u2192 ${step}`,
4007
- color: isIngestCommand ? COLORS.brand : void 0,
4008
- bold: isIngestCommand
4015
+ color: isIngestCommand ? COLORS.brand : isWorktreeCommand ? COLORS.secondary : void 0,
4016
+ bold: isIngestCommand || isWorktreeCommand
4009
4017
  };
4010
4018
  });
4011
4019
  return [
@@ -4040,13 +4048,15 @@ ${formatCompletedSteps(ctx.completedSteps)}`,
4040
4048
 
4041
4049
  // src/actions/implement.ts
4042
4050
  import z29 from "zod";
4043
- import { join as join12, relative as relative6 } from "node:path";
4051
+ import { join as join12 } from "node:path";
4044
4052
 
4045
- // src/lib/git.ts
4053
+ // src/lib/worktree.ts
4046
4054
  import { execFile as execFile2 } from "node:child_process";
4047
- import { copyFile, mkdir as mkdir6, readFile as readFile8, stat as stat3, writeFile as writeFile7 } from "node:fs/promises";
4055
+ import { copyFile, mkdir as mkdir6, readdir as readdir3, readFile as readFile8, stat as stat3, writeFile as writeFile7 } from "node:fs/promises";
4048
4056
  import { basename as basename2, dirname as dirname7, isAbsolute as isAbsolute2, join as join10, resolve as resolve3 } from "node:path";
4049
4057
  var MAX_BUFFER = 32 * 1024 * 1024;
4058
+ var MAX_WIZARD_WORKTREES = 3;
4059
+ var WIZARD_BRANCH_PREFIX = "wizard/implement-";
4050
4060
  function git(args) {
4051
4061
  return new Promise((resolve4, reject) => {
4052
4062
  execFile2("git", args, { maxBuffer: MAX_BUFFER }, (err, stdout, stderr) => {
@@ -4069,7 +4079,44 @@ async function assertGitRepoWithHead(repoRoot) {
4069
4079
  );
4070
4080
  }
4071
4081
  }
4072
- async function copyUploadIntoProject(repoRoot, ingestDir, sourcePath) {
4082
+ async function isWorkingTreeDirty(repoRoot) {
4083
+ const out = await git(["-C", repoRoot, "status", "--porcelain"]);
4084
+ return out.trim().length > 0;
4085
+ }
4086
+ async function pruneOldWorktrees(repoRoot) {
4087
+ const dir = join10(stateDir(repoRoot), "worktrees");
4088
+ const stale = (await readdir3(dir).catch(() => [])).filter((name) => /^wizard-implement-\d+$/.test(name)).sort().reverse().slice(MAX_WIZARD_WORKTREES - 1);
4089
+ for (const slug of stale) {
4090
+ const branch = slug.replace("wizard-implement-", WIZARD_BRANCH_PREFIX);
4091
+ try {
4092
+ await git([
4093
+ "-C",
4094
+ repoRoot,
4095
+ "worktree",
4096
+ "remove",
4097
+ "--force",
4098
+ join10(dir, slug)
4099
+ ]);
4100
+ await git(["-C", repoRoot, "branch", "-D", branch]);
4101
+ } catch (err) {
4102
+ logger.warn(
4103
+ { branch, err: err.message },
4104
+ "createWorktree: failed to prune a stale wizard worktree; continuing"
4105
+ );
4106
+ }
4107
+ }
4108
+ }
4109
+ async function createWorktree(repoRoot) {
4110
+ const branch = `${WIZARD_BRANCH_PREFIX}${Date.now()}`;
4111
+ const dirSlug = branch.replace(/\//g, "-");
4112
+ const path = join10(stateDir(repoRoot), "worktrees", dirSlug);
4113
+ await git(["-C", repoRoot, "worktree", "prune"]);
4114
+ await pruneOldWorktrees(repoRoot);
4115
+ await mkdir6(dirname7(path), { recursive: true });
4116
+ await git(["-C", repoRoot, "worktree", "add", "-b", branch, path, "HEAD"]);
4117
+ return { path, branch };
4118
+ }
4119
+ async function copyUploadIntoWorktree(repoRoot, worktreePath, ingestDir, sourcePath) {
4073
4120
  const trimmed = sourcePath.trim();
4074
4121
  if (!trimmed) {
4075
4122
  return { ok: false, reason: "no file path was provided" };
@@ -4083,10 +4130,7 @@ async function copyUploadIntoProject(repoRoot, ingestDir, sourcePath) {
4083
4130
  return { ok: false, reason: `"${sourcePath}" does not exist` };
4084
4131
  }
4085
4132
  const relPath = join10(ingestDir, basename2(source));
4086
- const dest = join10(repoRoot, relPath);
4087
- if (resolve3(source) === resolve3(dest)) {
4088
- return { ok: true, relPath };
4089
- }
4133
+ const dest = join10(worktreePath, relPath);
4090
4134
  try {
4091
4135
  await mkdir6(dirname7(dest), { recursive: true });
4092
4136
  await copyFile(source, dest);
@@ -4101,10 +4145,10 @@ async function copyUploadIntoProject(repoRoot, ingestDir, sourcePath) {
4101
4145
  function hasEnvVar(content, name) {
4102
4146
  return new RegExp(`^(\\s*(?:export\\s+)?${name})\\s*=`, "m").test(content);
4103
4147
  }
4104
- async function readEnvVar(repoRoot, name) {
4148
+ async function readEnvVar(worktreePath, name) {
4105
4149
  let content;
4106
4150
  try {
4107
- content = await readFile8(join10(repoRoot, ".env"), "utf8");
4151
+ content = await readFile8(join10(worktreePath, ".env"), "utf8");
4108
4152
  } catch (err) {
4109
4153
  if (err.code !== "ENOENT") throw err;
4110
4154
  return void 0;
@@ -4118,8 +4162,8 @@ async function readEnvVar(repoRoot, name) {
4118
4162
  if (!value || value.startsWith("<")) return void 0;
4119
4163
  return value;
4120
4164
  }
4121
- async function writeSearchEnvValues(repoRoot, vars) {
4122
- const target = join10(repoRoot, ".env");
4165
+ async function writeSearchEnvValues(worktreePath, vars) {
4166
+ const target = join10(worktreePath, ".env");
4123
4167
  let existing = "";
4124
4168
  try {
4125
4169
  existing = await readFile8(target, "utf8");
@@ -4134,27 +4178,61 @@ async function writeSearchEnvValues(repoRoot, vars) {
4134
4178
  await writeFile7(target, existing + prefix + lines, "utf8");
4135
4179
  return missing.map((v) => v.name);
4136
4180
  }
4181
+ async function listChangedFiles(worktreePath) {
4182
+ const raw = await git(["-C", worktreePath, "status", "--porcelain", "-z"]);
4183
+ const entries = raw.split("\0");
4184
+ const files = [];
4185
+ for (let i = 0; i < entries.length; i += 1) {
4186
+ const entry = entries[i];
4187
+ if (!entry) continue;
4188
+ files.push(entry.slice(3));
4189
+ if (["R", "C"].includes(entry[0]) || ["R", "C"].includes(entry[1])) i += 1;
4190
+ }
4191
+ return files;
4192
+ }
4137
4193
  function normalizeFindingPaths(findings) {
4138
4194
  return {
4139
4195
  ...findings,
4140
4196
  ingestionAnalysis: findings.ingestionAnalysis?.map((e) => ({
4141
4197
  ...e,
4142
- paths: e.paths.map(toRootRelative)
4198
+ paths: e.paths.map(toWorktreeRelative)
4143
4199
  })),
4144
4200
  searchImplementationAnalysis: findings.searchImplementationAnalysis ? normalizeSearchLocation(findings.searchImplementationAnalysis) : void 0,
4145
4201
  confirmedEntities: findings.confirmedEntities?.map((e) => ({
4146
4202
  ...e,
4147
- paths: e.paths.map(toRootRelative)
4203
+ paths: e.paths.map(toWorktreeRelative)
4148
4204
  }))
4149
4205
  };
4150
4206
  }
4151
4207
  function normalizeSearchLocation(path) {
4152
- const normalized = path ? toRootRelative(path).trim() : "";
4208
+ const normalized = path ? toWorktreeRelative(path).trim() : "";
4153
4209
  return normalized && normalized.toLowerCase() !== "unknown" ? normalized : void 0;
4154
4210
  }
4155
- function toRootRelative(p) {
4211
+ function toWorktreeRelative(p) {
4156
4212
  return p.replace(/^\/+/, "");
4157
4213
  }
4214
+ async function confirmDirtyWorkingTree(ctx, repoRoot) {
4215
+ const MAX_LISTED_DIRTY_FILES = 10;
4216
+ const dirty = await listChangedFiles(repoRoot);
4217
+ const shown = dirty.slice(0, MAX_LISTED_DIRTY_FILES);
4218
+ const overflow = dirty.length - shown.length;
4219
+ const answer = await ctx.requestUserInput({
4220
+ prompt: "Proceed using HEAD only? Uncommitted changes will NOT be included in the generated implementation.",
4221
+ promptType: "acceptReject",
4222
+ options: [],
4223
+ messages: [
4224
+ `${dirty.length} uncommitted change(s) detected. The wizard builds an isolated worktree from HEAD, so these are ignored:`,
4225
+ ...shown.map((file) => ` \u2022 ${file}`),
4226
+ ...overflow > 0 ? [` \u2022 \u2026and ${overflow} more`] : [],
4227
+ "Commit or stash them first to include them in the implementation."
4228
+ ]
4229
+ });
4230
+ if (answer !== true) {
4231
+ throw new Error(
4232
+ "implement aborted: commit or stash your changes so they are built into the worktree, then re-run the wizard."
4233
+ );
4234
+ }
4235
+ }
4158
4236
 
4159
4237
  // src/lib/algoliaDocs.ts
4160
4238
  import { readFileSync, readdirSync, existsSync } from "node:fs";
@@ -4214,6 +4292,11 @@ function getFrameworkSpecificDoc(frameworks) {
4214
4292
  return loadAlgoliaDoc("js");
4215
4293
  }
4216
4294
 
4295
+ // src/lib/shell.ts
4296
+ function shellQuote(value) {
4297
+ return "'" + value.replace(/'/g, "'\\''") + "'";
4298
+ }
4299
+
4217
4300
  // src/actions/resolveEnvVarPrefix.ts
4218
4301
  import z28 from "zod";
4219
4302
  var resolveEnvVarPrefixSchema = z28.object({
@@ -4233,7 +4316,9 @@ var resolveEnvVarPrefix = (frameworkName) => runAgent({
4233
4316
 
4234
4317
  // src/actions/implement.ts
4235
4318
  var implementSchema = z29.object({
4319
+ filesChanged: z29.array(z29.string()),
4236
4320
  summary: z29.string(),
4321
+ worktreePath: z29.string().optional(),
4237
4322
  ingestCommand: z29.string().optional(),
4238
4323
  ingestScriptRan: z29.boolean().optional(),
4239
4324
  ingestRecordCount: z29.number().optional(),
@@ -4286,6 +4371,8 @@ function frameworksForDoc(language) {
4286
4371
  }
4287
4372
  function baseInstructions(input) {
4288
4373
  return [
4374
+ // Agents have renamed this (e.g. appending the project name), which the
4375
+ // index-scoped keys then reject with a 403.
4289
4376
  `Target Algolia index, to be used exactly as written \u2014 never renamed, re-cased, prefixed, or suffixed: "${input.targetIndex}"`,
4290
4377
  `Project languages and frameworks: ${JSON.stringify(input.language)}`,
4291
4378
  "Make minimal, idiomatic changes; do not touch unrelated code.",
@@ -4303,14 +4390,14 @@ function sourceSpecificInstructions(input) {
4303
4390
  "Add env vars for any local source access (DB URL, API base, file paths) that is not a fixed repo path."
4304
4391
  ],
4305
4392
  fileUpload: [
4306
- `Records come from the developer's file, already copied into the project at "${input.uploadFilePath}". Read and parse that exact file.`,
4393
+ `Records come from the developer's file, already copied into the worktree at "${input.uploadFilePath}". Read and parse that exact file.`,
4307
4394
  "Parse by extension: JSON = array of objects; CSV/TSV = header row maps to keys.",
4308
4395
  "Map parsed columns/fields to the confirmed entity attributes.",
4309
4396
  "Never fabricate, hardcode, or substitute a different file."
4310
4397
  ],
4311
4398
  generated: [
4312
4399
  "No real data source exists; use sample records for each confirmed entity.",
4313
- "Call the generateRecord tool once per entity (entityName, attributes, count 20-50); it invents the values and unique objectIDs and writes them to a JSON file, returning the file path. Do not write records or objectIDs yourself.",
4400
+ "Call the generateRecord tool once per entity (entityName, attributes, count 20-50); it invents the values and unique objectIDs and writes them to a JSON file in the worktree, returning the file path. Do not write records or objectIDs yourself.",
4314
4401
  "In the script, read and parse each returned file path at runtime using your language's standard JSON support, instead of inlining the records as literals.",
4315
4402
  "Add a prominent TODO where the developer swaps the generated records (and the JSON file under `.algolia-wizard/data/`) for their real record source."
4316
4403
  ]
@@ -4359,11 +4446,14 @@ function searchInstructions(input) {
4359
4446
  "If a search box already exists, replace it with yours.",
4360
4447
  `Read the index name from the ${publicIndexNameVar(input.publicEnvVarPrefix)} env var, which the wizard sets to "${input.targetIndex}". Never hardcode an index name or derive one from the project, file, or component name.`,
4361
4448
  "Read the App ID, the search-only API key, and the index name from env vars; never hardcode them. A search-only key is safe to expose client-side.",
4362
- // The key is provisioned only after verification passes, and the wizard
4363
- // reads .env to decide whether a key already exists an agent-invented
4364
- // value there would be reused as if it were real.
4449
+ // The key is provisioned only after verification passes, so the agent never
4450
+ // sees one. It must also leave .env alone: the wizard reads that file to
4451
+ // decide whether a key already exists, and an agent-invented value there
4452
+ // would be reused as if it were real.
4365
4453
  `Add Algolia App ID "${input.appId}"; leave the search-only key as a placeholder. Do not create or edit .env \u2014 the wizard writes the resolved key there itself.`,
4366
- // The wizard writes these exact names into .env right after this step.
4454
+ // Not the agent's to rename: the wizard writes these exact names into
4455
+ // ".env" right after this step, so a renamed prefix would leave the code
4456
+ // reading a var the wizard never wrote.
4367
4457
  `Use exactly these env var names in the code: ${input.searchEnvVars.map(({ name }) => name).join(", ")}.`,
4368
4458
  "Install any Algolia packages you import with the project's own package manager via runShell, and declare them in the project's dependency manifest.",
4369
4459
  "Match the styles of the application as closely as possible.",
@@ -4372,10 +4462,10 @@ function searchInstructions(input) {
4372
4462
  }
4373
4463
  function verificationInstructions(input) {
4374
4464
  return [
4375
- "Verify the Algolia implementation changes.",
4465
+ "Verify the Algolia implementation changes in the current worktree.",
4376
4466
  `Verification tools found in the codebase: ${JSON.stringify(input.findings.verification ?? [])}.`,
4377
4467
  "Run the project's own checks (lint, type check, tests) via runShell, using the commands the project actually defines \u2014 its task runner, manifest scripts, or Makefile. Run every check that applies, not just the first.",
4378
- "If a check fails because packages or modules are missing, install the dependencies via runShell and re-run it rather than changing the code.",
4468
+ "This worktree starts with no installed dependencies. If a check fails because packages or modules are missing, install the dependencies via runShell and re-run it rather than changing the code.",
4379
4469
  "For issues caused by the implementation, make minimal fixes with writeFile and re-run the checks.",
4380
4470
  "Do not make speculative fixes when no checks exist, a check cannot run, or failures are unrelated to these changes \u2014 note the limitation in your summary.",
4381
4471
  "Do not add new Algolia functionality here; only validate and make minimal correctness fixes.",
@@ -4470,11 +4560,11 @@ function ingestFailure(attempt, executions) {
4470
4560
  }
4471
4561
  return { reason: `it failed (exit ${attempt.exitCode ?? "unknown"})`, detail };
4472
4562
  }
4473
- function makeToolContext(root, env = async () => ({})) {
4563
+ function makeToolContext(worktree, env = async () => ({})) {
4474
4564
  return createToolContext(
4475
4565
  DEFAULT_TOOL_LIMITS,
4476
- root,
4477
- createShellContext({ env, approve: storeApproval(root) })
4566
+ worktree,
4567
+ createShellContext({ env, approve: storeApproval(worktree) })
4478
4568
  );
4479
4569
  }
4480
4570
  function verificationRetryInstructions(verification) {
@@ -4482,7 +4572,7 @@ function verificationRetryInstructions(verification) {
4482
4572
  `Implementation insufficient. Address these findings before reporting completion: ${verification.additionalInstructions ?? verification.summary}`
4483
4573
  ];
4484
4574
  }
4485
- async function implement(ctx, useCases = DEFAULT_IMPLEMENT_USE_CASES) {
4575
+ async function implement(ctx, useCases = DEFAULT_IMPLEMENT_USE_CASES, existingWorktreePath) {
4486
4576
  const repoRoot = process.cwd();
4487
4577
  const scan = ctx.getStepOutput("project-scan");
4488
4578
  const entities = ctx.getStepOutput(
@@ -4563,6 +4653,9 @@ async function implement(ctx, useCases = DEFAULT_IMPLEMENT_USE_CASES) {
4563
4653
  const targetIndex = selected?.selection;
4564
4654
  useWizard.getState().setTargetIndex(targetIndex ?? null);
4565
4655
  await assertGitRepoWithHead(repoRoot);
4656
+ if (await isWorkingTreeDirty(repoRoot)) {
4657
+ await confirmDirtyWorkingTree(ctx, repoRoot);
4658
+ }
4566
4659
  const normalized = normalizeFindingPaths(findings);
4567
4660
  const confirmed2 = normalized.confirmedEntities;
4568
4661
  const searchLocation = normalized.searchImplementationAnalysis;
@@ -4574,303 +4667,328 @@ async function implement(ctx, useCases = DEFAULT_IMPLEMENT_USE_CASES) {
4574
4667
  if (useCases.includes("ingestion")) {
4575
4668
  ingestAppId = appId ?? (await requireApplication()).id;
4576
4669
  }
4577
- let uploadFilePath;
4578
- let uploadWarning;
4579
- if (ingestionSource === "fileUpload") {
4580
- const copied = await copyUploadIntoProject(
4581
- repoRoot,
4582
- INGEST_DIR,
4583
- uploadSourcePath ?? ""
4584
- );
4585
- if (copied.ok) {
4586
- uploadFilePath = copied.relPath;
4587
- } else {
4588
- ingestionSource = "generated";
4589
- uploadWarning = `\u26A0\uFE0F Could not use the uploaded file (${copied.reason}); generating sample records instead.`;
4590
- logger.warn(
4591
- { reason: copied.reason },
4592
- "implement: file upload unavailable; falling back to generated sample records"
4593
- );
4594
- }
4595
- }
4596
- const publicEnvVarPrefix = await publicEnvVarPrefixPromise;
4597
- const input = {
4598
- findings: normalized,
4599
- confirmed: confirmed2,
4600
- searchLocation,
4601
- targetIndex,
4602
- language,
4603
- publicEnvVarPrefix,
4604
- appId,
4605
- searchEnvVars: publicSearchEnvVars(publicEnvVarPrefix, targetIndex, appId),
4606
- ingestDir: INGEST_DIR,
4607
- ingestionSource,
4608
- uploadFilePath,
4609
- searchUiTarget: searchUiTarget(language)
4610
- };
4611
- const summaries = [];
4612
- if (uploadWarning) summaries.push(uploadWarning);
4613
- let envSearchKey;
4614
- let envAppIdMismatch = false;
4615
- if (useCases.includes("search") && appId) {
4616
- const envAppId = await readEnvVar(
4617
- repoRoot,
4618
- publicAppIdVar(publicEnvVarPrefix)
4619
- );
4620
- if (envAppId === appId) {
4621
- envSearchKey = await readEnvVar(
4670
+ const worktree = existingWorktreePath ?? (await createWorktree(repoRoot)).path;
4671
+ try {
4672
+ process.chdir(worktree);
4673
+ let uploadFilePath;
4674
+ let uploadWarning;
4675
+ if (ingestionSource === "fileUpload") {
4676
+ const copied = await copyUploadIntoWorktree(
4622
4677
  repoRoot,
4623
- publicSearchKeyVar(publicEnvVarPrefix)
4624
- );
4625
- } else if (envAppId) {
4626
- envAppIdMismatch = true;
4627
- const appIdVarName = publicAppIdVar(publicEnvVarPrefix);
4628
- const searchKeyVarName = publicSearchKeyVar(publicEnvVarPrefix);
4629
- summaries.push(
4630
- `\u26A0\uFE0F .env already sets ${appIdVarName}=${envAppId}, but the active Algolia application is ${appId}. The wizard left those values alone \u2014 update ${appIdVarName} and ${searchKeyVarName} by hand, or searches will fail.`
4631
- );
4632
- logger.warn(
4633
- { envAppId, appId },
4634
- "implement: .env holds credentials for a different Algolia application; not reusing its search key"
4678
+ worktree,
4679
+ INGEST_DIR,
4680
+ uploadSourcePath ?? ""
4635
4681
  );
4682
+ if (copied.ok) {
4683
+ uploadFilePath = copied.relPath;
4684
+ } else {
4685
+ ingestionSource = "generated";
4686
+ uploadWarning = `\u26A0\uFE0F Could not use the uploaded file (${copied.reason}); generating sample records instead.`;
4687
+ logger.warn(
4688
+ { reason: copied.reason },
4689
+ "implement: file upload unavailable; falling back to generated sample records"
4690
+ );
4691
+ }
4636
4692
  }
4637
- }
4638
- let finalSearchEnvVars = input.searchEnvVars;
4639
- let agentRuns = 0;
4640
- let ingestCommand;
4641
- let ingestScriptRan = false;
4642
- let ingestRecordCount;
4643
- let ingestDurationMs;
4644
- let ingestOutcomeMessage;
4645
- const ingestKeyAppId = ingestAppId;
4646
- const ingestionTools = ingestKeyAppId ? makeToolContext(repoRoot, async () => ({
4647
- [APP_ID_VAR]: ingestKeyAppId,
4648
- [API_KEY_VAR]: (await resolveWriteKey(targetIndex, ingestKeyAppId)).key,
4649
- [INDEX_NAME_VAR]: targetIndex
4650
- })) : void 0;
4651
- const searchTools = makeToolContext(repoRoot);
4652
- async function runImplementationUseCase(currentUseCase, extraInstructions = []) {
4653
- if (agentRuns > 0) ctx.recordStepExecution();
4654
- agentRuns += 1;
4655
- return runAgent({
4656
- instructions: buildAgentInstructions(
4657
- currentUseCase,
4658
- input,
4659
- extraInstructions
4693
+ const publicEnvVarPrefix = await publicEnvVarPrefixPromise;
4694
+ const input = {
4695
+ findings: normalized,
4696
+ confirmed: confirmed2,
4697
+ searchLocation,
4698
+ targetIndex,
4699
+ language,
4700
+ publicEnvVarPrefix,
4701
+ appId,
4702
+ searchEnvVars: publicSearchEnvVars(
4703
+ publicEnvVarPrefix,
4704
+ targetIndex,
4705
+ appId
4660
4706
  ),
4661
- tools: toolsForUseCase(currentUseCase, input.ingestionSource),
4662
- outputSchema: implementationOutputSchema,
4663
- toolContext: currentUseCase === "ingestion" ? ingestionTools ?? searchTools : searchTools
4664
- });
4665
- }
4666
- async function runVerificationUseCase() {
4667
- if (agentRuns > 0) ctx.recordStepExecution();
4668
- agentRuns += 1;
4669
- return runAgent({
4670
- instructions: buildAgentInstructions("verification", input),
4671
- tools: toolsForUseCase("verification"),
4672
- outputSchema: verificationOutputSchema,
4673
- toolContext: searchTools
4674
- });
4675
- }
4676
- if (useCases.includes("ingestion")) {
4677
- let ingestFailureDetail;
4678
- const result = await runImplementationUseCase("ingestion");
4679
- summaries.push(formatSummary("ingestion", result.summary));
4680
- ingestCommand = result.ingestCommand;
4681
- const ingestionContext = ingestionTools ?? searchTools;
4682
- const executions = ingestionContext.shell.executions;
4683
- const {
4684
- run: ingestRun,
4685
- attempt: ingestAttempt,
4686
- recordCount
4687
- } = ingestOutcome(executions, ingestCommand);
4688
- ingestScriptRan = ingestRun != null;
4689
- ingestRecordCount = recordCount;
4690
- ingestDurationMs = ingestRun?.durationMs;
4691
- if (ingestScriptRan && ingestionContext.reviewed.length === 0) {
4692
- summaries.push(
4693
- "\u26A0\uFE0F The ingestion script ran without being shown to you for review. Read it in your project before trusting the index contents."
4694
- );
4695
- logger.warn(
4696
- { ingestCommand },
4697
- "implement: ingestion ran without a reviewScript call"
4707
+ ingestDir: INGEST_DIR,
4708
+ ingestionSource,
4709
+ uploadFilePath,
4710
+ searchUiTarget: searchUiTarget(language)
4711
+ };
4712
+ const summaries = [];
4713
+ if (uploadWarning) summaries.push(uploadWarning);
4714
+ let envSearchKey;
4715
+ let envAppIdMismatch = false;
4716
+ if (useCases.includes("search") && appId) {
4717
+ const envAppId = await readEnvVar(
4718
+ worktree,
4719
+ publicAppIdVar(publicEnvVarPrefix)
4698
4720
  );
4699
- }
4700
- if (ingestScriptRan) {
4701
- ingestOutcomeMessage = `\u2705 Ingestion succeeded${ingestRecordCount != null ? ` \u2014 ${ingestRecordCount} record(s) indexed.` : "."}`;
4702
- if (ingestRecordCount != null) {
4703
- track("AI Wizard Ingest Successful", {
4704
- entity_name: confirmed2?.map((e) => e.name).join(", ") || "unknown",
4705
- record_count: ingestRecordCount,
4706
- duration_ms: ingestDurationMs ?? 0
4707
- });
4721
+ if (envAppId === appId) {
4722
+ envSearchKey = await readEnvVar(
4723
+ worktree,
4724
+ publicSearchKeyVar(publicEnvVarPrefix)
4725
+ );
4726
+ } else if (envAppId) {
4727
+ envAppIdMismatch = true;
4728
+ const appIdVarName = publicAppIdVar(publicEnvVarPrefix);
4729
+ const searchKeyVarName = publicSearchKeyVar(publicEnvVarPrefix);
4730
+ summaries.push(
4731
+ `\u26A0\uFE0F .env already sets ${appIdVarName}=${envAppId}, but the active Algolia application is ${appId}. The wizard left those values alone \u2014 update ${appIdVarName} and ${searchKeyVarName} by hand, or searches will fail.`
4732
+ );
4733
+ logger.warn(
4734
+ { envAppId, appId },
4735
+ "implement: .env holds credentials for a different Algolia application; not reusing its search key"
4736
+ );
4708
4737
  }
4709
- } else {
4710
- const { reason, detail } = ingestFailure(ingestAttempt, executions);
4711
- ingestOutcomeMessage = `\u26A0\uFE0F Records were not indexed \u2014 ${reason}.${ingestCommand ? " Run the command below when you are ready." : ""}`;
4712
- ingestFailureDetail = detail;
4713
- summaries.push(
4714
- `\u26A0\uFE0F Ingestion did not complete: ${reason}.${detail ? `
4715
- ${detail}` : ""}`
4716
- );
4717
- logger.warn(
4718
- {
4719
- ingestCommand,
4720
- reason,
4721
- approved: ingestAttempt?.approved,
4722
- exitCode: ingestAttempt?.exitCode,
4723
- timedOut: ingestAttempt?.timedOut,
4724
- commandsRun: executions.length
4725
- },
4726
- "implement: ingestion script did not complete successfully"
4727
- );
4728
- track("Error", {
4729
- step: "Push Data",
4730
- error: `ingestion did not complete: ${reason}`,
4731
- product_area: "AI Wizard"
4738
+ }
4739
+ let finalSearchEnvVars = input.searchEnvVars;
4740
+ let agentRuns = 0;
4741
+ let ingestCommand;
4742
+ let ingestScriptRan = false;
4743
+ let ingestRecordCount;
4744
+ let ingestDurationMs;
4745
+ let ingestOutcomeMessage;
4746
+ const ingestKeyAppId = ingestAppId;
4747
+ const ingestionTools = ingestKeyAppId ? makeToolContext(worktree, async () => ({
4748
+ [APP_ID_VAR]: ingestKeyAppId,
4749
+ [API_KEY_VAR]: (await resolveWriteKey(targetIndex, ingestKeyAppId)).key,
4750
+ [INDEX_NAME_VAR]: targetIndex
4751
+ })) : void 0;
4752
+ const searchTools = makeToolContext(worktree);
4753
+ async function runImplementationUseCase(currentUseCase, extraInstructions = []) {
4754
+ if (agentRuns > 0) ctx.recordStepExecution();
4755
+ agentRuns += 1;
4756
+ return runAgent({
4757
+ instructions: buildAgentInstructions(
4758
+ currentUseCase,
4759
+ input,
4760
+ extraInstructions
4761
+ ),
4762
+ tools: toolsForUseCase(currentUseCase, input.ingestionSource),
4763
+ outputSchema: implementationOutputSchema,
4764
+ toolContext: currentUseCase === "ingestion" ? ingestionTools ?? searchTools : searchTools
4732
4765
  });
4733
4766
  }
4734
- const commandMessages = ingestCommand ? [`Ingestion command: ${ingestCommand}`] : [];
4735
- await ctx.requestUserInput({
4736
- prompt: "",
4737
- promptType: "enterToContinue",
4738
- options: [],
4739
- // One message per line: Notices.tsx counts a message as one wrapped
4740
- // line, so an embedded newline overflows the panel's height accounting.
4741
- messages: [
4742
- ingestOutcomeMessage,
4743
- ...ingestFailureDetail?.split("\n").filter((l) => l.trim()) ?? [],
4744
- ...commandMessages
4745
- ]
4746
- });
4747
- }
4748
- if (useCases.includes("search")) {
4749
- let extraInstructions = [];
4750
- useWizard.getState().clearWrittenFiles();
4751
- for (let attempt = 1; attempt <= MAX_IMPLEMENT_VERIFICATION_ATTEMPTS; attempt++) {
4752
- if (attempt > 1) {
4753
- logger.info(
4754
- {
4755
- attempt,
4756
- maxAttempts: MAX_IMPLEMENT_VERIFICATION_ATTEMPTS,
4757
- extraInstructions
4758
- },
4759
- "implement: retrying search implementation after failed verification"
4767
+ async function runVerificationUseCase() {
4768
+ if (agentRuns > 0) ctx.recordStepExecution();
4769
+ agentRuns += 1;
4770
+ return runAgent({
4771
+ instructions: buildAgentInstructions("verification", input),
4772
+ tools: toolsForUseCase("verification"),
4773
+ outputSchema: verificationOutputSchema,
4774
+ toolContext: searchTools
4775
+ });
4776
+ }
4777
+ if (useCases.includes("ingestion")) {
4778
+ let ingestFailureDetail;
4779
+ const result = await runImplementationUseCase("ingestion");
4780
+ summaries.push(formatSummary("ingestion", result.summary));
4781
+ ingestCommand = result.ingestCommand;
4782
+ const ingestionContext = ingestionTools ?? searchTools;
4783
+ const executions = ingestionContext.shell.executions;
4784
+ const {
4785
+ run: ingestRun,
4786
+ attempt: ingestAttempt,
4787
+ recordCount
4788
+ } = ingestOutcome(executions, ingestCommand);
4789
+ ingestScriptRan = ingestRun != null;
4790
+ ingestRecordCount = recordCount;
4791
+ ingestDurationMs = ingestRun?.durationMs;
4792
+ if (ingestScriptRan && ingestionContext.reviewed.length === 0) {
4793
+ summaries.push(
4794
+ "\u26A0\uFE0F The ingestion script ran without being shown to you for review. Read it in the worktree before trusting the index contents."
4760
4795
  );
4761
- }
4762
- const { summary } = await runImplementationUseCase(
4763
- "search",
4764
- extraInstructions
4765
- );
4766
- summaries.push(formatSummary("search", summary));
4767
- const verification = await runVerificationUseCase();
4768
- summaries.push(formatSummary("verification", verification.summary));
4769
- if (verification.sufficient) {
4770
- ctx.setUserInput("implementation", "success");
4771
- const searchFilesChanged = [
4772
- ...new Set(useWizard.getState().writtenFiles)
4773
- ].map((file) => relative6(repoRoot, file));
4774
- track("AI Wizard Frontend Component Generated", {
4775
- filePaths: searchFilesChanged
4776
- });
4777
- track("AI Wizard Wired to UI", {
4778
- location_heuristic: searchLocation ?? "unknown"
4779
- });
4780
- break;
4781
- }
4782
- if (attempt === MAX_IMPLEMENT_VERIFICATION_ATTEMPTS) {
4783
- ctx.setUserInput("implementation", "fail");
4784
- throw new Error(
4785
- `Implementation verification failed after ${MAX_IMPLEMENT_VERIFICATION_ATTEMPTS} attempts: ${verification.additionalInstructions ?? verification.summary}`
4796
+ logger.warn(
4797
+ { ingestCommand },
4798
+ "implement: ingestion ran without a reviewScript call"
4786
4799
  );
4787
4800
  }
4788
- extraInstructions = verificationRetryInstructions(verification);
4789
- }
4790
- let searchKey;
4791
- let searchKeyError;
4792
- if (appId) {
4793
- try {
4794
- const resolved2 = await resolveSearchOnlyKey(
4795
- targetIndex,
4796
- appId,
4797
- envSearchKey
4798
- );
4799
- searchKey = resolved2.key;
4801
+ if (ingestScriptRan) {
4802
+ ingestOutcomeMessage = `\u2705 Ingestion succeeded${ingestRecordCount != null ? ` \u2014 ${ingestRecordCount} record(s) indexed.` : "."}`;
4803
+ if (ingestRecordCount != null) {
4804
+ track("AI Wizard Ingest Successful", {
4805
+ entity_name: confirmed2?.map((e) => e.name).join(", ") || "unknown",
4806
+ record_count: ingestRecordCount,
4807
+ duration_ms: ingestDurationMs ?? 0
4808
+ });
4809
+ }
4810
+ } else {
4811
+ const { reason, detail } = ingestFailure(ingestAttempt, executions);
4812
+ ingestOutcomeMessage = `\u26A0\uFE0F Records were not indexed \u2014 ${reason}.${ingestCommand ? " Run the command below when you are ready." : ""}`;
4813
+ ingestFailureDetail = detail;
4800
4814
  summaries.push(
4801
- resolved2.source === "created" ? `Created a new search-only Algolia API key for the "${targetIndex}" index in app ${appId} \u2014 safe to expose in frontend code.` : `Reused the existing search-only Algolia API key for the "${targetIndex}" index in app ${appId}.`
4815
+ `\u26A0\uFE0F Ingestion did not complete: ${reason}.${detail ? `
4816
+ ${detail}` : ""}`
4802
4817
  );
4803
- } catch (err) {
4804
- searchKeyError = err.message;
4805
4818
  logger.warn(
4806
- { err: searchKeyError },
4807
- "implement: could not provision a search-only API key; the .env value stays a placeholder"
4819
+ {
4820
+ ingestCommand,
4821
+ reason,
4822
+ approved: ingestAttempt?.approved,
4823
+ exitCode: ingestAttempt?.exitCode,
4824
+ timedOut: ingestAttempt?.timedOut,
4825
+ commandsRun: executions.length
4826
+ },
4827
+ "implement: ingestion script did not complete successfully"
4808
4828
  );
4829
+ track("Error", {
4830
+ step: "Push Data",
4831
+ error: `ingestion did not complete: ${reason}`,
4832
+ product_area: "AI Wizard"
4833
+ });
4809
4834
  }
4810
- }
4811
- finalSearchEnvVars = publicSearchEnvVars(
4812
- publicEnvVarPrefix,
4813
- targetIndex,
4814
- appId,
4815
- searchKey
4816
- );
4817
- const resolvedSearchEnvVars = finalSearchEnvVars.filter(
4818
- (v) => !v.value.startsWith("<")
4819
- );
4820
- if (resolvedSearchEnvVars.length > 0) {
4821
- const written = await writeSearchEnvValues(
4822
- repoRoot,
4823
- resolvedSearchEnvVars
4824
- );
4825
- if (written.length > 0) {
4826
- summaries.push(`Wrote ${written.join(", ")} to .env.`);
4835
+ const commandMessages = [`Open the worktree: cd ${shellQuote(worktree)}`];
4836
+ if (ingestCommand) {
4837
+ commandMessages.push(`Ingestion command: ${ingestCommand}`);
4827
4838
  }
4828
- const ignored = await ensureGitIgnored(repoRoot, join12(repoRoot, ".env"));
4829
- if (ignored === "added") {
4830
- summaries.push("Added .env to .gitignore.");
4831
- } else if (ignored === "tracked") {
4832
- summaries.push(
4833
- '\u26A0\uFE0F .env is tracked by git, so a .gitignore rule cannot un-stage it. Run "git rm --cached .env" before committing, or the credentials go into history.'
4839
+ await ctx.requestUserInput({
4840
+ prompt: "",
4841
+ promptType: "enterToContinue",
4842
+ options: [],
4843
+ // The wizard never streams command output, so a failed run's tail is the
4844
+ // only place the developer sees why it failed. One message per line:
4845
+ // the panel's height accounting counts a message as one wrapped line
4846
+ // (see Notices.tsx), so an embedded newline overflows it.
4847
+ messages: [
4848
+ ingestOutcomeMessage,
4849
+ ...ingestFailureDetail?.split("\n").filter((l) => l.trim()) ?? [],
4850
+ ...commandMessages
4851
+ ]
4852
+ });
4853
+ }
4854
+ if (useCases.includes("search")) {
4855
+ let extraInstructions = [];
4856
+ const preSearchFiles = new Set(await listChangedFiles(worktree));
4857
+ for (let attempt = 1; attempt <= MAX_IMPLEMENT_VERIFICATION_ATTEMPTS; attempt++) {
4858
+ if (attempt > 1) {
4859
+ logger.info(
4860
+ {
4861
+ attempt,
4862
+ maxAttempts: MAX_IMPLEMENT_VERIFICATION_ATTEMPTS,
4863
+ extraInstructions
4864
+ },
4865
+ "implement: retrying search implementation after failed verification"
4866
+ );
4867
+ }
4868
+ const { summary } = await runImplementationUseCase(
4869
+ "search",
4870
+ extraInstructions
4834
4871
  );
4872
+ summaries.push(formatSummary("search", summary));
4873
+ const verification = await runVerificationUseCase();
4874
+ summaries.push(formatSummary("verification", verification.summary));
4875
+ if (verification.sufficient) {
4876
+ ctx.setUserInput("implementation", "success");
4877
+ const searchFilesChanged = (await listChangedFiles(worktree)).filter(
4878
+ (file) => !preSearchFiles.has(file)
4879
+ );
4880
+ track("AI Wizard Frontend Component Generated", {
4881
+ filePaths: searchFilesChanged
4882
+ });
4883
+ track("AI Wizard Wired to UI", {
4884
+ location_heuristic: searchLocation ?? "unknown"
4885
+ });
4886
+ break;
4887
+ }
4888
+ if (attempt === MAX_IMPLEMENT_VERIFICATION_ATTEMPTS) {
4889
+ ctx.setUserInput("implementation", "fail");
4890
+ throw new Error(
4891
+ `Implementation verification failed after ${MAX_IMPLEMENT_VERIFICATION_ATTEMPTS} attempts: ${verification.additionalInstructions ?? verification.summary}`
4892
+ );
4893
+ }
4894
+ extraInstructions = verificationRetryInstructions(verification);
4835
4895
  }
4836
- const stale = [];
4837
- for (const v of resolvedSearchEnvVars) {
4838
- if (written.includes(v.name)) continue;
4839
- const current = await readEnvVar(repoRoot, v.name);
4840
- if (current && current !== v.value) stale.push(v);
4896
+ let searchKey;
4897
+ let searchKeyError;
4898
+ if (appId) {
4899
+ try {
4900
+ const resolved2 = await resolveSearchOnlyKey(
4901
+ targetIndex,
4902
+ appId,
4903
+ envSearchKey
4904
+ );
4905
+ searchKey = resolved2.key;
4906
+ summaries.push(
4907
+ resolved2.source === "created" ? `Created a new search-only Algolia API key for the "${targetIndex}" index in app ${appId} \u2014 safe to expose in frontend code.` : `Reused the existing search-only Algolia API key for the "${targetIndex}" index in app ${appId}.`
4908
+ );
4909
+ } catch (err) {
4910
+ searchKeyError = err.message;
4911
+ logger.warn(
4912
+ { err: searchKeyError },
4913
+ "implement: could not provision a search-only API key; the .env value stays a placeholder"
4914
+ );
4915
+ }
4841
4916
  }
4842
- if (stale.length > 0 && !envAppIdMismatch) {
4843
- summaries.push(
4844
- `\u26A0\uFE0F .env already assigns a different value to ${stale.map((v) => `${v.name} (should be ${v.value})`).join(", ")} \u2014 the wizard left it alone. Fix it by hand, or searches will fail.`
4917
+ finalSearchEnvVars = publicSearchEnvVars(
4918
+ publicEnvVarPrefix,
4919
+ targetIndex,
4920
+ appId,
4921
+ searchKey
4922
+ );
4923
+ const resolvedSearchEnvVars = finalSearchEnvVars.filter(
4924
+ (v) => !v.value.startsWith("<")
4925
+ );
4926
+ if (resolvedSearchEnvVars.length > 0) {
4927
+ const written = await writeSearchEnvValues(
4928
+ worktree,
4929
+ resolvedSearchEnvVars
4845
4930
  );
4846
- logger.warn(
4847
- { vars: stale.map((v) => v.name) },
4848
- "implement: .env holds different values for the resolved search credentials; not overwriting them"
4931
+ if (written.length > 0) {
4932
+ summaries.push(`Wrote ${written.join(", ")} to .env.`);
4933
+ }
4934
+ const ignored = await ensureGitIgnored(worktree, join12(worktree, ".env"));
4935
+ if (ignored === "added") {
4936
+ summaries.push("Added .env to .gitignore.");
4937
+ } else if (ignored === "tracked") {
4938
+ summaries.push(
4939
+ '\u26A0\uFE0F .env is tracked by git, so a .gitignore rule cannot un-stage it. Run "git rm --cached .env" before committing, or the credentials go into history.'
4940
+ );
4941
+ }
4942
+ const stale = [];
4943
+ for (const v of resolvedSearchEnvVars) {
4944
+ if (written.includes(v.name)) continue;
4945
+ const current = await readEnvVar(worktree, v.name);
4946
+ if (current && current !== v.value) stale.push(v);
4947
+ }
4948
+ if (stale.length > 0 && !envAppIdMismatch) {
4949
+ summaries.push(
4950
+ `\u26A0\uFE0F .env already assigns a different value to ${stale.map((v) => `${v.name} (should be ${v.value})`).join(", ")} \u2014 the wizard left it alone. Fix it by hand, or searches will fail.`
4951
+ );
4952
+ logger.warn(
4953
+ { vars: stale.map((v) => v.name) },
4954
+ "implement: .env holds different values for the resolved search credentials; not overwriting them"
4955
+ );
4956
+ }
4957
+ }
4958
+ const unresolvedSearchEnvVars = finalSearchEnvVars.filter(
4959
+ (v) => v.value.startsWith("<")
4960
+ );
4961
+ if (unresolvedSearchEnvVars.length > 0) {
4962
+ summaries.push(
4963
+ `Could not resolve a value for ${unresolvedSearchEnvVars.map((v) => v.name).join(", ")} \u2014 fill it in manually in .env.` + // Without the reason the line is a dead end.
4964
+ (searchKeyError ? ` Reason: ${searchKeyError}` : "")
4849
4965
  );
4850
4966
  }
4967
+ } else {
4968
+ ctx.setUserInput("implementation", "success");
4851
4969
  }
4852
- const unresolvedSearchEnvVars = finalSearchEnvVars.filter(
4853
- (v) => v.value.startsWith("<")
4854
- );
4855
- if (unresolvedSearchEnvVars.length > 0) {
4856
- summaries.push(
4857
- `Could not resolve a value for ${unresolvedSearchEnvVars.map((v) => v.name).join(", ")} \u2014 fill it in manually in .env.` + (searchKeyError ? ` Reason: ${searchKeyError}` : "")
4970
+ const filesChanged = await listChangedFiles(worktree);
4971
+ if (filesChanged.length === 0) {
4972
+ logger.warn(
4973
+ "implement: agent reported success but no files changed in the worktree"
4858
4974
  );
4859
4975
  }
4860
- } else {
4861
- ctx.setUserInput("implementation", "success");
4976
+ return {
4977
+ ingestionSource,
4978
+ filesChanged,
4979
+ summary: summaries.join("\n\n"),
4980
+ worktreePath: worktree,
4981
+ ...useCases.includes("ingestion") && ingestCommand ? {
4982
+ ingestCommand,
4983
+ ingestScriptRan,
4984
+ ...ingestRecordCount != null ? { ingestRecordCount } : {},
4985
+ ...ingestDurationMs != null ? { ingestDurationMs } : {}
4986
+ } : {},
4987
+ ...useCases.includes("search") ? { searchEnvVars: finalSearchEnvVars } : {}
4988
+ };
4989
+ } finally {
4990
+ process.chdir(repoRoot);
4862
4991
  }
4863
- return {
4864
- ingestionSource,
4865
- summary: summaries.join("\n\n"),
4866
- ...useCases.includes("ingestion") && ingestCommand ? {
4867
- ingestCommand,
4868
- ingestScriptRan,
4869
- ...ingestRecordCount != null ? { ingestRecordCount } : {},
4870
- ...ingestDurationMs != null ? { ingestDurationMs } : {}
4871
- } : {},
4872
- ...useCases.includes("search") ? { searchEnvVars: finalSearchEnvVars } : {}
4873
- };
4874
4992
  }
4875
4993
 
4876
4994
  // src/workflows/default.ts
@@ -4942,7 +5060,10 @@ var defaultWorkflow = {
4942
5060
  ctx.notify({
4943
5061
  messages: ["Building your Algolia search experience\u2026"]
4944
5062
  });
4945
- return implement(ctx, ["search"]);
5063
+ const ingestion2 = ctx.getStepOutput(
5064
+ "ingestion"
5065
+ );
5066
+ return implement(ctx, ["search"], ingestion2?.worktreePath);
4946
5067
  }
4947
5068
  }),
4948
5069
  defineStep({
@@ -4957,8 +5078,9 @@ var defaultWorkflow = {
4957
5078
  "ingestion"
4958
5079
  );
4959
5080
  return reviewStep(ctx, {
4960
- // ingestCommand was already shown verbatim as a notice; an
4961
- // LLM-paraphrased restatement in nextSteps risks being wrong.
5081
+ // The ingestion step already showed the user the exact `ingestCommand`
5082
+ // and worktree path as a notice, so nextSteps must not restate it —
5083
+ // an LLM-paraphrased command risks being wrong.
4962
5084
  nextStepsGuidance: ingestion2?.ingestScriptRan ? "The wizard already ran the ingestion script and records are in the index. Do NOT tell the user to run it again; instead point them at the target index to confirm the records. Do not restate the ingestion command \u2014 the wizard already showed it to them." : "Tell the user to run the ingestion script; do not restate the exact command \u2014 the wizard already showed it to them above."
4963
5085
  });
4964
5086
  }
@@ -5005,6 +5127,7 @@ var selectIndex = {
5005
5127
  selection: "wizard_seed_products"
5006
5128
  };
5007
5129
  var ingestion = {
5130
+ filesChanged: ["algolia/ingest.mjs", "algolia/records.json", "package.json"],
5008
5131
  summary: "Generated sample Product records and an ingestion script that pushes them to the target index with algoliasearch.",
5009
5132
  ingestCommand: "node algolia/ingest.mjs",
5010
5133
  ingestScriptRan: true,
@@ -5016,6 +5139,7 @@ var confirmFramework2 = {
5016
5139
  frameworks: projectScan2.frameworks
5017
5140
  };
5018
5141
  var search = {
5142
+ filesChanged: ["src/components/Search.tsx", "src/components/Header.tsx", ".env"],
5019
5143
  summary: "Added an InstantSearch-powered search box and results list, mounted in the shared header component.",
5020
5144
  ingestionSource: "generated",
5021
5145
  searchEnvVars: [
@@ -5028,7 +5152,7 @@ var review = {
5028
5152
  "Ingested 25 generated Product records into wizard_seed_products.",
5029
5153
  "Added an InstantSearch search experience to the shared header."
5030
5154
  ],
5031
- reviewPrompt: "Review the Algolia ingestion and search changes.",
5155
+ reviewPrompt: "Review the Algolia ingestion and search changes in this worktree.",
5032
5156
  nextSteps: ["Point the ingestion script at your real product data."]
5033
5157
  };
5034
5158
  var SEEDS = {
@@ -5145,9 +5269,9 @@ Options:
5145
5269
  steps pre-filled with test data. Pass with no value to print
5146
5270
  the step ids. See CONTRIBUTING.md.
5147
5271
  --no-telemetry Send no telemetry or analytics for this run.
5148
- --reset-on-run Wipe this project's wizard state (run state, AI consent)
5149
- before starting, so the run behaves like a first-ever
5150
- run. Also drops every API key the wizard has
5272
+ --reset-on-run Wipe this project's wizard state (run state, AI consent,
5273
+ worktrees) before starting, so the run behaves like a
5274
+ first-ever run. Also drops every API key the wizard has
5151
5275
  stored in your keychain (or, where the platform has none,
5152
5276
  the encrypted file it falls back to \u2014 see CONTRIBUTING.md),
5153
5277
  for this project and any other, so later runs create new
@@ -5187,7 +5311,7 @@ function parseCliArgs(argv) {
5187
5311
  }
5188
5312
 
5189
5313
  // src/lib/resetState.ts
5190
- import { readdir as readdir3, rm as rm2 } from "node:fs/promises";
5314
+ import { readdir as readdir4, rm as rm2 } from "node:fs/promises";
5191
5315
  import { join as join13 } from "node:path";
5192
5316
  var KEEP = ["wizard.log"];
5193
5317
  async function resetProjectState() {
@@ -5195,7 +5319,7 @@ async function resetProjectState() {
5195
5319
  await forgetResolvedKeys();
5196
5320
  let entries;
5197
5321
  try {
5198
- entries = await readdir3(dir);
5322
+ entries = await readdir4(dir);
5199
5323
  } catch {
5200
5324
  return { dir, removed: [] };
5201
5325
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@algolia/wizard",
3
- "version": "0.21.0-rc.111.195",
3
+ "version": "0.21.0",
4
4
  "description": "Magically implement Algolia functionality in your codebase",
5
5
  "type": "module",
6
6
  "engines": {