peekable 0.1.1 → 0.1.3

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 installed agent
99
+ skills at `~/.claude/skills/peekable` and `~/.codex/skills/peekable`. It does
100
+ not delete hosted 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
+ }
@@ -1,2 +1,10 @@
1
1
  import { Command } from "commander";
2
+ type LocalAgentSkillResult = {
3
+ claude_code: boolean;
4
+ skill_installed: boolean;
5
+ codex: boolean;
6
+ codex_skill_installed: boolean;
7
+ };
8
+ export declare function installLocalAgentSkills(skillSource: string, jsonOutput: boolean, home?: string): LocalAgentSkillResult;
2
9
  export declare const initCommand: Command;
10
+ export {};
@@ -1,11 +1,40 @@
1
1
  import { Command } from "commander";
2
2
  import { existsSync, mkdirSync, cpSync } from "fs";
3
- import { join, dirname } from "path";
4
3
  import { homedir } from "os";
4
+ import { join, dirname } from "path";
5
5
  import { fileURLToPath } from "url";
6
6
  import { requireConfig } from "../config.js";
7
7
  import { api } from "../api.js";
8
+ import { getClaudeDir, getClaudePeekableSkillDir, getCodexDir, getCodexPeekableSkillDir, } from "../paths.js";
8
9
  const __dirname = dirname(fileURLToPath(import.meta.url));
10
+ function installPeekableSkill(appName, appDir, skillDir, skillSource, jsonOutput) {
11
+ const detected = existsSync(appDir);
12
+ if (!detected) {
13
+ if (!jsonOutput)
14
+ console.log(`${appName} not detected — skipping skill install.`);
15
+ return { detected, installed: false };
16
+ }
17
+ if (!existsSync(skillSource)) {
18
+ if (!jsonOutput)
19
+ console.log(`${appName} detected but skill template not found.`);
20
+ return { detected, installed: false };
21
+ }
22
+ mkdirSync(skillDir, { recursive: true });
23
+ cpSync(skillSource, join(skillDir, "SKILL.md"));
24
+ if (!jsonOutput)
25
+ console.log(`${appName} /peekable skill installed.`);
26
+ return { detected, installed: true };
27
+ }
28
+ export function installLocalAgentSkills(skillSource, jsonOutput, home = homedir()) {
29
+ const claudeSkill = installPeekableSkill("Claude Code", getClaudeDir(home), getClaudePeekableSkillDir(home), skillSource, jsonOutput);
30
+ const codexSkill = installPeekableSkill("Codex", getCodexDir(home), getCodexPeekableSkillDir(home), skillSource, jsonOutput);
31
+ return {
32
+ claude_code: claudeSkill.detected,
33
+ skill_installed: claudeSkill.installed,
34
+ codex: codexSkill.detected,
35
+ codex_skill_installed: codexSkill.installed,
36
+ };
37
+ }
9
38
  export const initCommand = new Command("init")
10
39
  .description("Set up peekable — install Claude Code skill and verify connection")
11
40
  .option("--json", "Output JSON")
@@ -30,32 +59,9 @@ export const initCommand = new Command("init")
30
59
  }
31
60
  if (!opts.json)
32
61
  console.log("Connection verified.");
33
- // 2. Detect Claude Code and install skill
34
- const claudeDir = join(homedir(), ".claude");
35
- const claudeCodeDetected = existsSync(claudeDir);
36
- result.claude_code = claudeCodeDetected;
37
- if (claudeCodeDetected) {
38
- const skillDir = join(claudeDir, "skills", "peekable");
39
- // Resolve skill source relative to compiled output location
40
- const skillSource = join(__dirname, "..", "..", "skill", "SKILL.md");
41
- if (existsSync(skillSource)) {
42
- mkdirSync(skillDir, { recursive: true });
43
- cpSync(skillSource, join(skillDir, "SKILL.md"));
44
- result.skill_installed = true;
45
- if (!opts.json)
46
- console.log("Claude Code /peekable skill installed.");
47
- }
48
- else {
49
- result.skill_installed = false;
50
- if (!opts.json)
51
- console.log("Claude Code detected but skill template not found.");
52
- }
53
- }
54
- else {
55
- result.skill_installed = false;
56
- if (!opts.json)
57
- console.log("Claude Code not detected — skipping skill install.");
58
- }
62
+ // 2. Detect local agent tools and install skill
63
+ const skillSource = join(__dirname, "..", "..", "skill", "SKILL.md");
64
+ Object.assign(result, installLocalAgentSkills(skillSource, opts.json));
59
65
  // 3. Test push
