viber-channel 0.4.0 → 0.5.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/lib/connect.ts ADDED
@@ -0,0 +1,189 @@
1
+ /**
2
+ * connect.ts — one-shot claim flow that turns a Viber connection URL into
3
+ * `.viber/auth.json` so the channel can be started.
4
+ *
5
+ * Invoked from the CLI as:
6
+ * bunx viber-channel connect https://viber.dgypx.dev/connect/<claim_id>
7
+ *
8
+ * Pure logic: takes a URL + a target folder, talks to the Worker, writes the
9
+ * three files (`.viber/auth.json`, `.viber/readme.md`, `.gitignore` entry).
10
+ * No MCP, no SSE.
11
+ */
12
+
13
+ import {
14
+ appendFileSync,
15
+ existsSync,
16
+ mkdirSync,
17
+ readFileSync,
18
+ writeFileSync,
19
+ } from "node:fs";
20
+ import { join } from "node:path";
21
+ import { clientFingerprint } from "./fingerprint.js";
22
+
23
+ interface InitResponse {
24
+ confirm_url: string;
25
+ wait_token: string;
26
+ }
27
+
28
+ interface ConsumedResponse {
29
+ status: "consumed";
30
+ project_id: number;
31
+ project_name: string;
32
+ user_email: string;
33
+ project_token: string;
34
+ issued_at: string;
35
+ rotates_after?: string;
36
+ target_conversation_id?: string | null;
37
+ readme_md: string;
38
+ }
39
+
40
+ interface PendingResponse {
41
+ status: "pending";
42
+ }
43
+
44
+ interface TerminalResponse {
45
+ status: "rejected" | "expired";
46
+ reason?: string;
47
+ }
48
+
49
+ type PollResponse = ConsumedResponse | PendingResponse | TerminalResponse;
50
+
51
+ const POLL_INTERVAL_MS = 2000;
52
+ const MAX_POLL_DURATION_MS = 10 * 60 * 1000;
53
+
54
+ function parseClaimUrl(input: string): { baseUrl: string; claimId: string } {
55
+ let url: URL;
56
+ try {
57
+ url = new URL(input);
58
+ } catch {
59
+ throw new Error(`Invalid URL: ${input}`);
60
+ }
61
+ const match = url.pathname.match(/^\/connect\/([a-f0-9-]+)\/?$/i);
62
+ if (!match) {
63
+ throw new Error(
64
+ `Not a Viber claim URL. Expected format: https://<host>/connect/<claim_id>`,
65
+ );
66
+ }
67
+ return { baseUrl: `${url.protocol}//${url.host}`, claimId: match[1] };
68
+ }
69
+
70
+ function sleep(ms: number): Promise<void> {
71
+ return new Promise((resolve) => setTimeout(resolve, ms));
72
+ }
73
+
74
+ function writeAuthJson(
75
+ cwd: string,
76
+ data: ConsumedResponse,
77
+ fingerprint: string,
78
+ ): void {
79
+ const viberDir = join(cwd, ".viber");
80
+ mkdirSync(viberDir, { recursive: true });
81
+
82
+ const auth: Record<string, unknown> = {
83
+ schema_version: 1,
84
+ project_id: data.project_id,
85
+ project_name: data.project_name,
86
+ user_email: data.user_email,
87
+ project_token: data.project_token,
88
+ client_fingerprint: fingerprint,
89
+ issued_at: data.issued_at,
90
+ rotates_after: data.rotates_after,
91
+ };
92
+ // Only emit target_conversation_id when the server explicitly returned a
93
+ // non-null value (attach-to-project flow). Absent / null → omit to keep
94
+ // the file minimal for the common "fresh standalone conversation" case.
95
+ if (
96
+ data.target_conversation_id !== undefined &&
97
+ data.target_conversation_id !== null
98
+ ) {
99
+ auth.target_conversation_id = data.target_conversation_id;
100
+ }
101
+
102
+ writeFileSync(
103
+ join(viberDir, "auth.json"),
104
+ JSON.stringify(auth, null, 2),
105
+ "utf-8",
106
+ );
107
+ writeFileSync(join(viberDir, "readme.md"), data.readme_md, "utf-8");
108
+
109
+ // Append `.viber/` to .gitignore if not already present.
110
+ const gitignorePath = join(cwd, ".gitignore");
111
+ let current = "";
112
+ if (existsSync(gitignorePath)) {
113
+ current = readFileSync(gitignorePath, "utf-8");
114
+ }
115
+ const alreadyIgnored = current
116
+ .split(/\r?\n/)
117
+ .some((line) => {
118
+ const trimmed = line.trim();
119
+ return trimmed === ".viber/" || trimmed === ".viber";
120
+ });
121
+ if (!alreadyIgnored) {
122
+ const prefix = current === "" || current.endsWith("\n") ? "" : "\n";
123
+ appendFileSync(gitignorePath, `${prefix}.viber/\n`);
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Run the claim flow. Writes .viber/auth.json + .viber/readme.md + .gitignore
129
+ * entry on success. Exits the process on any terminal outcome.
130
+ */
131
+ export async function runConnect(
132
+ claimUrl: string,
133
+ cwd: string = process.cwd(),
134
+ ): Promise<void> {
135
+ const { baseUrl, claimId } = parseClaimUrl(claimUrl);
136
+
137
+ const fingerprint = clientFingerprint(cwd);
138
+ process.stderr.write(
139
+ `[viber-channel] fingerprint=${fingerprint.slice(0, 8)}…\n`,
140
+ );
141
+
142
+ const initResp = await fetch(`${baseUrl}/api/connect/${claimId}`, {
143
+ method: "POST",
144
+ headers: { "Content-Type": "application/json" },
145
+ body: JSON.stringify({ client_fingerprint: fingerprint }),
146
+ });
147
+ if (!initResp.ok) {
148
+ const body = await initResp.text().catch(() => "");
149
+ throw new Error(`Claim init failed (HTTP ${initResp.status}): ${body}`);
150
+ }
151
+ const init = (await initResp.json()) as InitResponse;
152
+ process.stdout.write(
153
+ `\nConfirm this connection in your browser:\n ${init.confirm_url}\n\n`,
154
+ );
155
+ process.stdout.write(`Polling for confirmation (up to 10 minutes)…\n`);
156
+
157
+ const pollUrl = `${baseUrl}/api/connect/${claimId}?wait_token=${encodeURIComponent(init.wait_token)}`;
158
+ const deadline = Date.now() + MAX_POLL_DURATION_MS;
159
+ while (Date.now() < deadline) {
160
+ await sleep(POLL_INTERVAL_MS);
161
+ const pollResp = await fetch(pollUrl);
162
+ if (!pollResp.ok) {
163
+ const body = await pollResp.text().catch(() => "");
164
+ throw new Error(`Poll failed (HTTP ${pollResp.status}): ${body}`);
165
+ }
166
+ const data = (await pollResp.json()) as PollResponse;
167
+ if (data.status === "consumed") {
168
+ writeAuthJson(cwd, data, fingerprint);
169
+ process.stdout.write(
170
+ `\n✓ Connected as ${data.user_email} to project '${data.project_name}'.\n` +
171
+ ` Wrote ${join(cwd, ".viber", "auth.json")}\n\n` +
172
+ `Next steps:\n` +
173
+ ` 1. Register the channel MCP server (one-time per machine):\n` +
174
+ ` claude mcp add viber-channel --scope user -- bunx viber-channel@latest\n` +
175
+ ` 2. Restart Claude Code in this directory.\n\n`,
176
+ );
177
+ return;
178
+ }
179
+ if (data.status === "rejected" || data.status === "expired") {
180
+ throw new Error(
181
+ `Connection ${data.status}${data.reason ? `: ${data.reason}` : ""}. Get a fresh URL from ${baseUrl}/projects.`,
182
+ );
183
+ }
184
+ // "pending" — keep polling
185
+ }
186
+ throw new Error(
187
+ `Confirmation timed out after ${MAX_POLL_DURATION_MS / 60_000} minutes. Get a fresh URL from ${baseUrl}/projects.`,
188
+ );
189
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "viber-channel",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Voice + text MCP channel between a Claude Code session and the Viber UI (https://viber.dgypx.dev). Push transcripts to Claude; send_message tool delivers text back to the UI.",
5
5
  "type": "module",
6
6
  "bin": {
package/viber-channel.ts CHANGED
@@ -14,6 +14,44 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
14
14
  import { ListToolsRequestSchema, CallToolRequestSchema } from "@modelcontextprotocol/sdk/types.js";
15
15
  import { mkdirSync, writeFileSync, readFileSync, unlinkSync } from "node:fs";
16
16
  import { join } from "node:path";
17
+ import { runConnect } from "./lib/connect.ts";
18
+
19
+ // ---- CLI subcommand dispatch (must happen before lock acquire + loadAuth) ----
20
+ //
21
+ // `bunx viber-channel connect <claim_url>` runs the one-shot claim flow and
22
+ // exits — it does not start the MCP server. Any other invocation (no args, or
23
+ // the unknown subcommand) falls through to the MCP server below.
24
+ {
25
+ const sub = process.argv[2];
26
+ if (sub === "--help" || sub === "-h") {
27
+ process.stdout.write(
28
+ `viber-channel — voice + text MCP channel for Claude Code\n\n` +
29
+ `USAGE\n` +
30
+ ` bunx viber-channel connect <claim_url> one-shot auth flow; writes .viber/auth.json\n` +
31
+ ` bunx viber-channel start the MCP server (used by Claude Code)\n\n` +
32
+ `Get <claim_url> from https://viber.dgypx.dev/projects → Create connection.\n`,
33
+ );
34
+ process.exit(0);
35
+ }
36
+ if (sub === "connect") {
37
+ const claimUrl = process.argv[3];
38
+ if (!claimUrl) {
39
+ process.stderr.write(
40
+ `[viber-channel] Missing claim URL.\n` +
41
+ `Usage: bunx viber-channel connect <claim_url>\n` +
42
+ `Get the URL from https://viber.dgypx.dev/projects → Create connection.\n`,
43
+ );
44
+ process.exit(1);
45
+ }
46
+ try {
47
+ await runConnect(claimUrl);
48
+ process.exit(0);
49
+ } catch (err) {
50
+ process.stderr.write(`[viber-channel] ${err instanceof Error ? err.message : String(err)}\n`);
51
+ process.exit(1);
52
+ }
53
+ }
54
+ }
17
55
 
18
56
  // Lock file path: %APPDATA%/viber/ (Windows) or ~/.config/viber/ (Linux/Mac)
19
57
  const LOCK_DIR =
@@ -136,7 +174,7 @@ const label = process.env.VIBER_CONVERSATION_LABEL ?? defaultLabel(process.cwd()
136
174
  // ---- MCP server ----
137
175
 
138
176
  const mcp = new Server(
139
- { name: "viber-channel", version: "0.3.0" },
177
+ { name: "viber-channel", version: "0.5.0" },
140
178
  {
141
179
  capabilities: {
142
180
  experimental: { "claude/channel": {} },