create-egregore 0.19.31 → 0.19.33

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.
@@ -232,6 +232,39 @@ class SelectionState:
232
232
  self.done.set()
233
233
  return self.result
234
234
 
235
+ def choose_connector(self, payload: dict) -> dict:
236
+ # A connector choice ends the local-selection flow. When the CLI
237
+ # pre-started an OAuth listener, the browser response carries its
238
+ # consent URL so THIS tab continues straight into Google's sign-in;
239
+ # otherwise the agent guides authorization from the terminal. No bytes
240
+ # are staged and no source contract is collected here.
241
+ with self.lock:
242
+ if self.done.is_set():
243
+ raise ValueError("selection already finished")
244
+ provider = str(payload.get("provider", "")).strip().lower()
245
+ if provider not in available_connectors():
246
+ raise ValueError(f"unknown connector: {provider or '(empty)'}")
247
+ redirect = connector_auth_urls().get(provider, "")
248
+ account = connector_accounts().get(provider, "")
249
+ self.result = {
250
+ "connector": provider,
251
+ "receipt_url": self.receipt_url,
252
+ }
253
+ if redirect:
254
+ self.result["auth"] = "in-browser"
255
+ elif account:
256
+ self.result["account"] = account
257
+ self.receipt = {
258
+ **self.result,
259
+ "status": "processing",
260
+ "message": "The agent is walking you through connecting this source in the terminal.",
261
+ }
262
+ self.done.set()
263
+ response = dict(self.result)
264
+ if redirect:
265
+ response["redirect"] = redirect
266
+ return response
267
+
235
268
  def finish_receipt(self, payload: dict) -> dict:
236
269
  with self.lock:
237
270
  if not self.done.is_set() or self.cancelled:
@@ -252,10 +285,53 @@ class SelectionState:
252
285
  shutil.rmtree(self.session_root, ignore_errors=True)
253
286
 
254
287
 
288
+ def available_connectors() -> list[str]:
289
+ # A connector is offered when its runtime shim ships with this instance.
290
+ # Authorization state is NOT checked here — the button's job is to route
291
+ # the user into the guided setup, which handles both fresh and connected.
292
+ here = Path(__file__).parent
293
+ providers = []
294
+ if (here / "connector-google.sh").exists():
295
+ providers.append("google")
296
+ if (here / "connector-notion.sh").exists():
297
+ providers.append("notion")
298
+ return providers
299
+
300
+
301
+ def _env_json_map(name: str) -> dict[str, str]:
302
+ try:
303
+ value = json.loads(os.environ.get(name, "") or "{}")
304
+ except json.JSONDecodeError:
305
+ return {}
306
+ if not isinstance(value, dict):
307
+ return {}
308
+ return {str(k): str(v) for k, v in value.items() if isinstance(v, (str, int))}
309
+
310
+
311
+ def connector_auth_urls() -> dict[str, str]:
312
+ # Consent URLs pre-fetched by the CLI (listener already running) so a
313
+ # connector click can redirect this same tab straight into sign-in.
314
+ # Only loopback/HTTPS URLs are accepted.
315
+ urls = {}
316
+ for provider, url in _env_json_map("EGREGORE_CONNECTOR_AUTH_URLS").items():
317
+ if url.startswith("https://") or url.startswith("http://127.0.0.1") or url.startswith("http://localhost"):
318
+ urls[provider] = url
319
+ return urls
320
+
321
+
322
+ def connector_accounts() -> dict[str, str]:
323
+ # Already-connected account per provider, for honest completion copy.
324
+ return _env_json_map("EGREGORE_CONNECTOR_ACCOUNTS")
325
+
326
+
255
327
  def handler_class(state: SelectionState, token: str, expected_origin: str, preference_file: Path | None = None):
256
328
  html_template = HTML_PATH.read_text(encoding="utf-8")
257
329
  design_css = DESIGN_CSS_PATH.read_text(encoding="utf-8")
258
- capabilities = {"pdftotext": bool(shutil.which("pdftotext"))}
330
+ capabilities = {
331
+ "pdftotext": bool(shutil.which("pdftotext")),
332
+ "connectors": available_connectors(),
333
+ "connector_accounts": connector_accounts(),
334
+ }
259
335
  preferences = load_preferences(preference_file or preferences_path())
