diff --git a/setup/add-teams.sh b/setup/add-teams.sh new file mode 100755 index 0000000..f116f24 --- /dev/null +++ b/setup/add-teams.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash +# +# Install the Teams adapter, persist TEAMS_APP_ID / _PASSWORD / _TENANT_ID / +# _TYPE to .env + data/env/env, and restart the service. Non-interactive — +# the operator-facing Azure portal walkthroughs live in +# setup/channels/teams.ts. Credentials come in via env vars: +# TEAMS_APP_ID (required) +# TEAMS_APP_PASSWORD (required — client secret value from Azure) +# TEAMS_APP_TYPE (required — SingleTenant | MultiTenant) +# TEAMS_APP_TENANT_ID (required when type=SingleTenant) +# +# Emits exactly one status block on stdout (ADD_TEAMS) at the end. All chatty +# progress messages go to stderr so setup:auto's raw-log capture sees the +# full story without cluttering the final block for the parser. +set -euo pipefail + +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$PROJECT_ROOT" + +# Keep in sync with .claude/skills/add-teams/SKILL.md. +ADAPTER_VERSION="@chat-adapter/teams@4.26.0" +CHANNELS_BRANCH="origin/channels" + +emit_status() { + local status=$1 error=${2:-} + local already=${ADAPTER_ALREADY_INSTALLED:-false} + echo "=== NANOCLAW SETUP: ADD_TEAMS ===" + echo "STATUS: ${status}" + echo "ADAPTER_VERSION: ${ADAPTER_VERSION}" + echo "ADAPTER_ALREADY_INSTALLED: ${already}" + [ -n "$error" ] && echo "ERROR: ${error}" + echo "=== END ===" +} + +log() { echo "[add-teams] $*" >&2; } + +if [ -z "${TEAMS_APP_ID:-}" ]; then + emit_status failed "TEAMS_APP_ID env var not set" + exit 1 +fi +if [ -z "${TEAMS_APP_PASSWORD:-}" ]; then + emit_status failed "TEAMS_APP_PASSWORD env var not set" + exit 1 +fi +if [ -z "${TEAMS_APP_TYPE:-}" ]; then + emit_status failed "TEAMS_APP_TYPE env var not set (SingleTenant|MultiTenant)" + exit 1 +fi +if [ "${TEAMS_APP_TYPE}" = "SingleTenant" ] && [ -z "${TEAMS_APP_TENANT_ID:-}" ]; then + emit_status failed "TEAMS_APP_TENANT_ID required when TEAMS_APP_TYPE=SingleTenant" + exit 1 +fi + +need_install() { + [ ! -f src/channels/teams.ts ] && return 0 + ! grep -q "^import './teams.js';" src/channels/index.ts 2>/dev/null && return 0 + return 1 +} + +ADAPTER_ALREADY_INSTALLED=true +if need_install; then + ADAPTER_ALREADY_INSTALLED=false + log "Fetching channels branch…" + git fetch origin channels >&2 2>/dev/null || { + emit_status failed "git fetch origin channels failed" + exit 1 + } + + log "Copying adapter from ${CHANNELS_BRANCH}…" + git show "${CHANNELS_BRANCH}:src/channels/teams.ts" > src/channels/teams.ts + + # Append self-registration import if missing. + if ! grep -q "^import './teams.js';" src/channels/index.ts; then + echo "import './teams.js';" >> src/channels/index.ts + fi + + log "Installing ${ADAPTER_VERSION}…" + pnpm install "${ADAPTER_VERSION}" >&2 2>/dev/null || { + emit_status failed "pnpm install ${ADAPTER_VERSION} failed" + exit 1 + } + + log "Building…" + pnpm run build >&2 2>/dev/null || { + emit_status failed "pnpm run build failed" + exit 1 + } +else + log "Adapter files already installed — skipping install phase." +fi + +# Persist credentials. +touch .env +upsert_env() { + local key=$1 value=$2 + if grep -q "^${key}=" .env; then + awk -v k="$key" -v v="$value" \ + 'BEGIN{FS=OFS="="} $1==k {print k "=" v; next} {print}' \ + .env > .env.tmp && mv .env.tmp .env + else + echo "${key}=${value}" >> .env + fi +} +upsert_env TEAMS_APP_ID "$TEAMS_APP_ID" +upsert_env TEAMS_APP_PASSWORD "$TEAMS_APP_PASSWORD" +upsert_env TEAMS_APP_TYPE "$TEAMS_APP_TYPE" +if [ -n "${TEAMS_APP_TENANT_ID:-}" ]; then + upsert_env TEAMS_APP_TENANT_ID "$TEAMS_APP_TENANT_ID" +fi + +# Container reads from data/env/env (the host mounts it). +mkdir -p data/env +cp .env data/env/env + +log "Restarting service so the new adapter picks up the credentials…" +case "$(uname -s)" in + Darwin) + launchctl kickstart -k "gui/$(id -u)/com.nanoclaw" >&2 2>/dev/null || true + ;; + Linux) + systemctl --user restart nanoclaw >&2 2>/dev/null \ + || sudo systemctl restart nanoclaw >&2 2>/dev/null \ + || true + ;; +esac + +# Give the Teams adapter a moment to register its webhook before the driver +# continues. +sleep 5 + +emit_status success diff --git a/setup/auto.ts b/setup/auto.ts index 3be7856..52586c2 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -26,6 +26,7 @@ import * as p from '@clack/prompts'; import k from 'kleur'; import { runDiscordChannel } from './channels/discord.js'; +import { runTeamsChannel } from './channels/teams.js'; import { runTelegramChannel } from './channels/telegram.js'; import { runWhatsAppChannel } from './channels/whatsapp.js'; import { pingCliAgent, type PingResult } from './lib/agent-ping.js'; @@ -224,10 +225,12 @@ async function main(): Promise { await runDiscordChannel(displayName!); } else if (choice === 'whatsapp') { await runWhatsAppChannel(displayName!); + } else if (choice === 'teams') { + await runTeamsChannel(displayName!); } else { p.log.info( wrapForGutter( - 'No messaging app for now. You can add one later (like Telegram, Discord, WhatsApp, or Slack).', + 'No messaging app for now. You can add one later (like Telegram, Discord, WhatsApp, Teams, or Slack).', 4, ), ); @@ -522,7 +525,9 @@ async function askDisplayName(fallback: string): Promise { return value; } -async function askChannelChoice(): Promise<'telegram' | 'discord' | 'whatsapp' | 'skip'> { +async function askChannelChoice(): Promise< + 'telegram' | 'discord' | 'whatsapp' | 'teams' | 'skip' +> { const choice = ensureAnswer( await p.select({ message: 'Want to chat with your assistant from your phone?', @@ -530,12 +535,13 @@ async function askChannelChoice(): Promise<'telegram' | 'discord' | 'whatsapp' | { value: 'telegram', label: 'Yes, connect Telegram', hint: 'recommended' }, { value: 'discord', label: 'Yes, connect Discord' }, { value: 'whatsapp', label: 'Yes, connect WhatsApp' }, + { value: 'teams', label: 'Yes, connect Microsoft Teams', hint: 'complex setup' }, { value: 'skip', label: 'Skip for now', hint: "I'll just use the terminal" }, ], }), ); setupLog.userInput('channel_choice', String(choice)); - return choice as 'telegram' | 'discord' | 'whatsapp' | 'skip'; + return choice as 'telegram' | 'discord' | 'whatsapp' | 'teams' | 'skip'; } // ─── interactive / env helpers ───────────────────────────────────────── diff --git a/setup/channels/teams.ts b/setup/channels/teams.ts new file mode 100644 index 0000000..432bcbf --- /dev/null +++ b/setup/channels/teams.ts @@ -0,0 +1,629 @@ +/** + * Microsoft Teams channel flow for setup:auto. + * + * Teams is the most complex channel NanoClaw supports — the Slack/Discord + * "paste a token" shortcut doesn't exist. The operator has to walk through + * ~7 Azure portal steps (app registration, client secret, Azure Bot + * resource, messaging endpoint, Teams channel enable, manifest, sideload). + * + * This driver's job is to make each of those steps as guided as possible + * inside the terminal: + * 1. Print a clack note with the exact sub-steps and the portal URL. + * 2. Ask for the value(s) that step yields (App ID, secret, tenant, etc.). + * 3. At every step boundary, offer `stepGate` — a Done / Stuck / Show-again + * select. "Stuck" hands off to interactive Claude with full context. + * + * Text/password prompts also accept `?` as an answer to trigger the handoff, + * so the operator can escape at any paste point without scrolling back to a + * step boundary. + * + * What's deferred (known limitation, instruct user how to finish manually): + * - Wait-for-first-DM to capture the auto-generated Teams platformId. + * Unlike Discord/Telegram, the Teams platform_id is only discoverable + * after the first inbound activity. The driver installs the adapter and + * stops there; the operator DMs the bot, NanoClaw auto-creates the + * messaging group, and they wire an agent via `/manage-channels`. + */ +import os from 'os'; +import path from 'path'; + +import * as p from '@clack/prompts'; +import k from 'kleur'; + +import { confirmThenOpen } from '../lib/browser.js'; +import { + isHelpEscape, + offerClaudeHandoff, + validateWithHelpEscape, + type HandoffContext, +} from '../lib/claude-handoff.js'; +import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js'; +import { buildTeamsAppPackage } from '../lib/teams-manifest.js'; +import * as setupLog from '../logs.js'; + +const CHANNEL = 'teams'; +const MANIFEST_DIR = path.join(process.cwd(), 'data', 'teams'); +const AZURE_PORTAL_URL = 'https://portal.azure.com'; + +interface Collected { + publicUrl?: string; + appId?: string; + tenantId?: string; + appType?: 'SingleTenant' | 'MultiTenant'; + appPassword?: string; + agentName?: string; +} + +export async function runTeamsChannel(_displayName: string): Promise { + const collected: Collected = {}; + const completed: string[] = []; + + printIntro(); + + await confirmPrereqs({ collected, completed }); + await stepPublicUrl({ collected, completed }); + await stepAppRegistration({ collected, completed }); + await stepClientSecret({ collected, completed }); + await stepAzureBot({ collected, completed }); + await stepEnableTeamsChannel({ collected, completed }); + const manifestResult = await stepGenerateManifest({ collected, completed }); + await stepSideload({ collected, completed, zipPath: manifestResult.zipPath }); + + await installAdapter(collected); + completed.push('Adapter installed and service restarted.'); + + printPostInstallGuidance(); +} + +// ─── step: intro / prereqs ────────────────────────────────────────────── + +function printIntro(): void { + p.note( + [ + 'Setting up Teams is more involved than the other channels — about', + '7 steps across the Azure portal and Teams admin.', + '', + k.dim("At any prompt you can type '?' and press Enter to hand off"), + k.dim("to Claude interactive mode with your current progress."), + k.dim("You can also pick 'Stuck' at any Done/Stuck/Show-again prompt."), + ].join('\n'), + 'Microsoft Teams setup', + ); +} + +async function confirmPrereqs(args: { collected: Collected; completed: string[] }): Promise { + p.note( + [ + 'Before we start, confirm you have:', + '', + ' • A Microsoft 365 tenant where you can sideload custom apps', + ' (free personal Teams does NOT support this — you need a', + ' Microsoft 365 Business / EDU / developer tenant)', + ' • Teams admin or developer tenant rights', + ' • A way to expose an HTTPS endpoint from this machine', + ' (ngrok, Cloudflare Tunnel, or a reverse-proxied VPS)', + ].join('\n'), + 'Prereqs', + ); + + await stepGate({ + stepName: 'teams-prereqs', + stepDescription: 'confirming they have the right Microsoft 365 tenant and tunnel', + reshow: () => confirmPrereqs(args), + args, + }); + args.completed.push('Prereqs confirmed.'); +} + +// ─── step: public URL ────────────────────────────────────────────────── + +async function stepPublicUrl(args: { collected: Collected; completed: string[] }): Promise { + p.note( + [ + "Azure Bot Service delivers messages to an HTTPS endpoint you", + "control. The endpoint needs to reach this machine's webhook", + "server at /api/webhooks/teams.", + '', + k.dim('Examples:'), + k.dim(' ngrok http 3000 → https://abcd1234.ngrok.io'), + k.dim(' cloudflared tunnel … → https://.trycloudflare.com'), + k.dim(' or a reverse proxy on your own domain'), + '', + "If you don't have a tunnel running yet, start one in another", + "terminal, then come back here.", + ].join('\n'), + 'Public HTTPS URL', + ); + + while (true) { + const answer = ensureAnswer( + await p.text({ + message: 'Paste your public base URL (e.g. https://abcd1234.ngrok.io)', + placeholder: 'https://…', + validate: validateWithHelpEscape((v) => { + const t = (v ?? '').trim(); + if (!t) return 'Required'; + if (!/^https:\/\/[^\s/]+/.test(t)) { + return 'Must be an https:// URL (Azure rejects http)'; + } + return undefined; + }), + }), + ); + if (isHelpEscape(answer)) { + await offerHandoff({ + step: 'teams-public-url', + stepDescription: + 'setting up a public HTTPS tunnel to reach this machine on port 3000', + args, + }); + continue; + } + const url = (answer as string).trim().replace(/\/$/, ''); + args.collected.publicUrl = url; + setupLog.userInput('teams_public_url', url); + break; + } + + args.completed.push(`Public URL: ${args.collected.publicUrl}`); +} + +// ─── step: Azure App Registration ────────────────────────────────────── + +async function stepAppRegistration(args: { + collected: Collected; + completed: string[]; +}): Promise { + p.note( + [ + `1. In ${AZURE_PORTAL_URL}, search "App registrations" → "New registration"`, + '2. Name it (e.g. "NanoClaw")', + '3. Supported account types: Single tenant (your org only) OR', + ' Multi tenant (any Microsoft 365 tenant can add the bot)', + '4. Click Register', + '5. On the Overview page, copy:', + ' • Application (client) ID', + ' • Directory (tenant) ID', + ].join('\n'), + 'Step 1 of 6 — Create Azure App Registration', + ); + await confirmThenOpen( + AZURE_PORTAL_URL, + 'Press Enter to open the Azure portal', + ); + + args.collected.appType = await askAppType(args); + args.collected.appId = await askUuid( + 'Paste the Application (client) ID', + 'teams-app-id', + args, + ); + if (args.collected.appType === 'SingleTenant') { + args.collected.tenantId = await askUuid( + 'Paste the Directory (tenant) ID', + 'teams-tenant-id', + args, + ); + } + + await stepGate({ + stepName: 'teams-app-registration', + stepDescription: 'registering an app in Azure and collecting App ID + tenant type', + reshow: () => stepAppRegistration(args), + args, + }); + args.completed.push( + `App registered: ${args.collected.appId} (${args.collected.appType})`, + ); +} + +async function askAppType(args: { + collected: Collected; + completed: string[]; +}): Promise<'SingleTenant' | 'MultiTenant'> { + while (true) { + const choice = ensureAnswer( + await p.select({ + message: 'Which account type did you pick?', + options: [ + { + value: 'SingleTenant', + label: 'Single tenant', + hint: 'your org only — most common for self-host', + }, + { + value: 'MultiTenant', + label: 'Multi tenant', + hint: 'any Microsoft 365 tenant can install the bot', + }, + { value: 'help', label: 'Stuck — hand me off to Claude' }, + ], + }), + ); + if (choice === 'help') { + await offerHandoff({ + step: 'teams-app-type', + stepDescription: "deciding between Single tenant and Multi tenant for their Azure app", + args, + }); + continue; + } + return choice as 'SingleTenant' | 'MultiTenant'; + } +} + +// ─── step: client secret ─────────────────────────────────────────────── + +async function stepClientSecret(args: { + collected: Collected; + completed: string[]; +}): Promise { + p.note( + [ + `1. In your app registration, open "Certificates & secrets"`, + '2. Click "New client secret"', + ' Description: nanoclaw', + ' Expires: 180 days (recommended) or longer', + '3. Click Add', + '4. ' + k.yellow('COPY THE VALUE NOW — Azure only shows it once'), + ' (the Value column, not the Secret ID)', + ].join('\n'), + 'Step 2 of 6 — Create a client secret', + ); + + while (true) { + const answer = ensureAnswer( + await p.password({ + message: 'Paste the client secret Value', + validate: validateWithHelpEscape((v) => { + const t = (v ?? '').trim(); + if (!t) return 'Required'; + if (t.length < 20) return "That looks too short — make sure you copied the Value, not the Secret ID"; + return undefined; + }), + }), + ); + if (isHelpEscape(answer)) { + await offerHandoff({ + step: 'teams-client-secret', + stepDescription: 'creating and copying the client secret value from Azure', + args, + }); + continue; + } + args.collected.appPassword = (answer as string).trim(); + setupLog.userInput( + 'teams_client_secret', + `${args.collected.appPassword.slice(0, 4)}…${args.collected.appPassword.slice(-4)}`, + ); + break; + } + + await stepGate({ + stepName: 'teams-client-secret', + stepDescription: 'creating and copying the client secret', + reshow: () => stepClientSecret(args), + args, + }); + args.completed.push('Client secret captured.'); +} + +// ─── step: Azure Bot resource ────────────────────────────────────────── + +async function stepAzureBot(args: { + collected: Collected; + completed: string[]; +}): Promise { + const endpoint = `${args.collected.publicUrl}/api/webhooks/teams`; + const tenantFlag = + args.collected.appType === 'SingleTenant' + ? `--tenant-id ${args.collected.tenantId} ` + : ''; + const cliCommand = + `az bot create \\\n` + + ` --resource-group nanoclaw-rg \\\n` + + ` --name nanoclaw-bot \\\n` + + ` --app-type ${args.collected.appType} \\\n` + + ` --appid ${args.collected.appId} \\\n` + + ` ${tenantFlag}--endpoint "${endpoint}"`; + + p.note( + [ + `In ${AZURE_PORTAL_URL}, search "Azure Bot" → Create.`, + '', + ' • Bot handle: unique name, e.g. nanoclaw-bot', + ` • Type of App: ${args.collected.appType}`, + ' • Creation type: Use existing app registration', + ` • App ID: ${args.collected.appId ?? ''}`, + ...(args.collected.appType === 'SingleTenant' + ? [` • App tenant ID: ${args.collected.tenantId ?? ''}`] + : []), + '', + 'After creating, open the bot → Configuration and set:', + ` Messaging endpoint: ${k.cyan(endpoint)}`, + '', + k.dim('Or via Azure CLI (if you have az installed):'), + k.dim(cliCommand), + ].join('\n'), + 'Step 3 of 6 — Create Azure Bot resource', + ); + + await stepGate({ + stepName: 'teams-azure-bot', + stepDescription: + 'creating an Azure Bot resource linked to the app registration and setting the messaging endpoint', + reshow: () => stepAzureBot(args), + args, + }); + args.completed.push('Azure Bot created; messaging endpoint configured.'); +} + +// ─── step: enable Teams channel ──────────────────────────────────────── + +async function stepEnableTeamsChannel(args: { + collected: Collected; + completed: string[]; +}): Promise { + p.note( + [ + '1. Open your Azure Bot resource → Channels', + '2. Click Microsoft Teams → Accept terms → Apply', + '', + k.dim('CLI alternative:'), + k.dim(' az bot msteams create --resource-group nanoclaw-rg --name nanoclaw-bot'), + ].join('\n'), + 'Step 4 of 6 — Enable Teams channel on the bot', + ); + await stepGate({ + stepName: 'teams-enable-channel', + stepDescription: 'enabling the Microsoft Teams channel on the Azure Bot resource', + reshow: () => stepEnableTeamsChannel(args), + args, + }); + args.completed.push('Teams channel enabled on the bot.'); +} + +// ─── step: manifest zip ──────────────────────────────────────────────── + +async function stepGenerateManifest(args: { + collected: Collected; + completed: string[]; +}): Promise<{ zipPath: string }> { + if (!args.collected.appId) { + fail( + 'teams-manifest', + 'Missing Azure App ID.', + "That's an internal bug — open an issue or retry setup.", + ); + } + const shortName = + process.env.NANOCLAW_AGENT_NAME?.trim() || 'NanoClaw'; + + const s = p.spinner(); + s.start('Generating your Teams app package…'); + try { + const result = buildTeamsAppPackage({ + appId: args.collected.appId!, + shortName, + longDescription: `${shortName} personal assistant powered by NanoClaw.`, + websiteUrl: args.collected.publicUrl!, + outDir: MANIFEST_DIR, + }); + s.stop(`Package ready: ${k.cyan(shortPath(result.zipPath))}`); + setupLog.step('teams-manifest', 'success', 0, { + ZIP: result.zipPath, + }); + args.completed.push(`Generated manifest zip at ${shortPath(result.zipPath)}.`); + return { zipPath: result.zipPath }; + } catch (err) { + s.stop("Couldn't build the manifest zip.", 1); + const message = err instanceof Error ? err.message : String(err); + setupLog.step('teams-manifest', 'failed', 0, { ERROR: message }); + fail( + 'teams-manifest', + "Couldn't generate the Teams app package.", + 'Make sure `zip` is available on your PATH, then retry.', + ); + } +} + +// ─── step: sideload ──────────────────────────────────────────────────── + +async function stepSideload(args: { + collected: Collected; + completed: string[]; + zipPath: string; +}): Promise { + p.note( + [ + '1. Open Microsoft Teams', + '2. Go to Apps → Manage your apps → Upload an app', + '3. Click "Upload a custom app" (or "Upload for me or my teams")', + `4. Select: ${k.cyan(args.zipPath)}`, + '5. Click Add', + '', + k.dim('If "Upload a custom app" is missing, your tenant admin has'), + k.dim('disabled sideloading. Enable it in Teams Admin Center →'), + k.dim('Teams apps → Setup policies → Global → Upload custom apps = On'), + ].join('\n'), + 'Step 5 of 6 — Sideload the app into Teams', + ); + await stepGate({ + stepName: 'teams-sideload', + stepDescription: 'uploading the generated zip into Teams as a custom app', + reshow: () => stepSideload(args), + args, + }); + args.completed.push('App sideloaded into Teams.'); +} + +// ─── step: install adapter ───────────────────────────────────────────── + +async function installAdapter(collected: Collected): Promise { + const env: Record = { + TEAMS_APP_ID: collected.appId!, + TEAMS_APP_PASSWORD: collected.appPassword!, + TEAMS_APP_TYPE: collected.appType!, + }; + if (collected.appType === 'SingleTenant') { + env.TEAMS_APP_TENANT_ID = collected.tenantId!; + } + + const install = await runQuietChild( + 'teams-install', + 'bash', + ['setup/add-teams.sh'], + { + running: 'Installing the Teams adapter and restarting the service…', + done: 'Teams adapter installed.', + }, + { + env, + extraFields: { + APP_ID: collected.appId!, + APP_TYPE: collected.appType!, + }, + }, + ); + if (!install.ok) { + fail( + 'teams-install', + "Couldn't install the Teams adapter.", + 'See logs/setup-steps/ for details, then retry setup.', + ); + } +} + +// ─── post-install: how to finish wiring ──────────────────────────────── + +function printPostInstallGuidance(): void { + p.note( + [ + "The Teams adapter is live and the service is running. To finish", + "hooking up an agent:", + '', + ' 1. Find your bot in Teams (search by name, or via the sideloaded', + ' app) and send it a message ("hi" is fine)', + ' 2. NanoClaw auto-creates a messaging group on the first inbound', + ' activity (Teams platform IDs are only discoverable after a', + ' real message arrives)', + ' 3. Run ' + k.cyan('/manage-channels') + ' to wire that messaging', + ' group to an agent group', + '', + k.dim('If the bot never replies, check logs/nanoclaw.log — most'), + k.dim("first-run failures are webhook reachability ('messages never"), + k.dim("reach the bot') or auth ('app password rejected')."), + ].join('\n'), + 'Step 6 of 6 — Finish wiring', + ); +} + +// ─── shared step gate ────────────────────────────────────────────────── + +async function stepGate(args: { + stepName: string; + stepDescription: string; + reshow: () => Promise | Promise; + args: { collected: Collected; completed: string[] }; +}): Promise { + while (true) { + const choice = ensureAnswer( + await p.select({ + message: 'How did that go?', + options: [ + { value: 'done', label: "Done — let's continue" }, + { value: 'help', label: 'Stuck — hand me off to Claude' }, + { value: 'reshow', label: 'Show me the steps again' }, + ], + }), + ); + if (choice === 'done') return; + if (choice === 'help') { + await offerHandoff({ + step: args.stepName, + stepDescription: args.stepDescription, + args: args.args, + }); + continue; + } + if (choice === 'reshow') { + await args.reshow(); + return; + } + } +} + +async function offerHandoff(args: { + step: string; + stepDescription: string; + args: { collected: Collected; completed: string[] }; +}): Promise { + const ctx: HandoffContext = { + channel: CHANNEL, + step: args.step, + stepDescription: args.stepDescription, + completedSteps: args.args.completed.slice(), + collectedValues: redactCollected(args.args.collected), + files: ['setup/channels/teams.ts', 'setup/add-teams.sh'], + }; + await offerClaudeHandoff(ctx); +} + +function redactCollected(c: Collected): Record { + const out: Record = {}; + if (c.publicUrl) out.publicUrl = c.publicUrl; + if (c.appId) out.appId = c.appId; + if (c.tenantId) out.tenantId = c.tenantId; + if (c.appType) out.appType = c.appType; + if (c.appPassword) { + out.appPassword = `${c.appPassword.slice(0, 4)}…${c.appPassword.slice(-4)}`; + } + return out; +} + +// ─── shared: UUID paste with help escape ─────────────────────────────── + +async function askUuid( + message: string, + logKey: string, + args: { collected: Collected; completed: string[] }, +): Promise { + while (true) { + const answer = ensureAnswer( + await p.text({ + message, + placeholder: '00000000-0000-0000-0000-000000000000', + validate: validateWithHelpEscape((v) => { + const t = (v ?? '').trim(); + if (!t) return 'Required'; + if (!/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(t)) { + return 'Expected a UUID like 00000000-0000-0000-0000-000000000000'; + } + return undefined; + }), + }), + ); + if (isHelpEscape(answer)) { + await offerHandoff({ + step: logKey, + stepDescription: `entering a UUID for ${logKey}`, + args, + }); + continue; + } + const value = (answer as string).trim().toLowerCase(); + setupLog.userInput(logKey, value); + return value; + } +} + +// ─── path helpers ────────────────────────────────────────────────────── + +function shortPath(abs: string): string { + const home = os.homedir(); + const cwd = process.cwd(); + if (abs.startsWith(`${cwd}/`)) return abs.slice(cwd.length + 1); + if (abs.startsWith(`${home}/`)) return `~/${abs.slice(home.length + 1)}`; + return abs; +} + diff --git a/setup/lib/claude-assist.ts b/setup/lib/claude-assist.ts index 4735be6..70b6a3d 100644 --- a/setup/lib/claude-assist.ts +++ b/setup/lib/claude-assist.ts @@ -63,6 +63,8 @@ const STEP_FILES: Record = { 'telegram-validate': ['setup/channels/telegram.ts'], 'pair-telegram': ['setup/pair-telegram.ts', 'setup/channels/telegram.ts'], 'discord-install': ['setup/add-discord.sh', 'setup/channels/discord.ts'], + 'teams-install': ['setup/add-teams.sh', 'setup/channels/teams.ts'], + 'teams-manifest': ['setup/lib/teams-manifest.ts', 'setup/channels/teams.ts'], 'init-first-agent': [ 'scripts/init-first-agent.ts', 'setup/channels/telegram.ts', diff --git a/setup/lib/claude-handoff.ts b/setup/lib/claude-handoff.ts new file mode 100644 index 0000000..9c931f2 --- /dev/null +++ b/setup/lib/claude-handoff.ts @@ -0,0 +1,194 @@ +/** + * User-initiated handoff to interactive Claude, parallel to claude-assist.ts. + * + * claude-assist is for failures: it runs `claude -p` non-interactively, parses + * a suggested command, and offers to run it. This module is for the opposite + * case — the user is mid-flow, not stuck on an error, and wants Claude to + * walk them through something the driver can't fully automate (Azure portal + * clickthrough, writing a manifest, tunneling a port, etc.). + * + * Flow: + * 1. Build a handoff prompt from the caller's context: channel, current + * step, completed steps, collected values (secrets redacted), relevant + * files to read. + * 2. Spawn `claude --append-system-prompt "" + * --permission-mode acceptEdits` with `stdio: 'inherit'` so Claude owns + * the terminal. + * 3. When Claude exits (user types /exit, Ctrl-D, or closes the session), + * control returns to the setup driver. The driver can then re-offer the + * same step (e.g., "How did that go?" select). + * + * Also exports a small helper for text/password prompts: `validateWithHelpEscape` + * wraps a validate callback so typing `?` triggers the handoff instead of + * attempting to parse it as a real answer. + */ +import { execSync, spawn } from 'child_process'; + +import * as p from '@clack/prompts'; +import k from 'kleur'; + +export interface HandoffContext { + /** Channel this handoff is happening in (e.g., 'teams'). */ + channel: string; + /** Short name of the current step the user is stuck on. */ + step: string; + /** Human-readable summary of what the user was trying to do at this step. */ + stepDescription: string; + /** Checklist of sub-steps already completed (displayed as `✓ `). */ + completedSteps?: string[]; + /** + * Key/value pairs of values collected so far. Callers should redact + * secrets before passing (e.g., show last 4 chars). Used to give Claude + * the state of the operator's progress. + */ + collectedValues?: Record; + /** + * Repo-relative paths Claude should consider reading. Always gets + * logs/setup.log and the relevant SKILL.md appended by the builder. + */ + files?: string[]; +} + +/** + * Spawn interactive Claude with context pre-loaded as a system-prompt + * append. Returns when Claude exits. + * + * Silently no-ops (returns `false`) if `claude` isn't on PATH — setup runs + * where the binary is guaranteed to exist (we install it in the auth step), + * but an ultra-early flow failure could technically reach this before that + * install, and crashing the handoff would be worse than the handoff not + * firing. + */ +export async function offerClaudeHandoff(ctx: HandoffContext): Promise { + if (!isClaudeUsable()) { + p.log.warn( + "Claude isn't installed yet — can't hand you off here. Finish setup first, then retry.", + ); + return false; + } + + const systemPrompt = buildSystemPrompt(ctx); + + p.note( + [ + "I'm handing you off to Claude in interactive mode.", + "It has the context of where you are in setup.", + "", + k.dim("Type /exit (or press Ctrl-D) when you're ready to come back to setup."), + ].join('\n'), + 'Handing off to Claude', + ); + + return new Promise((resolve) => { + const child = spawn( + 'claude', + [ + '--append-system-prompt', + systemPrompt, + '--permission-mode', + 'acceptEdits', + ], + { stdio: 'inherit' }, + ); + child.on('close', () => { + p.log.success("Back from Claude. Let's continue."); + resolve(true); + }); + child.on('error', () => { + p.log.error("Couldn't launch Claude. Continuing without handoff."); + resolve(false); + }); + }); +} + +/** + * Sentinel returned by `validateWithHelpEscape` when the user types `?`. + * The caller compares against this to decide whether to trigger a handoff. + */ +export const HELP_ESCAPE_SENTINEL = '__NANOCLAW_HELP_ESCAPE__'; + +/** + * Wrap a clack `validate` callback so typing `?` short-circuits validation + * and returns the HELP_ESCAPE_SENTINEL. Caller should check for the sentinel + * after awaiting the prompt and trigger offerClaudeHandoff if matched. + * + * Usage: + * const answer = await p.text({ + * message: 'Paste your Azure App ID', + * validate: validateWithHelpEscape((v) => { + * if (!/^[0-9a-f-]{36}$/.test(v)) return 'Expected a UUID'; + * return undefined; + * }), + * }); + * if (answer === HELP_ESCAPE_SENTINEL) { await offerClaudeHandoff(ctx); ... } + */ +export function validateWithHelpEscape( + inner?: (value: string) => string | Error | undefined, +): (value: string) => string | Error | undefined { + return (value: string) => { + if ((value ?? '').trim() === '?') { + // Returning undefined lets clack accept the `?` as the "answer". The + // caller sees a literal "?" and should compare + escape to handoff. + return undefined; + } + return inner ? inner(value) : undefined; + }; +} + +/** + * True if the value returned by a text/password prompt should trigger a + * handoff. Abstracts the sentinel check so callers don't have to import it + * directly at every site. + */ +export function isHelpEscape(value: unknown): boolean { + return typeof value === 'string' && value.trim() === '?'; +} + +function isClaudeUsable(): boolean { + try { + execSync('command -v claude', { stdio: 'ignore' }); + return true; + } catch { + return false; + } +} + +function buildSystemPrompt(ctx: HandoffContext): string { + const lines: string[] = [ + `The user is running NanoClaw's interactive \`setup:auto\` flow to wire the ${ctx.channel} channel.`, + `They got stuck at the step: "${ctx.step}" (${ctx.stepDescription}) and asked for help.`, + '', + "Your job: help them complete this specific step and get back to setup.", + "You can read files, run commands (with acceptEdits permissions), search the web,", + "and explain concepts. Be concise. When they're ready to resume, tell them to type", + "/exit and they'll return to the setup flow at the same step.", + '', + ]; + + if (ctx.completedSteps && ctx.completedSteps.length > 0) { + lines.push('Steps they have already completed:'); + for (const s of ctx.completedSteps) lines.push(` ✓ ${s}`); + lines.push(''); + } + + if (ctx.collectedValues && Object.keys(ctx.collectedValues).length > 0) { + lines.push('Values collected so far (secrets redacted):'); + for (const [k, v] of Object.entries(ctx.collectedValues)) { + lines.push(` ${k}: ${v}`); + } + lines.push(''); + } + + const files = [ + ...(ctx.files ?? []), + 'logs/setup.log', + 'logs/setup-steps/', + `.claude/skills/add-${ctx.channel}/SKILL.md`, + `setup/channels/${ctx.channel}.ts`, + ].filter((v, i, a) => a.indexOf(v) === i); + + lines.push('Relevant files (read as needed with the Read tool):'); + for (const f of files) lines.push(` - ${f}`); + + return lines.join('\n'); +} diff --git a/setup/lib/teams-manifest.ts b/setup/lib/teams-manifest.ts new file mode 100644 index 0000000..c40837a --- /dev/null +++ b/setup/lib/teams-manifest.ts @@ -0,0 +1,271 @@ +/** + * Build the Teams app package zip that the operator sideloads from the Teams + * "Manage your apps" screen. + * + * A Teams app package is a zip containing: + * - manifest.json — declares the bot, scopes, required permissions + * - outline.png — 32×32 transparent outline icon + * - color.png — 192×192 full-color icon + * + * Icons are generated in-process using a minimal PNG encoder so we don't + * need ImageMagick or vendor binary icon blobs into the repo. The outline + * icon is a simple rounded square outline; the color icon is a brand-blue + * filled square with a small white "N" blocked in by pixel setting. Good + * enough for a working sideload — teams admins who care can replace the + * icons later. + * + * The manifest is pinned to schema v1.16 to match the skill doc. + */ +import { execSync } from 'child_process'; +import fs from 'fs'; +import path from 'path'; +import zlib from 'zlib'; + +const MANIFEST_SCHEMA = + 'https://developer.microsoft.com/en-us/json-schemas/teams/v1.16/MicrosoftTeams.schema.json'; +const MANIFEST_VERSION = '1.16'; + +export interface ManifestOptions { + /** The Azure AD app ID (same value used for `bots[0].botId`). */ + appId: string; + /** Short bot name shown in Teams (<= 30 chars). */ + shortName: string; + /** Long bot description. */ + longDescription: string; + /** Developer website URL (required by schema — any reachable URL works). */ + websiteUrl: string; + /** Out-dir for the generated zip + loose files. */ + outDir: string; +} + +export interface ManifestResult { + zipPath: string; + manifestPath: string; + outlinePath: string; + colorPath: string; +} + +/** Build the full app package zip and return the paths. */ +export function buildTeamsAppPackage(opts: ManifestOptions): ManifestResult { + fs.mkdirSync(opts.outDir, { recursive: true }); + + const manifestPath = path.join(opts.outDir, 'manifest.json'); + const outlinePath = path.join(opts.outDir, 'outline.png'); + const colorPath = path.join(opts.outDir, 'color.png'); + const zipPath = path.join(opts.outDir, 'teams-app-package.zip'); + + fs.writeFileSync(manifestPath, renderManifest(opts)); + fs.writeFileSync(outlinePath, encodeOutlineIcon()); + fs.writeFileSync(colorPath, encodeColorIcon()); + + // Fresh zip every run — idempotent, no stale files. + try { + fs.unlinkSync(zipPath); + } catch { + // noop if missing + } + execSync(`zip -j -q "${zipPath}" "${manifestPath}" "${outlinePath}" "${colorPath}"`, { + stdio: ['ignore', 'ignore', 'inherit'], + }); + + return { zipPath, manifestPath, outlinePath, colorPath }; +} + +function renderManifest(opts: ManifestOptions): string { + const manifest = { + $schema: MANIFEST_SCHEMA, + manifestVersion: MANIFEST_VERSION, + version: '1.0.0', + id: opts.appId, + packageName: 'com.nanoclaw.bot', + developer: { + name: 'NanoClaw', + websiteUrl: opts.websiteUrl, + privacyUrl: opts.websiteUrl, + termsOfUseUrl: opts.websiteUrl, + }, + name: { + short: opts.shortName.slice(0, 30), + full: `${opts.shortName} Assistant`, + }, + description: { + short: 'Your personal assistant in Teams.', + full: opts.longDescription, + }, + icons: { outline: 'outline.png', color: 'color.png' }, + accentColor: '#4A90D9', + bots: [ + { + botId: opts.appId, + scopes: ['personal', 'team', 'groupchat'], + supportsFiles: false, + isNotificationOnly: false, + }, + ], + permissions: ['identity', 'messageTeamMembers'], + validDomains: [new URL(opts.websiteUrl).host], + }; + return JSON.stringify(manifest, null, 2) + '\n'; +} + +// ─── Minimal PNG encoder (solid color, no external deps) ────────────────── + +const PNG_SIG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + +// Precompute the CRC-32 table per the PNG spec. Node doesn't expose CRC32 +// directly (zlib.crc32 isn't part of the public API), so we roll our own. +const CRC_TABLE = (() => { + const table = new Uint32Array(256); + for (let n = 0; n < 256; n++) { + let c = n; + for (let k = 0; k < 8; k++) { + c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + } + table[n] = c >>> 0; + } + return table; +})(); + +function crc32(buf: Buffer): number { + let c = 0xffffffff; + for (let i = 0; i < buf.length; i++) { + c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8); + } + return (c ^ 0xffffffff) >>> 0; +} + +function chunk(type: string, data: Buffer): Buffer { + const len = Buffer.alloc(4); + len.writeUInt32BE(data.length, 0); + const typeBuf = Buffer.from(type, 'ascii'); + const crcBuf = Buffer.alloc(4); + crcBuf.writeUInt32BE(crc32(Buffer.concat([typeBuf, data])), 0); + return Buffer.concat([len, typeBuf, data, crcBuf]); +} + +/** + * Encode a solid-color RGBA image as a PNG. `pixels` is a width*height*4 + * byte array (R, G, B, A per pixel, row-major, top-to-bottom). + */ +function encodePng(width: number, height: number, pixels: Uint8Array): Buffer { + // IHDR + const ihdr = Buffer.alloc(13); + ihdr.writeUInt32BE(width, 0); + ihdr.writeUInt32BE(height, 4); + ihdr[8] = 8; // bit depth + ihdr[9] = 6; // color type: RGBA + ihdr[10] = 0; // compression + ihdr[11] = 0; // filter + ihdr[12] = 0; // interlace + + // IDAT: scanlines with filter byte 0 (None) prepended per row. + const rowBytes = width * 4; + const raw = Buffer.alloc(height * (rowBytes + 1)); + for (let y = 0; y < height; y++) { + raw[y * (rowBytes + 1)] = 0; + for (let x = 0; x < rowBytes; x++) { + raw[y * (rowBytes + 1) + 1 + x] = pixels[y * rowBytes + x]; + } + } + const idat = zlib.deflateSync(raw); + + return Buffer.concat([ + PNG_SIG, + chunk('IHDR', ihdr), + chunk('IDAT', idat), + chunk('IEND', Buffer.alloc(0)), + ]); +} + +/** + * Outline icon: 32×32 transparent background with a simple white rounded- + * square outline. Teams renders it against a colored background so the + * outline needs to be visible on both light and dark. + */ +function encodeOutlineIcon(): Buffer { + const size = 32; + const pixels = new Uint8Array(size * size * 4); + const inset = 4; + const stroke = 2; + for (let y = 0; y < size; y++) { + for (let x = 0; x < size; x++) { + const onBorder = + ((x >= inset && x < inset + stroke) || (x >= size - inset - stroke && x < size - inset)) && + y >= inset && + y < size - inset; + const onTopBot = + ((y >= inset && y < inset + stroke) || (y >= size - inset - stroke && y < size - inset)) && + x >= inset && + x < size - inset; + const i = (y * size + x) * 4; + if (onBorder || onTopBot) { + pixels[i] = 255; + pixels[i + 1] = 255; + pixels[i + 2] = 255; + pixels[i + 3] = 255; + } else { + pixels[i] = 0; + pixels[i + 1] = 0; + pixels[i + 2] = 0; + pixels[i + 3] = 0; // transparent + } + } + } + return encodePng(size, size, pixels); +} + +/** + * Color icon: 192×192 brand-blue filled square with a white "N" shape drawn + * with simple bars (left vertical, right vertical, diagonal from top-right + * to bottom-left). Crude but recognizable at a glance. + */ +function encodeColorIcon(): Buffer { + const size = 192; + const pixels = new Uint8Array(size * size * 4); + // Brand blue #4A90D9 + const BG_R = 0x4a; + const BG_G = 0x90; + const BG_B = 0xd9; + const thickness = 24; + const margin = 40; + const leftBarX = margin; + const rightBarX = size - margin - thickness; + for (let y = 0; y < size; y++) { + for (let x = 0; x < size; x++) { + const i = (y * size + x) * 4; + pixels[i] = BG_R; + pixels[i + 1] = BG_G; + pixels[i + 2] = BG_B; + pixels[i + 3] = 255; + } + } + // Vertical bars + for (let y = margin; y < size - margin; y++) { + for (let dx = 0; dx < thickness; dx++) { + setWhite(pixels, size, leftBarX + dx, y); + setWhite(pixels, size, rightBarX + dx, y); + } + } + // Diagonal from top-right of left bar to bottom-left of right bar + const diagSteps = size - margin * 2; + for (let s = 0; s < diagSteps; s++) { + const t = s / (diagSteps - 1); + const cx = Math.round(leftBarX + thickness + t * (rightBarX - leftBarX - thickness)); + const cy = Math.round(margin + t * (size - margin * 2 - 1)); + for (let dx = -Math.floor(thickness / 2); dx < Math.ceil(thickness / 2); dx++) { + for (let dy = -2; dy <= 2; dy++) { + setWhite(pixels, size, cx + dx, cy + dy); + } + } + } + return encodePng(size, size, pixels); +} + +function setWhite(pixels: Uint8Array, size: number, x: number, y: number): void { + if (x < 0 || x >= size || y < 0 || y >= size) return; + const i = (y * size + x) * 4; + pixels[i] = 255; + pixels[i + 1] = 255; + pixels[i + 2] = 255; + pixels[i + 3] = 255; +}