Compare commits

..

5 Commits

Author SHA1 Message Date
Koshkoshinsk cf3ca4dc36 fix(setup): hostExec tees each command to a per-skill raw log
The async hostExec already pipes stderr off the wizard screen; this adds
the level-3 paper trail. runSkill allocates one
logs/setup-steps/NN-skill-<name>.log per apply (default exec only) and
every command appends $ cmd + stdout/stderr — success and failure alike —
so silenced warnings (the Teams CLI libsecret note) stay inspectable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 14:57:30 +03:00
Koshkoshinsk ee1d2773c2 fix(setup): spawn hostExec so step spinners animate during skill steps
execSync blocks the event loop for the whole command, freezing every clack
ticker in the process — skill steps ran with a dead spinner while earlier
wizard phases (async spawn) animated fine. Spawn + promise keeps the loop
free; the engine's exec seam already awaits. The failure shape is unchanged:
`exit <code>: <first stderr line>` first, full stderr below.

Also retitles operator notes "Do this" -> "Your turn".
2026-07-07 14:40:32 +03:00
Koshkoshinsk 405643f8a7 fix(setup): SSF-002 follow-up — step children: silence logger info, force clack colors
QA on the VM showed two leaks in the effect:step tee: the host logger's
INFO lines (it always emits ANSI and defaults to info) landed on the
wizard screen, and the child's clack card drew colorless because
picocolors disables ANSI on a piped stdout, clashing with the wizard
theme.

hostExecStream now spawns steps with LOG_LEVEL=warn (operator-set level
wins; warnings/errors still pass) and FORCE_COLOR=1 when the operator's
terminal is a real TTY.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HB8rQxJN7itcFhNYVDntpm
2026-07-06 16:20:59 +03:00
Koshkoshinsk 1a3c3eaf9b fix(setup): wizard UX — pairing card, either/or selects, quiet bounces (SSF-002/003/004)
SSF-002 — the telegram pairing card renders via clack's static note/log
primitives instead of bare console.log. hostExecStream tees non-status
lines verbatim, and static clack output is just lines — only
interactive/animated widgets need the TTY the piped child doesn't have.

SSF-003 — an either/or nc:prompt (validate:^(a|b)$) renders as an
arrow-key p.select; the options come from the validate regex itself
(literalChoices), so no grammar addition and zero skill edits. Prefixes
and format unions keep the text prompt. The `?` help-escape doesn't
apply to selects — every choice is valid and self-describing.

SSF-004 — a bounced step no longer dumps a raw stacktrace or the skill's
reference prose into the wizard. hostExec recomposes failures as
`exit <code>: <first stderr line>` (full stderr kept below for the
agentTask reason an agent fixes from); run-channel-skill shows one line
per bounce and writes the full text to a logs/setup-steps raw log, which
fail() forwards to the Claude handoff.

The directive engine (scripts/skill-apply.ts, skill-directives.ts) is
untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HB8rQxJN7itcFhNYVDntpm
2026-07-06 16:06:26 +03:00
Koshkoshinsk 77a19a0c97 fix(skills): restore bot-event subscription in add-slack Socket Mode path (SSF-001)
'Subscribe to bot events' is shared by both delivery modes — only the
Request URL is webhook-specific. 2242f6c mis-sorted it into the webhook
operator block, so Socket Mode installs connected the socket but Slack
never pushed events: welcome DM sent, every reply dead on arrival.