260
336
  page = (
261
337
  html_template.replace("__EGREGORE_DESIGN_CSS__", design_css)
@@ -349,6 +425,10 @@ def handler_class(state: SelectionState, token: str, expected_origin: str, prefe
349
425
  result = state.confirm(self.read_json())
350
426
  self.send_json(200, result)
351
427
  return
428
+ if path == "/api/connector":
429
+ result = state.choose_connector(self.read_json())
430
+ self.send_json(200, result)
431
+ return
352
432
  if path == "/api/cancel":
353
433
  state.cancel()
354
434
  self.send_json(200, {"cancelled": True})
@@ -35,6 +35,7 @@ chmod 700 "$STATE_DIR" 2>/dev/null || true
35
35
  if [ -f "$SCRIPT_DIR/.env" ]; then
36
36
  EGREGORE_API_URL="${EGREGORE_API_URL:-$(grep '^EGREGORE_API_URL=' "$SCRIPT_DIR/.env" 2>/dev/null | cut -d'=' -f2- || true)}"
37
37
  EGREGORE_API_KEY="${EGREGORE_API_KEY:-$(grep '^EGREGORE_API_KEY=' "$SCRIPT_DIR/.env" 2>/dev/null | cut -d'=' -f2- || true)}"
38
+ SLACK_BOT_TOKEN="${SLACK_BOT_TOKEN:-$(grep '^SLACK_BOT_TOKEN=' "$SCRIPT_DIR/.env" 2>/dev/null | cut -d'=' -f2- || true)}"
38
39
  fi
39
40
  API_URL="${EGREGORE_API_URL:-$(jq -r '.api_url // empty' "$CONFIG" 2>/dev/null)}"
40
41
  API_KEY="${EGREGORE_API_KEY:-}"
@@ -178,6 +179,8 @@ write_plan() {
178
179
  }' "$path"
179
180
  }
180
181
 
182
+ # Channel-generic despite the name: reads any field from .egregore-state.json
183
+ # with egregore.json as the legacy fallback (telegram_*, slack_*, …).
181
184
  read_local_telegram_config() {
182
185
  local field="$1"
183
186
  local state_file="$SCRIPT_DIR/.egregore-state.json"
@@ -220,24 +223,38 @@ plan_local() {
220
223
  die "direct messages are unavailable in local mode; no group fallback was sent"
221
224
  fi
222
225
 
223
- local chat_id group_link destination
226
+ local chat_id group_link slack_channel deliveries
224
227
  chat_id="$(read_local_telegram_config telegram_chat_id)"
225
228
  group_link="$(read_local_telegram_config telegram_group_link)"
226
- [ -n "$chat_id" ] || [ -n "$group_link" ] ||
227
- die "no Telegram group is configured"
228
- destination="${group_link:-$chat_id}"
229
+ slack_channel="$(read_local_telegram_config slack_channel_id)"
230
+
231
+ deliveries="[]"
232
+ if [ -n "$chat_id" ] || [ -n "$group_link" ]; then
233
+ deliveries="$(printf '%s' "$deliveries" | jq -c \
234
+ --arg destination "${group_link:-$chat_id}" \
235
+ '. + [{channel:"telegram", destination:$destination, kind:"group"}]')"
236
+ fi
237
+ # Slack is local-direct (the org owns the app and token): plan it only when
238
+ # both halves of the config exist, so a half-configured Slack fails soft.
239
+ if [ -n "$slack_channel" ] && [ -n "${SLACK_BOT_TOKEN:-}" ]; then
240
+ deliveries="$(printf '%s' "$deliveries" | jq -c \
241
+ --arg destination "$slack_channel" \
242
+ '. + [{channel:"slack", destination:$destination, kind:"group"}]')"
243
+ fi
244
+ [ "$deliveries" != "[]" ] ||
245
+ die "no group notification channel is configured"
229
246
 
230
247
  local response
231
248
  response="$(jq -nc \
232
249
  --arg slug "$ORG_SLUG" \
233
250
  --arg org "$ORG_NAME" \
234
- --arg destination "$destination" \
251
+ --argjson deliveries "$deliveries" \
235
252
  '{
236
253
  status:"planned",
237
254
  org_slug:$slug,
238
255
  org_name:$org,
239
- channels:["telegram"],
240
- deliveries:[{channel:"telegram", destination:$destination, kind:"group"}],
256
+ channels:($deliveries | map(.channel)),
257
+ deliveries:$deliveries,
241
258
  no_fallback:true
242
259
  }')"
243
260
  write_plan "$response" "$kind" "$recipient" "$message"
