@agentteams/cli 0.1.92 → 0.1.94

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 (69) hide show
  1. package/.eslintcache +1 -1
  2. package/README.md +2 -0
  3. package/dist/api/linear.d.ts +1 -1
  4. package/dist/api/linear.d.ts.map +1 -1
  5. package/dist/api/linear.js +8 -2
  6. package/dist/api/linear.js.map +1 -1
  7. package/dist/api/report.d.ts +1 -0
  8. package/dist/api/report.d.ts.map +1 -1
  9. package/dist/api/report.js +5 -0
  10. package/dist/api/report.js.map +1 -1
  11. package/dist/commands/convention.d.ts +3 -0
  12. package/dist/commands/convention.d.ts.map +1 -1
  13. package/dist/commands/convention.js +35 -5
  14. package/dist/commands/convention.js.map +1 -1
  15. package/dist/commands/doctor.d.ts +5 -0
  16. package/dist/commands/doctor.d.ts.map +1 -1
  17. package/dist/commands/doctor.js +93 -45
  18. package/dist/commands/doctor.js.map +1 -1
  19. package/dist/commands/init.d.ts +110 -27
  20. package/dist/commands/init.d.ts.map +1 -1
  21. package/dist/commands/init.js +659 -462
  22. package/dist/commands/init.js.map +1 -1
  23. package/dist/commands/initWorktree.d.ts +32 -0
  24. package/dist/commands/initWorktree.d.ts.map +1 -0
  25. package/dist/commands/initWorktree.js +348 -0
  26. package/dist/commands/initWorktree.js.map +1 -0
  27. package/dist/commands/report.d.ts.map +1 -1
  28. package/dist/commands/report.js +40 -7
  29. package/dist/commands/report.js.map +1 -1
  30. package/dist/commands/resolve.js +1 -1
  31. package/dist/commands/resolve.js.map +1 -1
  32. package/dist/index.js +31 -8
  33. package/dist/index.js.map +1 -1
  34. package/dist/mcp/catalog.d.ts +3 -0
  35. package/dist/mcp/catalog.d.ts.map +1 -1
  36. package/dist/mcp/catalog.js +4 -0
  37. package/dist/mcp/catalog.js.map +1 -1
  38. package/dist/mcp/localTools.d.ts +65 -0
  39. package/dist/mcp/localTools.d.ts.map +1 -0
  40. package/dist/mcp/localTools.js +210 -0
  41. package/dist/mcp/localTools.js.map +1 -0
  42. package/dist/mcp/server.d.ts.map +1 -1
  43. package/dist/mcp/server.js +6 -5
  44. package/dist/mcp/server.js.map +1 -1
  45. package/dist/mcp/writeTools.d.ts +3 -12
  46. package/dist/mcp/writeTools.d.ts.map +1 -1
  47. package/dist/mcp/writeTools.js +2 -41
  48. package/dist/mcp/writeTools.js.map +1 -1
  49. package/dist/utils/agentEntryPoints.d.ts +68 -0
  50. package/dist/utils/agentEntryPoints.d.ts.map +1 -0
  51. package/dist/utils/agentEntryPoints.js +112 -0
  52. package/dist/utils/agentEntryPoints.js.map +1 -0
  53. package/dist/utils/authServer.d.ts +51 -1
  54. package/dist/utils/authServer.d.ts.map +1 -1
  55. package/dist/utils/authServer.js +77 -0
  56. package/dist/utils/authServer.js.map +1 -1
  57. package/dist/utils/doctorOutput.d.ts.map +1 -1
  58. package/dist/utils/doctorOutput.js +7 -3
  59. package/dist/utils/doctorOutput.js.map +1 -1
  60. package/dist/utils/git.d.ts +28 -0
  61. package/dist/utils/git.d.ts.map +1 -1
  62. package/dist/utils/git.js +43 -0
  63. package/dist/utils/git.js.map +1 -1
  64. package/dist/utils/initOutput.d.ts.map +1 -1
  65. package/dist/utils/initOutput.js +97 -15
  66. package/dist/utils/initOutput.js.map +1 -1
  67. package/dist/utils/report.d.ts +1 -1
  68. package/dist/utils/report.js +1 -1
  69. package/package.json +1 -1
@@ -1,31 +1,29 @@
1
- import { appendFileSync, cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, unlinkSync, writeFileSync, } from 'node:fs';
1
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import { basename, dirname, join, resolve } from 'node:path';
3
+ import { homedir } from 'node:os';
3
4
  import { constants, createCipheriv, publicEncrypt, randomBytes } from 'node:crypto';
4
5
  import { multiselect, isCancel, cancel } from '@clack/prompts';
5
6
  import httpClient from '../utils/httpClient.js';
6
7
  import open from 'open';
7
- import { createAuthState, startLocalAuthServer } from '../utils/authServer.js';
8
- import { DEFAULT_API_URL, saveConfig, saveLegacyApiKeyConfig, } from '../utils/config.js';
9
- import { performPersonalTokenLogin } from './auth.js';
8
+ import { createAuthState, createPkcePair, isUnifiedSetupMetadataMissing, SETUP_LEGACY_AGENT_KEY_HINT, SETUP_METADATA_MISSING_HINT, startLocalAuthServer, startUnifiedSetupServer, } from '../utils/authServer.js';
9
+ import { DEFAULT_API_URL, findProjectConfig, loadProjectConfig, loadConfigWithCredential, saveConfig, saveLegacyApiKeyConfig, } from '../utils/config.js';
10
+ import { getPersonalTokenClient } from '../auth/personalTokenClient.js';
10
11
  import { createSpinner, withSpinner } from '../utils/spinner.js';
11
12
  import { withCommandContext } from '../utils/commandContext.js';
12
- import { conventionDownload } from './convention.js';
13
- import { resolveGitTopLevel, resolveMainCheckoutRoot } from '../utils/git.js';
13
+ import { conventionDownload, conventionStatus, CONVENTION_MANIFEST_FILE, } from './convention.js';
14
+ import { executeDoctorCommand } from './doctor.js';
15
+ import { resolveGitTopLevel, shouldInstallWorktreeHook } from '../utils/git.js';
14
16
  import { canonicalizePath } from '../utils/path.js';
15
17
  import { readOrCreateMachineId } from '../utils/machineId.js';
16
18
  import { buildAuthHeaders } from '../utils/apiContext.js';
17
- import { DEFAULT_CONVENTION_REFERENCE, ensureConventionEntryPoints, ensureLocalExclude, ensurePostCheckoutHook, isReadableRegularFile, resolveGitCommonDir, toAnchoredExcludePattern, } from '../utils/conventionLink.js';
19
+ import { AGENT_ENTRY_POINT_FILES, detectAgentEntryPointFiles, parseAgentFilesOption, } from '../utils/agentEntryPoints.js';
20
+ import { bootstrapLinkedWorktree, resolveLinkedWorktreeSource } from './initWorktree.js';
21
+ import { DEFAULT_CONVENTION_REFERENCE, ensurePostCheckoutHook, } from '../utils/conventionLink.js';
22
+ export { bootstrapLinkedWorktree } from './initWorktree.js';
18
23
  const AUTH_BASE_URL = process.env.AGENTTEAMS_WEB_URL || 'https://agentteams.run';
19
- const AGENT_ENTRY_POINT_FILES = [
20
- { value: 'CLAUDE.md', label: 'CLAUDE.md', hint: 'Claude Code' },
21
- { value: 'AGENTS.md', label: 'AGENTS.md', hint: 'OpenCode / Codex' },
22
- { value: 'GEMINI.md', label: 'GEMINI.md', hint: 'Antigravity' },
23
- { value: '.cursor/rules/agentteams.mdc', label: '.cursor/rules/agentteams.mdc', hint: 'Cursor' },
24
- ];
25
24
  const CONFIG_DIR = '.agentteams';
26
25
  const CONFIG_FILE = 'config.json';
27
26
  const CONVENTION_FILE = 'convention.md';
