peekable 0.1.0 → 0.1.2

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 ADDED
@@ -0,0 +1,100 @@
1
+ # Peekable CLI
2
+
3
+ Share local HTML mockups, HTML response snapshots, and localhost apps with collaborators.
4
+ Reviewers annotate the page in the browser; you pull structured feedback back into
5
+ your terminal.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install -g peekable
11
+ ```
12
+
13
+ ## First Run
14
+
15
+ ```bash
16
+ peekable register --name "Your Name" --email "you@example.com" --invite-code "your-invite-code"
17
+ peekable init
18
+ peekable create "My first review"
19
+ ```
20
+
21
+ `peekable create` prints a session ID and share URL. Use the session ID with one
22
+ of the publish commands below.
23
+
24
+ ## Pick The Right Publish Command
25
+
26
+ Use `push` for a complete standalone HTML file:
27
+
28
+ ```bash
29
+ peekable push <session-id> ./out.html
30
+ ```
31
+
32
+ Use `push-url` when a running page returns a mostly self-contained HTML response
33
+ you want to capture:
34
+
35
+ ```bash
36
+ peekable push-url <session-id> http://localhost:3000
37
+ ```
38
+
39
+ Use `proxy` for a live localhost app with routes, assets, and interactivity:
40
+
41
+ ```bash
42
+ peekable proxy 3000 --name "Homepage review"
43
+ ```
44
+
45
+ ## Pull Feedback
46
+
47
+ ```bash
48
+ peekable feedback <session-id>
49
+ peekable watch <session-id>
50
+ peekable resolve <session-id> <annotation-id>
51
+ ```
52
+
53
+ ## Free Early Access Limits
54
+
55
+ The hosted free early-access tier allows 3 active sessions per user. Close old
56
+ sessions when you are done:
57
+
58
+ ```bash
59
+ peekable list
60
+ peekable close <session-id>
61
+ ```
62
+
63
+ Viewer connections are capped on the hosted service so early access stays
64
+ reliable for everyone.
65
+
66
+ `push-url` snapshots localhost by default. To upload HTML fetched from a remote
67
+ URL, pass `--allow-remote --yes`; for private-network URLs, also pass
68
+ `--allow-private`.
69
+
70
+ ## Debug Setup
71
+
72
+ If something feels off, run:
73
+
74
+ ```bash
75
+ peekable doctor
76
+ peekable doctor --json
77
+ ```
78
+
79
+ For a deeper connectivity check that creates, pushes, and closes a temporary
80
+ session:
81
+
82
+ ```bash
83
+ peekable doctor --test-push
84
+ ```
85
+
86
+ The doctor output intentionally avoids HTML payloads, annotation text, and API
87
+ keys so it is safe to paste into a support thread.
88
+
89
+ ## Remove Local Setup
90
+
91
+ To remove Peekable from your machine:
92
+
93
+ ```bash
94
+ peekable uninstall
95
+ npm uninstall -g peekable
96
+ ```
97
+
98
+ `peekable uninstall` removes local config at `~/.peekable` and the installed
99
+ Claude Code skill at `~/.claude/skills/peekable`. It does not delete hosted
100
+ sessions or account data.
package/dist/api.d.ts CHANGED
@@ -1,3 +1,25 @@
1
1
  import { type ShareConfig } from "./config.js";
2
- export declare function api(config: ShareConfig, method: string, path: string, body?: unknown): Promise<any>;
3
- export declare function apiNoAuth(url: string, method: string, path: string, body?: unknown): Promise<any>;
2
+ export declare const DEFAULT_REQUEST_TIMEOUT_MS = 15000;
3
+ export declare class ApiError extends Error {
4
+ status: number;
5
+ details: Record<string, unknown>;
6
+ constructor(status: number, details: Record<string, unknown>, fallback: string);
7
+ }
8
+ export declare function api(config: ShareConfig, method: string, path: string, body?: unknown, opts?: {
9
+ timeoutMs?: number;
10
+ }): Promise<any>;
11
+ export declare function postDebugEvent(config: ShareConfig, body: {
12
+ event_name: string;
13
+ session_id?: string;
14
+ source?: "cli";
15
+ command?: string;
16
+ cli_version?: string;
17
+ status?: string;
18
+ duration_ms?: number;
19
+ error_code?: string;
20
+ metadata?: Record<string, unknown>;
21
+ }): Promise<any>;
22
+ export declare function apiNoAuth(url: string, method: string, path: string, body?: unknown, opts?: {
23
+ timeoutMs?: number;
24
+ }): Promise<any>;
25
+ export declare function fetchWithTimeout(input: string | URL, init?: RequestInit, timeoutMs?: number): Promise<Response>;
package/dist/api.js CHANGED
@@ -1,27 +1,65 @@
1
- export async function api(config, method, path, body) {
2
- const res = await fetch(`${config.url}/api${path}`, {
1
+ export const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
2
+ export class ApiError extends Error {
3
+ status;
4
+ details;
5
+ constructor(status, details, fallback) {
6
+ super(typeof details.error === "string" ? details.error : fallback);
7
+ this.name = "ApiError";
8
+ this.status = status;
9
+ this.details = details;
10
+ }
11
+ }
12
+ export async function api(config, method, path, body, opts = {}) {
13
+ const res = await fetchWithTimeout(`${config.url}/api${path}`, {
3
14
  method,
4
15
  headers: {
5
16
  Authorization: `Bearer ${config.api_key}`,
6
17
  "Content-Type": "application/json",
7
18
  },
8
19
  body: body ? JSON.stringify(body) : undefined,
9
- });
20
+ }, opts.timeoutMs);
10
21
  if (!res.ok) {
11
- const err = await res.json().catch(() => ({ error: res.statusText }));
12
- throw new Error(err.error ?? res.statusText);
22
+ throw await buildApiError(res);
13
23
  }
14
24
  return res.json();
15
25
  }
16
- export async function apiNoAuth(url, method, path, body) {
17
- const res = await fetch(`${url}/api${path}`, {
26
+ export async function postDebugEvent(config, body) {
27
+ return api(config, "POST", "/debug-events", body);
28
+ }
29
+ export async function apiNoAuth(url, method, path, body, opts = {}) {
30
+ const res = await fetchWithTimeout(`${url}/api${path}`, {
18
31
  method,
19
32
  headers: { "Content-Type": "application/json" },
20
33
  body: body ? JSON.stringify(body) : undefined,
21
- });
34
+ }, opts.timeoutMs);
22
35
  if (!res.ok) {
23
- const err = await res.json().catch(() => ({ error: res.statusText }));
24
- throw new Error(err.error ?? res.statusText);
36
+ throw await buildApiError(res);
25
37
  }
26
38
  return res.json();
27
39
  }
40
+ export async function fetchWithTimeout(input, init = {}, timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS) {
41
+ const controller = new AbortController();
42
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
43
+ try {
44
+ return await fetch(input, {
45
+ ...init,
46
+ signal: init.signal ?? controller.signal,
47
+ });
48
+ }
49
+ catch (err) {
50
+ if (err?.name === "AbortError") {
51
+ throw new Error(`Request timed out after ${Math.round(timeoutMs / 1000)}s`);
52
+ }
53
+ throw err;
54
+ }
55
+ finally {
56
+ clearTimeout(timeout);
57
+ }
58
+ }
59
+ async function buildApiError(res) {
60
+ const details = await res.json().catch(() => ({ error: res.statusText }));
61
+ const body = details && typeof details === "object" && !Array.isArray(details)
62
+ ? details
63
+ : { error: res.statusText };
64
+ return new ApiError(res.status, body, res.statusText);
65
+ }
@@ -0,0 +1,3 @@
1
+ export declare function errorPayload(err: unknown): Record<string, unknown>;
2
+ export declare function getErrorMessage(err: unknown): string;
3
+ export declare function printCommandError(prefix: string, err: unknown, json?: boolean): void;
@@ -0,0 +1,23 @@
1
+ import { ApiError } from "./api.js";
2
+ export function errorPayload(err) {
3
+ if (err instanceof ApiError) {
4
+ return {
5
+ error: err.message,
6
+ ...err.details,
7
+ status: err.status,
8
+ };
9
+ }
10
+ return { error: getErrorMessage(err) };
11
+ }
12
+ export function getErrorMessage(err) {
13
+ if (err instanceof Error)
14
+ return err.message;
15
+ return String(err);
16
+ }
17
+ export function printCommandError(prefix, err, json) {
18
+ if (json) {
19
+ console.log(JSON.stringify(errorPayload(err)));
20
+ return;
21
+ }
22
+ console.error(`${prefix}: ${getErrorMessage(err)}`);
23
+ }
@@ -1,18 +1,25 @@
1
1
  import { Command } from "commander";
2
2
  import { requireConfig } from "../config.js";
3
3
  import { api } from "../api.js";
4
+ import { printCommandError } from "../command-error.js";
4
5
  export const createCommand = new Command("create")
5
6
  .argument("<name>", "Session name")
6
7
  .description("Create a new sharing session")
7
8
  .option("--json", "Output JSON")
8
9
  .action(async (name, opts) => {
9
10
  const config = requireConfig();
10
- const data = await api(config, "POST", "/sessions", { name });
11
- if (opts.json) {
12
- console.log(JSON.stringify(data));
11
+ try {
12
+ const data = await api(config, "POST", "/sessions", { name });
13
+ if (opts.json) {
14
+ console.log(JSON.stringify(data));
15
+ }
16
+ else {
17
+ console.log(`Created session: ${data.id}`);
18
+ console.log(`URL: ${data.url}`);
19
+ }
13
20
  }
14
- else {
15
- console.log(`Created session: ${data.id}`);
16
- console.log(`URL: ${data.url}`);
21
+ catch (err) {
22
+ printCommandError("Create failed", err, opts.json);
23
+ process.exit(1);
17
24
  }
18
25
  });
@@ -0,0 +1,21 @@
1
+ import { Command } from "commander";
2
+ export type DoctorStatus = "ok" | "failed" | "skipped";
3
+ export interface DoctorReport {
4
+ cliVersion: string;
5
+ nodeVersion: string;
6
+ configPath: string;
7
+ configured: boolean;
8
+ serverUrl: string | null;
9
+ health: DoctorStatus;
10
+ auth: DoctorStatus;
11
+ activeSessions: number | null;
12
+ maxActiveSessions: number | null;
13
+ activeSessionUnlimited?: boolean;
14
+ testPush: DoctorStatus;
15
+ errors?: string[];
16
+ }
17
+ export declare const doctorCommand: Command;
18
+ export declare function runDoctor(opts?: {
19
+ testPush?: boolean;
20
+ }): Promise<DoctorReport>;
21
+ export declare function formatDoctorReport(report: DoctorReport): string;
@@ -0,0 +1,141 @@
1
+ import { Command } from "commander";
2
+ import { api, fetchWithTimeout } from "../api.js";
3
+ import { getConfigPath, readConfig } from "../config.js";
4
+ import { getErrorMessage } from "../command-error.js";
5
+ import { formatQuota } from "../quota.js";
6
+ import { CLI_VERSION } from "../version.js";
7
+ export const doctorCommand = new Command("doctor")
8
+ .description("Print a support snapshot for debugging Peekable setup")
9
+ .option("--json", "Output JSON")
10
+ .option("--test-push", "Create, push, and close a temporary test session")
11
+ .action(async (opts) => {
12
+ const report = await runDoctor({ testPush: Boolean(opts.testPush) });
13
+ if (opts.json) {
14
+ console.log(JSON.stringify(report));
15
+ return;
16
+ }
17
+ console.log(formatDoctorReport(report));
18
+ });
19
+ export async function runDoctor(opts = {}) {
20
+ const config = readConfig();
21
+ const report = {
22
+ cliVersion: CLI_VERSION,
23
+ nodeVersion: process.version,
24
+ configPath: redactHomePath(getConfigPath()),
25
+ configured: Boolean(config),
26
+ serverUrl: config?.url ?? null,
27
+ health: "skipped",
28
+ auth: "skipped",
29
+ activeSessions: null,
30
+ maxActiveSessions: null,
31
+ activeSessionUnlimited: false,
32
+ testPush: "skipped",
33
+ errors: [],
34
+ };
35
+ if (!config) {
36
+ report.errors.push("Not configured. Run `peekable register` first.");
37
+ return report;
38
+ }
39
+ try {
40
+ const res = await fetchWithTimeout(`${config.url}/health`);
41
+ report.health = res.ok ? "ok" : "failed";
42
+ if (!res.ok)
43
+ report.errors.push(`Health check failed: HTTP ${res.status}`);
44
+ }
45
+ catch (err) {
46
+ report.health = "failed";
47
+ report.errors.push(`Health check failed: ${getErrorMessage(err)}`);
48
+ }
49
+ try {
50
+ const data = await getUsageSnapshot(config);
51
+ report.auth = "ok";
52
+ report.activeSessions = data.limits?.activeSessions ?? null;
53
+ report.maxActiveSessions = data.limits?.maxActiveSessions ?? null;
54
+ report.activeSessionUnlimited = Boolean(data.limits?.unlimited);
55
+ }
56
+ catch (err) {
57
+ report.auth = "failed";
58
+ report.errors.push(`Auth check failed: ${getErrorMessage(err)}`);
59
+ }
60
+ if (opts.testPush && report.auth === "ok") {
61
+ try {
62
+ const session = await api(config, "POST", "/sessions", { name: "__peekable_doctor__" });
63
+ try {
64
+ await api(config, "POST", `/sessions/${session.id}/push`, {
65
+ html: "<html><body><p>Peekable doctor test</p></body></html>",
66
+ });
67
+ report.testPush = "ok";
68
+ }
69
+ finally {
70
+ await api(config, "DELETE", `/sessions/${session.id}`).catch(() => undefined);
71
+ }
72
+ }
73
+ catch (err) {
74
+ report.testPush = "failed";
75
+ report.errors.push(`Test push failed: ${getErrorMessage(err)}`);
76
+ }
77
+ }
78
+ if (report.errors?.length === 0)
79
+ delete report.errors;
80
+ return report;
81
+ }
82
+ export function formatDoctorReport(report) {
83
+ const lines = [
84
+ `Peekable CLI: ${report.cliVersion}`,
85
+ `Node: ${report.nodeVersion}`,
86
+ `Config: ${report.configured ? report.configPath : "missing"}`,
87
+ `Server: ${formatServer(report)}`,
88
+ `Auth: ${formatAuth(report.auth)}`,
89
+ `Active sessions: ${formatQuota(report.activeSessions, report.maxActiveSessions, report.activeSessionUnlimited)}`,
90
+ `Test push: ${report.testPush}`,
91
+ ];
92
+ if (report.activeSessions !== null &&
93
+ report.maxActiveSessions !== null &&
94
+ !report.activeSessionUnlimited &&
95
+ report.activeSessions >= report.maxActiveSessions) {
96
+ lines.push("Free plan limit reached. Close one with `peekable close <id>`.");
97
+ }
98
+ if (report.errors?.length) {
99
+ lines.push("", "Errors:");
100
+ for (const error of report.errors)
101
+ lines.push(`- ${error}`);
102
+ }
103
+ return lines.join("\n");
104
+ }
105
+ function formatServer(report) {
106
+ if (!report.serverUrl)
107
+ return "not configured";
108
+ if (report.health === "ok")
109
+ return `${report.serverUrl} reachable`;
110
+ if (report.health === "failed")
111
+ return `${report.serverUrl} unreachable`;
112
+ return `${report.serverUrl} not checked`;
113
+ }
114
+ function formatAuth(status) {
115
+ if (status === "ok")
116
+ return "valid";
117
+ if (status === "failed")
118
+ return "failed";
119
+ return "not checked";
120
+ }
121
+ async function getUsageSnapshot(config) {
122
+ try {
123
+ return await api(config, "GET", "/me/usage");
124
+ }
125
+ catch {
126
+ const data = await api(config, "GET", "/sessions");
127
+ return {
128
+ limits: {
129
+ activeSessions: data.limits?.activeSessions ?? data.sessions?.length ?? null,
130
+ maxActiveSessions: data.limits?.maxActiveSessions ?? null,
131
+ unlimited: Boolean(data.limits?.unlimited),
132
+ },
133
+ };
134
+ }
135
+ }
136
+ function redactHomePath(path) {
137
+ const home = process.env.HOME;
138
+ if (!home || !path.startsWith(home))
139
+ return path;
140
+ return `~${path.slice(home.length)}`;
141
+ }
@@ -7,20 +7,50 @@ export const feedbackCommand = new Command("feedback")
7
7
  .option("--json", "Output JSON")
8
8
  .action(async (sessionId, opts) => {
9
9
  const config = requireConfig();
10
- const data = await api(config, "GET", `/sessions/${sessionId}/feedback`);
10
+ const [eventsData, annotationsData] = await Promise.all([
11
+ api(config, "GET", `/sessions/${sessionId}/feedback`),
12
+ api(config, "GET", `/sessions/${sessionId}/annotations`).catch(() => ({ annotations: [] })),
13
+ ]);
11
14
  if (opts.json) {
12
- console.log(JSON.stringify(data));
15
+ console.log(JSON.stringify({ ...eventsData, ...annotationsData }));
13
16
  return;
14
17
  }
15
- if (data.feedback.length === 0) {
18
+ const hasFeedback = eventsData.feedback.length > 0;
19
+ const hasAnnotations = annotationsData.annotations && annotationsData.annotations.length > 0;
20
+ if (!hasFeedback && !hasAnnotations) {
16
21
  console.log("No feedback yet.");
17
22
  return;
18
23
  }
19
- for (const group of data.feedback) {
20
- console.log(`\n--- Version ${group.version} ---`);
21
- for (const event of group.events) {
22
- const who = event.viewer ?? "Anonymous";
23
- console.log(` ${who} chose "${event.choice}" — ${event.label}`);
24
+ // Display choice feedback
25
+ if (hasFeedback) {
26
+ console.log("\nChoice Feedback:");
27
+ for (const group of eventsData.feedback) {
28
+ console.log(`\n--- Version ${group.version} ---`);
29
+ for (const event of group.events) {
30
+ const who = event.viewer ?? "Anonymous";
31
+ console.log(` ${who} chose "${event.choice}" — ${event.label}`);
32
+ }
33
+ }
34
+ }
35
+ // Display annotations
36
+ if (hasAnnotations) {
37
+ console.log("\nAnnotations:");
38
+ for (const group of annotationsData.annotations) {
39
+ console.log(`\n--- Version ${group.version} ---`);
40
+ for (const ann of group.items) {
41
+ const who = ann.viewer ?? "Anonymous";
42
+ const elementName = ann.element_name || "Unknown";
43
+ const selector = ann.selector || "";
44
+ console.log(` [${who}] ${elementName} (${selector})`);
45
+ console.log(` "${ann.note}"`);
46
+ if (ann.element_context?.computedStyles) {
47
+ const styles = Object.entries(ann.element_context.computedStyles)
48
+ .map(([key, val]) => `${key}: ${val}`)
49
+ .join(", ");
50
+ if (styles)
51
+ console.log(` Styles: ${styles}`);
52
+ }
53
+ }
24
54
  }
25
55
  }
26
56
  });
@@ -1,10 +1,10 @@
1
1
  import { Command } from "commander";
2
2
  import { existsSync, mkdirSync, cpSync } from "fs";
3
3
  import { join, dirname } from "path";
4
- import { homedir } from "os";
5
4
  import { fileURLToPath } from "url";
6
5
  import { requireConfig } from "../config.js";
7
6
  import { api } from "../api.js";
7
+ import { getClaudeDir, getClaudePeekableSkillDir } from "../paths.js";
8
8
  const __dirname = dirname(fileURLToPath(import.meta.url));
9
9
  export const initCommand = new Command("init")
10
10
  .description("Set up peekable — install Claude Code skill and verify connection")
@@ -31,11 +31,11 @@ export const initCommand = new Command("init")
31
31
  if (!opts.json)
32
32
  console.log("Connection verified.");
33
33
  // 2. Detect Claude Code and install skill
34
- const claudeDir = join(homedir(), ".claude");
34
+ const claudeDir = getClaudeDir();
35
35
  const claudeCodeDetected = existsSync(claudeDir);
36
36
  result.claude_code = claudeCodeDetected;
37
37
  if (claudeCodeDetected) {
38
- const skillDir = join(claudeDir, "skills", "peekable");
38
+ const skillDir = getClaudePeekableSkillDir();
39
39
  // Resolve skill source relative to compiled output location
40
40
  const skillSource = join(__dirname, "..", "..", "skill", "SKILL.md");
41
41
  if (existsSync(skillSource)) {
@@ -1,6 +1,7 @@
1
1
  import { Command } from "commander";
2
2
  import { requireConfig } from "../config.js";
3
3
  import { api } from "../api.js";
4
+ import { formatQuota } from "../quota.js";
4
5
  export const listCommand = new Command("list")
5
6
  .description("List active sessions")
6
7
  .option("--json", "Output JSON")
@@ -12,10 +13,20 @@ export const listCommand = new Command("list")
12
13
  return;
13
14
  }
14
15
  if (data.sessions.length === 0) {
15
- console.log("No active sessions.");
16
+ if (data.limits) {
17
+ console.log(`No active sessions. (${formatQuota(data.limits.activeSessions, data.limits.maxActiveSessions, data.limits.unlimited)})`);
18
+ }
19
+ else {
20
+ console.log("No active sessions.");
21
+ }
16
22
  return;
17
23
  }
18
- console.log("Active sessions:");
24
+ if (data.limits) {
25
+ console.log(`Active sessions (${formatQuota(data.limits.activeSessions, data.limits.maxActiveSessions, data.limits.unlimited)}):`);
26
+ }
27
+ else {
28
+ console.log("Active sessions:");
29
+ }
19
30
  for (const s of data.sessions) {
20
31
  console.log(` ${s.id} ${s.name} (${new Date(s.created_at).toLocaleDateString()})`);
21
32
  }
@@ -0,0 +1,2 @@
1
+ import { Command } from "commander";
2
+ export declare const proxyCommand: Command;