@kendoo.agentdesk/agentdesk 0.19.6 → 0.19.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -13,6 +13,17 @@ Internal refactors, infrastructure changes, and architectural notes are not list
13
13
  ### Added
14
14
  - `[UI]` Private-session sharing flow. When someone visits a session URL they don't own, they now see a friendly "Shh… this one's private" page with a one-click "Request access" button instead of a blank error. The session owner sees pending requests in a new header inbox and can grant viewer access for just that session or the whole project. Viewer-granted sessions show up in the teammate's sidebar tagged as a viewer.
15
15
 
16
+ ## [0.19.8] — 2026-04-19
17
+
18
+ ### Fixed
19
+ - `[CLI]` `agentdesk init` no longer silently dumps you into the new-project wizard when there's no `.agentdesk.json` and the git remote doesn't auto-match an existing project. It now asks upfront: "Connect this repo to an existing project on my account (bootstrap)" / "Set up this as a new project (full wizard)" / "Cancel". Picking "Connect" opens the project picker from your account — the same flow `agentdesk bootstrap` runs — so a second-machine user can always attach to an existing project without accidentally creating a duplicate. Auto-match still short-circuits to token refresh when it finds a confident single match.
20
+
21
+ ## [0.19.7] — 2026-04-19
22
+
23
+ ### Changed
24
+ - `[CLI]` `agentdesk init` now auto-detects the second-machine / cloned-repo case and switches to a token-refresh flow without opening the wizard. Two fast paths land before the full wizard: (1) when `.agentdesk.json` already has a `projectKey`, the first menu offers "Refresh tokens" as the default action (along with "Tracker only", "Full setup", "Cancel"); (2) when there's no local config but the user's account has a project whose id or name matches the git remote, init writes a minimal `.agentdesk.json` and goes straight to the token-refresh. Pass `--force-full` to bypass both and walk the full wizard. The standalone `agentdesk bootstrap` command is now a thin alias over the same flow.
25
+ - `[CLI]` All interactive menus (tracker picker, re-run action, retry/skip/cancel prompts, team/project picker) now use arrow-key navigation with color highlighting via `@inquirer/prompts` instead of numbered text prompts. Ctrl-C during a menu exits cleanly with status 130.
26
+
16
27
  ## [0.19.6] — 2026-04-19
17
28
 
18
29
  ### Changed
package/README.md CHANGED
@@ -45,9 +45,11 @@ agentdesk init
45
45
 
46
46
  A guided wizard walks you through it: picks up your project type, asks which task tracker you use (Linear, Jira, GitHub Issues, or none), shows the tradeoff between a **dedicated AgentDesk user** and your personal account with step-by-step setup for either, verifies the credentials, echoes "Posting as: …" so you can confirm which identity will appear on tickets, then writes `.agentdesk.json`.
47
47
 
48
- Re-running `agentdesk init` in a project that already has config offers a "tracker only" shortcut. Use `agentdesk init --quick` to skip the narrative copy for scripted setups.
48
+ Re-running `agentdesk init` in a project that already has config offers a four-way menu with **"Refresh tokens"** as the default — so rotating a token or setting up a second machine is a single enter-press, no wizard walk. The other options are "Tracker only", "Full setup", and "Cancel". Use `agentdesk init --quick` to skip the narrative copy for scripted setups, or `--force-full` to bypass the fast paths and walk the full wizard.
49
49
 