28
- const RELINK_BACKUP_NAME = 'agentteams-relink';
29
27
  const AUTH_PATH_PUBLIC_KEY_PEM = `-----BEGIN PUBLIC KEY-----
30
28
  MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAs9s9+n0C8Z099LrOTlKB
31
29
  83c2WluO/TxZFxJQ07XgfKJ2RG/8K2kvCwVKeSgzzBP/hmY2qWAgAOrXIoSHNYGt
@@ -72,26 +70,30 @@ export function detectOsType() {
72
70
  }
73
71
  return undefined;
74
72
  }
75
- export function buildAuthorizeUrl(port, projectName, authPathEnc, osType, state, machineId) {
73
+ export function buildAuthorizeUrl(input) {
76
74
  const params = new URLSearchParams({
77
- port: String(port),
78
- projectName,
75
+ port: String(input.port),
76
+ projectName: input.projectName,
79
77
  });
80
- if (authPathEnc && authPathEnc.length > 0) {
81
- params.set('ap', authPathEnc);
78
+ if (input.authPathEnc && input.authPathEnc.length > 0) {
79
+ params.set('ap', input.authPathEnc);
82
80
  }
83
- if (osType && osType.length > 0) {
84
- params.set('ot', osType);
81
+ if (input.osType && input.osType.length > 0) {
82
+ params.set('ot', input.osType);
85
83
  }
86
84
  // Machine identity, shared with the runner installed on this machine. It is not a secret and is
87
85
  // only used to bind the agent to the runner that can actually reach this workspace.
88
- if (machineId && machineId.length > 0) {
89
- params.set('mid', machineId);
86
+ if (input.machineId && input.machineId.length > 0) {
87
+ params.set('mid', input.machineId);
90
88
  }
91
89
  // The web page echoes this back through the callback; without it the local
92
90
  // server cannot tell this login apart from one someone else started.
93
- if (state && state.length > 0) {
94
- params.set('state', state);
91
+ if (input.state && input.state.length > 0) {
92
+ params.set('state', input.state);
93
+ }
94
+ if (input.codeChallenge && input.codeChallenge.length > 0) {
95
+ params.set('code_challenge', input.codeChallenge);
96
+ params.set('flow', 'setup');
95
97
  }
96
98
  return `${AUTH_BASE_URL}/cli/authorize?${params.toString()}`;
97
99
  }
@@ -183,29 +185,28 @@ async function fetchConventionTemplate(authResult, apiUrl) {
183
185
  return content;
184
186
  }
185
187
  /**
186
- * Hand the agent key back at the end of a personal-token init.
188
+ * Which entry point files this run may write.
187
189
  *
188
- * The browser round trip mints a `key_` no matter which auth mode was asked
189
- * for it is the only credential that can read the convention template. On the
190
- * personal-token path that key is never written to disk, so leaving it live
191
- * would mean a long-lived secret the user cannot see, use or cancel. The agent
192
- * config itself stays: it is the project's record of this machine, and only its
193
- * credential is the liability.
190
+ * The order is deliberate: an explicit `--agent-files` wins over everything, a
191
+ * TTY still gets the multiselect (now seeded with what was detected instead of
192
+ * everything), and a non-TTY run gets the detection result alone. The old
193
+ * non-TTY branch returned the full catalog, so every automated re-run wrote all
194
+ * four files and an `-example` sibling for each one that already existed.
194
195
  */
195
- async function revokeInitAgentKey(authResult, apiUrl) {
196
- try {
197
- await httpClient.delete(`${apiUrl}/api/projects/${authResult.projectId}/agent-configs/${authResult.configId}/api-keys`, { headers: buildAuthHeaders(authResult.apiKey) });
198
- return true;
199
- }
200
- catch {
201
- // Not fatal: init has everything it needs, and the caller warns instead so
202
- // the user can revoke it from the web app.
203
- return false;
196
+ /**
197
+ * `allowPrompt` is false on the configured-project repair pass: that path exists
198
+ * to make a re-run of `agentteams init` actually re-apply the local adapters,
199
+ * and a fast path that stops to ask a question is no longer a fast path.
200
+ * Detection still runs there, so a missing entry point is created and an
201
+ * existing one is reported as untouched.
202
+ */
203
+ async function resolveAgentFileSelection(cwd, explicitFiles, options) {
204
+ if (explicitFiles) {
205
+ return explicitFiles;
204
206
  }
205
- }
206
- async function promptAgentFileSelection() {
207
- if (!process.stdin.isTTY) {
208
- return AGENT_ENTRY_POINT_FILES.map((f) => f.value);
207
+ const detected = detectAgentEntryPointFiles(cwd);
208
+ if (options?.allowPrompt === false || !process.stdin.isTTY) {
209
+ return detected;
209
210
  }
210
211
  const selected = await multiselect({
211
212
  message: 'Select agent entry point files to create:',
@@ -214,7 +215,7 @@ async function promptAgentFileSelection() {
214
215
  label: f.label,
215
216
  hint: f.hint,
216
217
  })),
217
- initialValues: AGENT_ENTRY_POINT_FILES.map((f) => f.value),
218
+ initialValues: detected,
218
219
  required: false,
219
220
  });
220
221
  if (isCancel(selected)) {
@@ -258,378 +259,657 @@ function ensureGeminiIgnore(cwd) {
258
259
  writeFileSync(geminiIgnorePath, `# Allow AgentTeams convention files\n${entry}\n`, 'utf-8');
259
260
  }
260
261
  }