60
66
  try {
61
67
  const session = await api(config, "POST", "/sessions", { name: "__share_init_test__" });
@@ -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
  }
@@ -2,6 +2,7 @@ import { Command } from "commander";
2
2
  import { watch } from "fs";
3
3
  import { requireConfig } from "../config.js";
4
4
  import { api } from "../api.js";
5
+ import { printCommandError } from "../command-error.js";
5
6
  const MAX_RESPONSE_BYTES = 25 * 1024 * 1024; // 25 MB
6
7
  const TEXT_CONTENT_TYPES = [
7
8
  "text/",
@@ -26,7 +27,7 @@ export const proxyCommand = new Command("proxy")
26
27
  // Validate port
27
28
  const port = parseInt(portArg, 10);
28
29
  if (isNaN(port) || port < 1 || port > 65535) {
29
- console.error("Invalid port: must be 1-65535");
30
+ printCommandError("Proxy failed", new Error("Invalid port: must be 1-65535"), opts.json);
30
31
  process.exit(1);
31
32
  }
32
33
  // Create or reuse session
@@ -38,11 +39,18 @@ export const proxyCommand = new Command("proxy")
38
39
  shareUrl = `https://${sessionId}.${baseDomain}`;
39
40
  }
40
41
  else {
41
- const data = await api(config, "POST", "/sessions", {
42
- name: opts.name,
43
- mode: "proxy",
44
- proxy_target: `localhost:${port}`,
45
- });
42
+ let data;
43
+ try {
44
+ data = await api(config, "POST", "/sessions", {
45
+ name: opts.name,
46
+ mode: "proxy",
47
+ proxy_target: `localhost:${port}`,
48
+ });
49
+ }
50
+ catch (err) {
51
+ printCommandError("Proxy session failed", err, opts.json);
52
+ process.exit(1);
53
+ }
46
54
  sessionId = data.id;
47
55
  shareUrl = data.url;
48
56
  }
@@ -1,32 +1,45 @@
1
1
  import { Command } from "commander";
2
2
  import { requireConfig } from "../config.js";
3
- import { api } from "../api.js";
3
+ import { api, fetchWithTimeout } from "../api.js";
4
+ import { printCommandError } from "../command-error.js";
4
5
  const FETCH_TIMEOUT_MS = 30_000;
6
+ const MAX_HTML_BYTES = 5 * 1024 * 1024;
5
7
  export const pushUrlCommand = new Command("push-url")
6
8
  .argument("<session-id>", "Session ID")
7
- .argument("<url>", "URL that returns a rendered HTML page")
8
- .description("Fetch a rendered HTML page from a URL and push it as a new version")
9
+ .argument("<url>", "URL that returns an HTML response")
10
+ .description("Fetch an HTML response from a URL and push it as a new version")
9
11
  .option("--json", "Output JSON")
12
+ .option("--allow-remote", "Allow snapshotting a non-localhost URL")
13
+ .option("--allow-private", "Allow snapshotting a private-network URL")
14
+ .option("-y, --yes", "Confirm the fetched HTML can be uploaded to the session")
10
15
  .addHelpText("after", `
11
- Use this when a running page wraps fragment content with the CSS/layout shell you want to share.
16
+ Use this when a running page returns a mostly self-contained HTML response you want to share.
12
17
  For standalone HTML files, use: peekable push <session-id> <file>
13
18
  For live dev servers with routes/assets/interactivity, use: peekable proxy <port>
14
19
 
15
- Note: authenticated pages may snapshot as logged-out because this command does not use browser cookies.
20
+ Note: this does not execute JavaScript, use browser cookies, or inline external assets.
21
+ Remote URLs require --allow-remote --yes because the fetched HTML is uploaded to the session.
16
22
  `)