50
- **On a second machine** (repo cloned from a teammate's setup): run `agentdesk bootstrap` instead of `init`. It reads the committed `.agentdesk.json` for the project-wide settings (tracker type, team/project key, repo) and only prompts for per-machine secrets (Linear API key, Jira email + token, GitHub token) that end up in `.env`. The other machine's `.env` and the server config are left untouched.
50
+ **On a second machine** (repo cloned from a teammate's setup): just run `agentdesk init`. If no `.agentdesk.json` is committed, `init` fetches your account's project list, auto-matches this clone against the git remote, and switches to token-refresh mode — you only paste the per-machine tokens (Linear API key, Jira email + token, GitHub token) into `.env`. The explicit `agentdesk bootstrap` command still exists as an alias that always runs that flow. The other machine's `.env` and the server config are left untouched.
51
+
52
+ All menus use arrow-key navigation with color highlighting.
51
53
 
52
54
  `.agentdesk.json` is kept in sync with the server: every CLI run fetches the authoritative config and rewrites the local file as a credential-free snapshot. Credentials live only on the server, encrypted. If a local edit disagreed with the server's value, a one-line warning lists the overridden fields.
53
55
 
package/bin/agentdesk.mjs CHANGED
@@ -60,13 +60,14 @@ if (!command || command === "help" || command === "--help") {
60
60
 
61
61
  On a freshly cloned repo (already set up on another machine):
62
62
  1. agentdesk login Sign in
63
- 2. agentdesk bootstrap Fill in per-machine secrets (tokens)
63
+ 2. agentdesk init Auto-detects the project and asks only for tokens
64
+ (or run 'agentdesk bootstrap' — same flow)
64
65
  3. agentdesk team TASK-123 Run a team session
65
66
 
66
67
  Commands:
67
68
  agentdesk login Sign in to AgentDesk
68
69
  agentdesk logout Sign out and remove credentials
69
- agentdesk init [--quick] Set up project and configure tracker
70
+ agentdesk init [--quick | --force-full] Set up project (auto-detects cloned repos & offers token refresh)
70
71
  agentdesk bootstrap Fill in per-machine tokens on a cloned repo
71
72
  agentdesk team <TASK-ID> Run a team session on an existing task
72
73
  agentdesk team -d "..." Create a task and run a session
@@ -148,8 +149,10 @@ else if (command === "logout") {
148
149
 
149
150
  else if (command === "init") {
150
151
  const { runInit } = await import("../cli/init.mjs");
151
- const quick = args.slice(1).includes("--quick");
152
- await runInit(process.cwd(), { quick });
152
+ const rest = args.slice(1);
153
+ const quick = rest.includes("--quick");
154
+ const forceFull = rest.includes("--force-full") || rest.includes("--full");
155
+ await runInit(process.cwd(), { quick, forceFull });
153
156
  }
154
157
 
155
158
  else if (command === "bootstrap") {
package/cli/bootstrap.mjs CHANGED
@@ -7,22 +7,17 @@
7
7
  // JIRA_EMAIL/JIRA_API_TOKEN, GITHUB_TOKEN). Bootstrap detects which are
8
8
  // missing or invalid and prompts only for those.
9
9
  //
10
- // Hard constraints:
11
- // - Never write `.agentdesk.json` (besides the cached-server-state rewrite
12
- // that loadConfig already does on every CLI run — we don't add new ones).
13
- // - Never push to the server. Project-wide config on machine 1 is untouched.
14
- // - Only writes to `.env`.
15
- // - Idempotent. Running on a fully-configured machine prints "all set" and
16
- // exits 0.
10
+ // This module also exports reusable building blocks so `agentdesk init` can
11
+ // short-circuit to the bootstrap flow when it detects a fresh clone.
17
12
 
18
13
  import { existsSync, readFileSync, writeFileSync } from "fs";
19
14
  import { join } from "path";
20
- import { createInterface } from "readline";
21
15
  import { execSync } from "child_process";
22
16
  import { loadConfig } from "./config.mjs";
23
17
  import { getStoredApiKey } from "./login.mjs";
24
18
  import { checkTrackerPermissions, resolveCredentialsFromEnv } from "./tracker-check.mjs";
25
19
  import { assertPushable, PreflightError } from "./session-preflight.mjs";
20
+ import { select, promptRequired } from "./prompts.mjs";
26
21
 
27
22
  const SERVER = process.env.AGENTDESK_SERVER || "https://agentdesk.live";
28
23
 
@@ -65,14 +60,41 @@ function normalizeName(s) {
65
60
  return tail.toLowerCase().replace(/[^a-z0-9]/g, "");
66
61
  }
67
62
 
68
- // Discover the server-side project for this clone when .agentdesk.json is
69
- // absent. Strategy (each tier that yields exactly one match auto-selects):
70
- // Tier 1 server-side github.repo equals the local git remote (owner/repo).
71
- // Tier 2 the repo name from the remote matches the project id or name
72
- // (case-insensitive, scope-stripped).
73
- // When 0 or 2+ projects match, fall back to a numbered picker.
74
- // Returns the chosen projectKey, or null if the user aborts.
75
- async function discoverProject(rl, cwd, apiKey) {
63
+ // Silent auto-match: returns { projectKey, name, reason } when the caller can
64
+ // pick a server-side project with high confidence without asking the user,
65
+ // or null otherwise. Used by both `agentdesk init` (to decide whether to
66
+ // skip the wizard) and `agentdesk bootstrap` (as step 1 of discovery).
67
+ export async function autoMatchProject(cwd, apiKey) {
68
+ const remote = detectGitRemote(cwd);
69
+ if (!remote) return null;
70
+ const projects = await fetchProjects(apiKey);
71
+ if (!projects || projects.length === 0) return null;
72
+
73
+ const remoteRepoName = remote.split("/").pop();
74
+ const remoteKey = normalizeName(remoteRepoName);
75
+
76
+ // Tier 1: exact github.repo match via settings.
77
+ const settings = await Promise.all(projects.map(p => fetchProjectSettings(apiKey, p.id)));
78
+ const tier1 = projects.filter((_, i) => {
79
+ const repo = settings[i]?.github?.repo;
80
+ return repo && repo.toLowerCase() === remote.toLowerCase();
81
+ });
82
+ if (tier1.length === 1) {
83
+ return { projectKey: tier1[0].id, name: tier1[0].name, reason: `github.repo matches ${remote}` };
84
+ }
85
+
86
+ // Tier 2: id or name matches the repo name.
87
+ const tier2 = projects.filter(p => normalizeName(p.id) === remoteKey || normalizeName(p.name) === remoteKey);
88
+ if (tier2.length === 1) {
89
+ return { projectKey: tier2[0].id, name: tier2[0].name, reason: `project name matches repo "${remoteRepoName}"` };
90
+ }
91
+ return null;
92
+ }
93
+
94
+ // Full discovery with user fallback: auto-match first, then show a numbered
95
+ // picker. Writes `.agentdesk.json` with the chosen projectKey so subsequent
96
+ // loadConfig calls pick it up. Returns the chosen projectKey or null.
97
+ export async function discoverProject(cwd, apiKey) {
76
98
  const remote = detectGitRemote(cwd);
77
99
  if (remote) console.log(` Detected git remote: ${remote}`);
78
100
  console.log(" Fetching your projects from the server...");
@@ -86,7 +108,6 @@ async function discoverProject(rl, cwd, apiKey) {
86
108
  return null;
87
109
  }
88
110
 
89
- // Fetch settings in parallel so we can display/match the configured repo.
90
111
  const settings = await Promise.all(projects.map(p => fetchProjectSettings(apiKey, p.id)));
91
112
  const remoteRepoName = remote ? remote.split("/").pop() : null;
92
113
  const remoteKey = normalizeName(remoteRepoName);
@@ -116,47 +137,29 @@ async function discoverProject(rl, cwd, apiKey) {
116
137
  console.log(` ✓ Auto-matched: ${chosen.project.name} (${chosen.project.id}) — ${matchReason}`);
117
138
  } else {
118
139
  console.log("");
119
- console.log(" Couldn't auto-match this clone to one of your projects.");
120
- console.log("");
121
- rows.forEach((r, i) => {
122
- const tag = r.tier1 ? " ← repo match" : r.tier2 ? " ← name match" : "";
123
- const repoStr = r.repo ? ` [${r.repo}]` : "";
124
- console.log(` ${i + 1}) ${r.project.name} (${r.project.id})${repoStr}${tag}`);
140
+ const picked = await select({
141
+ message: "Couldn't auto-match this clone — pick the project it maps to",
142
+ choices: [
143
+ ...rows.map(r => {
144
+ const tag = r.tier1 ? " (repo match)" : r.tier2 ? " (name match)" : "";
145
+ const repoStr = r.repo ? ` [${r.repo}]` : "";
146
+ return { name: `${r.project.name} (${r.project.id})${repoStr}${tag}`, value: r.project.id };
147
+ }),
148
+ { name: "Cancel", value: "__cancel__" },
149
+ ],
125
150
  });
126
- console.log("");
127
- while (true) {
128
- const answer = await askInline(rl, ` Pick one (1-${rows.length}, or 'c' to cancel): `);
129
- const trimmed = answer.trim().toLowerCase();
130
- if (trimmed === "c" || trimmed === "cancel") return null;
131
- const num = parseInt(trimmed, 10);
132
- if (num >= 1 && num <= rows.length) { chosen = rows[num - 1]; break; }
133
- console.log(` Please enter a number between 1 and ${rows.length}`);
134
- }
151
+ if (picked === "__cancel__") return null;
152
+ chosen = rows.find(r => r.project.id === picked);
135
153
  }
136
154
 
137
- // Write a minimal .agentdesk.json — loadConfig will merge server state
138
- // into it on the next call, producing the full cached view.
139
- const minimal = { projectKey: chosen.project.id };
140
- writeFileSync(join(cwd, ".agentdesk.json"), JSON.stringify(minimal, null, 2) + "\n");
155
+ writeProjectConfig(cwd, chosen.project.id);
141
156
  console.log(` ✓ Wrote .agentdesk.json (projectKey: ${chosen.project.id})`);
142
157
  console.log(" (commit this file so future clones skip discovery)");
143
158
  return chosen.project.id;
144
159
  }
145
160
 
146
- function askInline(rl, question) {
147
- return new Promise(resolve => rl.question(question, resolve));
148
- }
149
-
150
- function ask(rl, question) {
151
- return new Promise(resolve => rl.question(question, resolve));
152
- }
153
-
154
- async function promptRequired(rl, label) {
155
- while (true) {
156
- const v = (await ask(rl, ` ${label}: `)).trim();
157
- if (v) return v;
158
- console.log(` ${label} is required. Ctrl+C to abort.`);
159
- }
161
+ export function writeProjectConfig(cwd, projectKey) {
162
+ writeFileSync(join(cwd, ".agentdesk.json"), JSON.stringify({ projectKey }, null, 2) + "\n");
160
163
  }
161
164
 
162
165
  function saveEnvVar(dir, key, value) {
@@ -202,15 +205,13 @@ function printTrackerHint(tracker) {
202
205
  }
203
206
  }
204
207
 
205
- // Walk missing tracker credentials and prompt for each. Writes to .env.
206
- // Returns the merged credentials map so the caller can verify.
207
- async function promptTrackerCreds(rl, cwd, tracker, existing) {
208
+ async function promptTrackerCreds(cwd, tracker, existing) {
208
209
  const creds = { ...existing };
209
210
  if (tracker === "linear") {
210
211
  if (!creds.LINEAR_API_KEY) {
211
212
  console.log("");
212
213
  printTrackerHint("linear");
213
- const v = await promptRequired(rl, "Linear API key");
214
+ const v = await promptRequired("Linear API key");
214
215
  saveEnvVar(cwd, "LINEAR_API_KEY", v);
215
216
  creds.LINEAR_API_KEY = v;
216
217
  console.log(" ✓ Saved LINEAR_API_KEY to .env");
@@ -219,14 +220,14 @@ async function promptTrackerCreds(rl, cwd, tracker, existing) {
219
220
  if (!creds.JIRA_EMAIL) {
220
221
  console.log("");
221
222
  printTrackerHint("jira");
222
- const v = await promptRequired(rl, "Your Atlassian login email");
223
+ const v = await promptRequired("Your Atlassian login email");
223
224
  saveEnvVar(cwd, "JIRA_EMAIL", v);
224
225
  creds.JIRA_EMAIL = v;
225
226
  console.log(" ✓ Saved JIRA_EMAIL to .env");
226
227
  }
227
228
  if (!creds.JIRA_API_TOKEN) {
228
229
  if (creds.JIRA_EMAIL) console.log("");
229
- const v = await promptRequired(rl, "Jira API token");
230
+ const v = await promptRequired("Jira API token");
230
231
  saveEnvVar(cwd, "JIRA_API_TOKEN", v);
231
232
  creds.JIRA_API_TOKEN = v;
232
233
  console.log(" ✓ Saved JIRA_API_TOKEN to .env");
@@ -235,67 +236,21 @@ async function promptTrackerCreds(rl, cwd, tracker, existing) {
235
236
  return creds;
236
237
  }
237
238
 
238
- async function promptGitHubToken(rl, cwd) {
239
+ async function promptGitHubToken(cwd) {
239
240
  console.log("");
240
241
  printTrackerHint("github");
241
- const v = await promptRequired(rl, "GitHub token");
242
+ const v = await promptRequired("GitHub token");
242
243
  saveEnvVar(cwd, "GITHUB_TOKEN", v);
243
244
  console.log(" ✓ Saved GITHUB_TOKEN to .env");
244
245
  return v;
245
246
  }
246
247
 
247
- async function selectOption(rl, prompt, options) {
248
- console.log(` ${prompt}`);
249
- console.log("");
250
- options.forEach((opt, i) => console.log(` ${i + 1}) ${opt.label}`));
251
- console.log("");
252
- while (true) {
253
- const answer = await ask(rl, ` Choose (1-${options.length}): `);
254
- const num = parseInt(answer.trim(), 10);
255
- if (num >= 1 && num <= options.length) return options[num - 1];
256
- console.log(` Please enter a number between 1 and ${options.length}`);
257
- }
258
- }
259
-
260
- export async function runBootstrap(cwd = process.cwd()) {
261
- console.log("");
262
- console.log(" AgentDesk — Bootstrap this machine");
263
- console.log(" ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
264
- console.log("");
265
-
266
- // 1. Must be logged in.
267
- const apiKey = getStoredApiKey();
268
- if (!apiKey) {
269
- console.log(" Not logged in. Run `agentdesk login` first.");
270
- console.log("");
271
- process.exit(1);
272
- }
273
-
274
- const rl = createInterface({ input: process.stdin, output: process.stdout });
275
-
276
- // 2. Find the server-side project for this clone. Prefer a committed
277
- // .agentdesk.json (clean path); fall back to discovering from the
278
- // user's server-side project list when the file is missing.
279
- let localConfig = readLocalConfig(cwd);
280
- let projectKey = localConfig?.projectKey || null;
281
-
282
- if (!projectKey) {
283
- if (localConfig) {
284
- console.log(" .agentdesk.json has no projectKey — discovering project from server.");
285
- } else {
286
- console.log(" No .agentdesk.json in this directory — discovering project from server.");
287
- }
288
- console.log("");
289
- projectKey = await discoverProject(rl, cwd, apiKey);
290
- if (!projectKey) { rl.close(); process.exit(1); }
291
- localConfig = readLocalConfig(cwd);
292
- console.log("");
293
- }
294
-
295
- // 3. Load merged config (server-authoritative when reachable). This is the
296
- // same call `agentdesk team` makes, so we see the same view.
248
+ // Shared gap-scan: given a cwd with a valid `.agentdesk.json` (or at least
249
+ // a projectKey), load server-authoritative config, figure out which secrets
250
+ // are missing or broken, prompt for them, validate. Writes to `.env` only.
251
+ // Returns { trackerOk, githubOk } for the caller to surface.
252
+ export async function gapScan({ cwd, apiKey, projectKey }) {
297
253
  const config = await loadConfig(cwd, { apiKey, serverUrl: SERVER, projectName: projectKey, silent: true });
298
-
299
254
  const tracker = config.tracker || null;
300
255
  const repo = config.github?.repo || null;
301
256
  const login = config.github?.login || null;
@@ -307,24 +262,19 @@ export async function runBootstrap(cwd = process.cwd()) {
307
262
  if (repo) console.log(` Repo: ${repo}${login ? ` (@${login})` : ""}`);
308
263
  console.log("");
309
264
 
310
- // 4. Gap scan + prompt loop. We retry tracker + github verification up to
311
- // three times, matching init's retry/skip/cancel UX.
312
265
  let creds = resolveCredentialsFromEnv(loadDotEnvLocal(cwd));
313
- let trackerOk = !tracker; // no tracker → nothing to verify
266
+ let trackerOk = !tracker;
314
267
  let githubOk = false;
315
268
  let attempts = 0;
316
269
 
317
270
  while (attempts < 5) {
318
271
  attempts += 1;
319
272
 
320
- // Tracker
321
273
  if (tracker && !trackerOk) {
322
274
  const missing =
323
275
  (tracker === "linear" && !creds.LINEAR_API_KEY) ||
324
276
  (tracker === "jira" && (!creds.JIRA_EMAIL || !creds.JIRA_API_TOKEN));
325
- if (missing) {
326
- creds = await promptTrackerCreds(rl, cwd, tracker, creds);
327
- }
277
+ if (missing) creds = await promptTrackerCreds(cwd, tracker, creds);
328
278
  process.stdout.write(" Verifying tracker access... ");
329
279
  const check = await checkTrackerPermissions({ tracker, config, credentials: creds });
330
280
  if (check.ok) {
@@ -338,32 +288,28 @@ export async function runBootstrap(cwd = process.cwd()) {
338
288
  console.log("failed");
339
289
  for (const e of check.errors || []) console.log(` • ${e}`);
340
290
  console.log("");
341
- const next = await selectOption(rl, "What now?", [
342
- { label: "Re-enter credentials", value: "retry" },
343
- { label: "Skip tracker (fix later in .env)", value: "skip" },
344
- { label: "Cancel", value: "cancel" },
345
- ]);
291
+ const next = await select({
292
+ message: "What now?",
293
+ choices: [
294
+ { name: "Re-enter credentials", value: "retry" },
295
+ { name: "Skip tracker (fix later in .env)", value: "skip" },
296
+ { name: "Cancel", value: "cancel" },
297
+ ],
298
+ });
346
299
  console.log("");
347
- if (next.value === "cancel") { rl.close(); process.exit(1); }
348
- if (next.value === "skip") { trackerOk = true; break; }
349
- // retry: clear the tracker-specific creds so the next loop re-prompts
300
+ if (next === "cancel") process.exit(1);
301
+ if (next === "skip") { trackerOk = true; break; }
350
302
  if (tracker === "linear") delete creds.LINEAR_API_KEY;
351
303
  if (tracker === "jira") { delete creds.JIRA_EMAIL; delete creds.JIRA_API_TOKEN; }
352
304
  continue;
353
305
  }
354
306
  }
355
307
 
356
- // GitHub — always required (agents push code regardless of tracker)
357
308
  if (!githubOk) {
358
- if (!creds.GITHUB_TOKEN) {
359
- creds.GITHUB_TOKEN = await promptGitHubToken(rl, cwd);
360
- }
309
+ if (!creds.GITHUB_TOKEN) creds.GITHUB_TOKEN = await promptGitHubToken(cwd);
361
310
  try {
362
311
  assertPushable({ cwd, creds: { GITHUB_TOKEN: creds.GITHUB_TOKEN }, projectName: projectKey });
363
- // Best-effort gh API check to confirm the token is valid and matches
364
- // the configured login. Mirrors init.mjs:495-506.
365
312
  try {
366
- const { execSync } = await import("child_process");
367
313
  const env = { ...process.env, GH_TOKEN: creds.GITHUB_TOKEN };
368
314
  const who = execSync("gh api user --jq .login", { env, stdio: ["ignore", "pipe", "ignore"], encoding: "utf-8" }).trim();
369
315
  if (who && login && who.toLowerCase() !== login.toLowerCase()) {
@@ -379,14 +325,17 @@ export async function runBootstrap(cwd = process.cwd()) {
379
325
  if (err instanceof PreflightError) {
380
326
  console.log(` ✗ ${err.message}`);
381
327
  console.log("");
382
- const next = await selectOption(rl, "What now?", [
383
- { label: "Re-enter GITHUB_TOKEN", value: "retry" },
384
- { label: "Skip (sessions will fail until fixed)", value: "skip" },
385
- { label: "Cancel", value: "cancel" },
386
- ]);
328
+ const next = await select({
329
+ message: "What now?",
330
+ choices: [
331
+ { name: "Re-enter GITHUB_TOKEN", value: "retry" },
332
+ { name: "Skip (sessions will fail until fixed)", value: "skip" },
333
+ { name: "Cancel", value: "cancel" },
334
+ ],
335
+ });
387
336
  console.log("");
388
- if (next.value === "cancel") { rl.close(); process.exit(1); }
389
- if (next.value === "skip") { githubOk = true; break; }
337
+ if (next === "cancel") process.exit(1);
338
+ if (next === "skip") { githubOk = true; break; }
390
339
  delete creds.GITHUB_TOKEN;
391
340
  continue;
392
341
  }
@@ -397,8 +346,6 @@ export async function runBootstrap(cwd = process.cwd()) {
397
346
  if (trackerOk && githubOk) break;
398
347
  }
399
348
 
400
- rl.close();
401
-
402
349
  console.log("");
403
350
  if (trackerOk && githubOk) {
404
351
  console.log(" Ready. Try:");
@@ -406,7 +353,39 @@ export async function runBootstrap(cwd = process.cwd()) {
406
353
  console.log(` agentdesk team ${prefix}-123`);
407
354
  console.log("");
408
355
  } else {
409
- console.log(" Bootstrap finished with missing pieces — edit .env directly or re-run.");
356
+ console.log(" Finished with missing pieces — edit .env directly or re-run.");
357
+ console.log("");
358
+ }
359
+ return { trackerOk, githubOk, config };
360
+ }
361
+
362
+ export async function runBootstrap(cwd = process.cwd()) {
363
+ console.log("");
364
+ console.log(" AgentDesk — Bootstrap this machine");
365
+ console.log(" ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
366
+ console.log("");
367
+
368
+ const apiKey = getStoredApiKey();
369
+ if (!apiKey) {
370
+ console.log(" Not logged in. Run `agentdesk login` first.");
410
371
  console.log("");
372
+ process.exit(1);
411
373
  }
374
+
375
+ let localConfig = readLocalConfig(cwd);
376
+ let projectKey = localConfig?.projectKey || null;
377
+
378
+ if (!projectKey) {
379
+ if (localConfig) {
380
+ console.log(" .agentdesk.json has no projectKey — discovering project from server.");
381
+ } else {
382
+ console.log(" No .agentdesk.json in this directory — discovering project from server.");
383
+ }
384
+ console.log("");
385
+ projectKey = await discoverProject(cwd, apiKey);
386
+ if (!projectKey) process.exit(1);
387
+ console.log("");
388
+ }
389
+
390
+ await gapScan({ cwd, apiKey, projectKey });
412
391
  }
package/cli/init.mjs CHANGED
@@ -15,6 +15,8 @@ import { loadConfig, pushConfig } from "./config.mjs";
15
15
  import { getStoredApiKey } from "./login.mjs";
16
16
  import { registerLocalProject } from "./projects.mjs";
17
17
  import { checkTrackerPermissions, resolveCredentialsFromEnv } from "./tracker-check.mjs";
18
+ import { autoMatchProject, discoverProject, gapScan, writeProjectConfig } from "./bootstrap.mjs";
19
+ import { select as promptSelect } from "./prompts.mjs";
18
20
 
19
21
  const SERVER = process.env.AGENTDESK_SERVER || "https://agentdesk.live";
20
22
 
@@ -101,22 +103,6 @@ function ask(rl, question) {
101
103
  return new Promise(resolve => rl.question(question, resolve));
102
104
  }
103
105
 
104
- async function selectOption(rl, prompt, options) {
105
- console.log(` ${prompt}`);
106
- console.log("");
107
- options.forEach((opt, i) => {
108
- console.log(` ${i + 1}) ${opt.label}`);
109
- });
110
- console.log("");
111
-
112
- while (true) {
113
- const answer = await ask(rl, ` Choose (1-${options.length}): `);
114
- const num = parseInt(answer.trim(), 10);
115
- if (num >= 1 && num <= options.length) return options[num - 1];
116
- console.log(` Please enter a number between 1 and ${options.length}`);
117
- }
118
- }
119
-
120
106
  function saveEnvVar(dir, key, value) {
121
107
  const envPath = join(dir, ".env");
122
108
  let content = existsSync(envPath) ? readFileSync(envPath, "utf-8") : "";
@@ -221,12 +207,12 @@ async function listTrackerProjects({ tracker, creds, location }) {
221
207
  return null;
222
208
  }
223
209
 
224
- async function pickFromList(rl, prompt, items, { allowManual = true } = {}) {
225
- const options = items.map(it => ({ label: it.name, value: it.id }));
226
- if (allowManual) options.push({ label: "Type it manually instead", value: "__manual__" });
227
- const choice = await selectOption(rl, prompt, options);
228
- if (choice.value === "__manual__") return null;
229
- return choice.value;
210
+ async function pickFromList(_rl, prompt, items, { allowManual = true } = {}) {
211
+ const choices = items.map(it => ({ name: it.name, value: it.id }));
212
+ if (allowManual) choices.push({ name: "Type it manually instead", value: "__manual__" });
213
+ const value = await promptSelect({ message: prompt, choices });
214
+ if (value === "__manual__") return null;
215
+ return value;
230
216
  }
231
217
 
232
218
  // Prompt for a value, showing instructions first, looping until non-empty.
@@ -283,34 +269,94 @@ async function verifyAndPickTrackerProject({ rl, cwd, finalProjectKey, tracker,
283
269
 
284
270
  export async function runInit(cwd, opts = {}) {
285
271
  const quick = !!opts.quick;
272
+ const forceFull = !!opts.forceFull;
286
273
  const project = detectProject(cwd);
287
274
  const existingConfig = loadConfig(cwd);
288
275
  const projectId = project.name || cwd.split("/").pop();
289
276
  const configPath = join(cwd, ".agentdesk.json");
290
277
  const hasConfig = existsSync(configPath);
278
+ let editScope = "full";
291
279
 
292
- const rl = createInterface({ input: process.stdin, output: process.stdout });
280
+ // Fast path A: `.agentdesk.json` already has a projectKey. Offer token
281
+ // refresh (bootstrap) as the default action so second-machine users — or
282
+ // anyone just rotating a token — don't have to walk the whole wizard.
283
+ if (hasConfig && !quick && !forceFull) {
284
+ let localKey = null;
285
+ try { localKey = JSON.parse(readFileSync(configPath, "utf-8"))?.projectKey || null; } catch {}
286
+ if (localKey) {
287
+ console.log("");
288
+ console.log(" AgentDesk — existing project detected");
289
+ console.log(" ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
290
+ console.log("");
291
+ const choice = await promptSelect({
292
+ message: "What would you like to do?",
293
+ default: "refresh",
294
+ choices: [
295
+ { name: "Refresh tokens (update .env only — recommended)", value: "refresh" },
296
+ { name: "Tracker only (change tracker or credentials)", value: "tracker" },
297
+ { name: "Full setup (walk the whole wizard)", value: "full" },
298
+ { name: "Cancel", value: "cancel" },
299
+ ],
300
+ });
301
+ console.log("");
302
+ if (choice === "cancel") return;
303
+ if (choice === "refresh") {
304
+ const apiKey = getStoredApiKey();
305
+ await gapScan({ cwd, apiKey, projectKey: localKey });
306
+ return;
307
+ }
308
+ editScope = choice; // "full" or "tracker"
309
+ }
310
+ }
293
311
 
294
- // Re-run mode: if config already exists (and we're not in --quick), let
295
- // the user jump to a specific section instead of walking the full wizard.
296
- let editScope = "full";
297
- if (hasConfig && !quick) {
298
- console.log("");
299
- console.log(" AgentDesk — existing project detected");
300
- console.log(" ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
301
- console.log("");
302
- const choice = await selectOption(rl, "What would you like to do?", [
303
- { label: "Full setup (walk the whole wizard again)", value: "full" },
304
- { label: "Tracker only (change tracker or credentials)", value: "tracker" },
305
- { label: "Cancel", value: "cancel" },
306
- ]);
307
- if (choice.value === "cancel") {
308
- rl.close();
309
- return;
312
+ // Fast path B: no local config, but the user's account has a project that
313
+ // silently matches this clone (git remote project id/name). Switch to
314
+ // bootstrap mode without opening the wizard.
315
+ if (!hasConfig && !forceFull) {
316
+ const apiKey = getStoredApiKey();
317
+ if (apiKey) {
318
+ const match = await autoMatchProject(cwd, apiKey);
319
+ if (match) {
320
+ console.log("");
321
+ console.log(` ✓ Detected project "${match.name}" on your account ${match.reason}`);
322
+ console.log(" Switching to token-refresh mode. Use `agentdesk init --force-full` to walk the wizard instead.");
323
+ console.log("");
324
+ writeProjectConfig(cwd, match.projectKey);
325
+ await gapScan({ cwd, apiKey, projectKey: match.projectKey });
326
+ return;
327
+ }
328
+
329
+ // No auto-match. Offer an explicit choice between connecting this
330
+ // clone to an existing server-side project (bootstrap) and creating
331
+ // a new one (full wizard). Prevents users from accidentally creating
332
+ // a duplicate project when they just wanted to attach a second machine.
333
+ console.log("");
334
+ console.log(" AgentDesk — new directory");
335
+ console.log(" ━━━━━━━━━━━━━━━━━━━━━━━━━");
336
+ console.log("");
337
+ const topChoice = await promptSelect({
338
+ message: "What would you like to do?",
339
+ choices: [
340
+ { name: "Connect this repo to an existing project on my account (bootstrap)", value: "connect" },
341
+ { name: "Set up this as a new project (full wizard)", value: "new" },
342
+ { name: "Cancel", value: "cancel" },
343
+ ],
344
+ });
345
+ console.log("");
346
+ if (topChoice === "cancel") return;
347
+ if (topChoice === "connect") {
348
+ const projectKey = await discoverProject(cwd, apiKey);
349
+ if (!projectKey) return;
350
+ console.log("");
351
+ await gapScan({ cwd, apiKey, projectKey });
352
+ return;
353
+ }
354
+ // "new" falls through to the wizard below.
310
355
  }
311
- editScope = choice.value;
312
356
  }
313
357
 
358
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
359
+
314
360
  console.log("");
315
361
  console.log(" AgentDesk — Project Setup");
316
362
  console.log(" ━━━━━━━━━━━━━━━━━━━━━━━━");
@@ -336,13 +382,15 @@ export async function runInit(cwd, opts = {}) {
336
382
 
337
383
  if (!quick) console.log(editScope === "tracker" ? " Tracker" : " Step 1 of 3 — Task tracker");
338
384
  console.log("");
339
- const trackerOptions = [
340
- { label: "Linear", value: "linear" },
341
- { label: "Jira", value: "jira" },
342
- { label: "GitHub Issues", value: "github" },
343
- { label: "None (use descriptions only)", value: null },
344
- ];
345
- const tracker = (await selectOption(rl, "Task tracker:", trackerOptions)).value;
385
+ const tracker = await promptSelect({
386
+ message: "Task tracker:",
387
+ choices: [
388
+ { name: "Linear", value: "linear" },
389
+ { name: "Jira", value: "jira" },
390
+ { name: "GitHub Issues", value: "github" },
391
+ { name: "None (use descriptions only)", value: null },
392
+ ],
393
+ });
346
394
  console.log("");
347
395
  if (tracker) config.tracker = tracker;
348
396
 
@@ -417,17 +465,20 @@ export async function runInit(cwd, opts = {}) {
417
465
  verified = await verifyAndPickTrackerProject({ rl, cwd, finalProjectKey, tracker, config, location: trackerLocation });
418
466
  if (verified.ok) break;
419
467
  console.log("");
420
- const next = await selectOption(rl, "What now?", [
421
- { label: "Re-enter credentials and try again", value: "retry" },
422
- { label: "Save config anyway (will need to fix before `agentdesk team`)", value: "skip" },
423
- { label: "Cancel init", value: "cancel" },
424
- ]);
468
+ const next = await promptSelect({
469
+ message: "What now?",
470
+ choices: [
471
+ { name: "Re-enter credentials and try again", value: "retry" },
472
+ { name: "Save config anyway (will need to fix before `agentdesk team`)", value: "skip" },
473
+ { name: "Cancel init", value: "cancel" },
474
+ ],
475
+ });
425
476
  console.log("");
426
- if (next.value === "cancel") {
477
+ if (next === "cancel") {
427
478
  rl.close();
428
479
  return;
429
480
  }
430
- if (next.value === "skip") break;
481
+ if (next === "skip") break;
431
482
  // Retry: re-prompt only the tracker auth fields, not the whole wizard.
432
483
  if (tracker === "linear") {
433
484
  const key = await promptRequired(rl, "Linear API key");
@@ -0,0 +1,64 @@
1
+ // Terminal prompt helpers.
2
+ //
3
+ // `select` — arrow-key menu via @inquirer/prompts (colored, one-line render).
4
+ // `ask` / `promptRequired` — free-text prompts via a short-lived readline.
5
+ //
6
+ // We deliberately do NOT keep a long-lived readline interface around; each
7
+ // text prompt creates its own so inquirer (which opens its own stdin reader
8
+ // during a select) never races against a parallel `rl.question` handler.
9
+
10
+ import { createInterface } from "readline";
11
+ import { select as inquirerSelect, input as inquirerInput } from "@inquirer/prompts";
12
+
13
+ // Ctrl-C inside an inquirer prompt throws `ExitPromptError`. Convert it to
14
+ // a clean exit(130) so the terminal doesn't see a node stack trace.
15
+ function handleExit(err) {
16
+ if (err?.name === "ExitPromptError") {
17
+ console.log("");
18
+ process.exit(130);
19
+ }
20
+ throw err;
21
+ }
22
+
23
+ // Arrow-key select menu. `choices` is an array of { name, value, description? }.
24
+ // Returns the selected value.
25
+ export async function select({ message, choices, default: def }) {
26
+ try {
27
+ return await inquirerSelect({
28
+ message,
29
+ choices,
30
+ default: def,
31
+ loop: false,
32
+ });
33
+ } catch (err) { handleExit(err); }
34
+ }
35
+
36
+ // Free-text prompt. Returns the raw answer (not trimmed).
37
+ export function ask(question) {
38
+ return new Promise(resolve => {
39
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
40
+ rl.question(question, answer => {
41
+ rl.close();
42
+ resolve(answer);
43
+ });
44
+ });
45
+ }
46
+
47
+ // Loop until a non-empty trimmed value is entered. `label` is the visible
48
+ // prompt text; we wrap it with the same two-space indent init uses elsewhere.
49
+ export async function promptRequired(label) {
50
+ while (true) {
51
+ const v = (await ask(` ${label}: `)).trim();
52
+ if (v) return v;
53
+ console.log(` ${label} is required. Ctrl+C to abort.`);
54
+ }
55
+ }
56
+
57
+ // Inquirer-based free-text prompt — used only where arrow-key UX is nice to
58
+ // have alongside the select menus (e.g. the project-picker fallback in
59
+ // bootstrap). Most text prompts in init still go through `ask`.
60
+ export async function promptInput({ message, default: def }) {
61
+ try {
62
+ return await inquirerInput({ message, default: def });
63
+ } catch (err) { handleExit(err); }
64
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kendoo.agentdesk/agentdesk",
3
- "version": "0.19.6",
3
+ "version": "0.19.8",
4
4
  "description": "AI team orchestrator for Claude Code — run collaborative agent sessions from your terminal",
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,6 +27,7 @@
27
27
  "prepublishOnly": "node scripts/lint-changelog.mjs"
28
28
  },
29
29
  "dependencies": {
30
+ "@inquirer/prompts": "^7.10.1",
30
31
  "@radix-ui/react-avatar": "^1.1.11",
31
32
  "@radix-ui/react-dialog": "^1.1.15",
32
33
  "@radix-ui/react-dropdown-menu": "^2.1.16",