add-teams: prompt for app/bot name and single-vs-multi tenant in both flows

The create step hardcoded --name NanoClaw and single-tenant; both are the
human's choice. Two new nc:prompt fences (app_name, tenant) feed a tenant-
branched create + env pairing — when:tenant=multi omits TEAMS_APP_TENANT_ID,
encoding the 401 pairing rule in the branch itself. Client-secret name and a
separate Azure bot name are NOT knobs on the CLI path (secret is auto-named
'default', 2y; --name covers registration+bot+Teams app) — documented, and
the manual portal path now names all four asks explicitly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Koshkoshinsk
2026-07-06 10:13:46 +03:00
parent ca298d3714
commit 37e2d2b2bb
2 changed files with 91 additions and 35 deletions
+43 -33
View File
@@ -110,6 +110,22 @@ reach this machine's webhook server (port 3000, configurable via
Paste the public https:// base URL that forwards to this machine's port 3000 (no trailing path) — e.g. https://abcd1234.ngrok.io from `ngrok http 3000`.
```
### App name and tenant
Two more choices belong to the human before anything is created. The name is
used everywhere at once: the Entra app registration, the bot, and the Teams
app are all created under it. There is no client-secret name to pick on this
path — the CLI generates the secret itself (Entra displayName `default`,
2-year expiry); rotating it later is in [Troubleshooting](#troubleshooting).
```nc:prompt app_name when:have_creds=no validate:^[\sA-Za-z0-9._-]{1,30}$ normalize:trim
What should the bot be called? One name covers the Entra app registration, the bot, and the Teams app (letters, digits, spaces, . _ -; max 30 characters) — e.g. NanoClaw.
```
```nc:prompt tenant when:have_creds=no validate:^(single|multi)$ normalize:lower
Who should be able to install the bot — answer "single" (only your own Microsoft 365 tenant; the safe default for a self-hosted assistant) or "multi" (any Microsoft 365 tenant).
```
### Install the Teams CLI
Installed globally with npm — not as a workspace dependency — deliberately:
@@ -153,32 +169,43 @@ the cache is not persisting (see Troubleshooting).
One command registers the Entra app, generates a client secret (Graph can take
~30s to see the new app — the CLI retries), registers a Teams-managed bot, and
uploads the app package to the Teams Developer Portal. It needs the sign-in
from the previous step (`AUTH_REQUIRED` means run that first). The bot is
created single-tenant (`--sign-in-audience myOrg`) — the safe default for a
self-hosted assistant; for a bot other Microsoft 365 tenants can install, see
[Alternatives](#alternatives). Change `--name` first if you want a different
bot name in Teams.
from the previous step (`AUTH_REQUIRED` means run that first). The tenant
answer picks the variant — they differ only in `--sign-in-audience`, and the
single-tenant one also captures the tenant ID (which the multi-tenant `.env`
pairing must omit). A `when:tenant=…` guard implies a fresh create: the tenant
prompt is only asked when the credentials probe answered no, so with existing
credentials both variants are skipped.
```nc:run effect:external when:have_creds=no capture:app_id=.credentials.CLIENT_ID,app_password=.credentials.CLIENT_SECRET,app_tenant_id=.credentials.TENANT_ID,teams_app_id=.teamsAppId,install_link=.installLink validate:^.+$
"$(npm prefix -g)/bin/teams" app create --name "NanoClaw" --endpoint "{{public_url}}/webhook/teams" --sign-in-audience myOrg --json
```nc:run effect:external when:tenant=single capture:app_id=.credentials.CLIENT_ID,app_password=.credentials.CLIENT_SECRET,app_tenant_id=.credentials.TENANT_ID,teams_app_id=.teamsAppId,install_link=.installLink validate:^.+$
"$(npm prefix -g)/bin/teams" app create --name "{{app_name}}" --endpoint "{{public_url}}/webhook/teams" --sign-in-audience myOrg --json
```
```nc:run effect:external when:tenant=multi capture:app_id=.credentials.CLIENT_ID,app_password=.credentials.CLIENT_SECRET,teams_app_id=.teamsAppId,install_link=.installLink validate:^.+$
"$(npm prefix -g)/bin/teams" app create --name "{{app_name}}" --endpoint "{{public_url}}/webhook/teams" --sign-in-audience multipleOrgs --json
```
### Store the credentials
The adapter reads these from `.env` (set-if-absent — a value you've already
filled in is never overwritten). The pairing matters: `SingleTenant` requires
`TEAMS_APP_TENANT_ID`, and a multi-tenant app must instead set
`TEAMS_APP_TYPE=MultiTenant` with **no** tenant ID — a mismatch makes the
adapter authenticate against the wrong authority and every message fails with
a 401 from Bot Framework.
filled in is never overwritten). The pairing matters, and the tenant branch
encodes it: `SingleTenant` requires `TEAMS_APP_TENANT_ID`, and a multi-tenant
app must instead set `TEAMS_APP_TYPE=MultiTenant` with **no** tenant ID — a
mismatch makes the adapter authenticate against the wrong authority and every
message fails with a 401 from Bot Framework.
```nc:env-set when:have_creds=no
```nc:env-set when:tenant=single
TEAMS_APP_ID={{app_id}}
TEAMS_APP_PASSWORD={{app_password}}
TEAMS_APP_TENANT_ID={{app_tenant_id}}
TEAMS_APP_TYPE=SingleTenant
```
```nc:env-set when:tenant=multi
TEAMS_APP_ID={{app_id}}
TEAMS_APP_PASSWORD={{app_password}}
TEAMS_APP_TYPE=MultiTenant
```
### Install the app in Teams
The app package is already uploaded — no manifest zip, no manual sideload.
@@ -233,31 +260,14 @@ once you've DM'd the bot, wire this channel with `/init-first-agent` (or
## Alternatives
### Multi-tenant bot
The Credentials flow above creates a single-tenant bot (only your Microsoft 365
tenant can install it). For a bot any tenant can install, create it without
`--sign-in-audience` (the CLI defaults to multi-tenant) and store the matching
env pairing — `MultiTenant` with **no** tenant ID:
```bash
teams app create --name "NanoClaw" --endpoint "https://your-domain/webhook/teams"
```
```bash
TEAMS_APP_ID=<CLIENT_ID from the CLI output>
TEAMS_APP_PASSWORD=<CLIENT_SECRET from the CLI output>
TEAMS_APP_TYPE=MultiTenant
```
Run interactively, the CLI ends with an install menu — pick **Install in Teams**.
### Manual Azure portal path
For tenants where the Teams Developer Portal is blocked. Unlike the CLI path,
the Azure Bot resource in step 3 requires an active **Azure subscription**.
This is the classic walk; every value it produces maps onto the same `.env`
keys.
keys. The choices are the human's here just as on the CLI path — ask before
creating anything: the app registration name, single vs multi tenant, a
client secret description, and (this path only) a separate Azure Bot handle.
1. **App registration**: in https://portal.azure.com, search "App registrations"
→ "New registration". Name it (e.g. "NanoClaw"); Supported account types:
+48 -2
View File
@@ -210,7 +210,7 @@ describe('runChannelSkill adapter (Option A)', () => {
return { ok: true, fields: { STATUS: 'success' } };
},
resolveRemote: () => 'origin',
inputs: { public_url: 'https://acme.example' },
inputs: { public_url: 'https://acme.example', app_name: 'NanoClaw', tenant: 'single' },
confirm: async (m) => {
log.push(`confirm:${m}`);
return true;
@@ -225,8 +225,10 @@ describe('runChannelSkill adapter (Option A)', () => {
// invoked by absolute path — $(npm prefix -g)/bin/teams — so match loosely)…
expect(steps.some((c) => c.includes('/bin/teams" login'))).toBe(true);
expect(log.some((c) => c.startsWith('exec:') && c.includes(' login'))).toBe(false);
// …create got the collected public URL on the real /webhook/teams route
// …create got the collected public URL on the real /webhook/teams route,
// and the prompted name + tenant choice landed as --name/--sign-in-audience…
expect(log.some((c) => c.includes('--endpoint "https://acme.example/webhook/teams"'))).toBe(true);
expect(log.some((c) => c.includes('--name "NanoClaw"') && c.includes('--sign-in-audience myOrg'))).toBe(true);
// …the captured credentials landed in .env with the safe SingleTenant pairing…
const env = readFileSync(join(root, '.env'), 'utf8');
expect(env).toContain('TEAMS_APP_ID=12345678-1234-1234-1234-123456789abc');
@@ -247,6 +249,50 @@ describe('runChannelSkill adapter (Option A)', () => {
expect(fullyApplied(res)).toBe(true);
});
// The multi-tenant leg of the tenant branch: tenant=multi must pick the
// multipleOrgs create variant and the MultiTenant env pairing, which omits
// TEAMS_APP_TENANT_ID entirely — writing it would flip the adapter to the
// wrong authority (the 401 pairing rule the document itself states).
it('Teams multi-tenant: tenant=multi drives the multipleOrgs create and the tenant-ID-free env pairing', async () => {
const root = mkdtempSync(join(tmpdir(), 'rcs-teams-multi-'));
mkdirSync(join(root, 'src/channels'), { recursive: true });
writeFileSync(join(root, 'src/channels/index.ts'), '// barrel\n');
writeFileSync(join(root, '.env'), '');
writeFileSync(join(root, 'package.json'), '{"name":"scratch"}');
const log: string[] = [];
const res = await runSkill('.claude/skills/add-teams', {
projectRoot: root,
exec: (c) => {
log.push(c);
if (c.includes('TEAMS_APP_ID=.')) return 'no'; // the have_creds probe
if (c.includes(' app create ')) {
// TENANT_ID is still present in the CLI's JSON (the creating tenant) —
// the multi branch simply must not capture or store it.
return JSON.stringify({
teamsAppId: 'tapp-456',
installLink: 'https://teams.microsoft.com/l/app/tapp-456?installAppPackage=true',
credentials: { CLIENT_ID: 'client-456', CLIENT_SECRET: 'secret-456', TENANT_ID: 'tenant-456' },
});
}
},
execStream: async () => ({ ok: true, fields: { STATUS: 'success' } }),
resolveRemote: () => 'origin',
// app_name with a space also exercises the prompt's validate charset.
inputs: { public_url: 'https://acme.example', app_name: 'Acme Bot', tenant: 'multi' },
confirm: async () => true,
openUrl: async () => undefined,
});
expect(log.some((c) => c.includes('--name "Acme Bot"') && c.includes('--sign-in-audience multipleOrgs'))).toBe(true);
expect(log.some((c) => c.includes('--sign-in-audience myOrg'))).toBe(false); // the single variant was skipped
const env = readFileSync(join(root, '.env'), 'utf8');
expect(env).toContain('TEAMS_APP_ID=client-456');
expect(env).toContain('TEAMS_APP_TYPE=MultiTenant');
expect(env).not.toContain('TEAMS_APP_TENANT_ID');
expect(fullyApplied(res)).toBe(true);
});
// The engine reads `.claude/skills/add-<channel>/SKILL.md` relative to cwd (the
// repo root in tests — same as the real add-slack the test above drives), so a
// bounce-fixture skill is created there and torn down afterward.