17
23
  .action(async (sessionId, urlArg, opts) => {
18
- const config = requireConfig();
19
- const url = parseHttpUrl(urlArg);
20
- const html = await fetchHtml(url);
21
- const data = await api(config, "POST", `/sessions/${sessionId}/push`, {
22
- html,
23
- source_path: url.toString(),
24
- });
25
- if (opts.json) {
26
- console.log(JSON.stringify(data));
27
- }
28
- else {
29
- console.log(`Pushed v${data.version} to ${sessionId} from ${url.toString()}`);
24
+ try {
25
+ const config = requireConfig();
26
+ const url = parseHttpUrl(urlArg);
27
+ validateSnapshotTarget(url, opts);
28
+ const html = await fetchHtml(url);
29
+ const data = await api(config, "POST", `/sessions/${sessionId}/push`, {
30
+ html,
31
+ source_path: url.toString(),
32
+ });
33
+ if (opts.json) {
34
+ console.log(JSON.stringify(data));
35
+ }
36
+ else {
37
+ console.log(`Pushed v${data.version} to ${sessionId} from ${url.toString()}`);
38
+ }
39
+ }
40
+ catch (err) {
41
+ printCommandError("Push URL failed", err, opts.json);
42
+ process.exit(1);
30
43
  }
31
44
  });
32
45
  function parseHttpUrl(urlArg) {
@@ -42,26 +55,30 @@ function parseHttpUrl(urlArg) {
42
55
  }
43
56
  return url;
44
57
  }
58
+ function validateSnapshotTarget(url, opts) {
59
+ const local = isLocalhost(url.hostname);
60
+ const privateNetwork = !local && isPrivateNetworkHost(url.hostname);
61
+ if (!local && !opts.allowRemote) {
62
+ throw new Error("push-url snapshots localhost by default. Pass --allow-remote --yes to upload HTML from a remote URL.");
63
+ }
64
+ if (privateNetwork && !opts.allowPrivate) {
65
+ throw new Error("Private-network URLs require --allow-private --yes so internal pages are not uploaded accidentally.");
66
+ }
67
+ if ((!local || privateNetwork) && !opts.yes) {
68
+ throw new Error("Pass --yes to confirm the fetched HTML can be uploaded to the session.");
69
+ }
70
+ }
45
71
  async function fetchHtml(url) {
46
- const controller = new AbortController();
47
- const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
48
72
  let res;
49
73
  try {
50
- res = await fetch(url, {
74
+ res = await fetchWithTimeout(url, {
51
75
  headers: { Accept: "text/html,*/*;q=0.8" },
52
76
  redirect: "follow",
53
- signal: controller.signal,
54
- });
77
+ }, FETCH_TIMEOUT_MS);
55
78
  }
56
79
  catch (err) {
57
- if (err?.name === "AbortError") {
58
- throw new Error(`Timed out fetching ${url.toString()} after ${FETCH_TIMEOUT_MS / 1000}s`);
59
- }
60
80
  throw err;
61
81
  }
62
- finally {
63
- clearTimeout(timeout);
64
- }
65
82
  if (!res.ok) {
66
83
  throw new Error(`Failed to fetch ${url.toString()}: HTTP ${res.status}`);
67
84
  }
@@ -69,5 +86,51 @@ async function fetchHtml(url) {
69
86
  if (!contentType.toLowerCase().includes("text/html")) {
70
87
  throw new Error(`Expected text/html from ${url.toString()}, got ${contentType || "no content-type"}`);
71
88
  }
72
- return res.text();
89
+ const declaredLength = Number(res.headers.get("content-length") ?? 0);
90
+ if (declaredLength > MAX_HTML_BYTES) {
91
+ throw new Error(`HTML response is too large (${declaredLength} bytes, max ${MAX_HTML_BYTES})`);
92
+ }
93
+ return readTextWithLimit(res, MAX_HTML_BYTES);
94
+ }
95
+ async function readTextWithLimit(res, maxBytes) {
96
+ if (!res.body) {
97
+ const text = await res.text();
98
+ if (new TextEncoder().encode(text).byteLength > maxBytes) {
99
+ throw new Error(`HTML response is too large (max ${maxBytes} bytes)`);
100
+ }
101
+ return text;
102
+ }
103
+ const reader = res.body.getReader();
104
+ const chunks = [];
105
+ let total = 0;
106
+ while (true) {
107
+ const { done, value } = await reader.read();
108
+ if (done)
109
+ break;
110
+ total += value.byteLength;
111
+ if (total > maxBytes) {
112
+ await reader.cancel();
113
+ throw new Error(`HTML response is too large (max ${maxBytes} bytes)`);
114
+ }
115
+ chunks.push(value);
116
+ }
117
+ const bytes = new Uint8Array(total);
118
+ let offset = 0;
119
+ for (const chunk of chunks) {
120
+ bytes.set(chunk, offset);
121
+ offset += chunk.byteLength;
122
+ }
123
+ return new TextDecoder("utf-8").decode(bytes);
124
+ }
125
+ function isLocalhost(hostname) {
126
+ const host = hostname.toLowerCase();
127
+ return host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]";
128
+ }
129
+ function isPrivateNetworkHost(hostname) {
130
+ const host = hostname.toLowerCase();
131
+ if (!host.includes("."))
132
+ return true;
133
+ if (host.endsWith(".local"))
134
+ return true;
135
+ return /^(10\.|192\.168\.|169\.254\.|172\.(1[6-9]|2\d|3[0-1])\.)/.test(host);
73
136
  }
@@ -3,6 +3,7 @@ import { readFileSync } from "fs";
3
3
  import { resolve } from "path";
4
4
  import { requireConfig } from "../config.js";
5
5
  import { api } from "../api.js";
6
+ import { printCommandError } from "../command-error.js";
6
7
  export const pushCommand = new Command("push")
7
8
  .argument("<session-id>", "Session ID")
8
9
  .argument("<file>", "Standalone HTML file path")
@@ -10,22 +11,28 @@ export const pushCommand = new Command("push")
10
11
  .option("--json", "Output JSON")
11
12
  .addHelpText("after", `
12
13
  Use this for complete HTML documents with their own <html>/<body> shell.
13
- For a rendered page snapshot, use: peekable push-url <session-id> <url>
14
+ For an HTML response snapshot, use: peekable push-url <session-id> <url>
14
15
  For a live dev server, use: peekable proxy <port>
15
16
  `)
16
17
  .action(async (sessionId, file, opts) => {
17
- const config = requireConfig();
18
- const html = readFileSync(file, "utf-8");
19
- if (!opts.json && looksLikeHtmlFragment(html)) {
20
- console.error("\x1b[33mNote:\x1b[0m this file looks like an HTML fragment. It may render unstyled when shared standalone. If it is part of a parent app, prefer `peekable push-url <session> <url>`. For live dev servers (React/Vite/Next), use `peekable proxy <port>`.");
18
+ try {
19
+ const config = requireConfig();
20
+ const html = readFileSync(file, "utf-8");
21
+ if (!opts.json && looksLikeHtmlFragment(html)) {
22
+ console.error("\x1b[33mNote:\x1b[0m this file looks like an HTML fragment. It may render unstyled when shared standalone. If it is part of a parent app, prefer `peekable push-url <session> <url>`. For live dev servers (React/Vite/Next), use `peekable proxy <port>`.");
23
+ }
24
+ const source_path = resolve(file);
25
+ const data = await api(config, "POST", `/sessions/${sessionId}/push`, { html, source_path });
26
+ if (opts.json) {
27
+ console.log(JSON.stringify(data));
28
+ }
29
+ else {
30
+ console.log(`Pushed v${data.version} to ${sessionId}`);
31
+ }
21
32
  }
22
- const source_path = resolve(file);
23
- const data = await api(config, "POST", `/sessions/${sessionId}/push`, { html, source_path });
24
- if (opts.json) {
25
- console.log(JSON.stringify(data));
26
- }
27
- else {
28
- console.log(`Pushed v${data.version} to ${sessionId}`);
33
+ catch (err) {
34
+ printCommandError("Push failed", err, opts.json);
35
+ process.exit(1);
29
36
  }
30
37
  });
31
38
  function looksLikeHtmlFragment(html) {
@@ -6,6 +6,7 @@ export const registerCommand = new Command("register")
6
6
  .description("Register for an API key")
7
7
  .requiredOption("--name <name>", "Your display name")
8
8
  .requiredOption("--email <email>", "Your email address")
9
+ .option("--invite-code <code>", "Beta invite code")
9
10
  .option("--url <url>", "Server URL", DEFAULT_URL)
10
11
  .option("--json", "Output JSON")
11
12
  .action(async (opts) => {
@@ -21,9 +22,18 @@ export const registerCommand = new Command("register")
21
22
  process.exit(1);
22
23
  }
23
24
  try {
24
- const data = await apiNoAuth(opts.url, "POST", "/register", {
25
+ const inviteCode = typeof opts.inviteCode === "string"
26
+ ? opts.inviteCode
27
+ : process.env.PEEKABLE_INVITE_CODE;
28
+ const body = {
25
29
  name: opts.name,
26
30
  email: opts.email,
31
+ };
32
+ if (inviteCode) {
33
+ body.invite_code = inviteCode;
34
+ }
35
+ const data = await apiNoAuth(opts.url, "POST", "/register", {
36
+ ...body,
27
37
  });
28
38
  writeConfig({
29
39
  url: opts.url,
@@ -0,0 +1,21 @@
1
+ import { Command } from "commander";
2
+ import { rmSync } from "fs";
3
+ type RemoveTarget = typeof rmSync;
4
+ type UninstallTarget = {
5
+ label: string;
6
+ path: string;
7
+ existed: boolean;
8
+ removed: boolean;
9
+ error?: string;
10
+ };
11
+ type UninstallResult = {
12
+ status: "ok" | "partial";
13
+ targets: UninstallTarget[];
14
+ npmUninstallCommand: string;
15
+ note: string;
16
+ };
17
+ export declare function removeLocalPeekableFiles(home?: string, removeTarget?: RemoveTarget): UninstallResult;
18
+ export declare function formatUninstallResult(result: UninstallResult): string;
19
+ export declare function createUninstallCommand(): Command;
20
+ export declare const uninstallCommand: Command;
21
+ export {};
@@ -0,0 +1,85 @@
1
+ import { Command } from "commander";
2
+ import { existsSync, rmSync } from "fs";
3
+ import { homedir } from "os";
4
+ import { getClaudePeekableSkillDir, getCodexPeekableSkillDir, getPeekableConfigDir } from "../paths.js";
5
+ function getUninstallTargets(home = homedir()) {
6
+ return [
7
+ {
8
+ label: "Peekable config",
9
+ path: getPeekableConfigDir(home),
10
+ },
11
+ {
12
+ label: "Claude Code skill",
13
+ path: getClaudePeekableSkillDir(home),
14
+ },
15
+ {
16
+ label: "Codex skill",
17
+ path: getCodexPeekableSkillDir(home),
18
+ },
19
+ ];
20
+ }
21
+ export function removeLocalPeekableFiles(home = homedir(), removeTarget = rmSync) {
22
+ const targets = getUninstallTargets(home).map((target) => {
23
+ const existed = existsSync(target.path);
24
+ if (!existed) {
25
+ return { ...target, existed, removed: false };
26
+ }
27
+ try {
28
+ removeTarget(target.path, { recursive: true, force: true });
29
+ }
30
+ catch (error) {
31
+ return {
32
+ ...target,
33
+ existed,
34
+ removed: false,
35
+ error: error instanceof Error ? error.message : String(error),
36
+ };
37
+ }
38
+ return {
39
+ ...target,
40
+ existed,
41
+ removed: !existsSync(target.path),
42
+ };
43
+ });
44
+ const hasFailure = targets.some((target) => target.error || (target.existed && !target.removed));
45
+ return {
46
+ status: hasFailure ? "partial" : "ok",
47
+ targets,
48
+ npmUninstallCommand: "npm uninstall -g peekable",
49
+ note: "This only removes local Peekable files. It does not delete hosted sessions or account data.",
50
+ };
51
+ }
52
+ export function formatUninstallResult(result) {
53
+ const lines = result.targets.map((target) => {
54
+ if (target.error) {
55
+ return `${target.label}: failed to remove (${target.path}) - ${target.error}`;
56
+ }
57
+ if (!target.existed) {
58
+ return `${target.label}: not found (${target.path})`;
59
+ }
60
+ return `${target.label}: ${target.removed ? "removed" : "not removed"} (${target.path})`;
61
+ });
62
+ lines.push("");
63
+ lines.push(`To remove the CLI package, run: ${result.npmUninstallCommand}`);
64
+ lines.push(result.note);
65
+ lines.push("For hosted account or data deletion, contact support.");
66
+ return lines.join("\n");
67
+ }
68
+ export function createUninstallCommand() {
69
+ return new Command("uninstall")
70
+ .description("Remove local Peekable config and installed Claude Code skill")
71
+ .option("--json", "Output JSON")
72
+ .action((opts) => {
73
+ const result = removeLocalPeekableFiles();
74
+ if (opts.json) {
75
+ console.log(JSON.stringify(result));
76
+ }
77
+ else {
78
+ console.log(formatUninstallResult(result));
79
+ }
80
+ if (result.status !== "ok") {
81
+ process.exitCode = 1;
82
+ }
83
+ });
84
+ }
85
+ export const uninstallCommand = createUninstallCommand();
package/dist/config.js CHANGED
@@ -1,8 +1,7 @@
1
1
  import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
2
- import { join } from "path";
3
- import { homedir } from "os";
4
- const CONFIG_DIR = join(homedir(), ".peekable");
5
- const CONFIG_FILE = join(CONFIG_DIR, "config.json");
2
+ import { getPeekableConfigDir, getPeekableConfigFile } from "./paths.js";
3
+ const CONFIG_DIR = getPeekableConfigDir();
4
+ const CONFIG_FILE = getPeekableConfigFile();
6
5
  export function readConfig() {
7
6
  if (process.env.SHARE_URL && process.env.SHARE_API_KEY) {
8
7
  return {
package/dist/index.js CHANGED
@@ -11,10 +11,13 @@ import { watchCommand } from "./commands/watch.js";
11
11
  import { resolveCommand } from "./commands/resolve.js";
12
12
  import { proxyCommand } from "./commands/proxy.js";
13
13
  import { pushUrlCommand } from "./commands/push-url.js";
14
+ import { doctorCommand } from "./commands/doctor.js";
15
+ import { uninstallCommand } from "./commands/uninstall.js";
16
+ import { CLI_VERSION } from "./version.js";
14
17
  program
15
18
  .name("peekable")
16
19
  .description("Share HTML mockups with collaborators via peekable")
17
- .version("0.1.1");
20
+ .version(CLI_VERSION);
18
21
  program.addCommand(registerCommand);
19
22
  program.addCommand(initCommand);
20
23
  program.addCommand(createCommand);
@@ -26,4 +29,6 @@ program.addCommand(closeCommand);
26
29
  program.addCommand(watchCommand);
27
30
  program.addCommand(resolveCommand);
28
31
  program.addCommand(proxyCommand);
32
+ program.addCommand(doctorCommand);
33
+ program.addCommand(uninstallCommand);
29
34
  program.parse();
@@ -0,0 +1,6 @@
1
+ export declare function getPeekableConfigDir(home?: string): string;
2
+ export declare function getPeekableConfigFile(home?: string): string;
3
+ export declare function getClaudeDir(home?: string): string;
4
+ export declare function getClaudePeekableSkillDir(home?: string): string;
5
+ export declare function getCodexDir(home?: string): string;
6
+ export declare function getCodexPeekableSkillDir(home?: string): string;
package/dist/paths.js ADDED
@@ -0,0 +1,20 @@
1
+ import { homedir } from "os";
2
+ import { join } from "path";
3
+ export function getPeekableConfigDir(home = homedir()) {
4
+ return join(home, ".peekable");
5
+ }
6
+ export function getPeekableConfigFile(home = homedir()) {
7
+ return join(getPeekableConfigDir(home), "config.json");
8
+ }
9
+ export function getClaudeDir(home = homedir()) {
10
+ return join(home, ".claude");
11
+ }
12
+ export function getClaudePeekableSkillDir(home = homedir()) {
13
+ return join(getClaudeDir(home), "skills", "peekable");
14
+ }
15
+ export function getCodexDir(home = homedir()) {
16
+ return join(home, ".codex");
17
+ }
18
+ export function getCodexPeekableSkillDir(home = homedir()) {
19
+ return join(getCodexDir(home), "skills", "peekable");
20
+ }
@@ -0,0 +1 @@
1
+ export declare function formatQuota(active: number | null, max: number | null, unlimited?: boolean): string;
package/dist/quota.js ADDED
@@ -0,0 +1,7 @@
1
+ export function formatQuota(active, max, unlimited) {
2
+ if (unlimited)
3
+ return active === null ? "unknown / unlimited" : `${active} / unlimited`;
4
+ if (active === null || max === null)
5
+ return "unknown";
6
+ return `${active} / ${max}`;
7
+ }
@@ -0,0 +1 @@
1
+ export declare const CLI_VERSION = "0.1.3";
@@ -0,0 +1 @@
1
+ export const CLI_VERSION = "0.1.3";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "peekable",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "type": "module",
5
5
  "description": "Share HTML mockups with collaborators — CLI for peekable-server",
6
6
  "bin": {
package/skill/SKILL.md CHANGED
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: peekable
3
- description: Share HTML mockups with collaborators via public URLs. Push from Claude Code, collect structured feedback and element-level annotations. Use when the user says "share this", "share with", "/share", "/peekable", "peekable", or wants to get a collaborator's feedback on a mockup.
3
+ description: Share HTML mockups with collaborators via public URLs. Push from Claude Code or Codex, collect structured feedback and element-level annotations. Use when the user says "share this", "share with", "/share", "/peekable", "peekable", or wants to get a collaborator's feedback on a mockup.
4
4
  ---
5
5
 
6
6
  # Peekable
@@ -18,21 +18,21 @@ When the user wants to share an HTML file (playground, mockup, brainstorming com
18
18
  1. Create a session: `peekable create "<name>" --json`
19
19
  2. Inspect the file before pushing:
20
20
  - If it contains `<html>` or `<body>`, push it directly: `peekable push <session-id> <file-path> --json`
21
- - If it looks like an HTML fragment, do not push it blindly. If you know the rendered page URL, snapshot that instead: `peekable push-url <session-id> <url> --json`
22
- - If it looks like a fragment and no rendered URL is known, ask for the localhost/page URL.
21
+ - If it looks like an HTML fragment, do not push it blindly. If you know a localhost URL that returns the full HTML response, snapshot that instead: `peekable push-url <session-id> <url> --json`
22
+ - If it looks like a fragment and no localhost/page URL is known, ask for it.
23
23
  3. Return the URL to the user
24
24
 
25
25
  If a session already exists for this topic, reuse it (push creates a new version, collaborators auto-reload).
26
26
 
27
- ### Snapshot a rendered page
27
+ ### Snapshot an HTML response
28
28
 
29
- When a local companion page or simple server wraps fragment content with styles, use:
29
+ When a local companion page or simple server returns a mostly self-contained HTML response, use:
30
30
 
31
31
  ```bash
32
32
  peekable push-url <session-id> <url> --json
33
33
  ```
34
34
 
35
- Use this for rendered pages where the useful CSS/layout comes from the running page shell. `push-url` fetches the HTML response at the URL and pushes that snapshot. It accepts any `http` or `https` URL; authenticated pages may snapshot as logged-out unless the page is publicly reachable without browser cookies.
35
+ `push-url` fetches the HTML response at the URL and pushes that snapshot. It does not execute JavaScript, use browser cookies, or inline external assets. Prefer `peekable proxy` for live React/Vite/Next apps with routes/assets/interactivity. It snapshots localhost by default; remote URLs require `--allow-remote --yes`, and private-network URLs also require `--allow-private`.
36
36
 
37
37
  ### Check feedback
38
38
 
@@ -72,6 +72,24 @@ peekable list --json
72
72
  peekable close <session-id> --json
73
73
  ```
74
74
 
75
+ Free hosted accounts have an active-session cap. If create/proxy returns a limit error, run `peekable list --json`, close stale sessions, then retry.
76
+
77
+ ### Diagnose setup
78
+
79
+ When sharing fails, auth looks broken, or a user asks for help debugging:
80
+
81
+ ```bash
82
+ peekable doctor --json
83
+ ```
84
+
85
+ For a deeper check that creates, pushes, and closes a temporary session:
86
+
87
+ ```bash
88
+ peekable doctor --test-push --json
89
+ ```
90
+
91
+ Doctor output is designed to be safe for support: it does not include API keys, HTML payloads, or annotation text.
92
+
75
93
  ### Watch for annotations
76
94
 
77
95
  Start a background listener for annotation notifications: