@kendoo.agentdesk/agentdesk 0.19.5 → 0.19.7

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.7] — 2026-04-19
17
+
18
+ ### Changed
19
+ - `[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.
20
+ - `[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.
21
+
22
+ ## [0.19.6] — 2026-04-19
23
+
24
+ ### Changed
25
+ - `[CLI]` `agentdesk bootstrap` now auto-selects the project silently when the git remote's repo name matches the project's id or name (case-insensitive, scope-stripped), in addition to the existing exact `github.repo` match. No confirmation prompt is shown for a confident single match — the clone just picks up the right project and continues. The numbered picker is only shown when auto-match finds zero or multiple candidates.
26
+
16
27
  ## [0.19.5] — 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
 
@@ -57,17 +52,49 @@ async function fetchProjectSettings(apiKey, projectId) {
57
52
  } catch { return {}; }
58
53
  }
59
54
 
60
- // Discover the server-side project for this clone when .agentdesk.json is
61
- // absent. Strategy:
62
- // 1. Fetch the user's projects list.
63
- // 2. For each, fetch settings so we can see github.repo.
64
- // 3. If we can match the local git remote to exactly one project's repo,
65
- // auto-select (confirmation prompt).
66
- // 4. Otherwise show a numbered picker of all the user's projects.
67
- // 5. Write a minimal `.agentdesk.json` locally so loadConfig takes over
68
- // from here.
69
- // Returns the chosen projectKey, or null if the user aborts.
70
- async function discoverProject(rl, cwd, apiKey) {
55
+ // Normalize a name for fuzzy comparison: lowercase, strip npm scope (@scope/)
56
+ // and everything up to the last `/`, then drop non-alphanumerics.
57
+ function normalizeName(s) {
58
+ if (!s) return "";
59
+ const tail = s.includes("/") ? s.slice(s.lastIndexOf("/") + 1) : s;
60
+ return tail.toLowerCase().replace(/[^a-z0-9]/g, "");
61
+ }
62
+
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) {
71
98
  const remote = detectGitRemote(cwd);
72
99
  if (remote) console.log(` Detected git remote: ${remote}`);
73
100
  console.log(" Fetching your projects from the server...");
@@ -81,69 +108,58 @@ async function discoverProject(rl, cwd, apiKey) {
81
108
  return null;
82
109
  }
83
110
 
84
- // Fetch settings in parallel so we can display/match the configured repo.
85
111
  const settings = await Promise.all(projects.map(p => fetchProjectSettings(apiKey, p.id)));
112
+ const remoteRepoName = remote ? remote.split("/").pop() : null;
113
+ const remoteKey = normalizeName(remoteRepoName);
86
114
  const rows = projects.map((p, i) => {
87
115
  const repo = settings[i]?.github?.repo || null;
88
- const matches = remote && repo && repo.toLowerCase() === remote.toLowerCase();
89
- return { project: p, settings: settings[i] || {}, repo, matches };
116
+ const tier1 = remote && repo && repo.toLowerCase() === remote.toLowerCase();
117
+ const tier2 = !tier1 && remoteKey && (
118
+ normalizeName(p.id) === remoteKey || normalizeName(p.name) === remoteKey
119
+ );
120
+ return { project: p, settings: settings[i] || {}, repo, tier1, tier2 };
90
121
  });
91
122
 
92
- const exactMatches = rows.filter(r => r.matches);
123
+ const tier1Matches = rows.filter(r => r.tier1);
124
+ const tier2Matches = rows.filter(r => r.tier2);
93
125
  let chosen = null;
94
-
95
- if (exactMatches.length === 1) {
96
- const r = exactMatches[0];
97
- console.log(` ✓ Match: ${r.project.name} (${r.project.id}) — repo ${r.repo}`);
98
- console.log("");
99
- const ans = (await askInline(rl, ` Use this project? [Y/n]: `)).trim().toLowerCase();
100
- if (ans === "" || ans === "y" || ans === "yes") chosen = r;
126
+ let matchReason = null;
127
+
128
+ if (tier1Matches.length === 1) {
129
+ chosen = tier1Matches[0];
130
+ matchReason = `github.repo matches ${chosen.repo}`;
131
+ } else if (tier1Matches.length === 0 && tier2Matches.length === 1) {
132
+ chosen = tier2Matches[0];
133
+ matchReason = `project name matches repo "${remoteRepoName}"`;
101
134
  }
102
135
 
103
- if (!chosen) {
104
- console.log("");
105
- console.log(" Pick the project this repo maps to:");
136
+ if (chosen) {
137
+ console.log(` ✓ Auto-matched: ${chosen.project.name} (${chosen.project.id}) — ${matchReason}`);
138
+ } else {
106
139
  console.log("");
107
- rows.forEach((r, i) => {
108
- const tag = r.matches ? " matches git remote" : "";
109
- const repoStr = r.repo ? ` [${r.repo}]` : "";
110
- 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
+ ],
111
150
  });
112
- console.log("");
113
- while (true) {
114
- const answer = await askInline(rl, ` Choose (1-${rows.length}, or 'c' to cancel): `);
115
- const trimmed = answer.trim().toLowerCase();
116
- if (trimmed === "c" || trimmed === "cancel") return null;
117
- const num = parseInt(trimmed, 10);
118
- if (num >= 1 && num <= rows.length) { chosen = rows[num - 1]; break; }
119
- console.log(` Please enter a number between 1 and ${rows.length}`);
120
- }
151
+ if (picked === "__cancel__") return null;
152
+ chosen = rows.find(r => r.project.id === picked);
121
153
  }
122
154
 
123
- // Write a minimal .agentdesk.json — loadConfig will merge server state
124
- // into it on the next call, producing the full cached view.
125
- const minimal = { projectKey: chosen.project.id };
126
- writeFileSync(join(cwd, ".agentdesk.json"), JSON.stringify(minimal, null, 2) + "\n");
127
- console.log("");
155
+ writeProjectConfig(cwd, chosen.project.id);
128
156
  console.log(` ✓ Wrote .agentdesk.json (projectKey: ${chosen.project.id})`);
129
- console.log(" (commit this file so future clones skip the picker)");
157
+ console.log(" (commit this file so future clones skip discovery)");
130
158
  return chosen.project.id;
131
159
  }
132
160
 
133
- function askInline(rl, question) {
134
- return new Promise(resolve => rl.question(question, resolve));
135
- }
136
-
137
- function ask(rl, question) {
138
- return new Promise(resolve => rl.question(question, resolve));
139
- }
140
-
141
- async function promptRequired(rl, label) {
142
- while (true) {
143
- const v = (await ask(rl, ` ${label}: `)).trim();
144
- if (v) return v;
145
- console.log(` ${label} is required. Ctrl+C to abort.`);
146
- }
161
+ export function writeProjectConfig(cwd, projectKey) {
162
+ writeFileSync(join(cwd, ".agentdesk.json"), JSON.stringify({ projectKey }, null, 2) + "\n");
147
163
  }
148
164
 
149
165
  function saveEnvVar(dir, key, value) {
@@ -189,15 +205,13 @@ function printTrackerHint(tracker) {
189
205
  }
190
206
  }
191
207
 
192
- // Walk missing tracker credentials and prompt for each. Writes to .env.
193
- // Returns the merged credentials map so the caller can verify.
194
- async function promptTrackerCreds(rl, cwd, tracker, existing) {
208
+ async function promptTrackerCreds(cwd, tracker, existing) {
195
209
  const creds = { ...existing };
196
210
  if (tracker === "linear") {
197
211
  if (!creds.LINEAR_API_KEY) {
198
212
  console.log("");
199
213
  printTrackerHint("linear");
200
- const v = await promptRequired(rl, "Linear API key");
214
+ const v = await promptRequired("Linear API key");
201
215
  saveEnvVar(cwd, "LINEAR_API_KEY", v);
202
216
  creds.LINEAR_API_KEY = v;
203
217
  console.log(" ✓ Saved LINEAR_API_KEY to .env");
@@ -206,14 +220,14 @@ async function promptTrackerCreds(rl, cwd, tracker, existing) {
206
220
  if (!creds.JIRA_EMAIL) {
207
221
  console.log("");
208
222
  printTrackerHint("jira");
209
- const v = await promptRequired(rl, "Your Atlassian login email");
223
+ const v = await promptRequired("Your Atlassian login email");
210
224
  saveEnvVar(cwd, "JIRA_EMAIL", v);
211
225
  creds.JIRA_EMAIL = v;
212
226
  console.log(" ✓ Saved JIRA_EMAIL to .env");
213
227
  }
214
228
  if (!creds.JIRA_API_TOKEN) {
215
229
  if (creds.JIRA_EMAIL) console.log("");
216
- const v = await promptRequired(rl, "Jira API token");
230
+ const v = await promptRequired("Jira API token");
217
231
  saveEnvVar(cwd, "JIRA_API_TOKEN", v);
218
232
  creds.JIRA_API_TOKEN = v;
219
233
  console.log(" ✓ Saved JIRA_API_TOKEN to .env");
@@ -222,67 +236,21 @@ async function promptTrackerCreds(rl, cwd, tracker, existing) {
222
236
  return creds;
223
237
  }
224
238
 
225
- async function promptGitHubToken(rl, cwd) {
239
+ async function promptGitHubToken(cwd) {
226
240
  console.log("");
227
241
  printTrackerHint("github");
228
- const v = await promptRequired(rl, "GitHub token");
242
+ const v = await promptRequired("GitHub token");
229
243
  saveEnvVar(cwd, "GITHUB_TOKEN", v);
230
244
  console.log(" ✓ Saved GITHUB_TOKEN to .env");
231
245
  return v;
232
246
  }
233
247
 
234
- async function selectOption(rl, prompt, options) {
235
- console.log(` ${prompt}`);
236
- console.log("");
237
- options.forEach((opt, i) => console.log(` ${i + 1}) ${opt.label}`));
238
- console.log("");
239
- while (true) {
240
- const answer = await ask(rl, ` Choose (1-${options.length}): `);
241
- const num = parseInt(answer.trim(), 10);
242
- if (num >= 1 && num <= options.length) return options[num - 1];
243
- console.log(` Please enter a number between 1 and ${options.length}`);
244
- }
245
- }
246
-
247
- export async function runBootstrap(cwd = process.cwd()) {
248
- console.log("");
249
- console.log(" AgentDesk — Bootstrap this machine");
250
- console.log(" ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
251
- console.log("");
252
-
253
- // 1. Must be logged in.
254
- const apiKey = getStoredApiKey();
255
- if (!apiKey) {
256
- console.log(" Not logged in. Run `agentdesk login` first.");
257
- console.log("");
258
- process.exit(1);
259
- }
260
-
261
- const rl = createInterface({ input: process.stdin, output: process.stdout });
262
-
263
- // 2. Find the server-side project for this clone. Prefer a committed
264
- // .agentdesk.json (clean path); fall back to discovering from the
265
- // user's server-side project list when the file is missing.
266
- let localConfig = readLocalConfig(cwd);
267
- let projectKey = localConfig?.projectKey || null;
268
-
269
- if (!projectKey) {
270
- if (localConfig) {
271
- console.log(" .agentdesk.json has no projectKey — discovering project from server.");
272
- } else {
273
- console.log(" No .agentdesk.json in this directory — discovering project from server.");
274
- }
275
- console.log("");
276
- projectKey = await discoverProject(rl, cwd, apiKey);
277
- if (!projectKey) { rl.close(); process.exit(1); }
278
- localConfig = readLocalConfig(cwd);
279
- console.log("");
280
- }
281
-
282
- // 3. Load merged config (server-authoritative when reachable). This is the
283
- // 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 }) {
284
253
  const config = await loadConfig(cwd, { apiKey, serverUrl: SERVER, projectName: projectKey, silent: true });
285
-
286
254
  const tracker = config.tracker || null;
287
255
  const repo = config.github?.repo || null;
288
256
  const login = config.github?.login || null;
@@ -294,24 +262,19 @@ export async function runBootstrap(cwd = process.cwd()) {
294
262
  if (repo) console.log(` Repo: ${repo}${login ? ` (@${login})` : ""}`);
295
263
  console.log("");
296
264
 
297
- // 4. Gap scan + prompt loop. We retry tracker + github verification up to
298
- // three times, matching init's retry/skip/cancel UX.
299
265
  let creds = resolveCredentialsFromEnv(loadDotEnvLocal(cwd));
300
- let trackerOk = !tracker; // no tracker → nothing to verify
266
+ let trackerOk = !tracker;
301
267
  let githubOk = false;
302
268
  let attempts = 0;
303
269
 
304
270
  while (attempts < 5) {
305
271
  attempts += 1;
306
272
 
307
- // Tracker
308
273
  if (tracker && !trackerOk) {
309
274
  const missing =
310
275
  (tracker === "linear" && !creds.LINEAR_API_KEY) ||
311
276
  (tracker === "jira" && (!creds.JIRA_EMAIL || !creds.JIRA_API_TOKEN));
312
- if (missing) {
313
- creds = await promptTrackerCreds(rl, cwd, tracker, creds);
314
- }
277
+ if (missing) creds = await promptTrackerCreds(cwd, tracker, creds);
315
278
  process.stdout.write(" Verifying tracker access... ");
316
279
  const check = await checkTrackerPermissions({ tracker, config, credentials: creds });
317
280
  if (check.ok) {
@@ -325,32 +288,28 @@ export async function runBootstrap(cwd = process.cwd()) {
325
288
  console.log("failed");
326
289
  for (const e of check.errors || []) console.log(` • ${e}`);
327
290
  console.log("");
328
- const next = await selectOption(rl, "What now?", [
329
- { label: "Re-enter credentials", value: "retry" },
330
- { label: "Skip tracker (fix later in .env)", value: "skip" },
331
- { label: "Cancel", value: "cancel" },
332
- ]);
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
+ });
333
299
  console.log("");
334
- if (next.value === "cancel") { rl.close(); process.exit(1); }
335
- if (next.value === "skip") { trackerOk = true; break; }
336
- // 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; }
337
302
  if (tracker === "linear") delete creds.LINEAR_API_KEY;
338
303
  if (tracker === "jira") { delete creds.JIRA_EMAIL; delete creds.JIRA_API_TOKEN; }
339
304
  continue;
340
305
  }
341
306
  }
342
307
 
343
- // GitHub — always required (agents push code regardless of tracker)
344
308
  if (!githubOk) {
345
- if (!creds.GITHUB_TOKEN) {
346
- creds.GITHUB_TOKEN = await promptGitHubToken(rl, cwd);
347
- }
309
+ if (!creds.GITHUB_TOKEN) creds.GITHUB_TOKEN = await promptGitHubToken(cwd);
348
310
  try {
349
311
  assertPushable({ cwd, creds: { GITHUB_TOKEN: creds.GITHUB_TOKEN }, projectName: projectKey });
350
- // Best-effort gh API check to confirm the token is valid and matches
351
- // the configured login. Mirrors init.mjs:495-506.
352
312
  try {
353
- const { execSync } = await import("child_process");
354
313
  const env = { ...process.env, GH_TOKEN: creds.GITHUB_TOKEN };
355
314
  const who = execSync("gh api user --jq .login", { env, stdio: ["ignore", "pipe", "ignore"], encoding: "utf-8" }).trim();
356
315
  if (who && login && who.toLowerCase() !== login.toLowerCase()) {
@@ -366,14 +325,17 @@ export async function runBootstrap(cwd = process.cwd()) {
366
325
  if (err instanceof PreflightError) {
367
326
  console.log(` ✗ ${err.message}`);
368
327
  console.log("");
369
- const next = await selectOption(rl, "What now?", [
370
- { label: "Re-enter GITHUB_TOKEN", value: "retry" },
371
- { label: "Skip (sessions will fail until fixed)", value: "skip" },
372
- { label: "Cancel", value: "cancel" },
373
- ]);
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
+ });
374
336
  console.log("");
375
- if (next.value === "cancel") { rl.close(); process.exit(1); }
376
- if (next.value === "skip") { githubOk = true; break; }
337
+ if (next === "cancel") process.exit(1);
338
+ if (next === "skip") { githubOk = true; break; }
377
339
  delete creds.GITHUB_TOKEN;
378
340
  continue;
379
341
  }
@@ -384,8 +346,6 @@ export async function runBootstrap(cwd = process.cwd()) {
384
346
  if (trackerOk && githubOk) break;
385
347
  }
386
348
 
387
- rl.close();
388
-
389
349
  console.log("");
390
350
  if (trackerOk && githubOk) {
391
351
  console.log(" Ready. Try:");
@@ -393,7 +353,39 @@ export async function runBootstrap(cwd = process.cwd()) {
393
353
  console.log(` agentdesk team ${prefix}-123`);
394
354
  console.log("");
395
355
  } else {
396
- 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.");
397
357
  console.log("");
398
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.");
371
+ console.log("");
372
+ process.exit(1);
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 });
399
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, 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,67 @@ 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
+ }
310
328
  }
311
- editScope = choice.value;
312
329
  }
313
330
 
331
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
332
+
314
333
  console.log("");
315
334
  console.log(" AgentDesk — Project Setup");
316
335
  console.log(" ━━━━━━━━━━━━━━━━━━━━━━━━");
@@ -336,13 +355,15 @@ export async function runInit(cwd, opts = {}) {
336
355
 
337
356
  if (!quick) console.log(editScope === "tracker" ? " Tracker" : " Step 1 of 3 — Task tracker");
338
357
  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;
358
+ const tracker = await promptSelect({
359
+ message: "Task tracker:",
360
+ choices: [
361
+ { name: "Linear", value: "linear" },
362
+ { name: "Jira", value: "jira" },
363
+ { name: "GitHub Issues", value: "github" },
364
+ { name: "None (use descriptions only)", value: null },
365
+ ],
366
+ });
346
367
  console.log("");
347
368
  if (tracker) config.tracker = tracker;
348
369
 
@@ -417,17 +438,20 @@ export async function runInit(cwd, opts = {}) {
417
438
  verified = await verifyAndPickTrackerProject({ rl, cwd, finalProjectKey, tracker, config, location: trackerLocation });
418
439
  if (verified.ok) break;
419
440
  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
- ]);
441
+ const next = await promptSelect({
442
+ message: "What now?",
443
+ choices: [
444
+ { name: "Re-enter credentials and try again", value: "retry" },
445
+ { name: "Save config anyway (will need to fix before `agentdesk team`)", value: "skip" },
446
+ { name: "Cancel init", value: "cancel" },
447
+ ],
448
+ });
425
449
  console.log("");
426
- if (next.value === "cancel") {
450
+ if (next === "cancel") {
427
451
  rl.close();
428
452
  return;
429
453
  }
430
- if (next.value === "skip") break;
454
+ if (next === "skip") break;
431
455
  // Retry: re-prompt only the tracker auth fields, not the whole wizard.
432
456
  if (tracker === "linear") {
433
457
  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.5",
3
+ "version": "0.19.7",
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",