@@ -342,11 +359,12 @@ dispatch_connected() {
342
359
  printf '%s\n' "$response"
343
360
  }
344
361
 
345
- dispatch_local() {
346
- local path="$1"
347
- local message destination chat_id group_link plan_slug plan_org response
348
- message="$(jq -r '.message' "$path")"
349
- destination="$(jq -r '.deliveries[0].destination' "$path")"
362
+ dispatch_local_telegram() {
363
+ local destination="$1"
364
+ local message="$2"
365
+ local plan_slug="$3"
366
+ local plan_org="$4"
367
+ local chat_id group_link
350
368
  case "$destination" in
351
369
  https://t.me/*)
352
370
  chat_id=""
@@ -357,9 +375,7 @@ dispatch_local() {
357
375
  group_link=""
358
376
  ;;
359
377
  esac
360
- plan_slug="$(jq -r '.org_slug' "$path")"
361
- plan_org="$(jq -r '.org_name' "$path")"
362
- response="$(curl -sS -X POST "${RELAY_URL}/api/notify/relay" \
378
+ curl -sS -X POST "${RELAY_URL}/api/notify/relay" \
363
379
  -H "Content-Type: application/json" \
364
380
  -d "$(jq -nc \
365
381
  --arg chat_id "$chat_id" \
@@ -368,12 +384,84 @@ dispatch_local() {
368
384
  --arg slug "$plan_slug" \
369
385
  --arg org_name "$plan_org" \
370
386
  '{chat_id:$chat_id,group_link:$group_link,message:$message,slug:$slug,org_name:$org_name}')" \
371
- --max-time 10)" || return 1
372
- printf '%s' "$response" | jq -e '.status == "sent"' >/dev/null 2>&1 || {
373
- printf '%s\n' "$response"
374
- return 1
387
+ --max-time 10
388
+ }
389
+
390
+ # Local-direct: the org owns the Slack app and token, so the message goes
391
+ # straight from this machine to Slack — never through the Egregore relay.
392
+ dispatch_local_slack() {
393
+ local destination="$1"
394
+ local message="$2"
395
+ if [ -z "${SLACK_BOT_TOKEN:-}" ]; then
396
+ jq -nc '{status:"error",detail:"SLACK_BOT_TOKEN is missing from .env"}'
397
+ return 0
398
+ fi
399
+ local response
400
+ response="$(curl -sS -X POST "https://slack.com/api/chat.postMessage" \
401
+ -H "Authorization: Bearer $SLACK_BOT_TOKEN" \
402
+ -H "Content-Type: application/json; charset=utf-8" \
403
+ -d "$(jq -nc \
404
+ --arg channel "$destination" \
405
+ --arg text "$message" \
406
+ '{channel:$channel,text:$text,unfurl_links:false}')" \
407
+ --max-time 10)" || {
408
+ jq -nc '{status:"error",detail:"Slack request failed"}'
409
+ return 0
375
410
  }
