viber-channel 0.5.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -37,12 +37,40 @@ claude mcp add viber-channel --scope user \
37
37
 
38
38
  Restart Claude Code in the project directory. The channel acquires a single-instance lock under `%APPDATA%/viber/` (or `~/.config/viber/` on Linux/macOS), mints a conversation against the Worker, and subscribes to the conversation-scoped SSE stream.
39
39
 
40
+ ## Codex bridge experiment
41
+
42
+ Issue #252 adds a Codex bridge as a sibling process to the Claude Code MCP channel. The bridge joins an existing Viber conversation as its own fresh Viber instance, listens to SSE events, forwards user/user_voice messages into a bridge-owned persistent `codex app-server` thread, and posts Codex's final reply back to Viber.
43
+
44
+ Launch it against a conversation that already exists:
45
+
46
+ ```bash
47
+ bun run viber-codex-bridge.ts --conversation <conversation_id>
48
+ ```
49
+
50
+ Or via env fallback:
51
+
52
+ ```bash
53
+ VIBER_TARGET_CONVERSATION_ID=<conversation_id> bun run viber-codex-bridge.ts
54
+ ```
55
+
56
+ For one-shot validation, add `--once`; the bridge exits after the first Codex reply.
57
+
58
+ The bridge persists its Codex thread id in `.viber/codex-bridge-threads.json` by default, keyed by project/fingerprint/conversation. Restarting the bridge resumes that thread, so Codex context carries across Viber turns. To reset/respawn the bridge-owned Codex session, start with `--new-thread`.
59
+
60
+ Initial validation runs conservatively: `sandbox=read-only`, `approvalPolicy=on-request`, network disabled for turns, and developer instructions tell Codex not to run shell commands or read/write files. This is a validation posture, not a complete security boundary yet; hard permission enforcement must be designed before widening the bridge beyond read-only. Widen permissions only after the voice round-trip and memory test are validated.
61
+
62
+ By default the bridge registers a fresh instance and does not persist it into the shared auth file. This avoids rotating another live agent's per-membership conversation token. For diagnostics only, `--reuse-auth-instance` restores the shared-auth behavior.
63
+
64
+ For an agent-to-agent proof, add `--include-agent-messages --echo-content`. The bridge then reacts to non-user messages from other instances and replies with the exact content it received.
65
+
40
66
  ## Configuration
41
67
 
42
68
  | Env var | Default | Purpose |
43
69
  |---|---|---|
44
70
  | `VIBER_BASE_URL` | `https://viber.dgypx.dev` | Worker base URL (staging today; switch later if a separate prod hostname appears). |
45
71
  | `VIBER_CHANNEL_LABEL` | folder basename | Human-readable name for the conversation in the UI. |
72
+ | `VIBER_CODEX_BIN` | `codex` | Codex executable used by `viber-codex-bridge` to spawn `codex app-server`. |
73
+ | `VIBER_CODEX_BRIDGE_STATE` | `.viber/codex-bridge-threads.json` | Optional path for the bridge-owned Codex thread state file. |
46
74
  | `VIBER_CF_CLIENT_ID` / `VIBER_CF_CLIENT_SECRET` | unset | CF Access service-token headers (only needed when targeting a CF Access-protected hostname without a public bypass policy). |
47
75
 
48
76
  ## Source