261
- function generateAgentEntryPointFiles(cwd, selectedFiles) {
262
- if (selectedFiles.length === 0) {
263
- return [];
264
- }
262
+ /**
263
+ * Write the selected entry points, never over an existing file.
264
+ *
265
+ * An occupied path is reported as `skipped` rather than answered with a
266
+ * `<name>-example` sibling: the example write is unconditional, so a repeated
267
+ * init kept re-creating files nobody merged. `--agent-files-example` restores
268
+ * the old behavior for the case it was built for — a first-time setup on a repo
269
+ * that already has its own CLAUDE.md.
270
+ *
271
+ * Each file is written under its own try/catch and failures come back alongside
272
+ * the entries instead of as an exception. Letting one unwritable path throw out
273
+ * of the loop discarded the record of every file already on disk, so the caller
274
+ * reported `agentFiles: []` for a run that had in fact created some of them —
275
+ * the JSON contract and the human output both disagreeing with the filesystem.
276
+ */
277
+ function generateAgentEntryPointFiles(cwd, selectedFiles, options) {
265
278
  const entries = [];
279
+ const failures = [];
266
280
  for (const relativePath of selectedFiles) {
267
281
  const fullPath = join(cwd, relativePath);
268
- const dir = dirname(fullPath);
269
- if (!existsSync(dir)) {
270
- mkdirSync(dir, { recursive: true });
271
- }
272
- if (!existsSync(fullPath)) {
282
+ try {
283
+ if (existsSync(fullPath)) {
284
+ if (!options.createExample) {
285
+ entries.push({ relativePath, type: 'skipped' });
286
+ continue;
287
+ }
288
+ const ext = relativePath.includes('.') ? `.${relativePath.split('.').pop()}` : '';
289
+ const base = ext ? relativePath.slice(0, -ext.length) : relativePath;
290
+ const exampleRelativePath = `${base}-example${ext}`;
291
+ const exampleFullPath = join(cwd, exampleRelativePath);
292
+ const exampleDir = dirname(exampleFullPath);
293
+ if (!existsSync(exampleDir)) {
294
+ mkdirSync(exampleDir, { recursive: true });
295
+ }
296
+ writeFileSync(exampleFullPath, DEFAULT_CONVENTION_REFERENCE, 'utf-8');
297
+ entries.push({ relativePath: exampleRelativePath, type: 'example' });
298
+ continue;
299
+ }
300
+ const dir = dirname(fullPath);
301
+ if (!existsSync(dir)) {
302
+ mkdirSync(dir, { recursive: true });
303
+ }
273
304
  writeFileSync(fullPath, DEFAULT_CONVENTION_REFERENCE, 'utf-8');
274
305
  entries.push({ relativePath, type: 'created' });
275
306
  }
276
- else {
277
- const ext = relativePath.includes('.') ? `.${relativePath.split('.').pop()}` : '';
278
- const base = ext ? relativePath.slice(0, -ext.length) : relativePath;
279
- const exampleRelativePath = `${base}-example${ext}`;
280
- const exampleFullPath = join(cwd, exampleRelativePath);
281
- const exampleDir = dirname(exampleFullPath);
282
- if (!existsSync(exampleDir)) {
283
- mkdirSync(exampleDir, { recursive: true });
284
- }
285
- writeFileSync(exampleFullPath, DEFAULT_CONVENTION_REFERENCE, 'utf-8');
286
- entries.push({ relativePath: exampleRelativePath, type: 'example' });
307
+ catch (error) {
308
+ failures.push({ relativePath, message: toErrorMessage(error) });
287
309
  }
288
310
  }
289
- return entries;
311
+ return { entries, failures };
290
312
  }
291
- function isConfiguredMainCheckout(sourcePath) {
313
+ function toComparablePath(path) {
292
314
  try {
293
- const config = JSON.parse(readFileSync(join(sourcePath, CONFIG_FILE), 'utf-8'));
294
- return ['teamId', 'projectId'].every((field) => typeof config[field] === 'string' && config[field].length > 0);
315
+ return canonicalizePath(path);
295
316
  }
296
317
  catch {
297
- return false;
318
+ return path;
298
319
  }
299
320
  }
300
321
  /**
301
- * Materialize the worktree's `.agentteams` entry as a real link.
302
- *
303
- * Windows directory symlinks require SeCreateSymbolicLinkPrivilege (Developer
304
- * Mode or elevation), so an unprivileged win32 run fails with EPERM and used to
305
- * fall through to a copy — silently breaking the guarantee that a worktree
306
- * tracks the main checkout's conventions. Junctions need no privilege, which is
307
- * why `utils/conventionLink.ts` and the daemon's worktree helper already use
308
- * them; this is the same documented platform exception.
322
+ * Does the discovered config actually belong to the directory init was asked to
323
+ * initialize?
309
324
  *
310
- * The copy fallback stays for environments neither link type supports (UNC
311
- * paths, filesystems without reparse points). It dereferences because in a
312
- * non-git-root layout the source is itself a link, and copying a link
313
- * recursively re-creates it which fails for exactly the same reason.
325
+ * `findProjectConfig` walks ancestors outside a repository all the way to the
326
+ * filesystem root, which includes the global `~/.agentteams/config.json` that
327
+ * `mergeConfigSources` reads as a fallback. The new-project path, in contrast,
328
+ * always writes to `join(cwd, CONFIG_DIR, CONFIG_FILE)`. Reusing an ancestor's
329
+ * binding as a "configured project" would therefore make `init` in a fresh
330
+ * folder a silent no-op that reports someone else's project.
314
331
  */
315
- function linkConventionDir(sourcePath, targetPath) {
316
- try {
317
- if (process.platform === 'win32') {
318
- // Junctions only accept absolute targets, so resolve the chain first.
319
- symlinkSync(realpathSync(sourcePath), targetPath, 'junction');
320
- }
321
- else {
322
- symlinkSync(sourcePath, targetPath, 'dir');
323
- }
324
- return { materialization: 'symlink' };
325
- }
326
- catch (error) {
327
- cpSync(sourcePath, targetPath, { recursive: true, dereference: true });
328
- return {
329
- materialization: 'copy',
330
- warning: `Could not create the .agentteams symlink (${error instanceof Error ? error.message : String(error)}). Copied the directory instead.`,
331
- };
332
- }
332
+ function ownsDiscoveredConfig(resolvedCwd, configPath, userHomeDir) {
333
+ const configRoot = toComparablePath(dirname(dirname(configPath)));
334
+ // The global config is a fallback for commands, never a project binding.
335
+ if (configRoot === toComparablePath(userHomeDir))
336
+ return false;
337
+ const owners = new Set([toComparablePath(resolvedCwd)]);
338
+ const repositoryRoot = resolveGitTopLevel(resolvedCwd);
339
+ if (repositoryRoot)
340
+ owners.add(toComparablePath(repositoryRoot));
341
+ return owners.has(configRoot);
333
342
  }
334
343
  /**
335
- * Decide whether a plain directory sitting at the worktree's `.agentteams` may
336
- * be the copy an earlier failed link attempt left behind, rather than something
337
- * the user created. Identity is judged by the config file, the one artifact a
338
- * copy reproduces byte for byte anything else is treated as the user's and is
339
- * never touched. This only authorizes moving the directory aside; whether the
340
- * moved copy may be deleted is decided separately by `findCopyOnlyFiles`.
344
+ * Classify init before it opens a browser or materializes any local adapters.
345
+ *
346
+ * This resolver is intentionally read-only: the linked-worktree branch calls
347
+ * `bootstrapLinkedWorktree` and the configured-project branch resolves its
348
+ * credential only after classification, so a classification never touches a
349
+ * keychain, a token, or the filesystem.
350
+ *
351
+ * `userHomeDir` is injectable for the same reason as in
352
+ * `getConfigurationNotFoundMessage`: `os.homedir()` does not follow a test's
353
+ * `process.env.HOME`, so the global-config rule would be untestable otherwise.
341
354
  */
342
- function isCopyOfMainCheckout(sourcePath, targetPath) {
343
- try {
344
- return (readFileSync(join(targetPath, CONFIG_FILE), 'utf-8') === readFileSync(join(sourcePath, CONFIG_FILE), 'utf-8'));
345
- }
346
- catch {
347
- return false;
348
- }
355
+ export function detectInitExecutionContext(cwd, explicitAuthMode, userHomeDir = homedir()) {
356
+ const resolvedCwd = resolve(cwd);
357
+ const configPath = findProjectConfig(resolvedCwd);
358
+ const config = configPath ? loadProjectConfig(resolvedCwd) : null;
359
+ if (resolveLinkedWorktreeSource(resolvedCwd)) {
360
+ return { kind: 'linked-worktree', configPath, config };
361
+ }
362
+ const hasProjectBinding = typeof config?.teamId === 'string' &&
363
+ config.teamId.length > 0 &&
364
+ typeof config.projectId === 'string' &&
365
+ config.projectId.length > 0;
366
+ // An explicit --auth choice means the caller intends to reconnect instead of
367
+ // validating the existing binding through the configured-project fast path.
368
+ const kind = configPath && hasProjectBinding && ownsDiscoveredConfig(resolvedCwd, configPath, userHomeDir) && !explicitAuthMode
369
+ ? 'configured-project'
370
+ : 'new-project';
371
+ return { kind, configPath, config };
349
372
  }
350
373
  function toErrorMessage(error) {
351
374
  return error instanceof Error ? error.message : String(error);
352
375
  }
353
- function hasIdenticalFile(sourceFile, backupFile) {
354
- try {
355
- const sourceStats = statSync(sourceFile);
356
- if (!sourceStats.isFile() || sourceStats.size !== statSync(backupFile).size)
357
- return false;
358
- return readFileSync(sourceFile).equals(readFileSync(backupFile));
359
- }
360
- catch {
361
- return false;
362
- }
376
+ export async function executeInitCommand(options) {
377
+ return withCommandContext('init', () => executeInitCommandWithContext(options));
363
378
  }
364
379
  /**
365
- * List every file in the moved-aside copy that the main checkout does not
366
- * already hold byte for byte. A worktree that ran as a copy accumulates its own
367
- * artifacts inside `.agentteams`runner history, evidence, downloaded plans,
368
- * review findings and none of them can be regenerated, so the copy is only
369
- * safe to delete when it is a strict subset of the source. Anything that is not
370
- * a plain matching file (a symlink, a special file, an unreadable directory)
371
- * counts as copy-only: it cannot be proven redundant.
380
+ * Wait for the browser round trip without losing Ctrl+C.
381
+ *
382
+ * Shared by both auth paths so the cancel handling SIGINT plus the raw-mode
383
+ * ^C read a spinner would otherwise swallow exists exactly once.
372
384
  */
373
- function findCopyOnlyFiles(backupPath, sourcePath) {
374
- const copyOnlyFiles = [];
375
- const walk = (relativeDir) => {
376
- let entries;
377
- try {
378
- entries = readdirSync(join(backupPath, relativeDir), { withFileTypes: true });
379
- }
380
- catch {
381
- copyOnlyFiles.push(relativeDir || '.');
385
+ async function waitForBrowserCallback(authContext, spinner) {
386
+ let restored = false;
387
+ // Runs on every exit — success, timeout, or server error. Leaving it on the
388
+ // success path only meant a failed init handed the shell back in raw mode,
389
+ // with input no longer echoing until the user typed `reset`.
390
+ const restoreTerminal = () => {
391
+ if (restored) {
382
392
  return;
383
393
  }
384
- for (const entry of entries) {
385
- const relativePath = relativeDir ? join(relativeDir, entry.name) : entry.name;
386
- if (entry.isDirectory()) {
387
- walk(relativePath);
388
- }
389
- else if (!entry.isFile() || !hasIdenticalFile(join(sourcePath, relativePath), join(backupPath, relativePath))) {
390
- copyOnlyFiles.push(relativePath);
391
- }
394
+ restored = true;
395
+ process.removeListener('SIGINT', onSigint);
396
+ if (process.stdin.isTTY) {
397
+ process.stdin.setRawMode(false);
398
+ process.stdin.pause();
399
+ process.stdin.removeListener('data', onKeypress);
392
400
  }
393
401
  };
394
- walk('');
395
- return copyOnlyFiles;
396
- }
397
- /**
398
- * Park the copy outside every tracked surface while the link is created. A
399
- * backup next to `.agentteams` would show up in `git status` — the anchored
400
- * `/.agentteams` exclude is an exact match and does not cover a sibling name —
401
- * and a copied legacy `config.json` may carry the project apiKey.
402
- * `git-common-dir` is part of no working tree, so nothing can surface from
403
- * there. A worktree on a different volume cannot be renamed into it, and only
404
- * then does the in-worktree fallback apply — after registering its own exclude
405
- * pattern.
406
- */
407
- function moveConventionCopyAside(worktreePath, targetPath) {
408
- const failures = [];
409
- const commonDir = resolveGitCommonDir(worktreePath);
410
- if (commonDir) {
411
- const backupPath = join(commonDir, `${RELINK_BACKUP_NAME}-${process.pid}`);
412
- try {
413
- renameSync(targetPath, backupPath);
414
- return { backupPath };
402
+ function onSigint() {
403
+ restoreTerminal();
404
+ spinner?.fail('Init cancelled.');
405
+ if (authContext.server.listening) {
406
+ authContext.server.close();
415
407
  }
416
- catch (error) {
417
- failures.push(toErrorMessage(error));
408
+ process.exit(0);
409
+ }
410
+ function onKeypress(key) {
411
+ if (key[0] === 0x03) {
412
+ onSigint();
418
413
  }
419
414
  }
420
- const excludeResult = ensureLocalExclude(worktreePath, [toAnchoredExcludePattern(`.${RELINK_BACKUP_NAME}`)]);
421
- if (excludeResult.status !== 'ready') {
422
- failures.push(`the in-worktree backup cannot be kept out of git status (${excludeResult.issue?.message ?? 'local exclude is blocked'})`);
423
- return { error: failures.join('; ') };
415
+ process.on('SIGINT', onSigint);
416
+ if (process.stdin.isTTY) {
417
+ process.stdin.setRawMode(true);
418
+ process.stdin.resume();
419
+ process.stdin.on('data', onKeypress);
424
420
  }
425
421
  try {
426
- const backupPath = join(worktreePath, `.${RELINK_BACKUP_NAME}`);
427
- renameSync(targetPath, backupPath);
428
- return { backupPath };
422
+ return await authContext.waitForCallback();
429
423
  }
430
- catch (error) {
431
- failures.push(toErrorMessage(error));
432
- return { error: failures.join('; ') };
433
- }
434
- }
435
- /** Undo the move-aside. The target may hold a fresh link or a partial copy, and
436
- * `renameSync` onto a non-empty directory fails with ENOTEMPTY, so clear it
437
- * first and never let the restore itself escape as an exception. */
438
- function restoreConventionCopy(backupPath, targetPath) {
439
- try {
440
- rmSync(targetPath, { recursive: true, force: true });
441
- renameSync(backupPath, targetPath);
442
- return true;
443
- }
444
- catch {
445
- return false;
424
+ finally {
425
+ restoreTerminal();
446
426
  }
447
427
  }
448
428
  /**
449
- * Replace a copied `.agentteams` directory with a real link so a worktree that
450
- * predates the link fix starts tracking the main checkout again. Without this
451
- * the `existing` branch treats the copy as settled and the worktree keeps
452
- * serving stale conventions forever.
429
+ * The default path: one browser screen, no agent API key, one convention write.
453
430
  *
454
- * The copy is moved aside first and is deleted only once the link is in place
455
- * *and* the copy proves to be a strict subset of the main checkout; otherwise
456
- * the backup is kept and its path reported. Any failure restores the original
457
- * directory, and a failed restore reports where the original is.
431
+ * The browser returns an authorization code plus the connection identifiers, so
432
+ * the second round trip that used to run here (`performPersonalTokenLogin`) is
433
+ * gone, and with it the `key_` that only ever existed to read the convention
434
+ * template once. `agentteams auth login` still owns that helper for the
435
+ * project-less login.
458
436
  */
459
- function promoteCopiedConventionDir(worktreePath, sourcePath, targetPath) {
460
- if (!isCopyOfMainCheckout(sourcePath, targetPath)) {
461
- return {
462
- materialization: 'existing',
463
- issue: {
464
- code: 'link-occupied',
465
- path: targetPath,
466
- message: `The directory at ${targetPath} does not match the main checkout's ${CONFIG_DIR}; leaving it untouched. Remove it manually to restore the convention link.`,
467
- },
468
- };
437
+ async function runUnifiedSetup(context) {
438
+ const pkce = createPkcePair();
439
+ let server;
440
+ try {
441
+ server = await startUnifiedSetupServer({ state: createAuthState() });
469
442
  }
470
- const moved = moveConventionCopyAside(worktreePath, targetPath);
471
- if ('error' in moved) {
472
- return {
473
- materialization: 'existing',
474
- issue: {
475
- code: 'link-create-failed',
476
- path: targetPath,
477
- message: `Could not move the copied ${CONFIG_DIR} aside at ${targetPath}: ${moved.error}`,
478
- },
479
- };
443
+ catch (error) {
444
+ throw new Error(`Failed to start local OAuth server: ${error instanceof Error ? error.message : String(error)}`);
480
445
  }
481
- const { backupPath } = moved;
446
+ const authUrl = buildAuthorizeUrl({
447
+ port: server.port,
448
+ projectName: context.projectName,
449
+ authPathEnc: context.authPathEnc,
450
+ osType: detectOsType(),
451
+ state: server.state,
452
+ machineId: readOrCreateMachineId() ?? undefined,
453
+ codeChallenge: pkce.challenge,
454
+ });
455
+ await tryOpenBrowser(authUrl);
456
+ const authSpinner = createSpinner('Waiting for authentication... (Ctrl+C to cancel)');
482
457
  try {
483
- const { materialization } = linkConventionDir(sourcePath, targetPath);
484
- // A copy fallback here would only rebuild the state being replaced.
485
- if (materialization !== 'symlink') {
486
- throw new Error('the link could not be created and a copy would not restore the convention chain');
458
+ const callback = await waitForBrowserCallback(server, authSpinner);
459
+ authSpinner?.succeed();
460
+ if (isUnifiedSetupMetadataMissing(callback)) {
461
+ throw new Error(callback.legacyAgentKeyIssued ? SETUP_LEGACY_AGENT_KEY_HINT : SETUP_METADATA_MISSING_HINT);
487
462
  }
488
- }
489
- catch (error) {
490
- const restored = restoreConventionCopy(backupPath, targetPath);
463
+ const setup = callback;
464
+ // Storing the credential comes before `saveConfig` on purpose: a login that
465
+ // cannot be kept throws here, so init stops rather than leaving a project
466
+ // configured for a credential nothing can find.
467
+ const client = getPersonalTokenClient(context.apiUrl);
468
+ const session = await client.exchangeAuthorizationCode({
469
+ code: setup.code,
470
+ codeVerifier: pkce.verifier,
471
+ // Must match the redirect the web registered the code against.
472
+ redirectUri: `http://localhost:${server.port}/callback`,
473
+ });
474
+ saveConfig(context.configPath, toConfig(setup, context.apiUrl));
475
+ // The identifiers are passed explicitly rather than re-read from disk: this
476
+ // command may run against a `cwd` that is not `process.cwd()`, and the
477
+ // config resolver only ever looks at the latter.
478
+ const runtimeConfig = await loadConfigWithCredential({
479
+ teamId: setup.teamId,
480
+ projectId: setup.projectId,
481
+ apiUrl: context.apiUrl,
482
+ authMode: 'personal-token',
483
+ });
484
+ // Falling back to a bare `conventionDownload()` here would undo the sentence
485
+ // above: with no config passed it re-resolves the project from
486
+ // `process.cwd()`, so an `init --cwd` (a worktree bootstrap included) would
487
+ // quietly pull conventions for whichever project the shell happens to sit
488
+ // in. Fail instead — this only happens if credential resolution regressed.
489
+ if (!runtimeConfig) {
490
+ throw new Error('Signed in, but the stored credential could not be read back, so the convention template was not downloaded. ' +
491
+ 'Check `agentteams auth status`, then run `agentteams convention download` in this folder.');
492
+ }
493
+ // The convention template is now written by `conventionDownload` alone —
494
+ // exactly one write of `.agentteams/convention.md` per init — using the
495
+ // agent this setup actually created instead of "whichever config is first".
496
+ await conventionDownload({
497
+ cwd: context.cwd,
498
+ agentConfigId: setup.configId,
499
+ config: runtimeConfig,
500
+ });
491
501
  return {
492
- materialization: 'existing',
493
- issue: {
494
- code: 'link-create-failed',
495
- path: targetPath,
496
- message: restored
497
- ? `Could not relink ${targetPath} to the main checkout: ${toErrorMessage(error)}`
498
- : `Could not relink ${targetPath} to the main checkout: ${toErrorMessage(error)}. The original ${CONFIG_DIR} was left at ${backupPath}; move it back manually.`,
502
+ authUrl,
503
+ teamId: setup.teamId,
504
+ projectId: setup.projectId,
505
+ agentName: setup.agentName,
506
+ seedPlanId: setup.seedPlanId ?? null,
507
+ personalLogin: {
508
+ email: session.identity.email,
509
+ nickname: session.identity.nickname,
510
+ persisted: client.state().persisted,
499
511
  },
500
512
  };
501
513
  }
502
- const copyOnlyFiles = findCopyOnlyFiles(backupPath, sourcePath);
503
- if (copyOnlyFiles.length > 0) {
504
- return {
505
- materialization: 'relinked',
506
- issue: {
507
- code: 'link-backup-retained',
508
- path: backupPath,
509
- message: `Kept ${copyOnlyFiles.length} file(s) that exist only in the replaced ${CONFIG_DIR} copy (${copyOnlyFiles.slice(0, 3).join(', ')}${copyOnlyFiles.length > 3 ? ', …' : ''}) at ${backupPath}; move what you still need out of it and delete it.`,
510
- },
511
- };
514
+ catch (error) {
515
+ authSpinner?.fail();
516
+ if (server.server.listening) {
517
+ server.server.close();
518
+ }
519
+ throw error;
512
520
  }
513
- rmSync(backupPath, { recursive: true, force: true });
514
- return { materialization: 'relinked' };
515
521
  }
516
- function isBrokenSymbolicLink(path) {
522
+ /**
523
+ * The explicit `--auth api-key` compatibility path, unchanged.
524
+ *
525
+ * It keeps the legacy authorize URL (no `flow=setup`, no `code_challenge`), the
526
+ * legacy callback payload with its `key_`, and the direct convention write. CI
527
+ * users depend on this shape, so it is isolated from the default path rather
528
+ * than sharing a branch with it.
529
+ */
530
+ async function runLegacyApiKeySetup(context) {
531
+ let authContext;
517
532
  try {
518
- return lstatSync(path).isSymbolicLink() && !existsSync(path);
519
- }
520
- catch {
521
- return false;
533
+ authContext = await startLocalAuthServer({ state: createAuthState() });
522
534
  }
523
- }
524
- export function bootstrapLinkedWorktree(cwd) {
525
- let worktreePath;
526
- try {
527
- worktreePath = canonicalizePath(resolve(cwd));
535
+ catch (error) {
536
+ throw new Error(`Failed to start local OAuth server: ${error instanceof Error ? error.message : String(error)}`);
528
537
  }
529
- catch {
530
- return null;
531
- }
532
- if (resolveGitTopLevel(worktreePath) !== worktreePath)
533
- return null;
534
- const mainCheckoutRoot = resolveMainCheckoutRoot(worktreePath);
535
- if (!mainCheckoutRoot)
536
- return null;
537
- const sourcePath = join(mainCheckoutRoot, CONFIG_DIR);
538
- if (!existsSync(sourcePath) || !isConfiguredMainCheckout(sourcePath))
539
- return null;
540
- // The convention root is the parent of the canonical .agentteams directory.
541
- // Following the canonical path resolves double links as well
542
- // (worktree/.agentteams → member/.agentteams → non-git-root/.agentteams),
543
- // so the entry point set is read from the actual root — not the member repo.
544
- let conventionRoot = null;
538
+ const authUrl = buildAuthorizeUrl({
539
+ port: authContext.port,
540
+ projectName: context.projectName,
541
+ authPathEnc: context.authPathEnc,
542
+ osType: detectOsType(),
543
+ state: authContext.state,
544
+ machineId: readOrCreateMachineId() ?? undefined,
545
+ });
546
+ await tryOpenBrowser(authUrl);
547
+ const authSpinner = createSpinner('Waiting for authentication... (Ctrl+C to cancel)');
545
548
  try {
546
- conventionRoot = dirname(canonicalizePath(sourcePath));
549
+ const authResult = await waitForBrowserCallback(authContext, authSpinner);
550
+ authSpinner?.succeed();
551
+ const runtimeConfig = {
552
+ teamId: authResult.teamId,
553
+ projectId: authResult.projectId,
554
+ apiKey: authResult.apiKey,
555
+ apiUrl: context.apiUrl,
556
+ };
557
+ const conventionContent = await withSpinner('Fetching convention template...', () => fetchConventionTemplate(authResult, context.apiUrl));
558
+ saveLegacyApiKeyConfig(context.configPath, toLegacyApiKeyConfig(authResult, context.apiUrl));
559
+ writeFileSync(context.conventionPath, conventionContent, 'utf-8');
560
+ await conventionDownload({ cwd: context.cwd, config: runtimeConfig });
561
+ return {
562
+ authUrl,
563
+ teamId: authResult.teamId,
564
+ projectId: authResult.projectId,
565
+ agentName: authResult.agentName,
566
+ seedPlanId: authResult.seedPlanId ?? null,
567
+ };
547
568
  }
548
- catch {
549
- conventionRoot = null;
550
- }
551
- const selectedEntryPoints = conventionRoot
552
- ? AGENT_ENTRY_POINT_FILES.map((f) => f.value).filter((relativePath) => isReadableRegularFile(join(conventionRoot, relativePath)))
553
- : [];
554
- // Local exclude registration comes before creating any managed path so a
555
- // bootstrap never dirties the shared repository state.
556
- const issues = [];
557
- const excludeResult = ensureLocalExclude(worktreePath, [
558
- toAnchoredExcludePattern(CONFIG_DIR),
559
- ...selectedEntryPoints.map(toAnchoredExcludePattern),
560
- ]);
561
- if (excludeResult.status === 'blocked' && excludeResult.issue) {
562
- issues.push(excludeResult.issue);
563
- }
564
- const excludeReady = excludeResult.status === 'ready';
565
- const targetPath = join(worktreePath, CONFIG_DIR);
566
- let materialization;
567
- let warning;
568
- if (!excludeReady) {
569
- materialization = existsSync(targetPath) ? 'existing' : 'blocked';
570
- }
571
- else if (isBrokenSymbolicLink(targetPath)) {
572
- unlinkSync(targetPath);
573
- ({ materialization, warning } = linkConventionDir(sourcePath, targetPath));
574
- }
575
- else if (existsSync(targetPath)) {
576
- // An entry that is already a link is settled; a plain directory is either a
577
- // copy left by a failed link attempt or something the user put there.
578
- if (lstatSync(targetPath).isSymbolicLink()) {
579
- materialization = 'existing';
580
- }
581
- else {
582
- const promotion = promoteCopiedConventionDir(worktreePath, sourcePath, targetPath);
583
- materialization = promotion.materialization;
584
- if (promotion.issue) {
585
- issues.push(promotion.issue);
586
- }
569
+ catch (error) {
570
+ authSpinner?.fail();
571
+ if (authContext.server.listening) {
572
+ authContext.server.close();
587
573
  }
574
+ throw error;
588
575
  }
589
- else {
590
- ({ materialization, warning } = linkConventionDir(sourcePath, targetPath));
591
- }
592
- const entryPointResult = ensureConventionEntryPoints(worktreePath, selectedEntryPoints, {
593
- allowCreate: excludeReady,
594
- validateExistingReference: false,
576
+ }
577
+ async function runConfiguredProjectInit(cwd, executionContext, adapterOptions) {
578
+ const configPath = executionContext.configPath;
579
+ const projectConfig = executionContext.config;
580
+ if (!configPath || !projectConfig) {
581
+ throw new Error('The configured project could not be read. Run `agentteams init --auth personal-token` to relink it.');
582
+ }
583
+ // Only the identifiers are forwarded as overrides. Handing the whole config
584
+ // document to `loadConfigWithCredential` would put it above the environment in
585
+ // `mergeConfigSources` (global → project → env → options), so AGENTTEAMS_*
586
+ // would be ignored on this path alone, and a leftover legacy `apiKey` would be
587
+ // read as an *explicit* key — skipping the migration warning and the refresh
588
+ // slot. `apiUrl` follows the same env-first rule as `resolveApiUrl()`.
589
+ const runtimeConfig = await loadConfigWithCredential({
590
+ teamId: projectConfig.teamId,
591
+ projectId: projectConfig.projectId,
592
+ apiUrl: normalizeApiUrl(process.env.AGENTTEAMS_API_URL || projectConfig.apiUrl || DEFAULT_API_URL),
593
+ // `loadConfigWithCredential` normally discovers the project from
594
+ // `process.cwd()`, but init may repair an explicit `options.cwd`. An empty
595
+ // override is intentional: it prevents a legacy key from an unrelated
596
+ // ambient project from becoming this binding's fallback credential.
597
+ apiKey: process.env.AGENTTEAMS_API_KEY || projectConfig.apiKey || '',
598
+ ...(projectConfig.authMode ? { authMode: projectConfig.authMode } : {}),
595
599
  });
596
- issues.push(...entryPointResult.issues);
597
- const entryPoints = entryPointResult.entries.map(({ relativePath, state }) => ({ relativePath, state }));
598
- const result = {
600
+ if (!runtimeConfig) {
601
+ throw new Error("The existing project binding has no usable credential. Run 'agentteams auth login'.");
602
+ }
603
+ // `checkConventionFreshness` reports "no changes" when the download manifest is
604
+ // absent, which is exactly the state of a project that has never synced. Left
605
+ // alone it would report READY while `.agentteams/convention.md` and
606
+ // `.agentteams/platform/*` — the rules every runner loads as always_on — are
607
+ // missing, so init treats that state as "sync required" instead.
608
+ const conventionDir = dirname(configPath);
609
+ const conventionsNeverSynced = !existsSync(join(conventionDir, CONVENTION_MANIFEST_FILE)) || !existsSync(join(conventionDir, CONVENTION_FILE));
610
+ let freshness;
611
+ let conventionError;
612
+ let conventionsUpdated = false;
613
+ try {
614
+ freshness = await conventionStatus({ cwd, config: runtimeConfig });
615
+ if (freshness.conventionUpdateAvailable || conventionsNeverSynced) {
616
+ await conventionDownload({ cwd, config: runtimeConfig });
617
+ conventionsUpdated = true;
618
+ }
619
+ }
620
+ catch (error) {
621
+ conventionError = toErrorMessage(error);
622
+ }
623
+ // The same adapters the new-project path runs. Without them a re-run of
624
+ // `agentteams init` — which is exactly what every adapter retry command tells
625
+ // the user to do — verified the binding and changed nothing else.
626
+ const adapterPass = await runLocalAdapterPass(cwd, adapterOptions);
627
+ const doctor = await executeDoctorCommand({ cwd, installWorktreeHook: adapterOptions.installWorktreeHook });
628
+ const configuredAuthMode = runtimeConfig.credentialSource === 'personal-token' ? 'personal-token' : 'api-key';
629
+ const readiness = buildConfiguredProjectReadiness(conventionError, doctor, adapterPass.adapters);
630
+ return {
599
631
  success: true,
600
- mode: 'worktree',
601
- worktreePath,
602
- sourcePath,
603
- targetPath,
604
- materialization,
605
- entryPoints,
606
- issues,
632
+ mode: 'configured-project',
633
+ configPath,
634
+ conventionPath: join(dirname(configPath), CONVENTION_FILE),
635
+ teamId: runtimeConfig.teamId,
636
+ projectId: runtimeConfig.projectId,
637
+ authMode: configuredAuthMode,
638
+ credentialSource: runtimeConfig.credentialSource,
639
+ conventionsUpdated,
640
+ ...(freshness ? { conventionStatus: freshness } : {}),
641
+ ...(conventionError ? { conventionError } : {}),
642
+ doctor,
643
+ readiness,
644
+ agentFiles: adapterPass.agentFiles,
645
+ localAdapters: adapterPass.adapters,
646
+ ...(adapterPass.postCheckoutHook ? { postCheckoutHook: adapterPass.postCheckoutHook } : {}),
647
+ };
648
+ }
649
+ /**
650
+ * Roll the doctor verdict *and* the local adapter outcomes into one
651
+ * `local-adapters` step.
652
+ *
653
+ * Both sources matter: the doctor owns the layout-level diagnosis (member repo
654
+ * links, config permissions) while the adapters own the files this command
655
+ * writes, and a step that reported only one of them would call the stage READY
656
+ * while the other was degraded.
657
+ */
658
+ function buildConfiguredProjectReadiness(conventionError, doctor, adapters) {
659
+ const conventionStep = conventionError
660
+ ? {
661
+ stage: 'convention-sync',
662
+ status: 'DEGRADED',
663
+ issues: [{ code: 'convention-sync-failed', message: conventionError }],
664
+ retryCommand: 'agentteams convention download',
665
+ }
666
+ : { stage: 'convention-sync', status: 'READY', issues: [] };
667
+ const degradedAdapters = adapters.filter((adapter) => adapter.status === 'DEGRADED');
668
+ const doctorDegraded = doctor.status === 'DEGRADED';
669
+ // A NOT_APPLICABLE doctor still contributes its reason: `printReadiness` shows
670
+ // issues at every status, so the note survives even when an adapter succeeded
671
+ // and the step as a whole is READY.
672
+ const issues = [
673
+ ...(doctor.status === 'READY' ? [] : doctor.issues.map(({ code, message }) => ({ code, message }))),
674
+ ...adapters.filter((adapter) => adapter.status !== 'READY').flatMap((adapter) => adapter.issues),
675
+ ];
676
+ const status = doctorDegraded || degradedAdapters.length > 0
677
+ ? 'DEGRADED'
678
+ : adapters.some((adapter) => adapter.status === 'READY')
679
+ ? 'READY'
680
+ : 'SKIPPED';
681
+ const localAdaptersStep = {
682
+ stage: 'local-adapters',
683
+ status,
684
+ issues: status === 'DEGRADED' && issues.length === 0
685
+ ? [{ code: 'doctor-degraded', message: 'Local adapters still need attention.' }]
686
+ : issues,
687
+ ...(status === 'DEGRADED' ? { retryCommand: degradedAdapters[0]?.retryCommand ?? 'agentteams doctor' } : {}),
607
688
  };
608
- if (warning) {
609
- result.warning = warning;
689
+ return [
690
+ { stage: 'project-binding', status: 'READY', issues: [] },
691
+ { stage: 'credential', status: 'READY', issues: [] },
692
+ conventionStep,
693
+ localAdaptersStep,
694
+ ];
695
+ }
696
+ /**
697
+ * Roll the per-adapter outcomes into the single `local-adapters` readiness step.
698
+ *
699
+ * A degraded adapter dominates, and `SKIPPED` requires *every* adapter to have
700
+ * skipped — which the always-on `.gitignore` adapter makes unreachable today.
701
+ * The rule is kept honest for the day that adapter becomes conditional, but it
702
+ * is deliberately not what makes a partial skip visible: one green
703
+ * `[READY] local-adapters` line above a hook that was never installed is exactly
704
+ * the mismatch this list exists to prevent, so `printReadiness` prints the
705
+ * issues of every step regardless of its status. Every non-READY adapter
706
+ * contributes its issues, so the reason travels with the line.
707
+ */
708
+ function buildNewProjectReadiness(adapters) {
709
+ const degraded = adapters.filter((adapter) => adapter.status === 'DEGRADED');
710
+ const status = degraded.length > 0 ? 'DEGRADED' : adapters.some((adapter) => adapter.status === 'READY') ? 'READY' : 'SKIPPED';
711
+ const localAdaptersStep = {
712
+ stage: 'local-adapters',
713
+ status,
714
+ issues: adapters.filter((adapter) => adapter.status !== 'READY').flatMap((adapter) => adapter.issues),
715
+ ...(degraded.length > 0 ? { retryCommand: degraded[0].retryCommand ?? 'agentteams doctor' } : {}),
716
+ };
717
+ return [
718
+ { stage: 'project-binding', status: 'READY', issues: [] },
719
+ { stage: 'credential', status: 'READY', issues: [] },
720
+ { stage: 'convention-sync', status: 'READY', issues: [] },
721
+ localAdaptersStep,
722
+ ];
723
+ }
724
+ /**
725
+ * Run one local adapter without letting it fail the init that already
726
+ * succeeded.
727
+ *
728
+ * By the time these run, the credential is in the OS store and the config is on
729
+ * disk. An exception escaping from here used to surface as
730
+ * `Initialization failed: EACCES ...` for a project that was, in fact, fully
731
+ * connected — so every adapter is isolated and reports `DEGRADED` with its own
732
+ * retry command instead. Failures are never swallowed: an adapter that throws
733
+ * always produces an issue.
734
+ */
735
+ async function runLocalAdapter(adapter, retryCommand, run) {
736
+ try {
737
+ const outcome = await run();
738
+ return {
739
+ adapter,
740
+ status: outcome.status,
741
+ issues: outcome.issues,
742
+ ...(outcome.status === 'DEGRADED' ? { retryCommand } : {}),
743
+ };
744
+ }
745
+ catch (error) {
746
+ return {
747
+ adapter,
748
+ status: 'DEGRADED',
749
+ issues: [{ code: `${adapter}-failed`, message: toErrorMessage(error) }],
750
+ retryCommand,
751
+ };
610
752
  }
611
- return result;
612
753
  }
613
- export async function executeInitCommand(options) {
614
- return withCommandContext('init', () => executeInitCommandWithContext(options));
754
+ /**
755
+ * The four local adapters, run identically by both init paths.
756
+ *
757
+ * This is shared rather than duplicated because the adapters advertise retry
758
+ * commands (`agentteams init`, `agentteams init --agent-files <list>`) and those
759
+ * are all `agentteams init`. A project that has just been configured takes the
760
+ * configured-project fast path on the *next* run, so while this pass lived only
761
+ * in the new-project branch every retry command it printed was a no-op: the
762
+ * user was told how to fix a degraded `.gitignore` — the single thing keeping a
763
+ * legacy `--auth api-key` config's agent key out of the repository — by running
764
+ * a command that would not touch `.gitignore` at all.
765
+ */
766
+ async function runLocalAdapterPass(cwd, options) {
767
+ const adapters = [];
768
+ adapters.push(
769
+ // `.gitignore` is not conditional and never will be: it is what keeps
770
+ // `.agentteams` — including a legacy config carrying an apiKey — out of the
771
+ // repository.
772
+ await runLocalAdapter('gitignore', 'agentteams init', () => {
773
+ ensureGitignore(cwd);
774
+ return { status: 'READY', issues: [] };
775
+ }));
776
+ let selectedFiles = [];
777
+ let agentFiles = [];
778
+ adapters.push(await runLocalAdapter('agent-entry-points', 'agentteams init --agent-files <list>', async () => {
779
+ selectedFiles = await resolveAgentFileSelection(cwd, options.explicitAgentFiles, {
780
+ allowPrompt: options.allowPrompt,
781
+ });
782
+ if (selectedFiles.length === 0) {
783
+ return {
784
+ status: 'SKIPPED',
785
+ issues: [
786
+ {
787
+ code: 'agent-entry-points-not-selected',
788
+ message: 'No agent entry point file was created. Pass --agent-files (CLAUDE.md, AGENTS.md, GEMINI.md, .cursor/rules/agentteams.mdc) to create them explicitly.',
789
+ },
790
+ ],
791
+ };
792
+ }
793
+ // Assigned before the failure check so a partially failed write still
794
+ // reports the files that actually reached disk.
795
+ const written = generateAgentEntryPointFiles(cwd, selectedFiles, {
796
+ createExample: options.agentFilesExample,
797
+ });
798
+ agentFiles = written.entries;
799
+ if (written.failures.length > 0) {
800
+ return {
801
+ status: 'DEGRADED',
802
+ issues: written.failures.map((failure) => ({
803
+ code: 'agent-entry-point-write-failed',
804
+ message: `${failure.relativePath} could not be written: ${failure.message}`,
805
+ })),
806
+ };
807
+ }
808
+ const skipped = agentFiles.filter((file) => file.type === 'skipped');
809
+ if (skipped.length === agentFiles.length) {
810
+ return {
811
+ status: 'SKIPPED',
812
+ issues: [
813
+ {
814
+ code: 'agent-entry-points-exist',
815
+ message: `Left ${skipped.map((file) => file.relativePath).join(', ')} untouched because the file already exists.`,
816
+ },
817
+ ],
818
+ };
819
+ }
820
+ return { status: 'READY', issues: [] };
821
+ }));
822
+ adapters.push(await runLocalAdapter('gemini-ignore', 'agentteams init --agent-files GEMINI.md', () => {
823
+ if (!selectedFiles.includes('GEMINI.md')) {
824
+ return {
825
+ status: 'SKIPPED',
826
+ issues: [{ code: 'gemini-ignore-not-selected', message: 'GEMINI.md was not selected.' }],
827
+ };
828
+ }
829
+ ensureGeminiIgnore(cwd);
830
+ return { status: 'READY', issues: [] };
831
+ }));
832
+ // A git-root project shares one hooks directory with all its worktrees, so the
833
+ // managed post-checkout hook lets future `git worktree add` runs bootstrap
834
+ // conventions automatically (via `agentteams init`). It is no longer installed
835
+ // unconditionally: a repository that never uses linked worktrees got its
836
+ // shared `.git/hooks` written for a hook it would never fire. The gate itself
837
+ // lives in `shouldInstallWorktreeHook` so `agentteams doctor` — which this
838
+ // command's own fast path invokes — applies the same rule.
839
+ let postCheckoutHook;
840
+ adapters.push(await runLocalAdapter('post-checkout-hook', 'agentteams doctor', () => {
841
+ if (resolveGitTopLevel(cwd) === null) {
842
+ // Non-git roots (a parent folder grouping member repos) have no
843
+ // git-common dir; doctor owns their per-member hooks instead.
844
+ return {
845
+ status: 'SKIPPED',
846
+ issues: [
847
+ {
848
+ code: 'post-checkout-hook-not-a-git-root',
849
+ message: "Not a git repository root, so no worktree bootstrap hook was installed. Run 'agentteams doctor' to set up member repository hooks.",
850
+ },
851
+ ],
852
+ };
853
+ }
854
+ if (!shouldInstallWorktreeHook(cwd, { force: options.installWorktreeHook })) {
855
+ return {
856
+ status: 'SKIPPED',
857
+ issues: [
858
+ {
859
+ code: 'post-checkout-hook-no-worktrees',
860
+ message: "This repository has no linked git worktrees, so the worktree bootstrap hook was not installed. Run 'agentteams init --install-worktree-hook' (or 'agentteams doctor --install-worktree-hook') to install it anyway.",
861
+ },
862
+ ],
863
+ };
864
+ }
865
+ postCheckoutHook = ensurePostCheckoutHook(cwd);
866
+ if (postCheckoutHook.status === 'blocked') {
867
+ // The existing issue code and message, verbatim — the user hook
868
+ // protection in `ensurePostCheckoutHook` is what produced them.
869
+ return {
870
+ status: 'DEGRADED',
871
+ issues: [
872
+ {
873
+ code: postCheckoutHook.issue?.code ?? 'post-checkout-hook-blocked',
874
+ message: postCheckoutHook.issue?.message ?? 'The worktree bootstrap hook could not be installed.',
875
+ },
876
+ ],
877
+ };
878
+ }
879
+ return { status: 'READY', issues: [] };
880
+ }));
881
+ return { adapters, agentFiles, ...(postCheckoutHook ? { postCheckoutHook } : {}) };
615
882
  }
616
883
  async function executeInitCommandWithContext(options) {
617
884
  const cwd = resolve(options?.cwd ?? process.cwd());
618
- // Only an explicit compatibility request persists the callback's agent key.
885
+ // Only an explicit compatibility request goes through the agent-key round trip.
619
886
  const authMode = options?.authMode === 'api-key' ? 'api-key' : 'personal-token';
620
- const worktreeResult = bootstrapLinkedWorktree(cwd);
621
- if (worktreeResult)
622
- return worktreeResult;
623
- const configPath = join(cwd, CONFIG_DIR, CONFIG_FILE);
624
- const conventionPath = join(cwd, CONFIG_DIR, CONVENTION_FILE);
625
- const projectName = basename(cwd);
626
- let authContext;
627
- try {
628
- authContext = await startLocalAuthServer({ state: createAuthState() });
887
+ // Parsed before anything opens a browser: a typo in --agent-files must not be
888
+ // discovered after the user has already signed in.
889
+ const explicitAgentFiles = parseAgentFilesOption(options?.agentFiles);
890
+ const adapterOptions = {
891
+ explicitAgentFiles,
892
+ agentFilesExample: options?.agentFilesExample === true,
893
+ installWorktreeHook: options?.installWorktreeHook === true,
894
+ // Overridden per path below: only a first-time setup may prompt.
895
+ allowPrompt: true,
896
+ };
897
+ const executionContext = detectInitExecutionContext(cwd, options?.authMode);
898
+ if (executionContext.kind === 'linked-worktree') {
899
+ const worktreeResult = bootstrapLinkedWorktree(cwd);
900
+ if (worktreeResult)
901
+ return worktreeResult;
629
902
  }
630
- catch (error) {
631
- throw new Error(`Failed to start local OAuth server: ${error instanceof Error ? error.message : String(error)}`);
903
+ if (executionContext.kind === 'configured-project') {
904
+ try {
905
+ return await runConfiguredProjectInit(cwd, executionContext, { ...adapterOptions, allowPrompt: false });
906
+ }
907
+ catch (error) {
908
+ throw new Error(`Initialization failed: ${toErrorMessage(error)}`);
909
+ }
632
910
  }
911
+ const configPath = join(cwd, CONFIG_DIR, CONFIG_FILE);
912
+ const conventionPath = join(cwd, CONFIG_DIR, CONVENTION_FILE);
633
913
  let authPathEnc;
634
914
  try {
635
915
  authPathEnc = encryptAuthPath(cwd);
@@ -637,125 +917,42 @@ async function executeInitCommandWithContext(options) {
637
917
  catch {
638
918
  authPathEnc = undefined;
639
919
  }
640
- const authUrl = buildAuthorizeUrl(authContext.port, projectName, authPathEnc, detectOsType(), authContext.state, readOrCreateMachineId() ?? undefined);
641
- await tryOpenBrowser(authUrl);
642
- const authSpinner = createSpinner('Waiting for authentication... (Ctrl+C to cancel)');
643
- const cleanup = () => {
644
- if (authContext.server.listening) {
645
- authContext.server.close();
646
- }
920
+ const context = {
921
+ cwd,
922
+ projectName: basename(cwd),
923
+ apiUrl: resolveApiUrl(),
924
+ configPath,
925
+ conventionPath,
926
+ authPathEnc,
647
927
  };
928
+ let setup;
648
929
  try {
649
- const authResult = await new Promise((resolve, reject) => {
650
- const onSigint = () => {
651
- authSpinner?.fail('Init cancelled.');
652
- cleanup();
653
- process.exit(0);
654
- };
655
- process.on('SIGINT', onSigint);
656
- if (process.stdin.isTTY) {
657
- process.stdin.setRawMode(true);
658
- process.stdin.resume();
659
- process.stdin.on('data', (key) => {
660
- if (key[0] === 0x03) {
661
- process.stdin.setRawMode(false);
662
- process.stdin.pause();
663
- onSigint();
664
- }
665
- });
666
- }
667
- authContext
668
- .waitForCallback()
669
- .then((result) => {
670
- process.removeListener('SIGINT', onSigint);
671
- if (process.stdin.isTTY) {
672
- process.stdin.setRawMode(false);
673
- process.stdin.pause();
674
- process.stdin.removeAllListeners('data');
675
- }
676
- resolve(result);
677
- })
678
- .catch(reject);
679
- });
680
- authSpinner?.succeed();
681
- const apiUrl = resolveApiUrl();
682
- const runtimeConfig = {
683
- teamId: authResult.teamId,
684
- projectId: authResult.projectId,
685
- apiKey: authResult.apiKey,
686
- apiUrl,
687
- };
688
- const conventionContent = await withSpinner('Fetching convention template...', () => fetchConventionTemplate(authResult, apiUrl));
689
- // The project is known now, so the personal login can run before anything is
690
- // written: a failed login must not leave a config with no usable credential.
691
- // A login that cannot be stored throws here, which is the point — init stops
692
- // before `saveConfig` rather than leaving a half-configured project behind.
693
- let personalLogin;
694
- const warnings = [];
695
- if (authMode === 'personal-token') {
696
- const outcome = await performPersonalTokenLogin({ apiUrl, projectName });
697
- personalLogin = {
698
- email: outcome.identity.email,
699
- nickname: outcome.identity.nickname,
700
- persisted: outcome.persisted,
701
- };
702
- }
703
- if (authMode === 'api-key') {
704
- saveLegacyApiKeyConfig(configPath, toLegacyApiKeyConfig(authResult, apiUrl));
705
- }
706
- else {
707
- saveConfig(configPath, toConfig(authResult, apiUrl));
708
- }
709
- writeFileSync(conventionPath, conventionContent, 'utf-8');
710
- await conventionDownload({ cwd, config: runtimeConfig });
711
- // Last use of the agent key is the convention download above.
712
- let agentKeyRevoked;
713
- if (authMode === 'personal-token') {
714
- agentKeyRevoked = await revokeInitAgentKey(authResult, apiUrl);
715
- if (!agentKeyRevoked) {
716
- warnings.push(`The agent key created for "${authResult.agentName}" could not be revoked and is still valid. ` +
717
- 'Revoke it in the AgentTeams web app (project settings → agents) — it was never written to this repository.');
718
- }
719
- }
720
- ensureGitignore(cwd);
721
- const selectedFiles = await promptAgentFileSelection();
722
- if (selectedFiles.includes('GEMINI.md')) {
723
- ensureGeminiIgnore(cwd);
724
- }
725
- const agentFiles = generateAgentEntryPointFiles(cwd, selectedFiles);
726
- const seedPlanId = authResult.seedPlanId ?? null;
727
- const seedPlanWebUrl = seedPlanId ? `${AUTH_BASE_URL.replace(/\/+$/, '')}/go?type=plan&id=${seedPlanId}` : null;
728
- // A git-root project shares one hooks directory with all its worktrees, so
729
- // installing the managed post-checkout hook here lets future `git worktree
730
- // add` runs bootstrap conventions automatically (via `agentteams init`).
731
- // Non-git roots (a parent folder grouping member repos) have no git-common
732
- // dir; `agentteams doctor` owns their per-member hooks instead, so skip.
733
- let postCheckoutHook;
734
- if (resolveGitTopLevel(cwd) !== null) {
735
- postCheckoutHook = ensurePostCheckoutHook(cwd);
736
- }
737
- return {
738
- success: true,
739
- authUrl,
740
- configPath,
741
- conventionPath,
742
- teamId: authResult.teamId,
743
- projectId: authResult.projectId,
744
- agentName: authResult.agentName,
745
- agentFiles,
746
- seedPlanId,
747
- seedPlanWebUrl,
748
- postCheckoutHook,
749
- authMode,
750
- ...(personalLogin ? { personalLogin } : {}),
751
- ...(agentKeyRevoked === undefined ? {} : { agentKeyRevoked }),
752
- ...(warnings.length > 0 ? { warning: warnings.join(' ') } : {}),
753
- };
930
+ setup = authMode === 'api-key' ? await runLegacyApiKeySetup(context) : await runUnifiedSetup(context);
754
931
  }
755
932
  catch (error) {
756
- authSpinner?.fail();
757
- cleanup();
758
933
  throw new Error(`Initialization failed: ${error instanceof Error ? error.message : String(error)}`);
759
934
  }
935
+ // Everything below runs *after* the credential and the config are stored, so
936
+ // none of it may fail the init. Each adapter reports itself.
937
+ const { adapters: localAdapters, agentFiles, postCheckoutHook } = await runLocalAdapterPass(cwd, adapterOptions);
938
+ const seedPlanId = setup.seedPlanId;
939
+ const seedPlanWebUrl = seedPlanId ? `${AUTH_BASE_URL.replace(/\/+$/, '')}/go?type=plan&id=${seedPlanId}` : null;
940
+ return {
941
+ success: true,
942
+ authUrl: setup.authUrl,
943
+ configPath,
944
+ conventionPath,
945
+ teamId: setup.teamId,
946
+ projectId: setup.projectId,
947
+ agentName: setup.agentName,
948
+ agentFiles,
949
+ seedPlanId,
950
+ seedPlanWebUrl,
951
+ postCheckoutHook,
952
+ authMode,
953
+ readiness: buildNewProjectReadiness(localAdapters),
954
+ localAdapters,
955
+ ...(setup.personalLogin ? { personalLogin: setup.personalLogin } : {}),
956
+ };
760
957
  }
761
958
  //# sourceMappingURL=init.js.map