376
- printf '%s\n' "$response"
411
+ if printf '%s' "$response" | jq -e '.ok == true' >/dev/null 2>&1; then
412
+ jq -nc '{status:"sent"}'
413
+ else
414
+ jq -nc \
415
+ --arg detail "$(printf '%s' "$response" | jq -r '.error // "unknown"' 2>/dev/null || echo unknown)" \
416
+ '{status:"error",detail:$detail}'
417
+ fi
418
+ }
419
+
420
+ dispatch_local() {
421
+ local path="$1"
422
+ local message plan_slug plan_org count i channel destination result
423
+ message="$(jq -r '.message' "$path")"
424
+ plan_slug="$(jq -r '.org_slug' "$path")"
425
+ plan_org="$(jq -r '.org_name' "$path")"
426
+ count="$(jq -r '.deliveries | length' "$path")"
427
+
428
+ # One platform failing must not mask another succeeding (mirror of the
429
+ # server-side send_group semantics).
430
+ local results="{}"
431
+ i=0
432
+ while [ "$i" -lt "$count" ]; do
433
+ channel="$(jq -r ".deliveries[$i].channel" "$path")"
434
+ destination="$(jq -r ".deliveries[$i].destination" "$path")"
435
+ case "$channel" in
436
+ telegram)
437
+ result="$(dispatch_local_telegram "$destination" "$message" "$plan_slug" "$plan_org")" ||
438
+ result='{"status":"error","detail":"relay request failed"}'
439
+ printf '%s' "$result" | jq -e . >/dev/null 2>&1 ||
440
+ result='{"status":"error","detail":"relay returned an unreadable response"}'
441
+ ;;
442
+ slack)
443
+ result="$(dispatch_local_slack "$destination" "$message")"
444
+ ;;
445
+ *)
446
+ result='{"status":"error","detail":"Unsupported channel"}'
447
+ ;;
448
+ esac
449
+ results="$(printf '%s' "$results" | jq -c --arg ch "$channel" --argjson r "$result" '. + {($ch): $r}')"
450
+ i=$((i + 1))
451
+ done
452
+
453
+ local sent
454
+ sent="$(printf '%s' "$results" | jq -c '[to_entries[] | select(.value.status == "sent") | .key]')"
455
+ if [ "$sent" = "[]" ]; then
456
+ printf '%s\n' "$results"
457
+ return 1
458
+ fi
459
+ printf '%s' "$results" | jq -c --argjson sent "$sent" \
460
+ '{status:"sent", platforms:$sent}
461
+ + ([to_entries[] | select(.value.status != "sent")] as $f
462
+ | if ($f | length) > 0
463
+ then {failed: ($f | map({(.key): (.value.detail // "unknown")}) | add)}
464
+ else {} end)'
377
465
  }
378
466
 
379
467
  dispatch_plan() {
@@ -469,12 +557,14 @@ test_connection() {
469
557
  -H "Authorization: Bearer $API_KEY" \
470
558
  --max-time 10
471
559
  elif [ "$MODE" = "local" ]; then
472
- local group_link
560
+ local group_link slack_channel
473
561
  group_link="$(read_local_telegram_config telegram_group_link)"
474
- if [ -n "$group_link" ]; then
562
+ slack_channel="$(read_local_telegram_config slack_channel_id)"
563
+ if [ -n "$group_link" ] ||
564
+ { [ -n "$slack_channel" ] && [ -n "${SLACK_BOT_TOKEN:-}" ]; }; then
475
565
  echo '{"status":"configured","mode":"local"}'
476
566
  else
477
- echo '{"status":"offline","reason":"no_telegram_group"}'
567
+ echo '{"status":"offline","reason":"no_notification_channel"}'
478
568
  fi
479
569
  else
480
570
  echo '{"status":"offline","reason":"no_api_key"}'
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "source": "egregore-runtime",
3
3
  "policy": "available OSS components selected by capability-distribution.json; Connect is an authenticated additive overlay",
4
- "skillCount": 42,
4
+ "skillCount": 43,
5
5
  "skills": [
6
6
  "activity",
7
7
  "announce",
@@ -33,6 +33,7 @@
33
33
  "reflect",
34
34
  "review-pr",
35
35
  "save",
36
+ "slack-connect",
36
37
  "summon",
37
38
  "sync-repos",
38
39
  "telegram-connect",
@@ -85,6 +86,7 @@
85
86
  ".codex/skills/reflect/SKILL.md",
86
87
  ".codex/skills/review-pr/SKILL.md",
87
88
  ".codex/skills/save/SKILL.md",
89
+ ".codex/skills/slack-connect/SKILL.md",
88
90
  ".codex/skills/summon/SKILL.md",
89
91
  ".codex/skills/sync-repos/SKILL.md",
90
92
  ".codex/skills/telegram-connect/SKILL.md",
@@ -112,6 +114,7 @@
112
114
  "bin/codex-session-start.sh",
113
115
  "bin/codex-skill-render.mjs",
114
116
  "bin/codex-sync-skills.sh",
117
+ "bin/connect-refresh.sh",
115
118
  "bin/dashboard-data.sh",
116
119
  "bin/graph-batch.sh",
117
120
  "bin/graph-op.sh",
@@ -0,0 +1,29 @@
1
+ ---
2
+ name: slack-connect
3
+ description: 'Fallback Codex adapter for the Egregore slack-connect workflow.'
4
+ ---
5
+
6
+ <!-- generated-by: bin/codex-sync-skills.sh -->
7
+
8
+ # Egregore slack-connect Adapter
9
+
10
+ This is fallback coverage for a long-tail Egregore workflow that has not been
11
+ ported to a hand-written Codex-native skill yet.
12
+
13
+ Use the project shell and filesystem directly. Do not invoke Claude Code
14
+ commands. Translate interactive choices to structured Codex question tooling
15
+ when it is available; otherwise render compact numbered choices with an
16
+ `Other:` option and wait for the user.
17
+
18
+ 1. Read `.claude/skills/slack-connect/SKILL.md` for the workflow details.
19
+ 2. Run the referenced `bin/` scripts directly from Codex.
20
+ 3. Treat graph and publish steps as best-effort unless that workflow explicitly
21
+ says they are required.
22
+ 4. For every external notification, follow
23
+ `.claude/context/notification-consent.md`: plan without sending, then show
24
+ a separate exact Send / Edit / Cancel checkpoint. Never infer notification
25
+ consent from the workflow request or a batch approval.
26
+ 5. Keep local-mode behavior filesystem-first and avoid graph or notification
27
+ calls when `egregore.json` declares `"mode": "local"`.
28
+ 6. Never call the deprecated `egregore-handoff` CLI for Egregore project
29
+ handoffs.
@@ -89,6 +89,11 @@ trap 'rm -rf "${CTX_DIR:-}"' EXIT
89
89
  # (terminal closed). Gated + idle-guarded inside the script; fire-and-forget.
90
90
  ( bash "$SCRIPT_DIR/bin/session-autosave.sh" --sweep >/dev/null 2>&1 & ) 2>/dev/null
91
91
 
92
+ # Connected instances pick up newly released Connect skills without
93
+ # re-running the launcher. Local mode exits instantly inside the script.
94
+ # Pi shares this path (pi-session-start.sh delegates here).
95
+ ( bash "$SCRIPT_DIR/bin/connect-refresh.sh" >/dev/null 2>&1 & ) 2>/dev/null
96
+
92
97
  read_json() {
93
98
  local file="$1"
94
99
  local expr="$2"
@@ -0,0 +1,33 @@
1
+ #!/usr/bin/env bash
2
+ # Refresh the Connect runtime overlay (skills served by the org's API) for
3
+ # this instance. Fired fire-and-forget from session start on every runtime;
4
+ # local-mode instances exit immediately and nothing here may block or break
5
+ # a session. New Connect features appear without re-running the launcher.
6
+ set -u
7
+
8
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
9
+ ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
10
+ cd "$ROOT" || exit 0
11
+
12
+ # Connected instances only — resolved the same way the runtime does.
13
+ MODE=$(jq -r '.mode // empty' egregore.json 2>/dev/null)
14
+ API_URL=$(jq -r '.api_url // empty' egregore.json 2>/dev/null)
15
+ if [ "$MODE" != "connected" ] && [ -z "$API_URL" ]; then
16
+ exit 0
17
+ fi
18
+
19
+ LOG_DIR="$ROOT/.egregore"
20
+ mkdir -p "$LOG_DIR" 2>/dev/null || true
21
+ LOG="$LOG_DIR/connect-refresh.log"
22
+
23
+ # The refresh logic lives in the installed create-egregore tooling (client
24
+ # instances do not carry packages/ in their tree). --prefer-offline keeps the
25
+ # common case fast; the first run may fetch the package once.
26
+ {
27
+ echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] connect refresh"
28
+ npx --yes --prefer-offline create-egregore --refresh-connect 2>&1
29
+ } >>"$LOG" 2>&1 || true
30
+
31
+ # Keep the log bounded.
32
+ tail -n 200 "$LOG" >"$LOG.tmp" 2>/dev/null && mv "$LOG.tmp" "$LOG" 2>/dev/null || true
33
+ exit 0
@@ -143,6 +143,14 @@ __EGREGORE_DESIGN_CSS__
143
143
  .button { border-radius: 99px; padding: 11px 18px; border: 1px solid var(--border); background: var(--card-bg); color: var(--dark); }
144
144
  .button.primary { border-color: var(--terracotta); background: var(--terracotta); color: var(--paper); }
145
145
  .button:disabled { cursor: not-allowed; color: var(--warm-gray); border-color: var(--border); background: var(--card-bg); }
146
+ .connectors { margin-top: 18px; padding-top: 16px; border-top: 1px solid var(--border); }
147
+ .connectors-head { display: flex; justify-content: space-between; align-items: baseline; }
148
+ .connectors-head h3 { font-size: 13px; font-weight: 600; color: var(--dark); }
149
+ .connector-row { display: flex; gap: 10px; margin-top: 10px; flex-wrap: wrap; }
150
+ .connector { display: flex; flex-direction: column; gap: 3px; text-align: left; border: 1px solid var(--border); border-radius: 12px; padding: 12px 16px; background: var(--card-bg); color: var(--dark); min-width: 180px; }
151
+ .connector:hover { border-color: var(--terracotta); }
152
+ .connector span { color: var(--muted); font-size: 12px; }
153
+ .connector-note { margin-top: 10px; color: var(--muted); font-size: 12px; line-height: 1.5; }
146
154
  .status { border-left: 2px solid var(--terracotta); padding: 10px 12px; color: var(--muted); font-size: 12px; line-height: 1.5; }
147
155
  .status.error { border-color: var(--danger); color: var(--danger); }
148
156
  .status.done { border-color: var(--success); color: var(--success); }
@@ -198,6 +206,11 @@ __EGREGORE_DESIGN_CSS__
198
206
  </div>
199
207
  <input id="file-input" type="file" multiple>
200
208
  <input id="folder-input" type="file" webkitdirectory multiple>
209
+ <div class="connectors hidden" id="connectors">
210
+ <div class="connectors-head"><h3>Or bring a connected source</h3><span class="step">guided setup</span></div>
211
+ <div class="connector-row" id="connector-row"></div>
212
+ <p class="connector-note">Setup continues in the terminal — the agent walks you through authorization, and your tokens stay on this machine.</p>
213
+ </div>
201
214
  </section>
202
215
 
203
216
  <section class="panel hidden" id="review-panel">
@@ -273,6 +286,59 @@ __EGREGORE_DESIGN_CSS__
273
286
  document.getElementById('completion').classList.remove('hidden');
274
287
  document.title = 'Return to the terminal — Egregore';
275
288
  }
289
+ var connectorMeta = {
290
+ google: { label: 'Google Workspace', detail: 'Gmail, Drive, Calendar — multiple accounts' },
291
+ notion: { label: 'Notion', detail: 'Pages and databases' }
292
+ };
293
+ function showConnectorCompletion(provider) {
294
+ var meta = connectorMeta[provider] || { label: provider };
295
+ var account = (capabilities.connector_accounts || {})[provider] || '';
296
+ document.getElementById('completion-kicker').textContent = account
297
+ ? 'Already connected'
298
+ : 'Guided setup continues in the terminal';
299
+ document.getElementById('completion-copy').textContent = account
300
+ ? meta.label + ' is already connected as ' + account + '. Back in the terminal, your agent can add another account or start bringing material in.'
301
+ : 'Your agent is waiting there to connect ' + meta.label + ' — it walks you through authorization step by step. Tokens stay on this machine.';
302
+ document.getElementById('completion-source').textContent = account || meta.label;
303
+ document.getElementById('completion-files').textContent = account ? 'connected' : 'connected source';
304
+ document.getElementById('completion-outcome').textContent = account ? meta.label : 'guided setup';
305
+ document.querySelector('.shell').classList.add('hidden');
306
+ document.getElementById('completion').classList.remove('hidden');
307
+ document.title = 'Return to the terminal — Egregore';
308
+ }
309
+ function chooseConnector(provider) {
310
+ if (busy) return;
311
+ busy = true;
312
+ post('/api/connector', {
313
+ method: 'POST',
314
+ headers: {'Content-Type': 'application/json'},
315
+ body: JSON.stringify({ provider: provider })
316
+ }).then(function (result) {
317
+ if (result.redirect) {
318
+ // The CLI pre-started the OAuth listener — continue this same tab
319
+ // straight into the sign-in page.
320
+ window.location.href = result.redirect;
321
+ return;
322
+ }
323
+ showConnectorCompletion(provider);
324
+ }).catch(function (err) {
325
+ busy = false;
326
+ setStatus(err.message, 'error');
327
+ statusBox.classList.remove('hidden');
328
+ });
329
+ }
330
+ (function renderConnectors() {
331
+ var list = (capabilities.connectors || []).filter(function (p) { return connectorMeta[p]; });
332
+ if (!list.length) return;
333
+ var row = document.getElementById('connector-row');
334
+ row.innerHTML = list.map(function (p) {
335
+ return '<button class="connector" type="button" data-provider="' + p + '"><strong>' + connectorMeta[p].label + '</strong><span>' + connectorMeta[p].detail + '</span></button>';
336
+ }).join('');
337
+ row.querySelectorAll('.connector').forEach(function (btn) {
338
+ btn.addEventListener('click', function () { chooseConnector(btn.dataset.provider); });
339
+ });
340
+ document.getElementById('connectors').classList.remove('hidden');
341
+ })();
276
342
  function choose(files) {
277
343
  var seen = new Set();
278
344
  selected = Array.from(files).filter(function (file) {