package/lib/auth.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { readFileSync, renameSync, writeFileSync } from "node:fs";
2
- import { join } from "node:path";
1
+ import { chmodSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { isAbsolute, join } from "node:path";
3
3
 
4
4
  export interface AuthJson {
5
5
  schema_version: number;
@@ -9,7 +9,6 @@ export interface AuthJson {
9
9
  project_token: string;
10
10
  client_fingerprint: string;
11
11
  issued_at: string;
12
- rotates_after?: string;
13
12
  /**
14
13
  * Optional — when present, the CLI must pass this id to the conversation
15
14
  * mint endpoint so the worker UPDATES the existing standalone conversation
@@ -21,33 +20,44 @@ export interface AuthJson {
21
20
  * the first successful mint, but doing so is not required for correctness.
22
21
  */
23
22
  target_conversation_id?: string | null;
23
+ /**
24
+ * Server-issued per-instance identity (#269). Both optional + additive, so
25
+ * `schema_version` stays **1**: an existing auth.json with neither field is
26
+ * valid and self-heals on first run (the channel registers an instance with
27
+ * its stable project_token and writes these back). `instance_token` is the
28
+ * durable, opaque per-agent credential (D4); `instance_id` is its server id,
29
+ * used to namespace the conversation handle (replacing the local fingerprint
30
+ * + VIBER_CHANNEL_SESSION_ID axes, #259).
31
+ */
32
+ instance_token?: string;
33
+ instance_id?: string;
24
34
  }
25
35
 
26
36
  /**
27
- * Atomically update the project_token in .viber/auth.json.
28
- * Writes to a tmp file first, then renames (atomic on POSIX; also atomic on Windows for same-volume).
37
+ * Resolve the auth file path.
38
+ *
39
+ * Defaults to `<cwd>/.viber/auth.json`. The optional `VIBER_AUTH_FILE` env var
40
+ * overrides it — absolute paths are used verbatim, relative paths are resolved
41
+ * against `cwd`. This lets a local launcher point the channel at an alternate
42
+ * credential file (e.g. `.viber/dev.auth.json`) WITHOUT swapping `auth.json`,
43
+ * so the default consumer flow (env var unset) is completely unaffected.
29
44
  */
30
- export function updateAuthToken(folderPath: string, currentAuth: AuthJson, newToken: string): AuthJson {
31
- const path = join(folderPath, ".viber", "auth.json");
32
- const tmp = `${path}.tmp`;
33
- const updated: AuthJson = {
34
- ...currentAuth,
35
- project_token: newToken,
36
- issued_at: new Date().toISOString(),
37
- };
38
- writeFileSync(tmp, JSON.stringify(updated, null, 2), "utf-8");
39
- renameSync(tmp, path);
40
- return updated;
45
+ export function authFilePath(cwd: string = process.cwd()): string {
46
+ const override = process.env.VIBER_AUTH_FILE;
47
+ if (override !== undefined && override.trim() !== "") {
48
+ return isAbsolute(override) ? override : join(cwd, override);
49
+ }
50
+ return join(cwd, ".viber", "auth.json");
41
51
  }
42
52
 
43
53
  export function loadAuth(cwd: string = process.cwd()): AuthJson {
44
- const path = join(cwd, ".viber", "auth.json");
54
+ const path = authFilePath(cwd);
45
55
  let raw: string;
46
56
  try {
47
57
  raw = readFileSync(path, "utf-8");
48
58
  } catch {
49
59
  process.stderr.write(
50
- `[viber-channel] No \`.viber/auth.json\` found in this folder.\n` +
60
+ `[viber-channel] No auth file found at \`${path}\`.\n` +
51
61
  `Run the connect flow first: visit https://viber.dgypx.dev/projects → Create connection.\n`
52
62
  );
53
63
  process.exit(1);
@@ -56,16 +66,54 @@ export function loadAuth(cwd: string = process.cwd()): AuthJson {
56
66
  try {
57
67
  auth = JSON.parse(raw) as AuthJson;
58
68
  } catch (err) {
59
- process.stderr.write(`[viber-channel] Malformed .viber/auth.json: ${String(err)}\n`);
69
+ process.stderr.write(`[viber-channel] Malformed auth file \`${path}\`: ${String(err)}\n`);
60
70
  process.exit(1);
61
71
  }
62
72
  if (auth.schema_version !== 1) {
63
- process.stderr.write(`[viber-channel] Unsupported auth schema version: ${auth.schema_version}\n`);
73
+ process.stderr.write(`[viber-channel] Unsupported auth schema version ${auth.schema_version} in \`${path}\`\n`);
64
74
  process.exit(1);
65
75
  }
66
76
  if (!auth.project_token) {
67
- process.stderr.write(`[viber-channel] Empty project_token in .viber/auth.json\n`);
77
+ process.stderr.write(`[viber-channel] Empty project_token in \`${path}\`\n`);
68
78
  process.exit(1);
69
79
  }
70
80
  return auth;
71
81
  }
82
+
83
+ /**
84
+ * Merge server-issued instance credentials into the auth file in place (#269
85
+ * self-healing migration). Reads the current JSON, sets `instance_id` +
86
+ * `instance_token`, and writes it back — preserving every other field and the
87
+ * `schema_version` (still 1; the fields are additive + optional).
88
+ *
89
+ * Best-effort: a write failure is logged, not fatal. The channel keeps the
90
+ * in-memory token for this run and simply re-registers on the next run (which
91
+ * yields a new instance — acceptable, the old one is just never reused).
92
+ */
93
+ export function persistInstanceCredentials(
94
+ path: string,
95
+ instanceId: string,
96
+ instanceToken: string,
97
+ log: (msg: string) => void = (m) => process.stderr.write(m),
98
+ ): void {
99
+ try {
100
+ const raw = readFileSync(path, "utf-8");
101
+ const obj = JSON.parse(raw) as AuthJson;
102
+ obj.instance_id = instanceId;
103
+ obj.instance_token = instanceToken;
104
+ // mode 0o600: the file holds long-lived secrets (project_token + now the
105
+ // instance_token) — owner-read-only on POSIX (no-op on Windows). `mode` only
106
+ // applies when CREATING the file, so chmod afterwards too, to tighten an
107
+ // already-loose auth.json from an older version (best-effort).
108
+ writeFileSync(path, `${JSON.stringify(obj, null, 2)}\n`, { encoding: "utf-8", mode: 0o600 });
109
+ try {
110
+ chmodSync(path, 0o600);
111
+ } catch {
112
+ /* best-effort — some filesystems (or Windows) reject chmod */
113
+ }
114
+ } catch (err) {
115
+ log(
116
+ `[viber-channel] Warning: failed to persist instance credentials to \`${path}\`: ${String(err)}\n`,
117
+ );
118
+ }
119
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Bridge spawn adapter (#280 step-12, slice 3).
3
+ *
4
+ * Turns an `AgentSpec` into a real launched `viber-codex-bridge` child process and
5
+ * adapts it to the Supervisor's `ChildHandle`. The node `spawn` primitive is
6
+ * injected so the mapping (command, args, env, lifecycle wiring) is unit-testable
7
+ * without launching anything.
8
+ *
9
+ * Per-agent identity (critical): each child must register its OWN Viber instance.
10
+ * The supervisor process may itself have been launched with VIBER_INSTANCE_TOKEN /
11
+ * VIBER_INSTANCE_ID in its environment; if those leaked into every child, all
12
+ * agents would share one instance identity and evict each other's membership (the
13
+ * known shared-auth gotcha). So the adapter strips them from the child env, forcing
14
+ * the bridge's default "register a fresh instance" path.
15
+ */
16
+
17
+ import { spawn as nodeSpawn } from "node:child_process";
18
+ import type { AgentSpec, ChildHandle, SpawnFn } from "./supervisor.ts";
19
+
20
+ /** Minimal child surface the adapter consumes (matches node's ChildProcess). */
21
+ export interface SpawnedProcess {
22
+ readonly pid?: number;
23
+ kill(signal?: NodeJS.Signals): boolean;
24
+ once(
25
+ event: "exit",
26
+ listener: (code: number | null, signal: NodeJS.Signals | null) => void,
27
+ ): unknown;
28
+ /**
29
+ * Launch failures (ENOENT for a missing command, EACCES, …) arrive here as an
30
+ * async event — NOT a synchronous throw — so the adapter must listen for it or
31
+ * a failed launch would leave the agent "running" with a dead child.
32
+ */
33
+ once(event: "error", listener: (err: Error) => void): unknown;
34
+ }
35
+
36
+ /** The spawn primitive (injected; defaults to node's child_process.spawn). */
37
+ export type NodeSpawnLike = (
38
+ command: string,
39
+ args: string[],
40
+ options: {
41
+ env: NodeJS.ProcessEnv;
42
+ stdio: ("ignore" | "inherit" | "pipe")[];
43
+ },
44
+ ) => SpawnedProcess;
45
+
46
+ export interface BridgeSpawnOptions {
47
+ /** Launcher command. Default: the `viber-codex-bridge` bin on PATH. */
48
+ command?: string;
49
+ /** Args prepended before each agent's own args (e.g. a script path for `bun`). */
50
+ baseArgs?: string[];
51
+ /** Spawn primitive (injected for tests). Default: node child_process.spawn. */
52
+ spawnImpl?: NodeSpawnLike;
53
+ }
54
+
55
+ /**
56
+ * Build a `SpawnFn` for the Supervisor that launches the Codex bridge per agent.
57
+ *
58
+ * `AgentSpec.tier` → `VIBER_AGENT_TIER`; `AgentSpec.args` → bridge CLI flags;
59
+ * `AgentSpec.env` is merged last (so a spec can override). `VIBER_INSTANCE_TOKEN`
60
+ * / `VIBER_INSTANCE_ID` are stripped unless the spec explicitly re-supplies them.
61
+ */
62
+ export function createBridgeSpawn(opts: BridgeSpawnOptions = {}): SpawnFn {
63
+ const command = opts.command ?? "viber-codex-bridge";
64
+ const baseArgs = opts.baseArgs ?? [];
65
+ const spawnImpl = opts.spawnImpl ?? (nodeSpawn as unknown as NodeSpawnLike);
66
+
67
+ return (spec: AgentSpec): ChildHandle => {
68
+ const env: NodeJS.ProcessEnv = { ...process.env };
69
+ // Force a fresh per-agent instance (see file header).
70
+ delete env.VIBER_INSTANCE_TOKEN;
71
+ delete env.VIBER_INSTANCE_ID;
72
+ env.VIBER_AGENT_TIER = spec.tier;
73
+ // Surface the agent id as the bridge's Viber instance label so distinct
74
+ // agents are identifiable in the control plane (participants strip / recipient
75
+ // picker) instead of all showing the same generic "Codex bridge" label.
76
+ env.VIBER_CODEX_BRIDGE_LABEL = spec.agentId;
77
+ if (spec.env) Object.assign(env, spec.env);
78
+
79
+ const child = spawnImpl(command, [...baseArgs, ...spec.args], {
80
+ env,
81
+ // stdin + stdout ignored, stderr inherited. stdout must NOT be inherited:
82
+ // if viber-channel runs over stdio MCP transport, the parent's stdout is
83
+ // the protocol wire and a child's stray stdout line would corrupt it.
84
+ stdio: ["ignore", "ignore", "inherit"],
85
+ });
86
+
87
+ return {
88
+ pid: child.pid,
89
+ kill: (signal?: NodeJS.Signals) => child.kill(signal),
90
+ onExit: (cb) => {
91
+ // Deliver exactly one terminal callback whether the child exits or
92
+ // fails to launch ("error"). A launch failure maps to a crash
93
+ // (null code/signal) so the supervisor runs its backoff path.
94
+ let fired = false;
95
+ const fire = (code: number | null, signal: NodeJS.Signals | null): void => {
96
+ if (fired) return;
97
+ fired = true;
98
+ cb(code, signal);
99
+ };
100
+ child.once("exit", (code, signal) => fire(code, signal));
101
+ child.once("error", () => fire(null, null));
102
+ },
103
+ };
104
+ };
105
+ }
@@ -5,8 +5,8 @@ import type { Server } from "@modelcontextprotocol/sdk/server/index.js";
5
5
  *
6
6
  * The CONVERSATION_TOKEN minted at startup has a 1h TTL. After expiry, the
7
7
  * server starts responding with 401. We notify the user and exit with code 3
8
- * (distinct from reverify exit code 2) so the process manager / user can
9
- * distinguish the cause.
8
+ * (distinct from the startup/mint failure exit code 1) so the process manager /
9
+ * user can distinguish the cause.
10
10
  *
11
11
  * We do NOT attempt to re-mint inline — the project_token is tied to the
12
12
  * original auth flow. The user must restart the channel to get a fresh token.
@@ -0,0 +1,107 @@
1
+ /**
2
+ * channel_session.ts — persist the channel's active conversation handle to disk.
3
+ *
4
+ * When a channel process mints or reattaches to a conversation, it writes the
5
+ * conversation_id here. A respawned channel process reads this file to find the
6
+ * existing conversation instead of minting a new one — avoiding duplicates (#257).
7
+ *
8
+ * Namespaced by `(VIBER_BASE_URL, instance_key)` since #269, where `instance_key`
9
+ * is the server-issued `instance_id` (or the `instance_token` when the id is
10
+ * unknown, e.g. an env-injected agent). This replaces the old
11
+ * `(client_fingerprint, VIBER_CHANNEL_SESSION_ID)` namespacing so:
12
+ * - dev and staging channels coexist on the same machine without colliding;
13
+ * - two *different* instances (different projects, OR two parallel agents of the
14
+ * same project) each have their own handle and never reattach to each other's
15
+ * conversation — the instance is the isolation axis now, no fingerprint or
16
+ * session id required (#259);
17
+ * - channel respawns reusing the same persisted instance_token inherit the same
18
+ * instance_id and find the same handle — reattach still works (#257).
19
+ */
20
+ import { createHash } from "node:crypto";
21
+ import { join } from "node:path";
22
+ import { readFileSync, writeFileSync, unlinkSync } from "node:fs";
23
+
24
+ export interface ChannelHandle {
25
+ conversation_id: string;
26
+ saved_at: number; // unix seconds
27
+ }
28
+
29
+ /**
30
+ * Return the session file path for a given base URL and instance key, under `dir`.
31
+ *
32
+ * The filename is `channel-session-{8-hex}.json` where the hex is a stable
33
+ * SHA-256 prefix of `${baseUrl}\n${instanceKey}`. Since #269 the handle is
34
+ * namespaced by the **server-issued instance** (its `instance_id`, or the
35
+ * `instance_token` when the id is unknown) instead of the local
36
+ * `(fingerprint, sessionId)` pair: one durable instance ⇒ one handle. Two
37
+ * parallel agents of the same project hold distinct instances, so they get
38
+ * distinct handles and never reattach to each other's conversation — without
39
+ * relying on `VIBER_CHANNEL_SESSION_ID`.
40
+ */
41
+ export function sessionFilePath(
42
+ baseUrl: string,
43
+ instanceKey: string,
44
+ dir: string,
45
+ ): string {
46
+ const suffix = createHash("sha256")
47
+ .update(`${baseUrl}\n${instanceKey}`)
48
+ .digest("hex")
49
+ .slice(0, 8);
50
+ return join(dir, `channel-session-${suffix}.json`);
51
+ }
52
+
53
+ /**
54
+ * Read a persisted handle from `path`.
55
+ *
56
+ * Returns `null` — NEVER throws — when the file is absent, unreadable,
57
+ * contains invalid JSON, or the parsed object does not match the expected
58
+ * shape.
59
+ */
60
+ export function readHandle(path: string): ChannelHandle | null {
61
+ try {
62
+ const raw = readFileSync(path, "utf-8");
63
+ const parsed: unknown = JSON.parse(raw);
64
+ if (
65
+ parsed === null ||
66
+ typeof parsed !== "object" ||
67
+ Array.isArray(parsed)
68
+ ) {
69
+ return null;
70
+ }
71
+ const obj = parsed as Record<string, unknown>;
72
+ if (typeof obj.conversation_id !== "string" || obj.conversation_id.length === 0) return null;
73
+ if (!Number.isFinite(obj.saved_at)) return null;
74
+ return { conversation_id: obj.conversation_id, saved_at: obj.saved_at as number };
75
+ } catch {
76
+ return null;
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Write the conversation handle to `path`.
82
+ *
83
+ * Overwrites any existing file. Propagates write errors (matches how
84
+ * `acquireLock` in viber-channel.ts propagates `writeFileSync` failures —
85
+ * the caller decides whether to handle them).
86
+ */
87
+ export function writeHandle(path: string, conversationId: string): void {
88
+ const handle: ChannelHandle = {
89
+ conversation_id: conversationId,
90
+ saved_at: Math.floor(Date.now() / 1000),
91
+ };
92
+ writeFileSync(path, JSON.stringify(handle), "utf-8");
93
+ }
94
+
95
+ /**
96
+ * Remove the handle file at `path`.
97
+ *
98
+ * Best-effort — swallows errors if the file is absent, mirroring
99
+ * `releaseLock()` in viber-channel.ts.
100
+ */
101
+ export function clearHandle(path: string): void {
102
+ try {
103
+ unlinkSync(path);
104
+ } catch {
105
+ // Best-effort — file may already be gone
106
+ }
107
+ }
package/lib/connect.ts CHANGED
@@ -12,12 +12,14 @@
12
12
 
13
13
  import {
14
14
  appendFileSync,
15
+ chmodSync,
15
16
  existsSync,
16
17
  mkdirSync,
17
18
  readFileSync,
18
19
  writeFileSync,
19
20
  } from "node:fs";
20
- import { join } from "node:path";
21
+ import { dirname, isAbsolute, join, relative } from "node:path";
22
+ import { authFilePath } from "./auth.js";
21
23
  import { clientFingerprint } from "./fingerprint.js";
22
24
  import { cfAccessHeaders } from "./cfAccess.js";
23
25
 
@@ -33,7 +35,6 @@ interface ConsumedResponse {
33
35
  user_email: string;
34
36
  project_token: string;
35
37
  issued_at: string;
36
- rotates_after?: string;
37
38
  target_conversation_id?: string | null;
38
39
  readme_md: string;
39
40
  }
@@ -80,6 +81,13 @@ function writeAuthJson(
80
81
  const viberDir = join(cwd, ".viber");
81
82
  mkdirSync(viberDir, { recursive: true });
82
83
 
84
+ // Honor VIBER_AUTH_FILE so connecting with the override set writes the
85
+ // credentials to the same file the channel will read (e.g. dev.auth.json),
86
+ // instead of silently writing auth.json while the channel reads elsewhere.
87
+ // readme.md + .gitignore stay in <cwd>/.viber regardless.
88
+ const authPath = authFilePath(cwd);
89
+ mkdirSync(dirname(authPath), { recursive: true });
90
+
83
91
  const auth: Record<string, unknown> = {
84
92
  schema_version: 1,
85
93
  project_id: data.project_id,
@@ -88,7 +96,6 @@ function writeAuthJson(
88
96
  project_token: data.project_token,
89
97
  client_fingerprint: fingerprint,
90
98
  issued_at: data.issued_at,
91
- rotates_after: data.rotates_after,
92
99
  };
93
100
  // Only emit target_conversation_id when the server explicitly returned a
94
101
  // non-null value (attach-to-project flow). Absent / null → omit to keep
@@ -100,11 +107,15 @@ function writeAuthJson(
100
107
  auth.target_conversation_id = data.target_conversation_id;
101
108
  }
102
109
 
103
- writeFileSync(
104
- join(viberDir, "auth.json"),
105
- JSON.stringify(auth, null, 2),
106
- "utf-8",
107
- );
110
+ // mode 0o600: auth.json holds the project_token (and later the instance_token)
111
+ // — owner-read-only on POSIX (no-op on Windows). chmod afterwards too, since
112
+ // `mode` only applies on file creation (tightens an older loose auth.json).
113
+ writeFileSync(authPath, JSON.stringify(auth, null, 2), { encoding: "utf-8", mode: 0o600 });
114
+ try {
115
+ chmodSync(authPath, 0o600);
116
+ } catch {
117
+ /* best-effort — some filesystems (or Windows) reject chmod */
118
+ }
108
119
  writeFileSync(join(viberDir, "readme.md"), data.readme_md, "utf-8");
109
120
 
110
121
  // Append `.viber/` to .gitignore if not already present.
@@ -123,6 +134,20 @@ function writeAuthJson(
123
134
  const prefix = current === "" || current.endsWith("\n") ? "" : "\n";
124
135
  appendFileSync(gitignorePath, `${prefix}.viber/\n`);
125
136
  }
137
+
138
+ // The `.viber/` entry only covers credentials inside `.viber/`. If
139
+ // VIBER_AUTH_FILE points the auth file elsewhere, it is NOT auto-ignored —
140
+ // warn loudly so a project_token doesn't get committed by a later `git add`.
141
+ const relFromViber = relative(viberDir, authPath);
142
+ const insideViber =
143
+ relFromViber !== "" && !relFromViber.startsWith("..") && !isAbsolute(relFromViber);
144
+ if (!insideViber) {
145
+ process.stderr.write(
146
+ `[viber-channel] Warning: VIBER_AUTH_FILE points outside .viber/ (${authPath}).\n` +
147
+ ` This file holds your project token and is NOT covered by the .viber/ .gitignore entry.\n` +
148
+ ` Make sure it is git-ignored so the token is not committed.\n`,
149
+ );
150
+ }
126
151
  }
127
152
 
128
153
  /**
@@ -169,7 +194,7 @@ export async function runConnect(
169
194
  writeAuthJson(cwd, data, fingerprint);
170
195
  process.stdout.write(
171
196
  `\n✓ Connected as ${data.user_email} to project '${data.project_name}'.\n` +
172
- ` Wrote ${join(cwd, ".viber", "auth.json")}\n\n` +
197
+ ` Wrote ${authFilePath(cwd)}\n\n` +
173
198
  `Next steps:\n` +
174
199
  ` 1. Register the channel MCP server (one-time per machine):\n` +
175
200
  ` claude mcp add viber-channel --scope user -- bunx viber-channel@latest\n` +