Adds the Event Subscriptions step to the socket block (after Socket Mode
is enabled, so the page doesn't demand a Request URL) and corrects the
false "Socket Mode skips all of this" line. Audited the other when:-split
skills (imessage, whatsapp, teams) — no other mis-sorted shared steps.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HB8rQxJN7itcFhNYVDntpm
2026-07-06 15:49:29 +03:00
20 changed files with 679 additions and 1327 deletions
+5 -3
View File
@@ -83,7 +83,8 @@ Create the Slack app (Socket Mode):
3. App Home → enable the Messages Tab, and check "Allow users to send Slash commands and messages from the messages tab."
4. Basic Information → App-Level Tokens → "Generate Token and Scopes" → add the connections:write scope → copy the token (starts with xapp-).
5. Socket Mode → toggle "Enable Socket Mode" on.
6. Install to Workspace, then copy the Bot User OAuth Token (starts with xoxb-).
6. Event Subscriptions → toggle "Enable Events" on, then under "Subscribe to bot events" add: message.channels, message.groups, message.im, app_mention. Save Changes. (No Request URL is needed in Socket Mode.)
7. Install to Workspace, then copy the Bot User OAuth Token (starts with xoxb-).
```
For webhook delivery, tell the user:
@@ -122,8 +123,9 @@ SLACK_SIGNING_SECRET={{signing_secret}}
With webhook delivery, the bridge serves port 3000 at `/webhook/slack`
automatically; to receive replies, that port must be reachable from the internet
and registered with Slack (Socket Mode skips all of this — events arrive over
the socket as soon as the service restarts). Tell the user:
and registered with Slack as the Request URL (Socket Mode needs no public URL —
with the bot events subscribed above, events arrive over the socket as soon as
the service restarts). Tell the user:
```nc:operator when:connection=webhook
Set up event delivery (needs a public HTTPS URL for port 3000 — ngrok, a Cloudflare Tunnel, or a reverse proxy on a VPS):
+8 -27
View File
@@ -18,38 +18,19 @@ rm -f src/channels/teams.ts src/channels/teams-registration.test.ts
## 2. Remove credentials
Remove `TEAMS_APP_ID`, `TEAMS_APP_PASSWORD`, `TEAMS_APP_TENANT_ID`, and `TEAMS_APP_TYPE` from `.env`.
## 3. Sign out the Teams CLI, then remove the packages
`teams login` caches a Microsoft 365 session on disk that outlives the package —
sign out first (skip if the CLI was never installed):
Remove `TEAMS_APP_ID`, `TEAMS_APP_PASSWORD`, `TEAMS_APP_TENANT_ID`, and `TEAMS_APP_TYPE` from `.env`, then re-sync to the container:
```bash
mkdir -p data/env && cp .env data/env/env
```
## 3. Remove the package
```bash
teams logout
npm uninstall -g @microsoft/teams.cli
pnpm uninstall @chat-adapter/teams
```
## 4. Remove local artifacts
```bash
rm -rf data/teams
```
## 5. Clean up cloud resources
Uninstall the app from Teams (Apps > Manage your apps). Then, on **both**
paths, delete the Entra app registration in Azure Portal > App registrations —
that is the step that actually revokes the client secret. Additionally:
- **Teams CLI path**: delete the app listing in the Teams Developer Portal
(https://dev.teams.microsoft.com/apps) — removing it there alone does NOT
revoke the secret.
- **Manual Azure path**: delete the Azure Bot resource, and the `nanoclaw-rg`
resource group if you created one (`az group delete --name nanoclaw-rg`).
## 6. Rebuild and restart
## 4. Rebuild and restart
```bash
pnpm run build
+165 -402
View File
@@ -14,14 +14,11 @@ reads the prose and applies them, and a parser can apply them deterministically
from the same document. Every directive is idempotent, so the whole skill is
safe to re-run; anything a parser can't apply falls back to the prose beside it.
Teams has no "paste a token" shortcut — a bot has to exist in Microsoft's cloud
before it can receive a message. The Microsoft Teams CLI collapses that into
one sign-in and one create command: it registers the Entra app, generates the
client secret, registers a Teams-managed bot (through the Teams Developer
Portal — **no Azure subscription needed**), uploads the app package, and hands
back an install link. The old ~7-step Azure portal walk survives only as a
fallback in [Alternatives](#alternatives) for tenants where the Developer
Portal is blocked.
Teams is the most involved channel NanoClaw supports — there's no "paste a
token" shortcut. You'll walk through about seven Azure portal steps (app
registration, client secret, Azure Bot resource, messaging endpoint, Teams
channel, app package, sideload). Take them one at a time; the prompts below
collect each value as you produce it.
## Apply
@@ -74,268 +71,139 @@ runs.
## Credentials
The adapter is installed and registered, but it can't receive a message until a
bot exists, points at this machine, and is installed into Teams. The Teams CLI
does all of that below.
bot exists in Azure, points at this machine, and is sideloaded into Teams. None
of those steps can be clicked through by a parser, so they're operator
instructions — relay each one, then collect the value it produces.
### Check for existing credentials
Before you start, tell the user:
Re-running `teams app create` provisions a brand-new app registration and bot
each time — it never reuses the first one. So the flow starts with a probe:
when `.env` already carries a Teams credential — either key; a partial pair
means a half-finished setup that creating ANOTHER app would only corrupt —
every step below (prompts included) is skipped and the flow drops straight
through to [Restart](#restart). To rotate credentials or finish a partial
configuration, see [Troubleshooting](#troubleshooting); if your tunnel URL
changed, the fix is `teams app update`, not a re-run (also in Troubleshooting).
```nc:run capture:have_creds
( grep -q '^TEAMS_APP_ID=.' .env 2>/dev/null || grep -q '^TEAMS_APP_PASSWORD=.' .env 2>/dev/null ) && echo yes || echo no
```
Before creating anything, tell the user:
```nc:operator when:have_creds=no
```nc:operator
Confirm you have everything Teams setup needs:
1. A Microsoft 365 account that can create Entra app registrations and upload custom apps (sideloading) — free personal Teams does NOT qualify; you need a Microsoft 365 Business / EDU / developer tenant.
2. A way to expose an HTTPS endpoint that forwards to this machine's webhook port 3000 (e.g. a Cloudflare Tunnel, or a reverse-proxied VPS). Start it now if it isn't running — e.g. `cloudflared tunnel --url http://localhost:3000` — the create step needs the URL up front. The next prompt asks for its public base URL: just the https:// origin, no trailing path.
Note: the bot is created single-tenant (only your own Microsoft 365 tenant can install it) — the right default for a self-hosted assistant. If you need a bot other tenants can install, set it up manually via the Alternatives section of this skill instead.
1. 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 with Teams admin or developer rights.
2. A way to expose an HTTPS endpoint from this machine (ngrok, a Cloudflare Tunnel, or a reverse-proxied VPS). Azure Bot Service delivers activities to it.
```
### Public URL
Microsoft delivers bot messages to an HTTPS endpoint you control; it has to
reach this machine's webhook server (port 3000, configurable via
`WEBHOOK_PORT`) at `/webhook/teams`.
Azure Bot Service delivers messages to an HTTPS endpoint you control; it has to
reach this machine's webhook server (port 3000) at `/api/webhooks/teams`. If you
don't have a tunnel running yet, start one in another terminal first — e.g.
`ngrok http 3000` gives you `https://abcd1234.ngrok.io`.
```nc:prompt public_url when:have_creds=no validate:^https:// normalize:rstrip-slash
Paste your tunnel's public https:// URL — e.g. https://your-tunnel.trycloudflare.com
```nc:prompt public_url validate:^https:// normalize:rstrip-slash
Paste your public base URL (https://…, no trailing path) — e.g. https://abcd1234.ngrok.io.
```
### App name
### Register the Azure app
One more choice belongs 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).
Tell the user:
```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:operator
Create the Azure AD app registration:
1. In https://portal.azure.com, search "App registrations" → "New registration".
2. Name it (e.g. "NanoClaw").
3. Supported account types: Single tenant (your org only — most common for self-host) OR Multi tenant (any Microsoft 365 tenant can add the bot).
4. Click Register.
5. On the Overview page, copy the Application (client) ID and, for a single-tenant app, the Directory (tenant) ID.
```
### Install the Teams CLI
Installed globally with npm — not as a workspace dependency — deliberately:
the CLI's credential store (keytar) is a native module whose install script
must run to fetch its prebuilt binary, and pnpm's supply-chain policy blocks
dependency build scripts — a workspace install leaves the sign-in unable to
persist. The global install matches Microsoft's own instruction and keeps the
workspace policy intact. Pinned; re-running is a no-op. (If npm reports
EACCES here, your global prefix needs root — prefer a user-level Node like
nvm, or `npm config set prefix ~/.npm-global`.) `--loglevel=error` because
npm runs inside a pnpm script here and warns about every pnpm config var it
inherits — pure noise; real errors still print.
```nc:run effect:external when:have_creds=no
npm install -g @microsoft/teams.cli@3.0.2 --loglevel=error
```nc:prompt app_id validate:^[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}$
Paste the Application (client) ID — App registration Overview page.
```
```nc:prompt app_type validate:^(SingleTenant|MultiTenant)$
Enter the app type — `SingleTenant` or `MultiTenant` (must match the account type you picked).
```
```nc:prompt app_tenant_id when:app_type=SingleTenant validate:^[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}$
Paste the Directory (tenant) ID — App registration Overview page (Single Tenant only).
```
npm's global bin directory is not reliably on PATH (custom prefixes rarely
are), so every step below calls the CLI by its absolute path,
`$(npm prefix -g)/bin/teams` (stderr of the prefix lookup silenced — same
pnpm-config noise as above). Where this document says to run `teams …` by
hand, use that path too if plain `teams` isn't found.
### Create a client secret
### Sign in to Microsoft 365
Tell the user:
Every `teams` command is a separate process, so the sign-in must survive into
the next one via the CLI's on-disk token cache. A "libsecret not found —
token cache will be stored unencrypted" warning here is safe to ignore: the
CLI falls back to a plaintext cache file that persists fine, and setup signs
the session out at the end anyway. The login output may
also report "Azure CLI: not installed" — informational only; this flow
creates a Teams-managed bot precisely so the Azure CLI is never needed (it
only matters for `--azure` bots and the manual portal path). The
step below verifies persistence by re-reading the session from a fresh
process after login. In an interactive terminal the login opens a browser;
on a headless box (SSH) it prints a device code — open
microsoft.com/devicelogin on any machine and enter it. If this step fails,
run `teams login` then `teams status` by hand: status must say logged in, or
the cache is not persisting (see Troubleshooting).
```nc:run effect:step when:have_creds=no
"$(npm prefix -g 2>/dev/null)/bin/teams" login && "$(npm prefix -g 2>/dev/null)/bin/teams" status --json 2>/dev/null | grep -q '"loggedIn": true' && printf '=== NANOCLAW SETUP: TEAMS-LOGIN ===\nSTATUS: success\n=== END ===\n'
```nc:operator
Create the client secret:
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. COPY THE VALUE NOW — Azure only shows it once (the Value column, not the Secret ID).
```
### Create the bot
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
always created single-tenant (`--sign-in-audience myOrg`) — the right default
for a self-hosted assistant, applied without asking; for a bot other
Microsoft 365 tenants can install, set it up manually per
[Alternatives](#alternatives).
```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 2>/dev/null)/bin/teams" app create --name "{{app_name}}" --endpoint "{{public_url}}/webhook/teams" --sign-in-audience myOrg --json
```nc:prompt app_password secret validate:^.{20,}$
Paste the client secret Value — Certificates & secrets (shown only once, at least 20 characters).
```
### 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.
The adapter reads these from `.env` (set-if-absent, so a value you've already
filled in is never overwritten).
`TEAMS_APP_TENANT_ID` is written only for a Single Tenant app; Multi Tenant
doesn't need it.
```nc:env-set when:have_creds=no
```nc:env-set
TEAMS_APP_ID={{app_id}}
TEAMS_APP_PASSWORD={{app_password}}
TEAMS_APP_TYPE={{app_type}}
```
```nc:env-set when:app_type=SingleTenant
TEAMS_APP_TENANT_ID={{app_tenant_id}}
TEAMS_APP_TYPE=SingleTenant
```
### Create the Azure Bot resource
### Set the app icons
The CLI-created app ships with placeholder icons; this swaps in the NanoClaw
mascot (the same PNGs the manual-path package bakes into its zip), so the
install dialog below already shows it. Cosmetic — a failure is logged and
skipped, never blocking setup. Re-runnable any time while signed in to the
Teams CLI:
```nc:run effect:external when:have_creds=no
"$(npm prefix -g 2>/dev/null)/bin/teams" app update {{teams_app_id}} --color-icon setup/assets/teams/color.png --outline-icon setup/assets/teams/outline.png --json || echo "Icon update failed — cosmetic only, continuing."
```
### Who owns this bot
The account signed into the Teams CLI is the account that just created the
bot — that human is the wiring target this flow suggests. Its identity comes
from the CLI session, so this runs before the sign-out step below:
```nc:run effect:fetch when:have_creds=no capture:owner_upn=.username,owner_aad_id=.userObjectId validate:^.+$
"$(npm prefix -g 2>/dev/null)/bin/teams" status --json 2>/dev/null
```
### Confirm the wiring target
Nothing is wired without a confirmed target, and someone is always wired —
there is no skip. The account signed into the Teams CLI is often NOT the
person setting up NanoClaw, so a no leads to a clarifying choice: wire the
logged-in Teams user after all, or a different Teams user by Microsoft Entra
object ID. Identities are shown by sign-in name, never a raw ID:
```nc:operator when:have_creds=no
Detected the account that created the bot: {{owner_upn}}. Wiring the assistant to it means its first message arrives in that account's Teams DMs.
```
```nc:prompt wire_owner when:have_creds=no validate:^(yes|no)$ normalize:lower
Wire the assistant to this account?
```
```nc:operator when:wire_owner=no
You're currently logged in to Teams as {{owner_upn}}.
- To wire the assistant to this logged-in Teams user, choose "logged-in-account".
- To wire a different Teams user, get their Microsoft Entra object ID — found at entra.microsoft.com > Users > (person) > Overview > Object ID, or Teams admin center > Manage users — and choose "other-account". Once wired, the assistant messages them first.
```
```nc:prompt wire_target when:wire_owner=no validate:^(logged-in-account|other-account)$ normalize:lower
Which Teams user should the assistant be wired to?
```
```nc:prompt target_aad_id when:wire_target=other-account validate:^[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}$ normalize:trim
Paste the Microsoft Entra object ID of the Teams user to wire (a GUID like 00000000-0000-0000-0000-000000000000).
```
Either choice re-enters the exact same path as a yes above — rebind the
wiring target and flip the branch, so the link chain below needs no second
copy per branch:
```nc:run when:wire_target=other-account capture:owner_aad_id=.aad,wire_owner=.wire validate:^.+$
printf '{"aad":"%s","wire":"yes"}' "{{target_aad_id}}"
```
```nc:run when:wire_target=logged-in-account capture:wire_owner validate:^yes$
echo yes
```
### Install the app in Teams
The app package is already uploaded — no manifest zip, no manual sideload.
Tell the user:
```nc:operator when:have_creds=no
Install the bot into Teams:
1. Open {{install_link}} — Teams opens with the app's install dialog. Click Add.
2. If you need the link again later, run: teams app get {{teams_app_id}} --install-link
3. If Teams refuses with a custom-app-upload error, a tenant admin must enable sideloading: Teams Admin Center > Teams apps > Setup policies > Global > "Upload custom apps" = On.
Once the app shows up in your Teams sidebar (or app list), continue.
```nc:operator
Create the Azure Bot resource and point it at this machine:
1. In https://portal.azure.com, search "Azure Bot" → Create.
2. Bot handle: a unique name, e.g. nanoclaw-bot.
3. Type of App: {{app_type}} — Creation type: Use existing app registration.
4. App ID: {{app_id}}.
5. After creating, open the bot → Configuration and set Messaging endpoint to {{public_url}}/api/webhooks/teams, then Apply.
```
### Link the bot to your account
### Enable the Teams channel
Nothing to do in Teams yet — these are background API calls, and the whole
chain runs only for a confirmed target from
[Confirm the wiring target](#confirm-the-wiring-target) (the detected owner
on a yes, or the provided Entra object ID). Same move as
Slack's `conversations.open` and Discord's `users/@me/channels`:
create the bot↔owner 1:1 conversation proactively with the bot's own
credentials, so the assistant messages the human first — nobody has to DM the
bot to bootstrap it. This only works now that the app is installed (the step
above); if Microsoft hasn't finished propagating the install yet, the create
below can fail once — re-running the skill is safe.
Tell the user (finish every Azure step above before continuing — the package
built next bakes in the app registration, so the Azure app and bot must already
exist):
First a Bot Framework token from the app credentials:
```nc:run effect:fetch when:wire_owner=yes capture:bot_token validate:^eyJ
curl -sf -X POST "https://login.microsoftonline.com/{{app_tenant_id}}/oauth2/v2.0/token" --data-urlencode "grant_type=client_credentials" --data-urlencode "client_id={{app_id}}" --data-urlencode "client_secret={{app_password}}" --data-urlencode "scope=https://api.botframework.com/.default" | jq -er '.access_token'
```nc:operator
Enable the Microsoft Teams channel on the bot:
1. Open your Azure Bot resource → Channels.
2. Click Microsoft Teams → Accept terms → Apply.
When the Azure app, bot resource, and Teams channel are all in place, continue.
```
Create the 1:1 conversation (the AAD object id from the CLI session is a
valid member id; `smba.trafficmanager.net/teams` is the global service URL —
the same default the adapter itself uses):
### Build the Teams app package
```nc:run effect:fetch when:wire_owner=yes capture:conversation_id validate:^.+$
curl -sf -X POST "https://smba.trafficmanager.net/teams/v3/conversations" -H "Authorization: Bearer {{bot_token}}" -H "Content-Type: application/json" -d '{"bot":{"id":"28:{{app_id}}"},"members":[{"id":"{{owner_aad_id}}","name":"","role":"user"}],"tenantId":"{{app_tenant_id}}","channelData":{"tenant":{"id":"{{app_tenant_id}}"}},"isGroup":false}' | jq -er '.id'
The manifest bakes in the Application (client) ID, so the Azure app registration
above must be done first — building with a blank `app_id` produces a package that
no bot can claim. Confirm it's set before generating the zip:
```nc:run effect:check
[ -n "{{app_id}}" ]
```
The adapter identifies inbound senders by their Bot Framework `29:` id, not
the AAD id — the owner must be wired under that handle or their replies
would not be recognized. The conversation was created with exactly one
member (the owner), so its member list is the owner by construction; the
filter only guards against channels that list the bot itself (`28:` ids).
(Don't select by `.aadObjectId` here — the field is not reliably present in
this response and its GUID casing varies.)
Generate the zip you'll sideload into Teams (manifest + icons, written to
`data/teams/teams-app-package.zip`). Re-running regenerates a fresh zip, so this
is safe to repeat.
```nc:run effect:fetch when:wire_owner=yes capture:owner_handle=.id,owner_name=.name validate:^.+$
curl -sf "https://smba.trafficmanager.net/teams/v3/conversations/{{conversation_id}}/members" -H "Authorization: Bearer {{bot_token}}" | jq -er '[.[] | select((.id // "") | startswith("28:") | not)][0] | {id, name: (.name // .givenName // "Teams user")}'
```nc:run effect:external
pnpm exec tsx setup/channels/teams-manifest-build.ts --app-id "{{app_id}}" --url "{{public_url}}"
```
Compose the platform id exactly as the adapter encodes thread ids
(`teams:{b64url conversation}:{b64url service url}`):
### Sideload the app into Teams
```nc:run when:wire_owner=yes capture:platform_id validate:^teams:
node -e 'const c=process.argv[1];const s="https://smba.trafficmanager.net/teams/";console.log("teams:"+Buffer.from(c).toString("base64url")+":"+Buffer.from(s).toString("base64url"))' "{{conversation_id}}"
```
Tell the user (do this before the restart below — the service should come up with
the app already sideloaded):
### Sign out of the Teams CLI
The Microsoft 365 session was only needed to create the bot and identify its
owner — the running adapter authenticates with the app credentials in
`.env`, never with your account. On a headless box that session is a
plaintext token file, which is worth removing unless you plan more `teams …`
commands (rotate secret, endpoint update, RSC — each just needs a fresh
`teams login`, a ~30-second device code):
```nc:prompt signout when:have_creds=no validate:^(yes|no)$ normalize:lower
Sign out of the Teams CLI now? The bot doesn't need this login to run — signing out is recommended on shared or headless boxes, and `teams login` gets you back any time.
```
```nc:run effect:external when:signout=yes
"$(npm prefix -g 2>/dev/null)/bin/teams" logout
```nc:operator
Sideload the generated app package into Teams:
1. Open Microsoft Teams → Apps → Manage your apps → Upload an app.
2. Click "Upload a custom app" (or "Upload for me or my teams").
3. Select data/teams/teams-app-package.zip and click Add.
4. If "Upload a custom app" is missing, your tenant admin has disabled sideloading — enable it in Teams Admin Center → Teams apps → Setup policies → Global → Upload custom apps = On.
Once the app is added in Teams, continue.
```
## Restart
@@ -349,29 +217,23 @@ bash setup/lib/restart.sh
## Finish wiring
On a fresh create, [Link the bot to your account](#link-the-bot-to-your-account) already resolved
everything the wire needs — `owner_handle` (the owner's `29:` id) and
`platform_id` (the bot↔owner DM). The setup wizard wires automatically from
those and the welcome message lands in the owner's Teams DMs. Applying this
skill outside the wizard? Run the same wire yourself:
Unlike Discord or Slack, a Teams bot's platform ID isn't known until you DM the
bot for the first time — the adapter derives it from the inbound activity. So
this skill installs the adapter and stops here; you finish the wiring once the
bot has seen its first message. Tell the user:
```bash
pnpm exec tsx scripts/init-first-agent.ts --channel teams --user-id "teams:<owner_handle>" --platform-id "<platform_id>" --display-name "<the human's name>" --agent-name "<assistant name>" --role owner
```nc:operator
The Teams adapter is live and the service is running. One thing is left: your Teams bot's platform ID (which NanoClaw needs to wire it to an agent group) only becomes known after you DM the bot for the first time. To finish:
1. Find your bot in Teams (search by name, or via the app you just sideloaded) and send it a message ("hi" is fine).
2. Tail logs/nanoclaw.log for the inbound — the router auto-creates a row in messaging_groups in data/v2.db.
3. Run scripts/init-first-agent.ts with --channel teams, the discovered platform_id, and your AAD user id — OR run /manage-channels to wire it interactively.
```
**Fallback (re-runs, or the link step failed):** with credentials already in
`.env` the resolve steps are skipped, so there is nothing new to wire — the
first run's wiring still stands. If the install was never wired at all, the
DM-first path always works: DM the bot once ("hi" is fine) — the router
auto-creates the messaging group row in `data/v2.db` from that first inbound
— then run `/init-first-agent` (or `/manage-channels`) with your coding
agent.
## Next Steps
If you're in the middle of `/setup`, return to the setup flow now — it wires
the owner automatically from the resolved values. Otherwise wire per
[Finish wiring](#finish-wiring).
If you're in the middle of `/setup`, return to the setup flow now. Otherwise,
once you've DM'd the bot, wire this channel with `/init-first-agent` (or
`/manage-channels`).
## Channel Info
@@ -385,78 +247,57 @@ the owner automatically from the resolved values. Otherwise wire per
## Alternatives
### Multi-tenant bot
### Auto: Teams CLI
The Credentials flow above always creates a single-tenant bot (only your
Microsoft 365 tenant can install it) — the right default for a self-hosted
assistant, so the skill doesn't ask. For a bot any tenant can install, run
the create by hand with `multipleOrgs` and store the matching env pairing —
`MultiTenant` with **no** tenant ID (the same 401 pairing rule from the
credentials step):
The Credentials flow above walks the manual Azure Portal path. If you'd rather
script it, the Microsoft Teams CLI creates the Entra app, client secret, and bot
registration in one command. Requires Node.js 18+, a Microsoft 365 account with
sideloading permissions, and a public HTTPS endpoint (ngrok, Cloudflare Tunnel,
or similar).
```bash
"$(npm prefix -g)/bin/teams" app create --name "YourBot" --endpoint "https://your-domain/webhook/teams" --sign-in-audience multipleOrgs --json
```
```bash
TEAMS_APP_ID=<CLIENT_ID from the output>
TEAMS_APP_PASSWORD=<CLIENT_SECRET from the output>
TEAMS_APP_TYPE=MultiTenant
```
Install via the `installLink` in the output, then continue from
[Restart](#restart). If this skill already created a single-tenant app,
start over first — see Rotate or recreate credentials in
[Troubleshooting](#troubleshooting).
### 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. Ask the human 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:
Single tenant (most common for self-host) or Multi tenant. From the Overview
page copy the **Application (client) ID** and — single tenant only — the
**Directory (tenant) ID**.
2. **Client secret**: in the app registration, "Certificates & secrets" → "New
client secret" (expires 180 days or longer). **Copy the Value now** — Azure
shows it once (the Value column, not the Secret ID).
3. **Azure Bot resource**: search "Azure Bot" → Create. Bot handle: any unique
name; Type of App: must match step 1; Creation type: "Use existing app
registration" with the App ID from step 1. After creating, open the bot →
Configuration and set **Messaging endpoint** to
`https://your-domain/webhook/teams`, then Apply.
4. **Enable the Teams channel**: Azure Bot resource → Channels → Microsoft
Teams → Accept terms → Apply.
5. **Store the credentials** in `.env` (the same 401 pairing rule applies —
`SingleTenant` needs the tenant ID, `MultiTenant` must omit it):
1. Install the CLI:
```bash
TEAMS_APP_ID=<Application (client) ID>
TEAMS_APP_PASSWORD=<client secret Value>
TEAMS_APP_TYPE=SingleTenant
TEAMS_APP_TENANT_ID=<Directory (tenant) ID>
npm install -g @microsoft/teams.cli@preview
```
6. **Build the app package** (manifest + icons, written in-process to
`data/teams/teams-app-package.zip` — no `zip` binary needed):
2. Sign in and verify:
```bash
pnpm exec tsx setup/channels/teams-manifest-build.ts --app-id YOUR_APP_ID --url https://your-domain
teams login
teams status
```
7. **Sideload**: Microsoft Teams → Apps → Manage your apps → Upload an app →
"Upload a custom app" → select the zip → Add.
8. Continue from [Restart](#restart).
Or create the bot resource with the Azure CLI instead of the portal:
3. Create the Entra app, client secret, and bot registration:
```bash
teams app create \
--name "NanoClaw" \
--endpoint "https://your-domain/api/webhooks/teams"
```
The CLI prints the credentials as `CLIENT_ID`, `CLIENT_SECRET`, and `TENANT_ID`. Map them to NanoClaw's env keys:
- `CLIENT_ID` → `TEAMS_APP_ID`
- `CLIENT_SECRET` → `TEAMS_APP_PASSWORD`
- `TENANT_ID` → `TEAMS_APP_TENANT_ID`
4. Pick **Install in Teams** from the post-create menu and confirm in the Teams dialog.
### Azure CLI for the bot resource
The Azure Bot resource and Teams channel can also be created with `az` instead of
clicking through the portal:
```bash
az group create --name nanoclaw-rg --location eastus
az bot create --resource-group nanoclaw-rg --name nanoclaw-bot --app-type SingleTenant --appid YOUR_APP_ID --tenant-id YOUR_TENANT_ID --endpoint "https://your-domain/webhook/teams"
az bot create \
--resource-group nanoclaw-rg \
--name nanoclaw-bot \
--app-type SingleTenant \
--appid YOUR_APP_ID \
--tenant-id YOUR_TENANT_ID \
--endpoint "https://your-domain/api/webhooks/teams"
az bot msteams create --resource-group nanoclaw-rg --name nanoclaw-bot
```
@@ -464,121 +305,43 @@ az bot msteams create --resource-group nanoclaw-rg --name nanoclaw-bot
### Receive all channel messages (without @-mention)
By default the bot only receives messages when @-mentioned. With a CLI-created
bot, grant the resource-specific-consent (RSC) permissions directly — no
manifest edit, no re-upload; the app version is bumped automatically:
By default the bot only receives messages when @-mentioned. To receive every
message in a channel, add an RSC (resource-specific consent) permission to your
Teams app `manifest.json`:
```bash
teams app rsc add <teams-app-id> ChannelMessage.Read.Group --type Application
teams app rsc add <teams-app-id> ChatMessage.Read.Chat --type Application
```json
{
"authorization": {
"permissions": {
"resourceSpecific": [
{ "name": "ChannelMessage.Read.Group", "type": "Application" }
]
}
}
}
```
Then update/reinstall the app in the team so the new permissions get consented.
(`<teams-app-id>` is the Teams App ID shown in the install step — recover it
any time with `teams app list`, or find the app at
https://dev.teams.microsoft.com/apps.)
On the manual path, regenerate the package with RSC baked in and sideload it
again (the manifest version is bumped so the upload supersedes the original):
```bash
pnpm exec tsx setup/channels/teams-manifest-build.ts --app-id YOUR_APP_ID --url https://your-domain --rsc
```
Re-sideload the updated app package for the change to take effect.
## Troubleshooting
### "Upload a custom app" is missing / sideloading blocked
`teams status` shows whether sideloading is enabled at both tenant
and user level; the login output prints the same check.
- **Tenant level off**: Teams Admin Center → **Teams apps** → **Setup
policies** → **Global** → **Upload custom apps** = On.
- **"Enabled for the tenant, but your user policy blocks it"**: the per-user
policy is the blocker — Teams Admin Center → **Users** → find the user →
**Policies** → **App setup policy** → assign one with **Upload custom
apps** = On. Policy changes can take a while to propagate.
### "Upload a custom app" is missing in Teams
Your tenant admin has disabled sideloading. Enable it in Teams Admin Center →
**Teams apps** → **Setup policies** → **Global** → **Upload custom apps** = On.
Free personal Teams does not support sideloading at all — use a Microsoft 365
Business / EDU / developer tenant.
The login step's sideloading probe is **advisory** — policy edits can take
hours to propagate and the probe has been seen flapping between runs on the
same account. The authoritative test is whether the install link's Add
actually works; only act on the probe if the install itself refuses.
### `teams: command not found`
The CLI installed fine but npm's global bin directory isn't on your PATH — a
common state with custom npm prefixes. Find it with `npm prefix -g` (the
binary is at `<prefix>/bin/teams`), then either add that directory to PATH or
symlink the binary somewhere already on it. The skill's own steps are immune —
they invoke the absolute path.
### Create fails immediately with `AUTH_REQUIRED` after a successful sign-in
The sign-in didn't persist: each `teams` command is a separate process, and
when the CLI's credential store can't load it silently falls back to an
in-memory cache that dies with the login process. Symptom check:
`teams status` says logged out right after a login succeeded. The known
cause: the **CLI was installed as a pnpm workspace dependency** — pnpm's
supply-chain policy skips dependency build scripts, so keytar (the CLI's
native credential store) never gets its binary and the whole store fails to
load. Use the global npm install this skill performs — and `pnpm uninstall
@microsoft/teams.cli` if a workspace copy lingers, so `teams` resolves to
the global one. (The "libsecret not found → stored unencrypted" warning is
NOT this failure — that fallback persists fine and is safe to ignore.)
After fixing, sign in again and confirm `teams status` shows logged in, then
re-run this skill.
### Bot never receives messages
1. The app is actually installed in Teams — if setup was interrupted before
the install step, nothing got installed. Recover the install link:
`teams app list` shows the Teams App ID, then
`teams app get <teams-app-id> --install-link`.
2. The tunnel is up and the messaging endpoint matches it — the endpoint must
be `https://<your-domain>/webhook/teams`, and your tunnel (e.g.
`cloudflared tunnel --url http://localhost:3000`) must be forwarding to
this machine's port 3000. Check
with `teams app doctor <teams-app-id>` (CLI-created bots) or Azure
Bot → **Configuration** (manual path).
3. The adapter started: `grep -i teams logs/nanoclaw.log | tail`.
4. The credentials are in `.env` (`TEAMS_APP_ID`, `TEAMS_APP_PASSWORD`,
`TEAMS_APP_TYPE`).
### Tunnel URL changed
Point the bot at the new endpoint:
`teams app update <teams-app-id> --endpoint "https://new-domain/webhook/teams"`
(manual path: Azure Bot → Configuration → Messaging endpoint).
1. The tunnel is up and the messaging endpoint matches it: Azure Bot → **Configuration** → **Messaging endpoint** must be `https://<your-domain>/api/webhooks/teams`, and your tunnel (e.g. `ngrok http 3000`) must be forwarding to this machine's port 3000.
2. The adapter started: `grep -i teams logs/nanoclaw.log | tail`.
3. The credentials are in `.env` (`TEAMS_APP_ID`, `TEAMS_APP_PASSWORD`, `TEAMS_APP_TYPE`).
### `Unauthorized` / 401 from Azure Bot Service
Either the credential pairing is wrong, or the secret is dead:
- **Pairing**: `TEAMS_APP_TYPE=SingleTenant` requires `TEAMS_APP_TENANT_ID`;
`MultiTenant` must have **no** tenant ID set. A mismatch authenticates
against the wrong authority and every send/receive 401s.
- **Secret**: expired or mispasted. Rotate with
`teams app auth secret create <teams-app-id>` (or Azure portal →
Certificates & secrets), update `TEAMS_APP_PASSWORD` in `.env`, and restart.
### Rotate or recreate credentials
The credentials flow skips creation while `.env` has `TEAMS_APP_ID` **or**
`TEAMS_APP_PASSWORD` — deleting just one line does not make the skill
regenerate it (that would pair a new app with stale keys). To rotate only the
secret, use the 401 section above. To start over completely: delete **all**
`TEAMS_*` lines from `.env`, optionally delete the old app at
https://dev.teams.microsoft.com/apps (CLI path) or in Azure Portal → App
registrations (manual path), then re-run this skill. Re-running
`teams app create` with old credentials still in `.env` would otherwise create
a second, orphaned app.
The client secret is wrong or expired, or — for a Single Tenant app — `TEAMS_APP_TENANT_ID` is missing. Regenerate the secret in **Certificates & secrets** (copy the Value, not the Secret ID), update `.env`, and restart.
### Replies land in the wrong place
A Teams bot's platform ID is derived from the first inbound activity, so wire
the messaging group that the router auto-creates after you DM the bot — don't
guess the platform ID. See **Finish wiring** above.
A Teams bot's platform ID is derived from the first inbound activity, so wire the messaging group that the router auto-creates after you DM the bot — don't guess the platform ID. See **Finish wiring** above.
+18 -21
View File
@@ -28,21 +28,24 @@ function operatorBody(md: string, n: number): string {
}
describe('gatePolicy — §5.1 parity table (real skills)', () => {
it('teams: one gate — the install-in-Teams operator pauses before the DM-open fetches', () => {
// Operators in order (CLI-first flow): prerequisites, the detected-owner
// note, the wire-declined note (when:wire_owner=no), install-in-Teams.
// The finish-wiring handoff is prose only — the wizard wires inline from
// the resolved vars.
it('teams: exactly the two authored gates — and NO confirm on the operator-chain head', () => {
// Operators in order: prerequisites, portal app, client secret, bot resource
// (chain head), enable-channel (chain tail), sideload, final handoff.
const d = decisions(loadSkill('teams'));
expect(d).toHaveLength(4);
expect(d).toHaveLength(7);
expect(d.map((g) => g.needsConfirm)).toEqual([
false, // prereqs → prompt public_url (prompt is the barrier)
false, // detected-owner note → prompt wire_owner (prompt is the barrier)
false, // wire-declined note → install operator (last operator of the chain carries the barrier)
true, // install-in-Teams → the DM-open effect:fetch chain
false, // → prompt public_url (prompt is the barrier)
false, // → prompt app_id
false, // → prompt app_password
false, // bot-resource block → next compatible is another OPERATOR (rule 2):
// the chain's LAST operator carries the barrier — never double-confirm
true, // enable-channel → run effect:check (the manifest hazard gate)
true, // sideload → run effect:restart
false, // final handoff → end of document
]);
// Completed-work flavor (fetch/external, not effect:step).
expect(d[3].flavor).toBe('completed');
// Both confirms are completed-work flavor (check/restart, not effect:step).
expect(d[4].flavor).toBe('completed');
expect(d[5].flavor).toBe('completed');
});
it('telegram: the pairing operator gains a readiness pause before the effect:step', () => {
@@ -137,16 +140,10 @@ describe('gatePolicy — rules on synthetic fixtures', () => {
// §5.2 URL-offer inventory — every operator body in the tree, plus the
// normative negative fixture (slack's placeholder URL).
describe('extractOfferUrl — §5.2 inventory', () => {
it('teams: raw bodies stay offer-free — the install link is a {{var}} until substitution', () => {
it('teams: both portal blocks offer a clean https://portal.azure.com (trailing comma stripped)', () => {
const md = loadSkill('teams');
// The install block's URL is {{install_link}} in the AUTHORED body — no
// candidate matches here; the offer materializes at runtime from the
// rendered body (proven in run-channel-skill.test.ts's fresh-create case).
expect(operatorBody(md, 3)).toContain('{{install_link}}');
expect(extractOfferUrl(operatorBody(md, 0))).toBeUndefined(); // prereqs
expect(extractOfferUrl(operatorBody(md, 1))).toBeUndefined(); // detected-owner note
expect(extractOfferUrl(operatorBody(md, 2))).toBeUndefined(); // wire-declined note (entra link is schemeless on purpose)
expect(extractOfferUrl(operatorBody(md, 3))).toBeUndefined(); // install-in-Teams
expect(extractOfferUrl(operatorBody(md, 1))).toBe('https://portal.azure.com'); // app registration
expect(extractOfferUrl(operatorBody(md, 3))).toBe('https://portal.azure.com'); // bot resource (new offer)
});
it('slack — the <your-public-host> placeholder is EXCLUDED (normative negative fixture)', () => {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

-36
View File
@@ -1,36 +0,0 @@
{
"$schema": "https://developer.microsoft.com/en-us/json-schemas/teams/v1.16/MicrosoftTeams.schema.json",
"manifestVersion": "1.16",
"version": "1.0.0",
"id": "00000000-0000-0000-0000-000000000000",
"packageName": "com.nanoclaw.bot",
"developer": {
"name": "NanoClaw",
"websiteUrl": "https://nanoclaw.invalid",
"privacyUrl": "https://nanoclaw.invalid",
"termsOfUseUrl": "https://nanoclaw.invalid"
},
"name": {
"short": "NanoClaw",
"full": "NanoClaw Assistant"
},
"description": {
"short": "Your personal assistant in Teams.",
"full": "NanoClaw personal assistant."
},
"icons": {
"outline": "outline.png",
"color": "color.png"
},
"accentColor": "#4A90D9",
"bots": [
{
"botId": "00000000-0000-0000-0000-000000000000",
"scopes": ["personal", "team", "groupchat"],
"supportsFiles": false,
"isNotificationOnly": false
}
],
"permissions": ["identity", "messageTeamMembers"],
"validDomains": ["nanoclaw.invalid"]
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 392 B

+5 -21
View File
@@ -555,7 +555,9 @@ async function main(): Promise<void> {
}
let result: void | typeof BACK_TO_CHANNEL_SELECTION;
// Every channel now runs through the SKILL.md-driven flow — the whole
// connect+wire procedure lives in each add-<channel>/SKILL.md.
// connect+wire procedure lives in each add-<channel>/SKILL.md. Teams
// defers the wire (its platform_id only exists after the first inbound),
// so it installs + hands off rather than wiring inline.
if (channelChoice === 'telegram') {
result = await runChannelSkill('telegram', displayName!, { offerBack: true });
} else if (channelChoice === 'discord') {
@@ -565,10 +567,7 @@ async function main(): Promise<void> {
} else if (channelChoice === 'signal') {
result = await runChannelSkill('signal', displayName!, { offerBack: true });
} else if (channelChoice === 'teams') {
// Fresh create resolves the owner DM proactively and wires inline (the
// welcome message reaches the human first); a drop-through re-run
// resolves nothing and falls back to the deferred-wire ending.
result = await runChannelSkill('teams', displayName!, { wireIfResolved: true, offerBack: true });
result = await runChannelSkill('teams', displayName!, { deferWire: true, offerBack: true });
} else if (channelChoice === 'slack') {
result = await runChannelSkill('slack', displayName!, { offerBack: true });
} else if (channelChoice === 'imessage') {
@@ -589,12 +588,6 @@ async function main(): Promise<void> {
}
}
// Deferred wire (Teams): verify passes with zero groups because the
// platform id only exists after the first DM. Tracked here so the ENDING
// changes too — the last box must be the one remaining action, not a
// premature "your assistant is saying hi" (no welcome DM exists yet).
let wiringPending = false;
if (!skip.has('verify')) {
const res = await runQuietStep('verify', {
running: 'Making sure everything works together…',
@@ -649,7 +642,6 @@ async function main(): Promise<void> {
p.outro(k.yellow('Almost there. A few things still need your attention.'));
return;
}
wiringPending = res.terminal?.fields.WIRING === 'pending_first_dm';
}
const rows: [string, string][] = [
@@ -675,15 +667,7 @@ async function main(): Promise<void> {
phEmit('setup_completed', { duration_ms: Date.now() - RUN_START });
const dmTarget = channelDmLabel(channelChoice);
if (wiringPending) {
// No welcome DM exists yet — the one remaining action is the last thing
// on screen, in the same bright framed style as the "go say hi" banner.
note(
`${brandBold('→')} ${k.bold(`Have the person you want wired DM the bot once in ${dmTarget ?? 'your chat app'} ("hi" works).`)}\nNanoClaw registers their identity and chat from that first message; then run /init-first-agent with your coding agent and pick them.`,
"What's left",
);
p.outro(k.green("You're set — one DM to go."));
} else if (dmTarget) {
if (dmTarget) {
// Bright framed banner (not dim) — the whole point of the feedback was
// that the welcome-message signal was too easy to miss. Use p.note so it
// renders with a visible box, cyan-bold the directive line, and put it
+30 -360
View File
@@ -1,13 +1,9 @@
import { describe, it, expect, afterEach, vi } from 'vitest';
import { execSync } from 'node:child_process';
import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'node:fs';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { runChannelSkill } from './run-channel-skill.js';
import { runSkill } from '../lib/skill-driver.js';
import { fullyApplied } from '../../scripts/skill-apply.js';
import { parseDirectives } from '../../scripts/skill-directives.js';
import { BACK_TO_CHANNEL_SELECTION, backGate } from '../lib/back-nav.js';
// Drive the first-prompt back gate (back-nav's brightSelect) from a queue
@@ -75,31 +71,30 @@ describe('runChannelSkill adapter (Option A)', () => {
expect(cmds.some((c) => c.startsWith('ncl '))).toBe(false);
});
// Teams wires inline only when a fresh create resolved the owner DM
// (wireIfResolved). This fixture answers the have_creds probe with "yes"
// (credentials already in .env), so every creation + resolve step — the
// teams-login step, teams app create, the env writes, the DM-open chain,
// the install-link operator — is when:-skipped, the wire inputs stay
// unresolved, and the run drops through to restart without wiring.
it('wireIfResolved (Teams): existing credentials skip the whole CLI create flow, never reach the shared wire', async () => {
// Teams' platform_id only exists after the first inbound, so its SKILL.md
// installs + hands off and runChannelSkill is called with deferWire — it must
// run the skill but never reach the shared wire. This is the driver-policy
// parity fixture: it runs the DEFAULT onEvent handler (never an injected
// onEvent, which would replace the policy — §5.0) and injects the
// confirm/openUrl seams to prove both natural barriers fire and the portal
// URL offer survives from the operator prose alone.
it('deferWire (Teams): default policy fires the gate barriers + portal URL offer, never reaches the shared wire', async () => {
const root = mkdtempSync(join(tmpdir(), 'rcs-teams-'));
mkdirSync(join(root, 'src/channels'), { recursive: true });
writeFileSync(join(root, 'src/channels/index.ts'), '// barrel\n');
writeFileSync(join(root, '.env'), 'TEAMS_APP_ID=existing\nTEAMS_APP_PASSWORD=existing-password\n');
writeFileSync(join(root, '.env'), '');
writeFileSync(join(root, 'package.json'), '{"name":"scratch"}');
const log: string[] = [];
const opened: string[] = [];
const wired: unknown[] = [];
await runChannelSkill('teams', 'Acme Corp', {
projectRoot: root,
exec: (c) => {
log.push(`exec:${c}`);
if (c.includes('TEAMS_APP_ID=.')) return 'yes'; // the have_creds probe
},
exec: (c) => void log.push(`exec:${c}`),
resolveRemote: () => 'origin',
reuse: false,
wireIfResolved: true,
deferWire: true,
// The injectable interaction seams — the default handler consults them for
// the URL offer and the natural-barrier confirms, so no real clack confirm
// (which would hang in CI) and no real browser open is reached.
@@ -107,358 +102,33 @@ describe('runChannelSkill adapter (Option A)', () => {
log.push(`confirm:${m}`);
return true;
},
openUrl: async () => undefined,
// NO inputs: the public_url prompt is when:have_creds=no-guarded, so the
// drop-through path must never ask for it. If the guard regressed, the
// prompt would defer (resolveInput undefined) and fail() would be called.
resolveInput: async () => undefined,
fail: async (step, msg) => {
throw new Error(`fail() called on drop-through path: ${step}${msg}`);
},
wire: (a) => {
wired.push(a);
return true;
},
});
// the adapter install ran, but no bot was created and no login step fired…
expect(log.some((c) => c.includes('pnpm add @chat-adapter/teams'))).toBe(true);
expect(log.some((c) => c.includes('app create'))).toBe(false);
expect(log.some((c) => c.includes('app update'))).toBe(false); // icon step is creation-side too
expect(log.some((c) => c.includes('login'))).toBe(false);
// …no logout either — the drop-through path never signed in, and must not
// sign out a session the operator may be using for something else…
expect(log.some((c) => c.includes('logout'))).toBe(false);
// …the Teams CLI install is also skipped (nothing to create)…
expect(log.some((c) => c.includes('npm install -g @microsoft/teams.cli'))).toBe(false);
// …the service still restarts (adapter + existing credentials load)…
expect(log.some((c) => c.includes('restart.sh'))).toBe(true);
// …the pre-existing .env values were left alone…
expect(readFileSync(join(root, '.env'), 'utf8')).toContain('TEAMS_APP_ID=existing');
// …and the shared wire was never reached (no owner_handle/platform_id needed)
expect(wired).toHaveLength(0);
});
// The probe's shell one-liner is dispatched by substring in the fixtures
// above — its actual semantics (EITHER key present ⇒ yes; a partial pair
// must NOT trigger a second `teams app create`) are asserted here by running
// the REAL command from the REAL SKILL.md against real .env states. Parsed
// from the document so the test can't drift from what ships.
it('Teams have_creds probe: either credential key present answers yes', () => {
const md = readFileSync(join(process.cwd(), '.claude/skills/add-teams/SKILL.md'), 'utf8');
const probe = parseDirectives(md).find(
(d) => d.kind === 'run' && d.attrs.capture === 'have_creds',
);
expect(probe).toBeDefined();
const cmd = probe!.body.join('\n');
const cases: Array<[string | null, string]> = [
['TEAMS_APP_ID=a\nTEAMS_APP_PASSWORD=b\n', 'yes'],
['TEAMS_APP_ID=a\n', 'yes'], // partial pair: creating another app would corrupt it
['TEAMS_APP_PASSWORD=b\n', 'yes'],
['OTHER=x\n', 'no'],
['TEAMS_APP_ID=\n', 'no'], // empty value counts as unset (mirrors env-set)
[null, 'no'], // no .env at all
];
for (const [env, expected] of cases) {
const dir = mkdtempSync(join(tmpdir(), 'rcs-probe-'));
if (env !== null) writeFileSync(join(dir, '.env'), env);
const out = execSync(cmd, { cwd: dir, shell: '/bin/bash', encoding: 'utf8' }).trim();
expect(out, `env=${JSON.stringify(env)}`).toBe(expected);
rmSync(dir, { recursive: true, force: true });
}
});
// The fresh-create leg of the same document, driven at the runSkill level so
// the effect:step gets an injected streaming exec (runChannelSkill exposes no
// execStream seam — CI must never spawn a real `teams login`). Proves the
// CLI-first chain end-to-end: login step → create's JSON multi-capture → the
// env writes → the substituted install link surviving into the URL offer.
it('Teams fresh create: login step + JSON capture drive the env writes and the install-link offer', async () => {
const root = mkdtempSync(join(tmpdir(), 'rcs-teams-create-'));
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 INSTALL_LINK =
'https://teams.microsoft.com/l/app/tapp-123?installAppPackage=true&appTenantId=tenant-1';
const log: string[] = [];
const opened: string[] = [];
const steps: string[] = [];
// The DM-open chain's expected results. The exec mock returns each
// command's FINAL stdout (post-jq), matching what the engine captures.
const EXPECTED_PLATFORM_ID = `teams:${Buffer.from('a:1conv').toString('base64url')}:${Buffer.from('https://smba.trafficmanager.net/teams/').toString('base64url')}`;
const res = await runSkill('.claude/skills/add-teams', {
projectRoot: root,
exec: (c) => {
log.push(`exec:${c}`);
if (c.includes('TEAMS_APP_ID=.')) return 'no'; // the have_creds probe: nothing configured yet
if (c.includes(' app create ')) {
// the --json shape teams.cli@3.0.2 prints (credentials keys are UPPERCASE)
return JSON.stringify({
appName: 'NanoClaw',
teamsAppId: 'tapp-123',
botId: '12345678-1234-1234-1234-123456789abc',
installLink: INSTALL_LINK,
portalLink: 'https://dev.teams.microsoft.com/apps/tapp-123',
credentials: {
CLIENT_ID: '12345678-1234-1234-1234-123456789abc',
CLIENT_SECRET: 'a-much-longer-app-secret',
TENANT_ID: '87654321-4321-4321-4321-cba987654321',
},
});
}
// owner identity from the CLI session (status --json fence, plain exec)
if (c.includes('status --json')) {
return JSON.stringify({ loggedIn: true, username: 'dan@acme.example', tenantId: 'tenant-1', userObjectId: 'aad-owner-1' });
}
if (c.includes('login.microsoftonline.com')) return 'eyJfake.bot.token';
// /members is a sub-path of /v3/conversations — match it FIRST
if (c.includes('/members')) return JSON.stringify({ id: '29:owner-xyz', name: 'Dan Mill' });
if (c.includes('/v3/conversations')) return 'a:1conv';
if (c.includes('node -e')) return EXPECTED_PLATFORM_ID;
},
execStream: async (cmd) => {
steps.push(cmd);
return { ok: true, fields: { STATUS: 'success' } };
},
resolveRemote: () => 'origin',
inputs: { public_url: 'https://acme.example', app_name: 'NanoClaw', wire_owner: 'yes', signout: 'yes' },
confirm: async (m) => {
log.push(`confirm:${m}`);
return true;
},
openUrl: async (u) => void opened.push(u),
});
// the CLI installed globally (npm runs keytar's install script; pnpm's
// build-script policy would leave the credential store unbuildable)…
expect(log.some((c) => c.includes('npm install -g @microsoft/teams.cli@3.0.2'))).toBe(true);
// …the login ran as a streaming step, never a plain exec (the CLI is
// 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,
// the prompted name, and the unconditional single-tenant default…
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 mascot icons were applied to the created app (captured teams app id,
// both committed assets) before the install-link operator…
expect(
log.some(
(c) =>
c.includes(' app update tapp-123') &&
c.includes('--color-icon setup/assets/teams/color.png') &&
c.includes('--outline-icon setup/assets/teams/outline.png'),
),
).toBe(true);
// …the DM-open chain resolved the wire inputs: the owner's 29: id from the
// conversation members (first non-bot member) and the adapter-encoded
// platform id from the created conversation…
expect(res.vars.owner_handle).toBe('29:owner-xyz');
expect(res.vars.owner_name).toBe('Dan Mill');
expect(res.vars.platform_id).toBe(EXPECTED_PLATFORM_ID);
// …the M365 session was signed out on the operator's "yes" (the adapter
// runs on the .env app credentials; staying signed in is now a choice)…
expect(log.some((c) => c.includes('/bin/teams" logout'))).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');
expect(env).toContain('TEAMS_APP_PASSWORD=a-much-longer-app-secret');
expect(env).toContain('TEAMS_APP_TENANT_ID=87654321-4321-4321-4321-cba987654321');
expect(env).toContain('TEAMS_APP_TYPE=SingleTenant');
// …the install-link operator offered the SUBSTITUTED link (policy §5.2 runs on
// the rendered body — an unsubstituted {{var}} would have been excluded)…
expect(opened).toContain(INSTALL_LINK);
// …a natural-barrier confirm fired between the install operator and restart…
const gateAt = log.findIndex((c) => c.startsWith('confirm:') && !c.startsWith('confirm:Open '));
const restartAt = log.findIndex((c) => c.includes('restart.sh'));
expect(gateAt).toBeGreaterThanOrEqual(0);
expect(gateAt).toBeLessThan(restartAt);
// …and the whole document applied with nothing deferred or bounced.
expect(res.deferred).toEqual([]);
expect(res.agentTasks).toEqual([]);
expect(fullyApplied(res)).toBe(true);
});
// A no at the wiring confirm then "other-account" collects a different
// user's Entra object ID, rebinds the wiring target, and re-enters the yes
// branch: the conversation is created with the PROVIDED id (not the CLI
// account's), and the wire inputs resolve to that person — the assistant
// messages the desired user first. There is no skip: someone is always wired.
it('Teams fresh create, wiring another account by Entra object ID: the chain runs against the target user', async () => {
const root = mkdtempSync(join(tmpdir(), 'rcs-teams-target-'));
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 TARGET_AAD = 'aaaabbbb-cccc-dddd-eeee-ffff00001111';
const EXPECTED_PLATFORM_ID = `teams:${Buffer.from('a:2conv').toString('base64url')}:${Buffer.from('https://smba.trafficmanager.net/teams/').toString('base64url')}`;
const log: string[] = [];
const res = await runSkill('.claude/skills/add-teams', {
projectRoot: root,
exec: (c) => {
log.push(`exec:${c}`);
if (c.includes('TEAMS_APP_ID=.')) return 'no';
if (c.includes(' app create ')) {
return JSON.stringify({
teamsAppId: 'tapp-123',
installLink: 'https://teams.microsoft.com/l/app/tapp-123',
credentials: { CLIENT_ID: 'app-1', CLIENT_SECRET: 'a-much-longer-app-secret', TENANT_ID: 'tenant-1' },
});
}
if (c.includes('status --json')) {
return JSON.stringify({ loggedIn: true, username: 'dan@acme.example', userObjectId: 'aad-owner-1' });
}
// the rebind fence: printf its own substituted JSON back
if (c.includes('"wire":"yes"')) return `{"aad":"${TARGET_AAD}","wire":"yes"}`;
if (c.includes('login.microsoftonline.com')) return 'eyJfake.bot.token';
if (c.includes('/members')) return JSON.stringify({ id: '29:target-xyz', name: 'Desired Person' });
if (c.includes('/v3/conversations')) return 'a:2conv';
if (c.includes('node -e')) return EXPECTED_PLATFORM_ID;
},
execStream: async () => ({ ok: true, fields: { STATUS: 'success' } }),
resolveRemote: () => 'origin',
// a MultiTenant app, so the SingleTenant-guarded app_tenant_id prompt is skipped
inputs: {
public_url: 'https://acme.example',
app_name: 'NanoClaw',
wire_owner: 'no',
wire_target: 'other-account',
target_aad_id: TARGET_AAD,
signout: 'yes',
app_id: '12345678-1234-1234-1234-123456789abc',
app_type: 'MultiTenant',
app_password: 'a-much-longer-app-password', // 20+ chars — valid for the declared shape
},
confirm: async () => true,
openUrl: async () => {},
});
// The conversation was created with the PROVIDED id, not the CLI account's…
const create = log.find((c) => c.includes('/v3/conversations') && !c.includes('/members'));
expect(create).toContain(TARGET_AAD);
expect(create).not.toContain('aad-owner-1');
// …and the wire inputs resolved to the target user.
expect(res.vars.owner_handle).toBe('29:target-xyz');
expect(res.vars.owner_name).toBe('Desired Person');
expect(res.vars.platform_id).toBe(EXPECTED_PLATFORM_ID);
expect(res.agentTasks).toEqual([]);
expect(fullyApplied(res)).toBe(true);
});
// A hesitant no recovered via "logged-in-account": the rebind flips the
// branch back to yes with the CLI account's own id — same outcome as a yes
// at the first ask, no ID ever typed or shown.
it('Teams fresh create, no then logged-in-account: the chain runs against the CLI account', async () => {
const root = mkdtempSync(join(tmpdir(), 'rcs-teams-loggedin-'));
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(`exec:${c}`);
if (c.includes('TEAMS_APP_ID=.')) return 'no'; // probe first — it also contains "echo yes"
if (c.trim() === 'echo yes') return 'yes'; // the logged-in-account rebind
if (c.includes(' app create ')) {
return JSON.stringify({
teamsAppId: 'tapp-123',
installLink: 'https://teams.microsoft.com/l/app/tapp-123',
credentials: { CLIENT_ID: 'app-1', CLIENT_SECRET: 'a-much-longer-app-secret', TENANT_ID: 'tenant-1' },
});
}
if (c.includes('status --json')) {
return JSON.stringify({ loggedIn: true, username: 'dan@acme.example', userObjectId: 'aad-owner-1' });
}
if (c.includes('login.microsoftonline.com')) return 'eyJfake.bot.token';
if (c.includes('/members')) return JSON.stringify({ id: '29:owner-xyz', name: 'Dan Mill' });
if (c.includes('/v3/conversations')) return 'a:3conv';
if (c.includes('node -e')) return 'teams:b64:b64';
},
execStream: async () => ({ ok: true, fields: { STATUS: 'success' } }),
resolveRemote: () => 'origin',
inputs: {
public_url: 'https://acme.example',
app_name: 'NanoClaw',
wire_owner: 'no',
wire_target: 'logged-in-account',
signout: 'yes',
},
confirm: async () => true,
openUrl: async () => {},
});
// The conversation was created with the CLI account's own id…
const create = log.find((c) => c.includes('/v3/conversations') && !c.includes('/members'));
expect(create).toContain('aad-owner-1');
// …and the wire inputs resolved exactly as a first-ask yes would.
expect(res.vars.owner_handle).toBe('29:owner-xyz');
expect(res.vars.platform_id).toBe('teams:b64:b64');
expect(res.agentTasks).toEqual([]);
expect(fullyApplied(res)).toBe(true);
});
// The resolved leg of wireIfResolved, driven with a minimal fixture skill
// (the real teams document needs a streaming exec runChannelSkill doesn't
// expose): when the skill binds owner_handle + platform_id, the adapter asks
// nothing extra (agentName/role injected) and reaches the shared wire with
// the composed teams user id.
const wireChannel = 'wiretest';
const wireSkillDir = join(process.cwd(), '.claude/skills', `add-${wireChannel}`);
afterEach(() => rmSync(wireSkillDir, { recursive: true, force: true }));
it('wireIfResolved: a run that resolves owner_handle + platform_id wires through init-first-agent', async () => {
const root = mkdtempSync(join(tmpdir(), 'rcs-wiretest-'));
writeFileSync(join(root, 'package.json'), '{"name":"scratch"}');
writeFileSync(join(root, '.env'), '');
mkdirSync(wireSkillDir, { recursive: true });
writeFileSync(
join(wireSkillDir, 'SKILL.md'),
[
`# add ${wireChannel}`,
'',
'## Resolve',
'```nc:run capture:owner_handle effect:fetch',
'echo-owner',
'```',
'```nc:run capture:platform_id effect:fetch',
'echo-platform',
'```',
'',
].join('\n'),
);
const wired: Array<Record<string, unknown>> = [];
await runChannelSkill(wireChannel, 'Dan Mill', {
projectRoot: root,
exec: (c) => {
if (c === 'echo-owner') return '29:owner-xyz\n';
if (c === 'echo-platform') return 'teams:enc-conv:enc-url\n';
},
resolveRemote: () => 'origin',
wireIfResolved: true,
agentName: 'Nano',
role: 'owner',
wire: (a) => {
wired.push(a);
return true;
},
});
expect(wired).toHaveLength(1);
expect(wired[0]).toMatchObject({
channel: wireChannel,
userId: `${wireChannel}:29:owner-xyz`,
platformId: 'teams:enc-conv:enc-url',
displayName: 'Dan Mill',
agentName: 'Nano',
role: 'owner',
});
// install + manifest ran…
expect(log.some((c) => c.includes('teams-manifest-build'))).toBe(true);
// …the Azure portal offer came from the operator BODY text (policy §5.2)…
expect(opened.some((u) => /portal\.azure\.com/.test(u))).toBe(true);
// …a natural-barrier confirm (not a URL offer) fired BEFORE the manifest
// build (the manifest-before-the-app hazard fix, now derived from document
// structure instead of an authored gate attr)…
const firstGate = log.findIndex((c) => c.startsWith('confirm:') && !c.startsWith('confirm:Open '));
const manifestAt = log.findIndex((c) => c.includes('teams-manifest-build'));
expect(firstGate).toBeGreaterThanOrEqual(0);
expect(firstGate).toBeLessThan(manifestAt);
// …but the shared wire was never reached (no owner_handle/platform_id needed)
expect(wired).toHaveLength(0);
});
// The engine reads `.claude/skills/add-<channel>/SKILL.md` relative to cwd (the
+35 -33
View File
@@ -15,9 +15,12 @@
* So the wire lives in exactly one place (init-first-agent) and is never
* duplicated across channel skills.
*/
import { writeFileSync } from 'node:fs';
import * as p from '@clack/prompts';
import { firstFailureHint, fullyApplied } from '../../scripts/skill-apply.js';
import * as setupLog from '../logs.js';
import { BACK_TO_CHANNEL_SELECTION, backGate, type ChannelFlowResult } from '../lib/back-nav.js';
import { askOperatorRole, type OperatorRole } from '../lib/role-prompt.js';
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
@@ -73,18 +76,17 @@ export interface ChannelSkillOverrides extends Partial<RunSkillOptions> {
/** The shared wire; defaults to init-first-agent. Injectable for tests. */
wire?: (args: WireArgs) => Promise<boolean> | boolean;
/**
* Wire only when the skill resolved owner_handle + platform_id this run
* (Teams: the guarded DM-open steps only run on a fresh create). Resolved →
* ask agent name/role and wire like any channel; unresolved (a drop-through
* re-run — the first run's wiring still stands) → skip the wire and let the
* SKILL's prose own the handoff. The name/role prompts are deferred until
* after the skill run so the drop-through path asks nothing.
* Skip the resolve+wire half. For a channel whose platform_id only exists
* after the first inbound (Teams): the SKILL.md installs the adapter and ends
* with an `nc:operator` handoff telling the operator to DM the bot and finish
* wiring later. No owner_handle/platform_id is expected, no agent name/role is
* asked, and init-first-agent is not run.
*/
wireIfResolved?: boolean;
deferWire?: boolean;
/**
* Offer the "← Back to channel selection" gate as the very first prompt,
* before any side effect (agent-name/role prompts, the skill run, the
* wire). On back, returns the
* before any side effect (agent-name/role prompts, the skill run, the wire —
* and the deferWire/Teams path too). On back, returns the
* `BACK_TO_CHANNEL_SELECTION` sentinel and does nothing else. Opt-in so
* headless callers (and the existing tests) never see the extra prompt.
*/
@@ -101,7 +103,7 @@ export async function runChannelSkill(
overrides: ChannelSkillOverrides = {},
): Promise<ChannelFlowResult> {
// First-prompt back gate — the very first thing, before any side effect
// (agent-name/role prompts, the skill run, the wire).
// (agent-name/role prompts, the skill run, the wire; covers deferWire too).
// Opt-in via offerBack so headless callers + existing tests are unaffected.
if (overrides.offerBack) {
const label = channel.charAt(0).toUpperCase() + channel.slice(1);
@@ -111,12 +113,9 @@ export async function runChannelSkill(
const projectRoot = overrides.projectRoot ?? process.cwd();
const failWith = overrides.fail ?? fail;
// The agent name + role are wire inputs — in wireIfResolved mode, defer the
// prompts past the skill run (only a fresh create resolves the wire inputs;
// a drop-through re-run asks nothing).
const askLater = overrides.wireIfResolved;
let agentName = askLater ? '' : overrides.agentName ?? (await resolveAgentName());
let role = askLater ? undefined : overrides.role ?? (await askOperatorRole(channel));
// The agent name + role are wire inputs — skip the prompts when the wire is deferred.
const agentName = overrides.deferWire ? '' : overrides.agentName ?? (await resolveAgentName());
const role = overrides.deferWire ? undefined : overrides.role ?? (await askOperatorRole(channel));
// Channel-specific: install adapter, collect credentials, resolve the wire
// inputs. The whole channel-specific procedure lives in the SKILL.md.
@@ -141,12 +140,21 @@ export async function runChannelSkill(
});
if (!fullyApplied(res)) {
if (res.deferred.length) p.log.warn(`Still needs: ${res.deferred.join(', ')}`);
for (const t of res.agentTasks) p.log.warn(`Needs an agent (${t.kind}): ${t.reason}`);
// On a real bounce, also surface the skill's reference floor (Alternatives /
// Optional configuration / Troubleshooting) — the same prose a human reader
// would scroll to when a step doesn't apply cleanly. Only on a bounce
// (agentTasks), and only when the skill actually ships a reference section.
if (res.agentTasks.length && res.referenceProse) p.log.info(res.referenceProse);
// A bounced reason can carry a full stderr dump (a Node stacktrace). The
// terminal gets ONE line per bounce — the first line, which hostExec
// composes as `exit <code>: <first stderr line>` — and the full text goes
// to a raw step log, written only when there's actually more than one line
// to keep (SSF-004; the reference prose is deliberately not dumped either).
let rawLog: string | undefined;
if (res.agentTasks.some((t) => t.reason.includes('\n'))) {
rawLog = setupLog.stepRawLog(`${channel}-install-bounce`);
writeFileSync(rawLog, res.agentTasks.map((t) => `## ${t.kind} (line ${t.line})\n${t.reason}\n`).join('\n'));
}
for (const t of res.agentTasks) {
const lines = t.reason.split('\n').map((l) => l.trim()).filter(Boolean);
const more = lines.length > 1 ? ` (+${lines.length - 1} more lines in ${rawLog})` : '';
p.log.warn(`Needs an agent (${t.kind}): ${lines[0] ?? t.reason}${more}`);
}
// Surface the bounced step's OWN prose as the failure hint + Claude-handoff
// context (fail() dims the hint and forwards it to offerClaudeOnFailure),
// instead of a generic "couldn't finish" message. Only a real bounce yields a
@@ -156,20 +164,18 @@ export async function runChannelSkill(
`${channel}-install`,
diag?.headline ?? `Couldn't finish setting up ${channel}.`,
diag?.hint ?? 'See logs/setup-steps/ for details, then retry setup.',
rawLog,
);
}
// Identity confirmation captured by the skill (e.g. add-slack's auth.test).
if (res.vars.connected_as) p.log.success(`Connected to ${channel} as ${res.vars.connected_as}.`);
// Deferred wire (Teams): the SKILL's operator handoff owns the rest. Done here.
if (overrides.deferWire) return;
const ownerHandle = res.vars.owner_handle;
const platformId = res.vars.platform_id;
if (overrides.wireIfResolved && (!ownerHandle || !platformId)) {
// Drop-through re-run: the guarded resolve steps were skipped, so there is
// nothing new to wire — the first run's wiring still stands (verify's
// pending path covers a truly unwired install).
return;
}
if (!ownerHandle || !platformId) {
await failWith(
`${channel}-resolve`,
@@ -177,13 +183,9 @@ export async function runChannelSkill(
'The skill did not produce owner_handle + platform_id.',
);
}
if (overrides.wireIfResolved) {
agentName = overrides.agentName ?? (await resolveAgentName());
role = overrides.role ?? (await askOperatorRole(channel));
}
// Shared wire — the same procedure for every channel. role is defined here:
// it's only undefined in an unresolved wireIfResolved run (returned above).
// it's only undefined in deferWire mode, which returned above.
const wire = overrides.wire ?? initFirstAgent;
const ok = await wire({ channel, userId: `${channel}:${ownerHandle}`, platformId, displayName, agentName, role: role! });
if (!ok) {
+4 -9
View File
@@ -5,17 +5,14 @@
* external image deps) already lives in `setup/lib/teams-manifest.ts`; this just
* maps a couple of CLI flags onto it and prints the resulting zip path.
*
* The short name comes from NANOCLAW_AGENT_NAME (falling back to "NanoClaw"), the
* Mirrors the bespoke `stepGenerateManifest` in the old setup/channels/teams.ts:
* the short name comes from NANOCLAW_AGENT_NAME (falling back to "NanoClaw"), the
* description is "<name> personal assistant powered by NanoClaw.", the website
* URL is the operator's public base URL, and the output lands in data/teams/.
*
* `--rsc` adds the resource-specific-consent permissions (receive all channel /
* group-chat messages without @-mention) and bumps the manifest version so the
* re-upload supersedes the original package.
*
* Usage:
* pnpm exec tsx setup/channels/teams-manifest-build.ts \
* --app-id <azure-app-id> --url https://your-domain [--out data/teams] [--rsc]
* --app-id <azure-app-id> --url https://your-domain [--out data/teams]
*/
import { buildTeamsAppPackage } from '../lib/teams-manifest.js';
@@ -27,12 +24,11 @@ function flag(name: string): string | undefined {
const appId = flag('app-id');
const url = flag('url');
const outDir = flag('out') ?? 'data/teams';
const rsc = process.argv.includes('--rsc');
const shortName = process.env.NANOCLAW_AGENT_NAME?.trim() || 'NanoClaw';
if (!appId || !url) {
console.error(
'usage: teams-manifest-build.ts --app-id <azure-app-id> --url <https-url> [--out <dir>] [--rsc]',
'usage: teams-manifest-build.ts --app-id <azure-app-id> --url <https-url> [--out <dir>]',
);
process.exit(2);
}
@@ -43,7 +39,6 @@ const result = buildTeamsAppPackage({
longDescription: `${shortName} personal assistant powered by NanoClaw.`,
websiteUrl: url,
outDir,
rsc,
});
console.log(`Teams app package: ${result.zipPath}`);
+2 -2
View File
@@ -73,8 +73,8 @@ export const STEP_FILES: Record<string, string[]> = {
'slack-validate': ['setup/channels/slack.ts'],
'imessage-install': ['.claude/skills/add-imessage/SKILL.md', 'scripts/skill-apply.ts', 'setup/channels/imessage.ts'],
'imessage': ['setup/channels/imessage.ts'],
'teams-install': ['.claude/skills/add-teams/SKILL.md', 'scripts/skill-apply.ts', 'setup/channels/run-channel-skill.ts'],
'teams-manifest': ['setup/lib/teams-manifest.ts', 'setup/channels/teams-manifest-build.ts'],
'teams-install': ['.claude/skills/add-teams/SKILL.md', 'scripts/skill-apply.ts', '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',
+79 -6
View File
@@ -3,7 +3,7 @@ import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, chmodSync } from '
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { runSkill, hostExec, hostExecStream, labelOrdinals, promptValidator, clackResolveInput, type RunSkillOptions } from './skill-driver.js';
import { runSkill, hostExec, hostExecStream, labelOrdinals, literalChoices, promptValidator, clackResolveInput, type RunSkillOptions } from './skill-driver.js';
import { fullyApplied, type ApplyEvent } from '../../scripts/skill-apply.js';
// Shared test state for the clack + claude-handoff mocks (hoisted so the vi.mock
@@ -15,6 +15,7 @@ const ce = vi.hoisted(() => ({
handoffSpy: vi.fn(async (_ctx: { channel: string; step: string; stepDescription: string }) => true),
answers: [] as string[],
lastValidate: { fn: undefined as undefined | ((v: string) => string | Error | void | undefined) },
lastSelectOptions: { values: undefined as undefined | string[] },
}));
// Keep isHelpEscape + validateWithHelpEscape real (clackResolveInput uses them);
@@ -32,7 +33,11 @@ vi.mock('@clack/prompts', async (importActual) => {
ce.lastValidate.fn = o?.validate;
return ce.answers.shift() ?? '';
};
return { ...actual, text: vi.fn(fromQueue), password: vi.fn(fromQueue) };
const fromQueueSelect = async (o: { options: Array<{ value: string }> }): Promise<string> => {
ce.lastSelectOptions.values = o.options.map((x) => x.value);
return ce.answers.shift() ?? o.options[0].value;
};
return { ...actual, text: vi.fn(fromQueue), password: vi.fn(fromQueue), select: vi.fn(fromQueueSelect) };
});
// A small SKILL.md exercising the three things the driver wires: an operator
@@ -113,18 +118,39 @@ describe('thin skill driver', () => {
expect(starts).toEqual([{ kind: 'run', label: 'Wire' }]);
});
it('hostExec puts the project bin/ on PATH so a bare command resolves to it', () => {
it('hostExec puts the project bin/ on PATH so a bare command resolves to it', async () => {
const root = mkdtempSync(join(tmpdir(), 'driver-bin-'));
mkdirSync(join(root, 'bin'));
writeFileSync(join(root, 'bin/greet'), '#!/usr/bin/env bash\necho hi-from-bin\n');
chmodSync(join(root, 'bin/greet'), 0o755);
const out = hostExec(root)('greet'); // bare name, not ./bin/greet
const out = await hostExec(root)('greet'); // bare name, not ./bin/greet
expect(String(out).trim()).toBe('hi-from-bin');
});
it('hostExec returns stdout so a capture run can bind it', () => {
it('hostExec returns stdout so a capture run can bind it', async () => {
const root = mkdtempSync(join(tmpdir(), 'driver-cap-'));
expect(String(hostExec(root)('echo D0CHANNEL')).trim()).toBe('D0CHANNEL');
expect(String(await hostExec(root)('echo D0CHANNEL')).trim()).toBe('D0CHANNEL');
});
it('hostExec recomposes a failure as `exit <code>: <first stderr line>` with the full stderr kept below', async () => {
const root = mkdtempSync(join(tmpdir(), 'driver-fail-'));
const run = (): Promise<string> => hostExec(root)('echo "boom: first line" >&2; echo "stack line two" >&2; exit 7');
await expect(run).rejects.toThrow(/^exit 7: boom: first line/); // one-line consumers read this
await expect(run).rejects.toThrow(/stack line two/); // full stderr survives for the agentTask reason
});
it('hostExec tees each command + stdout/stderr to the raw log, success and failure alike', async () => {
const root = mkdtempSync(join(tmpdir(), 'driver-tee-'));
const rawLog = join(root, 'raw.log');
const exec = hostExec(root, rawLog);
await exec('echo out-line; echo warn-line >&2');
await expect(exec('echo dying-gasp >&2; exit 3')).rejects.toThrow(/exit 3/);
const log = readFileSync(rawLog, 'utf8');
expect(log).toContain('$ echo out-line; echo warn-line >&2');
expect(log).toContain('out-line');
expect(log).toContain('warn-line'); // stderr captured, not echoed to the wizard
expect(log).toContain('$ echo dying-gasp >&2; exit 3');
expect(log).toContain('dying-gasp'); // the failing command's output survives too
});
it('hostExecStream runs a step and captures the terminal status block fields (for effect:step)', async () => {
@@ -136,6 +162,20 @@ describe('thin skill driver', () => {
expect(out.fields.PLATFORM_ID).toBe('telegram:42');
});
it('hostExecStream children run with LOG_LEVEL=warn — host logger info noise stays off the wizard', async () => {
const root = mkdtempSync(join(tmpdir(), 'driver-loglevel-'));
const prev = process.env.LOG_LEVEL;
delete process.env.LOG_LEVEL; // simulate an operator who didn't set one
try {
const out = await hostExecStream(root)(
'echo "=== NANOCLAW SETUP: ENV ==="; echo "STATUS: success"; echo "LVL: $LOG_LEVEL"; echo "=== END ==="',
);
expect(out.fields.LVL).toBe('warn');
} finally {
if (prev !== undefined) process.env.LOG_LEVEL = prev;
}
});
function reuseScratch(): { root: string; skill: string } {
const root = mkdtempSync(join(tmpdir(), 'reuse-'));
const skill = mkdtempSync(join(tmpdir(), 'reuse-skill-'));
@@ -379,6 +419,18 @@ describe('thin skill driver', () => {
}
});
it('clackResolveInput renders an either/or validate as an arrow-key select over the literal choices', async () => {
ce.answers = ['webhook'];
ce.lastSelectOptions.values = undefined;
const ans = await clackResolveInput()('connection', {
question: 'How should Slack deliver events?',
secret: false,
validate: '^(socket|webhook)$',
});
expect(ans).toBe('webhook');
expect(ce.lastSelectOptions.values).toEqual(['socket', 'webhook']); // the options came from the regex
});
it('clackResolveInput passes a normal answer straight through — no handoff', async () => {
ce.handoffSpy.mockClear();
ce.answers = ['just-a-token'];
@@ -402,6 +454,27 @@ describe('thin skill driver', () => {
});
});
// An either/or `nc:prompt` renders as a select — the choices come from the
// validate regex itself (no grammar addition). Only a fully-anchored
// pure-literal alternation qualifies; anything with real regex syntax stays a
// text prompt (SSF-003).
describe('literalChoices (either/or prompt → select)', () => {
it('extracts the choices from a pure-literal alternation', () => {
expect(literalChoices('^(socket|webhook)$')).toEqual(['socket', 'webhook']);
expect(literalChoices('^(qr|pairing-code)$')).toEqual(['qr', 'pairing-code']);
expect(literalChoices('^(SingleTenant|MultiTenant)$')).toEqual(['SingleTenant', 'MultiTenant']);
});
it('leaves prefixes, format unions, and non-alternations as text prompts', () => {
expect(literalChoices('^xoxb-')).toBeNull(); // unanchored prefix
expect(literalChoices('^https?://')).toBeNull(); // regex metachars
expect(literalChoices('^(\\+\\d{8,15}|[^\\s@]+@[^\\s@]+\\.[^\\s@]+)$')).toBeNull(); // imessage phone|email union
expect(literalChoices('^[0-9a-zA-Z-]+$')).toBeNull(); // char class, no alternation
expect(literalChoices('^(solo)$')).toBeNull(); // one option is not a choice
expect(literalChoices(undefined)).toBeNull();
});
});
// Two steps under one heading share a spinner caption (build + test both read
// "Build and validate") — the ordinal suffix marks them as a sequence, not a
// stuttered duplicate. Solo captions stay unsuffixed.
+91 -18
View File
@@ -13,8 +13,8 @@
* the URL offer, the prose-derived validation message.
*/
import { execSync, spawn } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { appendFileSync, readFileSync, writeFileSync } from 'node:fs';
import { basename, join } from 'node:path';
import * as p from '@clack/prompts';
@@ -30,6 +30,7 @@ import {
} from '../../scripts/skill-apply.js';
import { parseDirectives, promptVar } from '../../scripts/skill-directives.js';
import { extractOfferUrl, gatePolicy } from '../../scripts/skill-policy.js';
import * as setupLog from '../logs.js';
import { isHeadless } from '../platform.js';
import { openUrl } from './browser.js';
import { isHelpEscape, offerClaudeHandoff, validateWithHelpEscape } from './claude-handoff.js';
@@ -56,6 +57,19 @@ export function promptValidator(
return (v) => (re.test((v ?? '').trim()) ? undefined : `That doesn't match the expected format. ${question}`);
}
/**
* The literal alternatives of a fully-anchored pure-literal alternation
* `^(socket|webhook)$` ['socket', 'webhook'] or null for anything else
* (an unanchored prefix like `^xoxb-`, a format union with real regex syntax
* like imessage's `^(\+\d{8,15}|…)$`). This is what lets an either/or
* `nc:prompt` render as an arrow-key select with no grammar addition: the
* validate regex already enumerates the choices. Exported for tests.
*/
export function literalChoices(validate: string | undefined): string[] | null {
const m = validate?.match(/^\^\(([A-Za-z0-9_-]+(?:\|[A-Za-z0-9_-]+)+)\)\$$/);
return m ? m[1].split('|') : null;
}
/**
* Handoff context for the `?` help-escape (Step 8 / mechanism M3). A lone `?` at
* any prompt hands the operator to interactive Claude with this context, then
@@ -73,9 +87,10 @@ export interface PrompterContext {
/**
* The wizard's `resolveInput` implementation: collect an `nc:prompt` through
* clack (password for secrets, text otherwise; a cancel defers), running the
* interactive re-ask loop against the prompt's declared `validate:`/`flags:`
* (the engine's validate-at-bind is the programmatic backstop, not the UX).
* clack (password for secrets, an arrow-key select for an either/or validate
* regex, text otherwise; a cancel defers), running the interactive re-ask loop
* against the prompt's declared `validate:`/`flags:` (the engine's
* validate-at-bind is the programmatic backstop, not the UX).
*/
export function clackResolveInput(ctx: PrompterContext = {}): (name: string, meta: InputMeta) => Promise<string | undefined> {
// The `?` help-escape is only meaningful at a real terminal: it hands the
@@ -90,9 +105,15 @@ export function clackResolveInput(ctx: PrompterContext = {}): (name: string, met
const guarded = validateWithHelpEscape(check);
// clearOnError wipes a rejected secret so the operator re-pastes cleanly
// (a half-pasted token isn't left masked in the field).
const ans = meta.secret
? await p.password({ message: meta.question, validate: guarded, clearOnError: true })
: await p.text({ message: meta.question, validate: guarded });
// An either/or prompt renders as an arrow-key select — the options come
// straight from the validate regex (literalChoices). No re-ask loop and no
// `?` help-escape there: every choice is valid and self-describing.
const choices = meta.secret ? null : literalChoices(meta.validate);
const ans = choices
? await p.select({ message: meta.question, options: choices.map((c) => ({ value: c, label: c })) })
: meta.secret
? await p.password({ message: meta.question, validate: guarded, clearOnError: true })
: await p.text({ message: meta.question, validate: guarded });
if (p.isCancel(ans)) return undefined; // cancelled ⇒ defer
if (isHelpEscape(ans) && process.stdout.isTTY) {
// Operator asked for help: hand off to interactive Claude with this
@@ -224,14 +245,45 @@ async function reuseFromEnv(
* `run capture:<var>` can bind it. Puts the project's `bin/` on PATH so a bare
* `ncl …` in a wire directive resolves to `bin/ncl` even when it isn't
* symlinked onto the operator's PATH.
*
* Async (spawn, not execSync) so the step spinner keeps animating: a sync exec
* blocks the event loop for the whole command and freezes every ticker in the
* process. A failure rejects with the FIRST line as the actionable summary
* `exit <code>: <first stderr line>` and the full stderr kept below, so
* one-line consumers (run-channel-skill's bounce warn) stay readable while the
* agentTask reason an agent fixes from still carries everything.
*
* Non-step effects are captured-output steps the spinner is the only UI, and
* stderr is piped, never echoed (a chatty tool's warnings don't belong on the
* wizard screen). When `rawLog` is given, every command's stdout+stderr is
* appended there (level 3, like runner.ts's per-step raw logs) so the silenced
* noise stays inspectable.
*/
export function hostExec(projectRoot: string): (cmd: string) => string {
export function hostExec(projectRoot: string, rawLog?: string): (cmd: string) => Promise<string> {
const tee = (cmd: string, stdout: string, stderr: string): void => {
if (!rawLog) return;
const body = [stdout, stderr].filter(Boolean).join('');
appendFileSync(rawLog, `$ ${cmd}\n${body}${body && !body.endsWith('\n') ? '\n' : ''}\n`);
};
return (cmd) =>
execSync(cmd, {
cwd: projectRoot,
shell: '/bin/bash',
encoding: 'utf8',
env: { ...process.env, PATH: `${join(projectRoot, 'bin')}:${process.env.PATH ?? ''}` },
new Promise((resolve, reject) => {
const child = spawn('bash', ['-c', cmd], {
cwd: projectRoot,
env: { ...process.env, PATH: `${join(projectRoot, 'bin')}:${process.env.PATH ?? ''}` },
stdio: ['ignore', 'pipe', 'pipe'],
});
let out = '';
let err = '';
child.stdout.on('data', (c: Buffer) => { out += c.toString('utf8'); });
child.stderr.on('data', (c: Buffer) => { err += c.toString('utf8'); });
child.on('error', reject);
child.on('close', (code) => {
tee(cmd, out, err);
if (code === 0) return resolve(out);
const stderr = err.trim();
const head = stderr.split('\n').map((l) => l.trim()).find(Boolean) ?? 'command failed';
reject(new Error(`exit ${code ?? '?'}: ${head}${stderr ? `\n${stderr}` : ''}`));
});
});
}
@@ -248,7 +300,20 @@ export function hostExecStream(projectRoot: string): (cmd: string) => Promise<St
new Promise((resolve) => {
const child = spawn('bash', ['-c', cmd], {
cwd: projectRoot,
env: { ...process.env, PATH: `${join(projectRoot, 'bin')}:${process.env.PATH ?? ''}` },
env: {
...process.env,
PATH: `${join(projectRoot, 'bin')}:${process.env.PATH ?? ''}`,
// A step renders curated operator UI (a code card, a QR) — the host
// logger's info noise doesn't belong on the wizard screen, and it
// always emits ANSI so it can't be filtered by stream. Warnings and
// errors still pass. An operator-set LOG_LEVEL wins (debugging).
LOG_LEVEL: process.env.LOG_LEVEL ?? 'warn',
// The child's stdout is a pipe, so picocolors would strip its clack
// rendering to bare box chars that clash with the wizard theme.
// When the OPERATOR's terminal is a real TTY, the teed lines land
// there — force color so the child's card matches the parent.
...(process.stdout.isTTY ? { FORCE_COLOR: '1' } : {}),
},
stdio: ['inherit', 'pipe', 'pipe'],
});
const blocks: Array<{ fields: Record<string, string> }> = [];
@@ -351,7 +416,7 @@ function defaultOnEvent(
return;
}
// operator: note → URL offer → natural-barrier confirm.
p.note(e.text, 'Do this');
p.note(e.text, 'Your turn');
const url = extractOfferUrl(e.text);
if (url !== undefined && (await confirm(`Open ${url} in your browser?`))) await open(url);
const gate = gates.get(e.line);
@@ -382,7 +447,7 @@ export interface RunSkillOptions {
*/
resolveInput?: (name: string, meta: InputMeta) => Promise<string | undefined>;
/** Defaults to `hostExec`. */
exec?: (cmd: string) => string | void;
exec?: (cmd: string) => string | void | Promise<string | void>;
/** Defaults to `hostExecStream`. Streaming exec for `nc:run effect:step`. */
execStream?: (cmd: string) => Promise<StepOutcome>;
/** Defaults to the fork-aware channels-branch resolver. */
@@ -445,11 +510,19 @@ export async function runSkill(skillDir: string, opts: RunSkillOptions = {}): Pr
} catch {
// missing SKILL.md — the engine will produce an empty result anyway
}
// One raw log per skill apply (level 3): every default-exec command appends
// its `$ cmd` + output there. Allocated only when the default exec is used —
// an injected exec (tests, agent relay) owns its own capture.
let rawLog: string | undefined;
if (!opts.exec) {
rawLog = setupLog.stepRawLog(`skill-${basename(skillDir)}`);
writeFileSync(rawLog, `# skill ${basename(skillDir)}${new Date().toISOString()}\n\n`);
}
return applySkill(skillDir, projectRoot, {
inputs,
resolveInput: opts.resolveInput ?? clackResolveInput({ channel: opts.channel, step: opts.step }),
onEvent: opts.onEvent ?? defaultOnEvent(md, confirm, open),
exec: opts.exec ?? hostExec(projectRoot),
exec: opts.exec ?? hostExec(projectRoot, rawLog),
execStream: opts.execStream ?? hostExecStream(projectRoot),
resolveRemote: opts.resolveRemote ?? channelsRemote(projectRoot),
skipEffects: opts.skipEffects,
-150
View File
@@ -1,150 +0,0 @@
/**
* The zip is written by our own in-process writer (no `zip` binary), so this
* test parses the output with an independent minimal reader: EOCD central
* directory local headers stored data. If the writer emits a structure
* Teams (or any unzip tool) would reject, these assertions go red.
*/
import fs from 'fs';
import os from 'os';
import path from 'path';
import zlib from 'zlib';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { buildTeamsAppPackage } from './teams-manifest.js';
const APP_ID = '11111111-2222-3333-4444-555555555555';
const PNG_SIG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
interface ParsedEntry {
name: string;
crc: number;
data: Buffer;
}
/** Independent stored-entry zip reader — deliberately shares no code with the writer. */
function readZip(zip: Buffer): ParsedEntry[] {
// No archive comment is written, so EOCD is exactly the last 22 bytes.
const eocd = zip.subarray(zip.length - 22);
expect(eocd.readUInt32LE(0)).toBe(0x06054b50);
const entryCount = eocd.readUInt16LE(10);
let pos = eocd.readUInt32LE(16); // central directory offset
const entries: ParsedEntry[] = [];
for (let i = 0; i < entryCount; i++) {
expect(zip.readUInt32LE(pos)).toBe(0x02014b50);
expect(zip.readUInt16LE(pos + 10)).toBe(0); // stored, no compression
const crc = zip.readUInt32LE(pos + 16);
const size = zip.readUInt32LE(pos + 24);
const nameLen = zip.readUInt16LE(pos + 28);
const extraLen = zip.readUInt16LE(pos + 30);
const commentLen = zip.readUInt16LE(pos + 32);
const localOffset = zip.readUInt32LE(pos + 42);
const name = zip.subarray(pos + 46, pos + 46 + nameLen).toString('ascii');
expect(zip.readUInt32LE(localOffset)).toBe(0x04034b50);
expect(zip.readUInt32LE(localOffset + 14)).toBe(crc);
const localNameLen = zip.readUInt16LE(localOffset + 26);
const localExtraLen = zip.readUInt16LE(localOffset + 28);
const dataStart = localOffset + 30 + localNameLen + localExtraLen;
entries.push({ name, crc, data: zip.subarray(dataStart, dataStart + size) });
pos += 46 + nameLen + extraLen + commentLen;
}
return entries;
}
function build(outDir: string) {
return buildTeamsAppPackage({
appId: APP_ID,
shortName: 'TestBot',
longDescription: 'TestBot assistant for the manifest test.',
websiteUrl: 'https://nanoclaw.example.test',
outDir,
});
}
describe('buildTeamsAppPackage', () => {
// Built in beforeAll (not at describe-collection time) so a writer regression
// fails the tests that own the assertions instead of erroring the whole suite.
let outDir: string;
let rscDir: string;
let result: ReturnType<typeof build>;
let zip: Buffer;
let entries: ParsedEntry[];
beforeAll(() => {
outDir = fs.mkdtempSync(path.join(os.tmpdir(), 'teams-manifest-'));
rscDir = fs.mkdtempSync(path.join(os.tmpdir(), 'teams-manifest-rsc-'));
result = build(outDir);
zip = fs.readFileSync(result.zipPath);
entries = readZip(zip);
});
afterAll(() => {
fs.rmSync(outDir, { recursive: true, force: true });
fs.rmSync(rscDir, { recursive: true, force: true });
});
it('packages exactly manifest.json + both icons, flat', () => {
expect(entries.map((e) => e.name)).toEqual(['manifest.json', 'outline.png', 'color.png']);
});
it('stores each entry byte-identical to the loose file, with a correct CRC', () => {
const loose: Record<string, string> = {
'manifest.json': result.manifestPath,
'outline.png': result.outlinePath,
'color.png': result.colorPath,
};
for (const entry of entries) {
expect(entry.data.equals(fs.readFileSync(loose[entry.name]))).toBe(true);
// zlib.crc32 is public API from Node 20.15 — cross-check when present.
if (typeof zlib.crc32 === 'function') {
expect(entry.crc).toBe(zlib.crc32(entry.data));
}
}
});
it('writes a valid manifest wired to the app id', () => {
const manifest = JSON.parse(entries[0].data.toString('utf8'));
expect(manifest.id).toBe(APP_ID);
expect(manifest.bots[0].botId).toBe(APP_ID);
expect(manifest.validDomains).toEqual(['nanoclaw.example.test']);
expect(manifest.icons).toEqual({ outline: 'outline.png', color: 'color.png' });
});
it('leaves no template placeholder values in the rendered manifest', () => {
const raw = entries[0].data.toString('utf8');
expect(raw).not.toContain('00000000-0000-0000-0000-000000000000');
expect(raw).not.toContain('nanoclaw.invalid');
});
it('emits real PNGs for both icons', () => {
expect(entries[1].data.subarray(0, 8).equals(PNG_SIG)).toBe(true);
expect(entries[2].data.subarray(0, 8).equals(PNG_SIG)).toBe(true);
});
it('is deterministic and idempotent across rebuilds', () => {
const again = build(outDir);
expect(fs.readFileSync(again.zipPath).equals(zip)).toBe(true);
});
it('adds RSC permissions and bumps the version when rsc is set', () => {
const rscResult = buildTeamsAppPackage({
appId: APP_ID,
shortName: 'TestBot',
longDescription: 'TestBot assistant for the manifest test.',
websiteUrl: 'https://nanoclaw.example.test',
outDir: rscDir,
rsc: true,
});
const manifest = JSON.parse(fs.readFileSync(rscResult.manifestPath, 'utf8'));
expect(manifest.version).toBe('1.1.0');
expect(manifest.authorization.permissions.resourceSpecific).toEqual([
{ name: 'ChannelMessage.Read.Group', type: 'Application' },
{ name: 'ChatMessage.Read.Chat', type: 'Application' },
]);
// RSC consent binds to webApplicationInfo.id — required alongside the block above.
expect(manifest.webApplicationInfo).toEqual({ id: APP_ID, resource: 'https://notapplicable' });
});
});
+219 -73
View File
@@ -7,19 +7,23 @@
* - outline.png 32×32 transparent outline icon
* - color.png 192×192 full-color icon
*
* The static parts live in setup/assets/teams/ manifest.template.json
* (pinned to schema v1.16 to match the skill doc) plus the two icons so
* the manifest is reviewable as plain JSON. This module only fills in the
* per-install fields (app id, name, domain, optional RSC block) and zips the
* three files in-process via ./zip.ts, so no `zip` binary is needed on the
* host.
* 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';
import { buildZip } from './zip.js';
const ASSETS_DIR = new URL('../assets/teams/', import.meta.url);
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`). */
@@ -32,11 +36,6 @@ export interface ManifestOptions {
websiteUrl: string;
/** Out-dir for the generated zip + loose files. */
outDir: string;
/**
* Include RSC permissions (ChannelMessage.Read.Group, ChatMessage.Read.Chat)
* so the bot receives all channel/group-chat messages without @-mention.
*/
rsc?: boolean;
}
export interface ManifestResult {
@@ -46,19 +45,6 @@ export interface ManifestResult {
colorPath: string;
}
/** The manifest.template.json fields this module rewrites per install. */
interface ManifestTemplate {
version: string;
id: string;
developer: { name: string; websiteUrl: string; privacyUrl: string; termsOfUseUrl: string };
name: { short: string; full: string };
description: { short: string; full: string };
bots: [{ botId: string }];
validDomains: string[];
webApplicationInfo?: { id: string; resource: string };
authorization?: { permissions: { resourceSpecific: Array<{ name: string; type: string }> } };
}
/** Build the full app package zip and return the paths. */
export function buildTeamsAppPackage(opts: ManifestOptions): ManifestResult {
fs.mkdirSync(opts.outDir, { recursive: true });
@@ -68,58 +54,218 @@ export function buildTeamsAppPackage(opts: ManifestOptions): ManifestResult {
const colorPath = path.join(opts.outDir, 'color.png');
const zipPath = path.join(opts.outDir, 'teams-app-package.zip');
const manifest = Buffer.from(renderManifest(opts));
const outline = fs.readFileSync(new URL('outline.png', ASSETS_DIR));
const color = fs.readFileSync(new URL('color.png', ASSETS_DIR));
fs.writeFileSync(manifestPath, renderManifest(opts));
fs.writeFileSync(outlinePath, encodeOutlineIcon());
fs.writeFileSync(colorPath, encodeColorIcon());
fs.writeFileSync(manifestPath, manifest);
fs.writeFileSync(outlinePath, outline);
fs.writeFileSync(colorPath, color);
fs.writeFileSync(
zipPath,
buildZip([
{ name: 'manifest.json', data: manifest },
{ name: 'outline.png', data: outline },
{ name: 'color.png', data: color },
]),
);
// 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 = JSON.parse(
fs.readFileSync(new URL('manifest.template.json', ASSETS_DIR), 'utf8'),
) as ManifestTemplate;
manifest.id = opts.appId;
manifest.bots[0].botId = opts.appId;
manifest.name.short = opts.shortName.slice(0, 30);
manifest.name.full = `${opts.shortName} Assistant`;
manifest.description.full = opts.longDescription;
manifest.developer.websiteUrl = opts.websiteUrl;
manifest.developer.privacyUrl = opts.websiteUrl;
manifest.developer.termsOfUseUrl = opts.websiteUrl;
manifest.validDomains = [new URL(opts.websiteUrl).host];
if (opts.rsc) {
// Teams app-update flows want a higher version than the already-uploaded
// package, so the RSC variant (typically a re-upload) bumps it.
manifest.version = '1.1.0';
// RSC grants bind to webApplicationInfo.id, not bots[].botId — without
// this block the permissions are never attached to the app and the bot
// silently keeps requiring @-mention. `resource` must be non-empty but
// its value is unused for RSC-only apps.
manifest.webApplicationInfo = { id: opts.appId, resource: 'https://notapplicable' };
manifest.authorization = {
permissions: {
resourceSpecific: [
{ name: 'ChannelMessage.Read.Group', type: 'Application' },
{ name: 'ChatMessage.Read.Chat', type: 'Application' },
],
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;
}
-87
View File
@@ -1,87 +0,0 @@
/**
* Minimal in-process ZIP writer needs no external dep and no `zip` binary
* on the host (minimal Linux images often lack one).
*
* Entries are stored uncompressed (method 0): callers package a few tiny
* files, and stored entries keep the output trivially small and
* byte-deterministic (fixed 1980-01-01 timestamp), which tests rely on.
* ASCII entry names only; no zip64 fine below 4 GB and 65k entries.
*/
export interface ZipEntry {
name: string;
data: Buffer;
}
// DOS-format date for 1980-01-01: bits 159 year-1980, 85 month, 40 day.
const ZIP_DOS_DATE = (1 << 5) | 1;
export function buildZip(entries: ZipEntry[]): Buffer {
const locals: Buffer[] = [];
const centrals: Buffer[] = [];
let offset = 0;
for (const { name, data } of entries) {
const nameBuf = Buffer.from(name, 'ascii');
const crc = crc32(data);
const local = Buffer.alloc(30);
local.writeUInt32LE(0x04034b50, 0); // local file header signature
local.writeUInt16LE(20, 4); // version needed to extract (2.0)
local.writeUInt16LE(0, 8); // compression method: stored
local.writeUInt16LE(ZIP_DOS_DATE, 12);
local.writeUInt32LE(crc, 14);
local.writeUInt32LE(data.length, 18); // compressed size
local.writeUInt32LE(data.length, 22); // uncompressed size
local.writeUInt16LE(nameBuf.length, 26);
locals.push(local, nameBuf, data);
const central = Buffer.alloc(46);
central.writeUInt32LE(0x02014b50, 0); // central directory header signature
central.writeUInt16LE(20, 4); // version made by
central.writeUInt16LE(20, 6); // version needed to extract
central.writeUInt16LE(0, 10); // compression method: stored
central.writeUInt16LE(ZIP_DOS_DATE, 14);
central.writeUInt32LE(crc, 16);
central.writeUInt32LE(data.length, 20); // compressed size
central.writeUInt32LE(data.length, 24); // uncompressed size
central.writeUInt16LE(nameBuf.length, 28);
central.writeUInt32LE(offset, 42); // offset of local header
centrals.push(central, nameBuf);
offset += 30 + nameBuf.length + data.length;
}
const centralDir = Buffer.concat(centrals);
const eocd = Buffer.alloc(22);
eocd.writeUInt32LE(0x06054b50, 0); // end-of-central-directory signature
eocd.writeUInt16LE(entries.length, 8); // entries on this disk
eocd.writeUInt16LE(entries.length, 10); // entries total
eocd.writeUInt32LE(centralDir.length, 12);
eocd.writeUInt32LE(offset, 16); // central directory offset
return Buffer.concat([...locals, centralDir, eocd]);
}
// Precompute the CRC-32 table per the ZIP spec. zlib.crc32 only became
// public API in Node 20.15/22.2, so we roll our own rather than gamble on
// the host's Node version.
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;
}
+16 -22
View File
@@ -2,9 +2,9 @@
* Step: pair-telegram issue a one-time pairing code and wait for the
* operator to send the code from the chat they want to register.
*
* Emits machine-readable status blocks only. The parent driver
* (`setup:auto`) renders the code / attempt / success UI with clack. Running
* this step directly will look sparse that's intentional.
* Renders the human-facing code card itself (see printCodeCard) and emits
* machine-readable status blocks alongside for the programmatic callers
* (/manage-channels, /init-first-agent) that parse them.
*
* Blocks emitted:
* PAIR_TELEGRAM_CODE { CODE, REASON=initial|regenerated }
@@ -20,6 +20,8 @@
*/
import path from 'path';
import * as p from '@clack/prompts';
import {
createPairing,
waitForPairing,
@@ -56,34 +58,26 @@ function intentToString(intent: PairingIntent): string {
}
/**
* Render the pairing code and live feedback as PLAIN stdout lines.
* Render the pairing code card with clack's STATIC primitives (note/log).
*
* The Option A driver's streaming exec (setup/lib/skill-driver.ts
* `hostExecStream`) CONSUMES the `=== NANOCLAW SETUP: … ===` status blocks (it
* does not show them) and tees every OTHER stdout line straight to the
* operator's terminal. So the human-facing code card has to be printed as plain
* lines here the bespoke setup/channels/telegram.ts used to render these from
* the blocks, and that rendering now lives in the step itself. The structured
* blocks are still emitted alongside for the agent-driven callers
* (/manage-channels, /init-first-agent) that parse them.
* does not show them) and tees every OTHER stdout line verbatim to the
* operator's terminal. Static clack output is just lines, so it survives that
* tee and reads like the rest of the wizard only INTERACTIVE/animated clack
* widgets need the real TTY the piped child doesn't have (SSF-002).
*/
function printCodeCard(code: string, reason: 'initial' | 'regenerated'): void {
const spaced = code.split('').join(' ');
console.log('');
console.log(
reason === 'initial'
? 'Your pairing code is ready.'
: 'That code was used up — here is a fresh one.',
p.note(
`${spaced}\n\nSend these 4 digits to your bot from Telegram.`,
reason === 'initial' ? 'Your pairing code is ready' : 'That code was used up — here is a fresh one',
);
console.log('');
console.log(` ${spaced}`);
console.log('');
console.log('Send these 4 digits to your bot from Telegram.');
console.log('Waiting for you to send the code…');
p.log.message('Waiting for you to send the code…');
}
function printAttempt(candidate: string): void {
console.log(`Got "${candidate}", which doesn't match — waiting for the correct code…`);
p.log.warn(`Got "${candidate}", which doesn't match — waiting for the correct code…`);
}
export async function run(args: string[]): Promise<void> {
@@ -115,7 +109,7 @@ export async function run(args: string[]): Promise<void> {
},
});
console.log('\nTelegram paired.');
p.log.success('Telegram paired.');
emitStatus('PAIR_TELEGRAM', {
STATUS: 'success',
CODE: record.code,
-31
View File
@@ -22,37 +22,6 @@ describe('determineVerifyStatus', () => {
).toBe('failed');
});
// Deferred wire (Teams): configured but zero groups is pending operator
// action (first DM), not a broken install — success, not failed.
it('accepts zero groups when wiring is pending a first DM', () => {
expect(
determineVerifyStatus({
...healthyBase,
registeredGroups: 0,
wiringPending: true,
}),
).toBe('success');
});
it('pending wiring never rescues a stopped service or missing credentials', () => {
expect(
determineVerifyStatus({
...healthyBase,
registeredGroups: 0,
wiringPending: true,
service: 'stopped',
}),
).toBe('failed');
expect(
determineVerifyStatus({
...healthyBase,
registeredGroups: 0,
wiringPending: true,
credentials: 'missing',
}),
).toBe('failed');
});
it('fails when the service is not running', () => {
expect(
determineVerifyStatus({
+2 -26
View File
@@ -217,27 +217,15 @@ export async function run(_args: string[]): Promise<void> {
mountAllowlist = 'configured';
}
// Deferred-wire channels can't have a group yet: their platform id only
// exists after the first inbound DM (see add-teams' "Finish wiring"), so
// configured-but-unwired is pending operator action, not a broken install.
// Only claim pending when EVERY configured channel is defer-wire — a
// wire-during-setup channel (slack, telegram, …) with zero groups is a
// genuine failure this must not mask.
const wiringPending =
registeredGroups === 0 &&
configuredChannels.length > 0 &&
configuredChannels.every((c) => DEFER_WIRE_CHANNELS.has(c));
// Determine overall status. The cli-agent step earlier in setup already
// proved the agent round-trip works; verify is a static health check.
const status = determineVerifyStatus({
service,
credentials,
registeredGroups,
wiringPending,
});
log.info('Verification complete', { status, channelAuth, wiringPending });
log.info('Verification complete', { status, channelAuth });
emitStatus('VERIFY', {
SERVICE: service,
@@ -247,7 +235,6 @@ export async function run(_args: string[]): Promise<void> {
CHANNEL_AUTH: JSON.stringify(channelAuth),
REGISTERED_GROUPS: registeredGroups,
MOUNT_ALLOWLIST: mountAllowlist,
...(wiringPending ? { WIRING: 'pending_first_dm' } : {}),
STATUS: status,
LOG: 'logs/setup.log',
});
@@ -255,25 +242,14 @@ export async function run(_args: string[]): Promise<void> {
if (status === 'failed') process.exit(1);
}
/**
* Channels whose wiring only completes after the first inbound message
* the platform id doesn't exist until the bot is DM'd, so setup ends with
* the channel configured but no group wired. Kept in lockstep with the
* wireIfResolved call site in setup/auto.ts (its unresolved drop-through
* leaves the channel configured but unwired).
*/
export const DEFER_WIRE_CHANNELS = new Set(['teams']);
export function determineVerifyStatus(input: {
service: 'not_found' | 'stopped' | 'running' | 'running_other_checkout';
credentials: string;
registeredGroups: number;
/** Zero groups but every configured channel defers wiring to the first DM. */
wiringPending?: boolean;
}): 'success' | 'failed' {
return input.service === 'running' &&
input.credentials !== 'missing' &&
(input.registeredGroups > 0 || input.wiringPending === true)
input.registeredGroups > 0
? 'success'
: 'failed';
}