refactor(skills): retire nc:env-sync — the data/env/env mirror is dead

Upstream removed every setup-side writer of data/env/env (c82f062d) because
nothing has read it since the container mount was dropped — before this
branch even forked. Our engine codified the dead pattern as a directive:
every apply copied the full .env (live tokens included) into a file nothing
consumes.

- engine: env-sync handler removed from selfStatus + applyOne
- grammar: dropped from KNOWN; a new RETIRED table gives a targeted lint
  error ("delete the fence, the adapter reads .env directly") instead of a
  generic unknown-directive message
- skills: fence stripped from the 14 converted skills; orphaned "sync to the
  container" prose cleaned (incl. add-deltachat/add-wechat old-format prose
  upstream's sweep missed); teams troubleshooting entry rewritten
- policy/spec doc lists updated

Suite 827 passed | 1 skipped; all nc: skills lint clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
gavrielc
2026-07-05 08:27:40 +03:00
parent 81fea1d7ec
commit 40b65521c0
21 changed files with 27 additions and 70 deletions
-1
View File
@@ -89,7 +89,6 @@ DC_SMTP_SECURITY=2 # 2=STARTTLS (default), 1=SSL/TLS, 3=plain
Security settings are applied on every startup, so changing them in `.env` and restarting takes effect without wiping the account.
Sync to container: `mkdir -p data/env && cp .env data/env/env`
### Optional settings
+1 -5
View File
@@ -96,17 +96,13 @@ curl -sf https://discord.com/api/v10/oauth2/applications/@me -H "Authorization:
Store the token and the two derived credentials — the adapter reads them from
`.env` and fails to start without `DISCORD_PUBLIC_KEY` and `DISCORD_APPLICATION_ID`
(set-if-absent, so a value you've already filled in is never overwritten) — and
sync them to the container:
(set-if-absent, so a value you've already filled in is never overwritten):
```nc:env-set
DISCORD_BOT_TOKEN={{bot_token}}
DISCORD_APPLICATION_ID={{application_id}}
DISCORD_PUBLIC_KEY={{public_key}}
```
```nc:env-sync
```
## Restart
Restart the service so it loads the Discord adapter and the credentials you just
+1 -4
View File
@@ -86,7 +86,7 @@ Capture the service account JSON, then write it. `prompt` only *asks* and binds
the answer to a name; a separate directive consumes it — so the same prompt
could feed `ncl` or the OneCLI vault instead of `.env` by swapping only the
consumer. Here it goes to `.env` (set-if-absent — a value you've already filled
in is never overwritten) as a single-line string, then syncs to the container:
in is never overwritten) as a single-line string:
```nc:prompt gchat_credentials secret
Paste the service account JSON as a single line — the key file you downloaded, e.g. `{"type":"service_account","project_id":"...","private_key":"...","client_email":"..."}`.
@@ -94,9 +94,6 @@ Paste the service account JSON as a single line — the key file you downloaded,
```nc:env-set
GCHAT_CREDENTIALS={{gchat_credentials}}
```
```nc:env-sync
```
### Webhook server
The Chat SDK bridge automatically starts a shared webhook server on port 3000
+1 -4
View File
@@ -96,7 +96,7 @@ Capture the three values, then write them. `prompt` only *asks* and binds the
answer to a name; a separate directive consumes it — so the same prompts could
feed `ncl` or the OneCLI vault instead of `.env` by swapping only the consumer.
Here they go to `.env` (set-if-absent — a value you've already filled in is
never overwritten) and sync to the container:
never overwritten):
```nc:prompt github_token secret
Paste the Fine-grained Personal Access Token for the bot account — starts with `github_pat_`.
@@ -112,9 +112,6 @@ GITHUB_TOKEN={{github_token}}
GITHUB_WEBHOOK_SECRET={{webhook_secret}}
GITHUB_BOT_USERNAME={{bot_username}}
```
```nc:env-sync
```
`GITHUB_BOT_USERNAME` must match the bot account's GitHub username exactly. This is used for @-mention detection — the agent responds when someone writes `@your-bot-username` in a PR or issue comment.
## Wiring
-6
View File
@@ -159,12 +159,6 @@ bash setup/channels/imessage-configure.sh local
bash setup/channels/imessage-configure.sh remote "{{server_url}}" "{{api_key}}"
```
Once the keys for your mode are written, sync `.env` to the container (the host
mounts `data/env/env`):
```nc:env-sync
```
## Restart
Restart the service so it loads the iMessage adapter and the credentials you
-3
View File
@@ -135,9 +135,6 @@ LINEAR_WEBHOOK_SECRET={{linear_webhook_secret}}
LINEAR_TEAM_KEY={{linear_team_key}}
LINEAR_BOT_USERNAME={{linear_bot_username}}
```
```nc:env-sync
```
If you went the Personal API key route, add this line to `.env` instead of the
OAuth pair (agent posts as you, your own comments are filtered):
-5
View File
@@ -180,11 +180,6 @@ Option B only — the access token from Element Settings > Help & About, or from
MATRIX_ACCESS_TOKEN={{access_token}}
```
Then sync `.env` into the container:
```nc:env-sync
```
## Next Steps
If you're in the middle of `/setup`, return to the setup flow now.
+1 -4
View File
@@ -84,7 +84,7 @@ Capture the secrets, then write them. `prompt` only *asks* and binds the answer
to a name; a separate directive consumes it — so the same prompts could feed
`ncl` or the OneCLI vault instead of `.env` by swapping only the consumer. Here
they go to `.env` (set-if-absent — a value you've already filled in is never
overwritten) and sync to the container:
overwritten):
```nc:prompt api_key secret
Paste the Resend API key — API Keys, starts with `re_`.
@@ -104,9 +104,6 @@ RESEND_FROM_ADDRESS={{from_address}}
RESEND_FROM_NAME={{from_name}}
RESEND_WEBHOOK_SECRET={{webhook_secret}}
```
```nc:env-sync
```
## Connect yourself
Because email is direct-addressable, the bot can write to you first — so wire
-3
View File
@@ -118,9 +118,6 @@ it into the container env:
```nc:env-set
SIGNAL_ACCOUNT={{platform_id}}
```
```nc:env-sync
```
## Restart
Restart the service so it loads the Signal adapter and binds the account you just
-3
View File
@@ -87,9 +87,6 @@ Paste the Signing Secret — Basic Information.
SLACK_BOT_TOKEN={{bot_token}}
SLACK_SIGNING_SECRET={{signing_secret}}
```
```nc:env-sync
```
The bridge serves the webhook on port 3000 at `/webhook/slack` automatically; to
receive replies, that port must be reachable from the internet and registered
with Slack. Tell the user:
+2 -5
View File
@@ -136,7 +136,7 @@ Paste the client secret Value — Certificates & secrets (shown only once, at le
### Store the credentials
The adapter reads these from `.env` (set-if-absent, so a value you've already
filled in is never overwritten) and syncs them to the container.
filled in is never overwritten).
`TEAMS_APP_TENANT_ID` is written only for a Single Tenant app; Multi Tenant
doesn't need it.
@@ -148,9 +148,6 @@ TEAMS_APP_TYPE={{app_type}}
```nc:env-set when:app_type=SingleTenant
TEAMS_APP_TENANT_ID={{app_tenant_id}}
```
```nc:env-sync
```
### Create the Azure Bot resource
Tell the user:
@@ -339,7 +336,7 @@ Business / EDU / developer tenant.
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` and synced to `data/env/env` (re-run the env-sync step if not).
3. The credentials are in `.env` (`TEAMS_APP_ID`, `TEAMS_APP_PASSWORD`, `TEAMS_APP_TYPE`).
### `Unauthorized` / 401 from Azure Bot Service
-3
View File
@@ -102,9 +102,6 @@ Paste the bot token from BotFather (looks like `123456:ABC-DEF...`).
```nc:env-set
TELEGRAM_BOT_TOKEN={{bot_token}}
```
```nc:env-sync
```
Confirm the token works and capture the bot's handle — `getMe` returns the bot
account and fails here if the token is bad. You'll use the handle to open the
right chat just before pairing:
+1 -4
View File
@@ -85,7 +85,7 @@ Capture the two values, then write them. `prompt` only *asks* and binds the
answer to a name; a separate directive consumes it — so the same prompts could
feed `ncl` or the OneCLI vault instead of `.env` by swapping only the consumer.
Here they go to `.env` (set-if-absent — a value you've already filled in is
never overwritten) and sync to the container:
never overwritten):
```nc:prompt bot_token secret
Paste the Bot Access Token — from the Webex bot you created.
@@ -97,9 +97,6 @@ Paste the webhook secret you set for signature verification.
WEBEX_BOT_TOKEN={{bot_token}}
WEBEX_WEBHOOK_SECRET={{webhook_secret}}
```
```nc:env-sync
```
### Webhook server
The Chat SDK bridge automatically starts a shared webhook server on port 3000
-1
View File
@@ -85,7 +85,6 @@ Add to `.env`:
WECHAT_ENABLED=true
```
Sync to container: `mkdir -p data/env && cp .env data/env/env`
### 2. Start the service and scan the QR
+1 -4
View File
@@ -88,7 +88,7 @@ Capture the four values, then write them. `prompt` only *asks* and binds the
answer to a name; a separate directive consumes it — so the same prompts could
feed `ncl` or the OneCLI vault instead of `.env` by swapping only the consumer.
Here they go to `.env` (set-if-absent — a value you've already filled in is
never overwritten) and sync to the container:
never overwritten):
```nc:prompt access_token secret
Paste the System User access token — WhatsApp > API Setup, with `whatsapp_business_messaging` permission.
@@ -108,9 +108,6 @@ WHATSAPP_PHONE_NUMBER_ID={{phone_number_id}}
WHATSAPP_APP_SECRET={{app_secret}}
WHATSAPP_VERIFY_TOKEN={{verify_token}}
```
```nc:env-sync
```
### Webhook server
The Chat SDK bridge automatically starts a shared webhook server on port 3000
-3
View File
@@ -169,9 +169,6 @@ shouldn't be prefixed with its name. Record that (skipped for a shared number):
```nc:env-set when:number_kind=dedicated
ASSISTANT_HAS_OWN_NUMBER=true
```
```nc:env-sync when:number_kind=dedicated
```
Resolve the conversation address as the WhatsApp JID for the number you chat
from — the linked number for a shared account, or the dedicated number you just
gave. Run the one matching the choice above:
+1 -1
View File
@@ -315,7 +315,7 @@ For each `nc:operator` directive at line L, `gatePolicy` computes
and double-confirm — the exact bug the teams parity table below forbids.)
3. Next compatible directive is a `prompt`**no confirm** (the prompt is the barrier).
4. No such directive (end of document) → **no confirm** (a final handoff block, e.g. teams `:228`).
5. Anything else (`run`, `copy`, `dep`, `append`, `env-set`, `env-sync`, `json-merge`) →
5. Anything else (`run`, `copy`, `dep`, `append`, `env-set`, `json-merge`) →
**confirm** after rendering. Confirm wording is derived from the barrier's
*flavor* — the next compatible directive's effect: `effect:step`
readiness phrasing (`"Ready? The next step starts immediately."` — the block
-6
View File
@@ -142,8 +142,6 @@ function selfStatus(d: Directive, root: string): { status: StepStatus; detail: s
? { status: 'apply', detail: `set ${missing.join(', ')} in .env` }
: { status: 'skip', detail: `${keys.join(', ')} already set` };
}
case 'env-sync':
return { status: 'apply', detail: 'sync .env → data/env/env' };
case 'json-merge': {
const into = String(d.attrs.into ?? '');
const key = String(d.attrs.key ?? '');
@@ -668,10 +666,6 @@ async function applyOne(
}
break;
}
case 'env-sync':
mkdirSync(join(root, 'data/env'), { recursive: true });
copyFileSync(join(root, '.env'), join(root, 'data/env/env'));
break;
case 'json-merge': {
const into = String(d.attrs.into);
const key = String(d.attrs.key);
+9 -1
View File
@@ -19,7 +19,6 @@ describe('skill-directives parser, on the converted add-slack', () => {
'prompt', // credentials: capture bot token
'prompt', // credentials: capture signing secret
'env-set', // credentials: write captured values to .env
'env-sync', // credentials: sync to container
'operator', // credentials: event-delivery walkthrough
'prompt', // resolve: owner member id (owner_handle)
'run', // resolve: validate token (auth.test) — fast-fail before the restart
@@ -187,6 +186,15 @@ describe('append at:<marker> attribute', () => {
});
});
describe('retired directives', () => {
it('flags nc:env-sync with a targeted retirement error, not a generic unknown', () => {
const probs = validate(parseDirectives(['```nc:env-sync', '```'].join('\n')));
expect(probs).toHaveLength(1);
expect(probs[0].message).toMatch(/retired/);
expect(probs[0].message).toMatch(/data\/env\/env/);
});
});
describe('when: guard + multi-field capture', () => {
it('parses when: into attrs and lints a guard whose var an earlier prompt defined', () => {
const md = ['```nc:prompt mode', 'local or remote', '```', '```nc:prompt server_url when:mode=remote', 'url', '```'].join('\n');
+8 -3
View File
@@ -68,7 +68,6 @@
// pauses for confirmation before the next side effect is derived from
// document structure (scripts/skill-policy.ts), never authored here.
// env-set body: `KEY=value` ({{var}} allowed) set-if-absent
// env-sync (no body) `.env` → data/env/env idempotent copy
// json-merge into:<file> key:<field> body: a JSON object push-if-absent
//
// `append` without `at:` adds to EOF; with `at:<marker>` it inserts before the
@@ -113,7 +112,12 @@ export interface Problem {
const FENCE = /^```(\S.*)?$/;
const EXACT_SEMVER = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/;
const VAR_REF = /\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/g;
const KNOWN = new Set(['copy', 'append', 'dep', 'run', 'prompt', 'operator', 'env-set', 'env-sync', 'json-merge']);
const KNOWN = new Set(['copy', 'append', 'dep', 'run', 'prompt', 'operator', 'env-set', 'json-merge']);
// Retired directives get a targeted lint error (not just "unknown") so an
// author knows the removal was deliberate and what to do instead.
const RETIRED: Record<string, string> = {
'env-sync': 'nc:env-sync was retired — nothing reads the data/env/env mirror (and it copied live tokens); delete the fence, the adapter reads .env directly',
};
const PROMPT_FLAGS = new Set(['secret']);
export function parseDirectives(markdown: string): Directive[] {
@@ -203,7 +207,8 @@ export function validate(directives: Directive[], ctx?: { chatVersion?: string }
const defined = new Set<string>();
const flag = (d: Directive, message: string) => problems.push({ line: d.line, kind: d.kind, message });
for (const d of directives) {
if (!KNOWN.has(d.kind)) flag(d, `unknown directive nc:${d.kind}`);
if (RETIRED[d.kind]) flag(d, RETIRED[d.kind]);
else if (!KNOWN.has(d.kind)) flag(d, `unknown directive nc:${d.kind}`);
switch (d.kind) {
case 'dep':
for (const spec of d.body) {
+1 -1
View File
@@ -65,7 +65,7 @@ function guardOf(d: Directive): { v: string; value: string } | undefined {
* 3. Next compatible directive is a `prompt` no confirm (the prompt is the
* barrier the human can't paste a token before doing the steps).
* 4. No such directive (end of document) no confirm (a final handoff block).
* 5. Anything else (`run`, `copy`, `dep`, `append`, `env-set`, `env-sync`,
* 5. Anything else (`run`, `copy`, `dep`, `append`, `env-set`,
* `json-merge`) confirm, with the flavor derived from that barrier
* directive's effect (`effect:step` readiness, else completed).
*