agentplan-cli 0.1.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/dist/api.js ADDED
@@ -0,0 +1,79 @@
1
+ export const DEFAULT_API_URL = "https://agentplan.app";
2
+ export class ApiError extends Error {
3
+ status;
4
+ code;
5
+ constructor(status, code, message) {
6
+ super(message);
7
+ this.status = status;
8
+ this.code = code;
9
+ }
10
+ }
11
+ export class AgentPlanApi {
12
+ baseUrl;
13
+ token;
14
+ constructor(baseUrl, token) {
15
+ this.baseUrl = baseUrl;
16
+ this.token = token;
17
+ }
18
+ async request(path, init = {}) {
19
+ let response;
20
+ try {
21
+ response = await fetch(`${this.baseUrl}${path}`, {
22
+ ...init,
23
+ // API endpoints are canonical. Refuse redirects so a custom or
24
+ // compromised endpoint cannot forward the bearer token elsewhere.
25
+ redirect: "error",
26
+ headers: {
27
+ authorization: `Bearer ${this.token}`,
28
+ ...(init.headers ?? {}),
29
+ },
30
+ });
31
+ }
32
+ catch (error) {
33
+ throw new ApiError(0, "NETWORK_ERROR", `Could not reach ${this.baseUrl}: ${String(error)}`);
34
+ }
35
+ if (response.status === 204)
36
+ return undefined;
37
+ let body;
38
+ try {
39
+ body = await response.json();
40
+ }
41
+ catch {
42
+ throw new ApiError(response.status, "BAD_RESPONSE", `Unexpected response (${response.status}).`);
43
+ }
44
+ if (!response.ok) {
45
+ const error = body.error;
46
+ throw new ApiError(response.status, error?.code ?? "UNKNOWN_ERROR", error?.message ?? `Request failed (${response.status}).`);
47
+ }
48
+ return body;
49
+ }
50
+ uploadForm(bytes, filename, fields) {
51
+ const form = new FormData();
52
+ form.set("file", new File([new Uint8Array(bytes)], filename, { type: "text/html" }));
53
+ if (fields.title)
54
+ form.set("title", fields.title);
55
+ if (fields.visibility)
56
+ form.set("visibility", fields.visibility);
57
+ if (fields.password)
58
+ form.set("password", fields.password);
59
+ return form;
60
+ }
61
+ listDrafts() {
62
+ return this.request("/api/v1/drafts");
63
+ }
64
+ getDraft(id) {
65
+ return this.request(`/api/v1/drafts/${encodeURIComponent(id)}`);
66
+ }
67
+ createDraft(bytes, filename, fields) {
68
+ return this.request("/api/v1/drafts", {
69
+ method: "POST",
70
+ body: this.uploadForm(bytes, filename, fields),
71
+ });
72
+ }
73
+ addVersion(draftId, bytes, filename) {
74
+ return this.request(`/api/v1/drafts/${encodeURIComponent(draftId)}/versions`, {
75
+ method: "POST",
76
+ body: this.uploadForm(bytes, filename, {}),
77
+ });
78
+ }
79
+ }
package/dist/config.js ADDED
@@ -0,0 +1,42 @@
1
+ import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ /** OS configuration directory — never the project directory. */
5
+ export function configDir() {
6
+ if (process.platform === "win32") {
7
+ const base = process.env.APPDATA ?? path.join(os.homedir(), "AppData", "Roaming");
8
+ return path.join(base, "agentplan");
9
+ }
10
+ const base = process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config");
11
+ return path.join(base, "agentplan");
12
+ }
13
+ function configPath() {
14
+ return path.join(configDir(), "config.json");
15
+ }
16
+ export async function loadConfig() {
17
+ try {
18
+ const raw = await readFile(configPath(), "utf8");
19
+ const parsed = JSON.parse(raw);
20
+ if (typeof parsed !== "object" || parsed === null)
21
+ return {};
22
+ const record = parsed;
23
+ return {
24
+ token: typeof record.token === "string" ? record.token : undefined,
25
+ apiUrl: typeof record.apiUrl === "string" ? record.apiUrl : undefined,
26
+ };
27
+ }
28
+ catch {
29
+ return {};
30
+ }
31
+ }
32
+ export async function saveConfig(config) {
33
+ await mkdir(configDir(), { recursive: true, mode: 0o700 });
34
+ await writeFile(configPath(), `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
35
+ if (process.platform !== "win32") {
36
+ // writeFile's mode only applies on creation; enforce on every save.
37
+ await chmod(configPath(), 0o600);
38
+ }
39
+ }
40
+ export async function clearConfig() {
41
+ await rm(configPath(), { force: true });
42
+ }
package/dist/index.js ADDED
@@ -0,0 +1,278 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from "node:child_process";
3
+ import { readFile, stat } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { parseArgs } from "node:util";
6
+ import { AgentPlanApi, ApiError, DEFAULT_API_URL } from "./api.js";
7
+ import { clearConfig, loadConfig, saveConfig } from "./config.js";
8
+ import { hasNewDraftOnlyOptions } from "./upload-options.js";
9
+ import { isSafeHttpUrl, normalizeApiBaseUrl } from "./url.js";
10
+ const MAX_UPLOAD_BYTES = 2 * 1024 * 1024;
11
+ const USAGE = `agentplan — publish agent-generated HTML behind stable links
12
+
13
+ Usage:
14
+ agentplan login store an API token (created in the dashboard)
15
+ agentplan logout remove the stored token
16
+ agentplan upload <file.html> upload a new draft (private by default)
17
+ --public | --private set visibility
18
+ --password <password> protect the draft with a password
19
+ --password-stdin read the draft password from stdin (safer)
20
+ --title <title> set the draft title
21
+ --draft <id> add a version to an existing draft
22
+ --json machine-readable output on stdout
23
+ agentplan list [--json] list your drafts
24
+ agentplan open <id> open a draft in the browser
25
+
26
+ Environment:
27
+ AGENTPLAN_TOKEN API token (takes precedence over stored login)
28
+ AGENTPLAN_API_URL API base URL (default: ${DEFAULT_API_URL})
29
+ `;
30
+ function fail(message, exitCode = 1) {
31
+ process.stderr.write(`agentplan: ${message}\n`);
32
+ process.exit(exitCode);
33
+ }
34
+ function apiUrl(config) {
35
+ return normalizeApiBaseUrl(process.env.AGENTPLAN_API_URL ?? config.apiUrl ?? DEFAULT_API_URL);
36
+ }
37
+ async function hiddenLine(prompt) {
38
+ const input = process.stdin;
39
+ if (!input.isTTY || typeof input.setRawMode !== "function") {
40
+ fail("A secure terminal is required. Set AGENTPLAN_TOKEN to read it from the environment.");
41
+ }
42
+ process.stderr.write(prompt);
43
+ const wasRaw = input.isRaw;
44
+ input.setRawMode(true);
45
+ input.setEncoding("utf8");
46
+ input.resume();
47
+ return new Promise((resolve, reject) => {
48
+ let value = "";
49
+ const cleanup = () => {
50
+ input.removeListener("data", onData);
51
+ input.setRawMode(Boolean(wasRaw));
52
+ input.pause();
53
+ process.stderr.write("\n");
54
+ };
55
+ const onData = (chunk) => {
56
+ for (const character of String(chunk)) {
57
+ if (character === "\r" || character === "\n") {
58
+ cleanup();
59
+ resolve(value);
60
+ return;
61
+ }
62
+ if (character === "\u0003" || character === "\u0004") {
63
+ cleanup();
64
+ reject(new Error("Token entry cancelled."));
65
+ return;
66
+ }
67
+ if (character === "\u007f" || character === "\b") {
68
+ value = value.slice(0, -1);
69
+ continue;
70
+ }
71
+ if (character >= " " && character <= "~" && value.length < 512)
72
+ value += character;
73
+ }
74
+ };
75
+ input.on("data", onData);
76
+ });
77
+ }
78
+ async function promptForToken() {
79
+ if (!process.stdin.isTTY) {
80
+ fail("No API token. Set AGENTPLAN_TOKEN or run `agentplan login` in a terminal.");
81
+ }
82
+ process.stderr.write("Create a token in the dashboard: Settings → API tokens\n");
83
+ const token = (await hiddenLine("Paste your API token: ")).trim();
84
+ if (!token.startsWith("ap_live_"))
85
+ fail("That does not look like an AgentPlan token.");
86
+ return token;
87
+ }
88
+ async function resolveApi() {
89
+ const config = await loadConfig();
90
+ const base = apiUrl(config);
91
+ if (process.env.AGENTPLAN_TOKEN)
92
+ return new AgentPlanApi(base, process.env.AGENTPLAN_TOKEN);
93
+ if (config.token)
94
+ return new AgentPlanApi(base, config.token);
95
+ const token = await promptForToken();
96
+ await saveConfig({ ...config, token });
97
+ process.stderr.write("Token saved.\n");
98
+ return new AgentPlanApi(base, token);
99
+ }
100
+ async function verifyToken(api) {
101
+ await api.listDrafts();
102
+ }
103
+ async function commandLogin() {
104
+ const config = await loadConfig();
105
+ const token = await promptForToken();
106
+ const api = new AgentPlanApi(apiUrl(config), token);
107
+ await verifyToken(api);
108
+ await saveConfig({ ...config, token });
109
+ process.stderr.write("Logged in. Token verified and saved.\n");
110
+ }
111
+ async function commandLogout() {
112
+ await clearConfig();
113
+ process.stderr.write("Logged out. Stored token removed.\n");
114
+ }
115
+ async function readHtmlFile(filePath) {
116
+ const filename = path.basename(filePath);
117
+ if (!/\.html?$/i.test(filename))
118
+ fail("Only .html and .htm files are supported.", 2);
119
+ let size;
120
+ try {
121
+ size = (await stat(filePath)).size;
122
+ }
123
+ catch {
124
+ fail(`Cannot read ${filePath}.`, 2);
125
+ }
126
+ if (size === 0)
127
+ fail("The file is empty.", 2);
128
+ if (size > MAX_UPLOAD_BYTES)
129
+ fail("The file exceeds the 2 MiB limit.", 2);
130
+ return { bytes: new Uint8Array(await readFile(filePath)), filename };
131
+ }
132
+ async function readPasswordFromStdin() {
133
+ if (process.stdin.isTTY) {
134
+ fail("--password-stdin requires a pipe or redirected stdin.", 2);
135
+ }
136
+ process.stdin.setEncoding("utf8");
137
+ let password = "";
138
+ for await (const chunk of process.stdin) {
139
+ password += chunk;
140
+ if (password.length > 130)
141
+ fail("The password exceeds the 128 character limit.", 2);
142
+ }
143
+ password = password.replace(/\r?\n$/, "");
144
+ if (!password)
145
+ fail("No password was provided on stdin.", 2);
146
+ if (password.length > 128)
147
+ fail("The password exceeds the 128 character limit.", 2);
148
+ return password;
149
+ }
150
+ function printDraft(draft, action) {
151
+ process.stdout.write(`${action} ${draft.title}\nVisibility: ${draft.visibility}\nVersion: ${draft.version ?? "-"}\n${draft.url}\n`);
152
+ }
153
+ async function commandUpload(file, flags) {
154
+ if (!file)
155
+ fail("Usage: agentplan upload <file.html>", 2);
156
+ if (flags.password !== undefined && flags["password-stdin"]) {
157
+ fail("Use only one of --password or --password-stdin.", 2);
158
+ }
159
+ const hasPasswordOption = flags.password !== undefined || flags["password-stdin"];
160
+ const chosen = [flags.public, flags.private, hasPasswordOption].filter(Boolean).length;
161
+ if (chosen > 1) {
162
+ fail("Use only one of --public, --private, or a password option.", 2);
163
+ }
164
+ if (flags.draft && hasNewDraftOnlyOptions(flags)) {
165
+ fail("--draft only uploads a new version; visibility, password, and title options apply only when creating a draft.", 2);
166
+ }
167
+ const password = flags["password-stdin"] ? await readPasswordFromStdin() : flags.password;
168
+ const { bytes, filename } = await readHtmlFile(file);
169
+ const api = await resolveApi();
170
+ if (flags.draft) {
171
+ const result = await api.addVersion(flags.draft, bytes, filename);
172
+ if (flags.json) {
173
+ process.stdout.write(`${JSON.stringify(result)}\n`);
174
+ }
175
+ else {
176
+ printDraft(result.draft, "Uploaded new version of");
177
+ }
178
+ return;
179
+ }
180
+ const visibility = flags.public
181
+ ? "public"
182
+ : password !== undefined
183
+ ? "password"
184
+ : flags.private
185
+ ? "private"
186
+ : undefined;
187
+ const result = await api.createDraft(bytes, filename, {
188
+ title: flags.title,
189
+ visibility,
190
+ password,
191
+ });
192
+ if (flags.json) {
193
+ process.stdout.write(`${JSON.stringify(result)}\n`);
194
+ }
195
+ else {
196
+ printDraft(result.draft, "Uploaded");
197
+ }
198
+ }
199
+ async function commandList(flags) {
200
+ const api = await resolveApi();
201
+ const result = await api.listDrafts();
202
+ if (flags.json) {
203
+ process.stdout.write(`${JSON.stringify(result)}\n`);
204
+ return;
205
+ }
206
+ if (result.drafts.length === 0) {
207
+ process.stderr.write("No drafts yet. Upload one with `agentplan upload ./plan.html`.\n");
208
+ return;
209
+ }
210
+ for (const draft of result.drafts) {
211
+ process.stdout.write(`${draft.visibility.padEnd(7)} v${String(draft.version ?? "-").padEnd(3)} ${draft.title} — ${draft.url}\n`);
212
+ }
213
+ }
214
+ async function commandOpen(id) {
215
+ if (!id)
216
+ fail("Usage: agentplan open <id>", 2);
217
+ const api = await resolveApi();
218
+ const { draft } = await api.getDraft(id);
219
+ // The URL comes from an HTTP response and is untrusted; refuse anything that
220
+ // is not a plain, metacharacter-free http(s) URL before handing it to the OS.
221
+ if (!isSafeHttpUrl(draft.url))
222
+ fail(`Server returned an unsafe URL: ${draft.url}`);
223
+ const url = draft.url;
224
+ // Never launch through a shell: pass the URL as a discrete argument so no
225
+ // interpreter can act on its contents.
226
+ const [opener, args] = process.platform === "darwin"
227
+ ? ["open", [url]]
228
+ : process.platform === "win32"
229
+ ? ["rundll32.exe", ["url.dll,FileProtocolHandler", url]]
230
+ : ["xdg-open", [url]];
231
+ spawn(opener, args, { shell: false, detached: true, stdio: "ignore" }).unref();
232
+ process.stderr.write(`Opening ${url}\n`);
233
+ }
234
+ async function main() {
235
+ const { values, positionals } = parseArgs({
236
+ args: process.argv.slice(2),
237
+ options: {
238
+ public: { type: "boolean" },
239
+ private: { type: "boolean" },
240
+ password: { type: "string" },
241
+ "password-stdin": { type: "boolean" },
242
+ title: { type: "string" },
243
+ draft: { type: "string" },
244
+ json: { type: "boolean" },
245
+ help: { type: "boolean", short: "h" },
246
+ },
247
+ allowPositionals: true,
248
+ });
249
+ const [command, argument] = positionals;
250
+ if (values.help || !command) {
251
+ process.stderr.write(USAGE);
252
+ process.exit(values.help ? 0 : 2);
253
+ }
254
+ switch (command) {
255
+ case "login":
256
+ return commandLogin();
257
+ case "logout":
258
+ return commandLogout();
259
+ case "upload":
260
+ return commandUpload(argument, values);
261
+ case "list":
262
+ return commandList(values);
263
+ case "open":
264
+ return commandOpen(argument);
265
+ default:
266
+ process.stderr.write(USAGE);
267
+ fail(`Unknown command: ${command}`, 2);
268
+ }
269
+ }
270
+ main().catch((error) => {
271
+ if (error instanceof ApiError) {
272
+ if (error.status === 401) {
273
+ fail(`${error.message} Run \`agentplan login\` with a valid token.`);
274
+ }
275
+ fail(`${error.code}: ${error.message}`);
276
+ }
277
+ fail(error instanceof Error ? error.message : String(error));
278
+ });
@@ -0,0 +1,8 @@
1
+ /** Options that the version-upload endpoint cannot apply must never be ignored. */
2
+ export function hasNewDraftOnlyOptions(flags) {
3
+ return Boolean(flags.public ||
4
+ flags.private ||
5
+ flags.password !== undefined ||
6
+ flags["password-stdin"] ||
7
+ flags.title !== undefined);
8
+ }
package/dist/url.js ADDED
@@ -0,0 +1,38 @@
1
+ function isLocalHostname(hostname) {
2
+ return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]";
3
+ }
4
+ function parseSecureHttpUrl(candidate) {
5
+ let parsed;
6
+ try {
7
+ parsed = new URL(candidate);
8
+ }
9
+ catch {
10
+ return null;
11
+ }
12
+ if (parsed.username || parsed.password)
13
+ return null;
14
+ if (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && isLocalHostname(parsed.hostname))) {
15
+ return null;
16
+ }
17
+ return parsed;
18
+ }
19
+ /** Validates the bearer-token destination before any request is made. */
20
+ export function normalizeApiBaseUrl(candidate) {
21
+ const parsed = parseSecureHttpUrl(candidate);
22
+ if (!parsed || parsed.search || parsed.hash) {
23
+ throw new Error("API URL must use HTTPS (HTTP is allowed only for localhost).");
24
+ }
25
+ return parsed.toString().replace(/\/$/, "");
26
+ }
27
+ /**
28
+ * True only for secure, metacharacter-free URLs safe to pass to an OS opener.
29
+ * Server responses are untrusted, so this guards `agentplan open` against both
30
+ * command injection and unexpected cleartext navigation.
31
+ */
32
+ export function isSafeHttpUrl(candidate) {
33
+ if (!parseSecureHttpUrl(candidate))
34
+ return false;
35
+ // Conservative allowlist: agentplan URLs are always https://host/p/<slug>.
36
+ // Excludes shell/cmd metacharacters (& | % ^ ! < > ( ) ; ' " * $ etc.).
37
+ return /^[A-Za-z0-9\-._~:/?#@]+$/.test(candidate);
38
+ }
package/package.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "agentplan-cli",
3
+ "version": "0.1.0",
4
+ "description": "CLI for agentplan.app — publish agent-generated HTML behind stable links.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "agentplan": "dist/index.js"
9
+ },
10
+ "files": [
11
+ "dist"
12
+ ],
13
+ "engines": {
14
+ "node": ">=20"
15
+ },
16
+ "scripts": {
17
+ "build": "tsc -p tsconfig.json",
18
+ "prepack": "npm run build",
19
+ "typecheck": "tsc -p tsconfig.json --noEmit"
20
+ },
21
+ "devDependencies": {
22
+ "@types/node": "^24.0.0",
23
+ "typescript": "^5.9.0"
24
+ }
25
+ }