Teams is the most complex channel NanoClaw supports — no "paste a
token" shortcut exists. Operators walk through ~6 Azure portal steps
(app registration, client secret, Azure Bot resource, messaging
endpoint, Teams channel, manifest sideload). The driver makes each
step as guided as possible and gives the operator an explicit
escape to interactive Claude whenever they get stuck.
Handoff mechanism (reusable across channels):
- setup/lib/claude-handoff.ts: offerClaudeHandoff(ctx) spawns
`claude --append-system-prompt <context> --permission-mode acceptEdits`
with stdio: 'inherit', returns when Claude exits so the driver can
re-offer the same step. Context captures channel, current step,
completed steps, collected values (secrets redacted), and file refs.
- validateWithHelpEscape / isHelpEscape: wrap clack text/password
prompts so typing '?' triggers the handoff mid-paste.
- Parallel to the existing claude-assist.ts (which is failure-triggered
and runs claude -p for a one-shot command suggestion). This is the
user-initiated, interactive counterpart.
Teams driver (setup/channels/teams.ts):
- 6-step walkthrough, each a clack note + paste prompts + stepGate
select ("Done / Stuck — hand me off to Claude / Show me again").
- Collects TEAMS_APP_ID / TEAMS_APP_TENANT_ID / TEAMS_APP_PASSWORD /
TEAMS_APP_TYPE plus the operator's public HTTPS URL (advisory —
no tunnel automation yet).
- Emits the full Azure CLI invocation alongside the portal steps for
operators who prefer scripted creation.
- UUID/password prompts accept '?' as a help escape; select prompts
have an explicit 'Stuck' option that triggers the handoff.
Manifest generator (setup/lib/teams-manifest.ts):
- Builds data/teams/teams-app-package.zip in-process: manifest.json
(schema v1.16) with app ID injected, a 32×32 outline icon, a
192×192 brand-blue color icon, bundled with the system `zip`.
- Minimal hand-rolled PNG encoder (CRC32 table + zlib deflate) so we
don't need ImageMagick or vendored binary blobs.
- ~2.5KB zip, validates with `unzip -l`; icons verify as valid PNGs.
Installer (setup/add-teams.sh):
- Non-interactive mirror of add-discord.sh. Validates the four env
vars, copies adapter from origin/channels, installs
@chat-adapter/teams@4.26.0, upserts creds to .env + data/env/env,
restarts the service.
auto.ts: Teams option in askChannelChoice with 'complex setup' hint,
dispatch to runTeamsChannel.
Deferred (known limitation, operator instructed to finish manually):
- Wait-for-first-DM pairing to capture the auto-generated Teams
platform_id. Teams platform IDs are 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.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
413 lines
13 KiB
TypeScript
413 lines
13 KiB
TypeScript
/**
|
|
* Offer Claude-assisted debugging when a setup step fails.
|
|
*
|
|
* Flow:
|
|
* 1. Check `claude` is on PATH and has a working credential. If not,
|
|
* silently skip — pre-auth failures can't use this path.
|
|
* 2. Ask the user for consent ("Want me to ask Claude for a fix?").
|
|
* 3. Build a minimal prompt: the one-paragraph situation, the failing
|
|
* step's name/message/hint, and a short list of *file references*
|
|
* (not contents) so Claude can Read what it needs on its own.
|
|
* 4. Spawn `claude -p --output-format text` with a 2-minute timeout and
|
|
* a spinner that shows elapsed time.
|
|
* 5. Parse `REASON:` / `COMMAND:` out of the response. Show the reason
|
|
* in a clack note, then hand off to `setup/run-suggested.sh` for
|
|
* editable pre-fill + exec.
|
|
*
|
|
* Skippable with NANOCLAW_SKIP_CLAUDE_ASSIST=1 for CI/scripted runs.
|
|
*/
|
|
import { execSync, spawn } from 'child_process';
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
|
|
import * as p from '@clack/prompts';
|
|
import k from 'kleur';
|
|
|
|
import { ensureAnswer } from './runner.js';
|
|
import { fitToWidth } from './theme.js';
|
|
|
|
export interface AssistContext {
|
|
stepName: string;
|
|
msg: string;
|
|
hint?: string;
|
|
/** Absolute path to the per-step raw log, if the caller has one. */
|
|
rawLogPath?: string;
|
|
}
|
|
|
|
/**
|
|
* File-path hints per step. Claude reads these on its own via its Read tool
|
|
* rather than us stuffing contents into the prompt. Keys are step names as
|
|
* they appear in fail() calls; values are repo-relative paths.
|
|
*/
|
|
const STEP_FILES: Record<string, string[]> = {
|
|
bootstrap: ['setup.sh', 'setup/install-node.sh', 'nanoclaw.sh'],
|
|
environment: ['setup/environment.ts'],
|
|
container: [
|
|
'setup/container.ts',
|
|
'setup/install-docker.sh',
|
|
'container/Dockerfile',
|
|
],
|
|
onecli: ['setup/onecli.ts'],
|
|
auth: [
|
|
'setup/auth.ts',
|
|
'setup/register-claude-token.sh',
|
|
'setup/install-claude.sh',
|
|
],
|
|
mounts: ['setup/mounts.ts'],
|
|
service: ['setup/service.ts'],
|
|
'cli-agent': ['setup/cli-agent.ts', 'scripts/init-cli-agent.ts'],
|
|
channel: ['setup/auto.ts'],
|
|
verify: ['setup/verify.ts'],
|
|
// Channel-specific sub-steps:
|
|
'telegram-install': ['setup/add-telegram.sh', 'setup/channels/telegram.ts'],
|
|
'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',
|
|
'setup/channels/discord.ts',
|
|
],
|
|
};
|
|
|
|
const BIG_PICTURE_FILES = ['README.md', 'setup/auto.ts'];
|
|
|
|
/**
|
|
* Returns `true` if the user ran a Claude-suggested fix command; callers
|
|
* can use that signal to offer a retry instead of aborting outright.
|
|
* Returns `false` for every other outcome (skipped, declined, no command,
|
|
* Claude unreachable, user chose not to run).
|
|
*/
|
|
export async function offerClaudeAssist(
|
|
ctx: AssistContext,
|
|
projectRoot: string = process.cwd(),
|
|
): Promise<boolean> {
|
|
if (process.env.NANOCLAW_SKIP_CLAUDE_ASSIST === '1') return false;
|
|
if (!isClaudeUsable()) return false;
|
|
|
|
const want = ensureAnswer(
|
|
await p.confirm({
|
|
message: 'Want me to ask Claude to diagnose this?',
|
|
initialValue: true,
|
|
}),
|
|
);
|
|
if (!want) return false;
|
|
|
|
const prompt = buildPrompt(ctx, projectRoot);
|
|
const response = await queryClaudeUnderSpinner(prompt, projectRoot);
|
|
if (!response) return false;
|
|
|
|
const parsed = parseResponse(response);
|
|
if (!parsed) {
|
|
p.log.warn("Claude responded but I couldn't parse a command out of it.");
|
|
p.log.message(k.dim(response.trim().slice(0, 500)));
|
|
return false;
|
|
}
|
|
|
|
p.note(
|
|
`${parsed.reason}\n\n${k.cyan('$')} ${parsed.command}`,
|
|
"Claude's suggestion",
|
|
);
|
|
|
|
const run = ensureAnswer(
|
|
await p.confirm({
|
|
message: 'Run this command? (you can edit it before executing)',
|
|
initialValue: false,
|
|
}),
|
|
);
|
|
if (!run) return false;
|
|
|
|
await runSuggested(parsed.command, projectRoot);
|
|
return true;
|
|
}
|
|
|
|
function isClaudeUsable(): boolean {
|
|
try {
|
|
execSync('command -v claude', { stdio: 'ignore' });
|
|
} catch {
|
|
return false;
|
|
}
|
|
// Availability without auth is half the story; a real query will still
|
|
// fail if the token isn't registered. We try first and surface the error
|
|
// rather than pre-checking auth with a separate round trip.
|
|
return true;
|
|
}
|
|
|
|
function buildPrompt(ctx: AssistContext, projectRoot: string): string {
|
|
const stepRefs = STEP_FILES[ctx.stepName] ?? [];
|
|
const references = [
|
|
...BIG_PICTURE_FILES,
|
|
...stepRefs,
|
|
'logs/setup.log',
|
|
ctx.rawLogPath
|
|
? path.relative(projectRoot, ctx.rawLogPath)
|
|
: 'logs/setup-steps/',
|
|
].filter((v, i, a) => a.indexOf(v) === i);
|
|
|
|
const hintLine = ctx.hint ? `Hint shown to the user: ${ctx.hint}\n` : '';
|
|
|
|
return [
|
|
"I'm trying to set up NanoClaw on my machine and ran into an issue",
|
|
'during the setup flow. Please read the referenced files to understand',
|
|
'the flow and the step that failed, look at the logs to see what went',
|
|
'wrong, then suggest a single bash command I can run to fix it.',
|
|
'',
|
|
`Failed step: ${ctx.stepName}`,
|
|
`Error shown to the user: ${ctx.msg}`,
|
|
hintLine,
|
|
'References (read as needed with your Read tool):',
|
|
...references.map((r) => ` - ${r}`),
|
|
'',
|
|
'Respond in EXACTLY this format, nothing before or after:',
|
|
'',
|
|
'REASON: <one short line describing the root cause>',
|
|
'COMMAND: <single bash command, one line, no backticks>',
|
|
'',
|
|
'If no safe single command can fix it, respond with:',
|
|
'REASON: <why>',
|
|
'COMMAND: none',
|
|
].join('\n');
|
|
}
|
|
|
|
/**
|
|
* Fixed-height scrolling window for Claude's progress.
|
|
*
|
|
* Clack's spinner only owns one line, so long tool-use breadcrumbs wrap
|
|
* and blow out the gutter. Instead we manage a 4-line window ourselves:
|
|
* a spinner header + 3 lines showing the most recent tool actions. On
|
|
* each update we use raw ANSI (cursor up, clear line) to redraw in
|
|
* place. When the query finishes we clear the whole block and emit a
|
|
* single `p.log.success` / `p.log.error` so the flow continues in
|
|
* standard clack style.
|
|
*/
|
|
const WINDOW_SIZE = 3;
|
|
const SPINNER_FRAMES = ['◒', '◐', '◓', '◑'];
|
|
const HIDE_CURSOR = '\x1b[?25l';
|
|
const SHOW_CURSOR = '\x1b[?25h';
|
|
|
|
async function queryClaudeUnderSpinner(
|
|
prompt: string,
|
|
projectRoot: string,
|
|
): Promise<string | null> {
|
|
const out = process.stdout;
|
|
const start = Date.now();
|
|
const actions: string[] = [];
|
|
let frameIdx = 0;
|
|
|
|
const redraw = (): void => {
|
|
// Move cursor back to the start of the block (WINDOW_SIZE + 1 = header + window).
|
|
out.write(`\x1b[${WINDOW_SIZE + 1}A`);
|
|
|
|
const elapsed = Math.round((Date.now() - start) / 1000);
|
|
const icon = SPINNER_FRAMES[frameIdx % SPINNER_FRAMES.length];
|
|
const suffix = ` (${elapsed}s)`;
|
|
const header = fitToWidth('Asking Claude to diagnose…', suffix);
|
|
out.write(`\x1b[2K${k.cyan(icon)} ${header}${k.dim(suffix)}\n`);
|
|
|
|
for (let i = 0; i < WINDOW_SIZE; i++) {
|
|
const idx = actions.length - WINDOW_SIZE + i;
|
|
const action = idx >= 0 ? actions[idx] : '';
|
|
out.write('\x1b[2K');
|
|
if (action) {
|
|
out.write(`${k.gray('│')} ${k.dim(`▸ ${fitToWidth(action, '')}`)}`);
|
|
} else {
|
|
out.write(k.gray('│'));
|
|
}
|
|
out.write('\n');
|
|
}
|
|
};
|
|
|
|
const clearBlock = (): void => {
|
|
out.write(`\x1b[${WINDOW_SIZE + 1}A`);
|
|
for (let i = 0; i < WINDOW_SIZE + 1; i++) {
|
|
out.write('\x1b[2K\n');
|
|
}
|
|
out.write(`\x1b[${WINDOW_SIZE + 1}A`);
|
|
};
|
|
|
|
// Seed the block: move cursor to a fresh line, then write (header + window)
|
|
// blank lines so `redraw()`'s cursor-up math lands correctly. Hide the
|
|
// cursor for the duration so the redraw doesn't flicker.
|
|
out.write(HIDE_CURSOR);
|
|
for (let i = 0; i < WINDOW_SIZE + 1; i++) out.write('\n');
|
|
redraw();
|
|
|
|
// If the user Ctrl-C's during the query, we never reach `finish()` —
|
|
// add an exit hook so the cursor comes back regardless.
|
|
const restoreCursorOnExit = (): void => {
|
|
out.write(SHOW_CURSOR);
|
|
};
|
|
process.once('exit', restoreCursorOnExit);
|
|
|
|
const frameTick = setInterval(() => {
|
|
frameIdx++;
|
|
redraw();
|
|
}, 250);
|
|
|
|
return new Promise((resolve) => {
|
|
let lineBuf = '';
|
|
let finalText = '';
|
|
let stderr = '';
|
|
let settled = false;
|
|
|
|
const finish = (
|
|
kind: 'ok' | 'error',
|
|
payload: string | null,
|
|
): void => {
|
|
clearInterval(frameTick);
|
|
clearBlock();
|
|
out.write(SHOW_CURSOR);
|
|
process.off('exit', restoreCursorOnExit);
|
|
const elapsed = Math.round((Date.now() - start) / 1000);
|
|
const suffix = ` (${elapsed}s)`;
|
|
if (kind === 'ok') {
|
|
p.log.success(`${fitToWidth('Claude replied.', suffix)}${k.dim(suffix)}`);
|
|
resolve(payload);
|
|
} else {
|
|
p.log.error(
|
|
`${fitToWidth("Claude couldn't help here.", suffix)}${k.dim(suffix)}`,
|
|
);
|
|
const tail = stderr.trim().split('\n').slice(-3).join('\n');
|
|
if (tail) p.log.message(k.dim(tail));
|
|
resolve(null);
|
|
}
|
|
};
|
|
|
|
// No hard timeout — debugging can take a long time, and the cost of
|
|
// cutting Claude off mid-investigation is worse than letting the
|
|
// spinner run. The user can Ctrl-C if they want to abort.
|
|
const child = spawn(
|
|
'claude',
|
|
[
|
|
'-p',
|
|
'--output-format',
|
|
'stream-json',
|
|
'--verbose',
|
|
'--permission-mode',
|
|
'bypassPermissions',
|
|
],
|
|
{ cwd: projectRoot, stdio: ['pipe', 'pipe', 'pipe'] },
|
|
);
|
|
|
|
child.stdout.on('data', (c: Buffer) => {
|
|
lineBuf += c.toString('utf-8');
|
|
let idx: number;
|
|
while ((idx = lineBuf.indexOf('\n')) !== -1) {
|
|
const line = lineBuf.slice(0, idx);
|
|
lineBuf = lineBuf.slice(idx + 1);
|
|
if (!line.trim()) continue;
|
|
try {
|
|
const event = JSON.parse(line) as StreamEvent;
|
|
handleStreamEvent(event, {
|
|
setAction: (a) => {
|
|
actions.push(a);
|
|
redraw();
|
|
},
|
|
appendText: (t) => {
|
|
finalText += t;
|
|
},
|
|
});
|
|
} catch {
|
|
// Malformed or non-JSON line — ignore.
|
|
}
|
|
}
|
|
});
|
|
child.stderr.on('data', (c: Buffer) => {
|
|
stderr += c.toString('utf-8');
|
|
});
|
|
child.on('close', (code) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
if (code === 0 && finalText.trim()) finish('ok', finalText);
|
|
else finish('error', null);
|
|
});
|
|
child.on('error', () => {
|
|
if (settled) return;
|
|
settled = true;
|
|
finish('error', null);
|
|
});
|
|
|
|
child.stdin.end(prompt);
|
|
});
|
|
}
|
|
|
|
// Minimal shape of the stream-json events we care about. Claude emits
|
|
// many more, but we only read tool_use blocks (for breadcrumbs) and text
|
|
// blocks (to reassemble the final REASON/COMMAND answer).
|
|
interface StreamEvent {
|
|
type: string;
|
|
message?: {
|
|
content?: Array<
|
|
| { type: 'text'; text: string }
|
|
| { type: 'tool_use'; name: string; input: Record<string, unknown> }
|
|
>;
|
|
};
|
|
}
|
|
|
|
function handleStreamEvent(
|
|
event: StreamEvent,
|
|
cb: { setAction: (a: string) => void; appendText: (t: string) => void },
|
|
): void {
|
|
if (event.type !== 'assistant') return;
|
|
const blocks = event.message?.content ?? [];
|
|
for (const block of blocks) {
|
|
if (block.type === 'text') {
|
|
cb.appendText(block.text);
|
|
} else if (block.type === 'tool_use') {
|
|
cb.setAction(formatToolUse(block.name, block.input));
|
|
}
|
|
}
|
|
}
|
|
|
|
function formatToolUse(name: string, input: Record<string, unknown>): string {
|
|
const truncate = (v: string, n: number): string =>
|
|
v.length > n ? v.slice(0, n) + '…' : v;
|
|
if (name === 'Read') {
|
|
const f = String(input.file_path ?? '');
|
|
return `Reading ${shortenPath(f)}`;
|
|
}
|
|
if (name === 'Bash') {
|
|
const cmd = String(input.command ?? '').replace(/\s+/g, ' ').trim();
|
|
return `Running ${truncate(cmd, 60)}`;
|
|
}
|
|
if (name === 'Grep') return `Searching for "${truncate(String(input.pattern ?? ''), 40)}"`;
|
|
if (name === 'Glob') return `Finding ${truncate(String(input.pattern ?? ''), 40)}`;
|
|
return `Using ${name}`;
|
|
}
|
|
|
|
function shortenPath(abs: string): string {
|
|
const root = process.cwd();
|
|
return abs.startsWith(`${root}/`) ? abs.slice(root.length + 1) : abs;
|
|
}
|
|
|
|
function parseResponse(
|
|
raw: string,
|
|
): { reason: string; command: string } | null {
|
|
// Accept the fields anywhere in the output — Claude sometimes wraps the
|
|
// answer in a trailing explanation we can safely ignore.
|
|
const reasonMatch = raw.match(/^\s*REASON:\s*(.+?)\s*$/m);
|
|
const commandMatch = raw.match(/^\s*COMMAND:\s*(.+?)\s*$/m);
|
|
if (!reasonMatch || !commandMatch) return null;
|
|
const command = commandMatch[1].trim();
|
|
if (!command || command.toLowerCase() === 'none') return null;
|
|
return { reason: reasonMatch[1].trim(), command };
|
|
}
|
|
|
|
function runSuggested(command: string, projectRoot: string): Promise<void> {
|
|
const script = path.join(projectRoot, 'setup/run-suggested.sh');
|
|
if (!fs.existsSync(script)) {
|
|
p.log.error(`Missing helper: ${script}`);
|
|
return Promise.resolve();
|
|
}
|
|
return new Promise((resolve) => {
|
|
const child = spawn('bash', [script, command], {
|
|
cwd: projectRoot,
|
|
stdio: 'inherit',
|
|
});
|
|
child.on('close', () => resolve());
|
|
child.on('error', () => resolve());
|
|
});
|
|
}
|