@paneui/cli 0.0.9 → 0.0.10

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/store.js CHANGED
@@ -1,11 +1,35 @@
1
1
  // Persisted CLI config: ${XDG_CONFIG_HOME or ~/.config}/pane/config.json.
2
2
  //
3
- // Holds the relay URL and the agent API key obtained via `pane agent register`, so
4
- // later commands need no env vars. The file holds a secret — it is written
5
- // 0600. Tiny and synchronous; no deps.
3
+ // Holds one or more named profiles. Each profile is one agent identity on
4
+ // one relay (url, api_key). Switching profiles is the multi-environment
5
+ // story: dev / staging / prod, or personal / work agents on the same relay,
6
+ // without re-running `pane agent register` between them.
7
+ //
8
+ // On-disk shape:
9
+ //
10
+ // {
11
+ // "current_profile": "prod",
12
+ // "profiles": {
13
+ // "prod": { "url": "https://…", "api_key": "pane_…" },
14
+ // "dev": { "url": "http://localhost:3000", "api_key": "pane_…" }
15
+ // }
16
+ // }
17
+ //
18
+ // Tiny and synchronous; no deps. Holds secrets — files written mode 0600.
6
19
  import { readFileSync, writeFileSync, mkdirSync, chmodSync, rmSync, } from "node:fs";
7
20
  import { homedir } from "node:os";
8
21
  import { join, dirname } from "node:path";
22
+ /**
23
+ * Default profile name when the user runs `pane agent register` without
24
+ * `--profile` on a fresh install. Stable, predictable, and short enough to
25
+ * type in `pane --profile default …` if needed.
26
+ */
27
+ export const DEFAULT_PROFILE_NAME = "default";
28
+ /** Profile-name validation (a-z, A-Z, 0-9, _ and -, 1..32 chars). */
29
+ const PROFILE_NAME_RX = /^[A-Za-z0-9_-]{1,32}$/;
30
+ export function isValidProfileName(name) {
31
+ return PROFILE_NAME_RX.test(name);
32
+ }
9
33
  /** Absolute path to the config file (honours XDG_CONFIG_HOME). */
10
34
  export function storePath() {
11
35
  const base = process.env.XDG_CONFIG_HOME && process.env.XDG_CONFIG_HOME.trim() !== ""
@@ -13,52 +37,169 @@ export function storePath() {
13
37
  : join(homedir(), ".config");
14
38
  return join(base, "pane", "config.json");
15
39
  }
16
- /** Read the persisted config. Returns {} if the file is missing or unparseable. */
40
+ /**
41
+ * Read the persisted config. Returns an empty store if the file is missing,
42
+ * unparseable, or doesn't carry a `profiles` object.
43
+ */
17
44
  export function readStore() {
18
45
  let text;
19
46
  try {
20
47
  text = readFileSync(storePath(), "utf8");
21
48
  }
22
49
  catch {
23
- return {};
50
+ return { profiles: {} };
24
51
  }
52
+ let parsed;
25
53
  try {
26
- const parsed = JSON.parse(text);
27
- if (parsed === null ||
28
- typeof parsed !== "object" ||
29
- Array.isArray(parsed)) {
30
- return {};
31
- }
32
- const out = {};
33
- if (typeof parsed.url === "string")
34
- out.url = parsed.url;
35
- if (typeof parsed.apiKey === "string")
36
- out.apiKey = parsed.apiKey;
37
- return out;
54
+ parsed = JSON.parse(text);
38
55
  }
39
56
  catch {
40
- return {};
57
+ return { profiles: {} };
41
58
  }
59
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
60
+ return { profiles: {} };
61
+ }
62
+ const obj = parsed;
63
+ if (!obj["profiles"] || typeof obj["profiles"] !== "object") {
64
+ return { profiles: {} };
65
+ }
66
+ const rawProfiles = obj["profiles"];
67
+ const profiles = {};
68
+ for (const [name, raw] of Object.entries(rawProfiles)) {
69
+ if (raw === null || typeof raw !== "object")
70
+ continue;
71
+ const p = raw;
72
+ const profile = {};
73
+ if (typeof p["url"] === "string")
74
+ profile.url = p["url"];
75
+ if (typeof p["api_key"] === "string")
76
+ profile.apiKey = p["api_key"];
77
+ profiles[name] = profile;
78
+ }
79
+ const currentProfile = typeof obj["current_profile"] === "string"
80
+ ? obj["current_profile"]
81
+ : undefined;
82
+ // If the named current profile was deleted out-of-band, drop it back to
83
+ // undefined so the resolver can fall through to env / default URL.
84
+ return {
85
+ currentProfile: currentProfile && profiles[currentProfile] !== undefined
86
+ ? currentProfile
87
+ : undefined,
88
+ profiles,
89
+ };
90
+ }
91
+ /** Serialise a Store to the on-disk JSON shape (snake_case fields). */
92
+ function serialize(store) {
93
+ const profilesOut = {};
94
+ for (const [name, p] of Object.entries(store.profiles)) {
95
+ const o = {};
96
+ if (p.url !== undefined)
97
+ o["url"] = p.url;
98
+ if (p.apiKey !== undefined)
99
+ o["api_key"] = p.apiKey;
100
+ profilesOut[name] = o;
101
+ }
102
+ const body = { profiles: profilesOut };
103
+ if (store.currentProfile !== undefined) {
104
+ body["current_profile"] = store.currentProfile;
105
+ }
106
+ return JSON.stringify(body, null, 2) + "\n";
42
107
  }
43
108
  /**
44
- * Merge `patch` into the existing config and write it back as pretty JSON.
45
- * Creates the parent directory if needed; the file is written with mode 0600.
109
+ * Atomically write the whole Store to disk. The file is created with mode
110
+ * 0600 and the parent directory is created as needed.
46
111
  */
47
- export function writeStore(patch) {
112
+ export function writeStoreFull(store) {
48
113
  const path = storePath();
49
- const merged = { ...readStore(), ...patch };
50
114
  mkdirSync(dirname(path), { recursive: true });
51
- writeFileSync(path, JSON.stringify(merged, null, 2) + "\n", { mode: 0o600 });
52
- // Ensure mode even if the file pre-existed with looser permissions.
115
+ writeFileSync(path, serialize(store), { mode: 0o600 });
116
+ // Ensure mode even when the file pre-existed with looser permissions.
53
117
  chmodSync(path, 0o600);
54
118
  return path;
55
119
  }
56
120
  /**
57
- * Delete the persisted config file (URL + API key). Idempotent no error if
58
- * the file never existed. Returns the path it targeted. Used by `pane agent logout`.
121
+ * Upsert a single profile and write back. If `setCurrent` is true, the
122
+ * profile becomes the active one. If the store had no current profile yet
123
+ * (empty store), the newly-written profile becomes current regardless —
124
+ * there's no other choice that makes sense.
125
+ */
126
+ export function upsertProfile(name, patch, setCurrent = false) {
127
+ if (!isValidProfileName(name)) {
128
+ throw new Error(`invalid profile name '${name}' — must match ${PROFILE_NAME_RX} (letters, digits, underscore, dash; 1..32 chars)`);
129
+ }
130
+ const store = readStore();
131
+ const merged = { ...(store.profiles[name] ?? {}), ...patch };
132
+ store.profiles[name] = merged;
133
+ if (setCurrent || store.currentProfile === undefined) {
134
+ store.currentProfile = name;
135
+ }
136
+ return writeStoreFull(store);
137
+ }
138
+ /**
139
+ * Set the active profile by name. Throws if `name` is not in the store.
140
+ * Use `upsertProfile` if you also want to create it.
141
+ */
142
+ export function setCurrentProfile(name) {
143
+ const store = readStore();
144
+ if (store.profiles[name] === undefined) {
145
+ throw new Error(`profile '${name}' does not exist — run 'pane config list' to see available profiles`);
146
+ }
147
+ store.currentProfile = name;
148
+ return writeStoreFull(store);
149
+ }
150
+ /**
151
+ * Remove a profile. If it was current, drop `current_profile` (the resolver
152
+ * falls through to env / default URL). If the resulting store is empty,
153
+ * delete the file entirely so a `readStore` looks identical to "fresh".
154
+ * Returns `{ path, was_current }`. Throws if the profile doesn't exist.
155
+ */
156
+ export function removeProfile(name) {
157
+ const store = readStore();
158
+ if (store.profiles[name] === undefined) {
159
+ throw new Error(`profile '${name}' does not exist`);
160
+ }
161
+ const wasCurrent = store.currentProfile === name;
162
+ delete store.profiles[name];
163
+ if (wasCurrent) {
164
+ store.currentProfile = undefined;
165
+ }
166
+ if (Object.keys(store.profiles).length === 0) {
167
+ // Empty store → delete the file so a subsequent register starts fresh.
168
+ return { path: clearStore(), was_current: wasCurrent };
169
+ }
170
+ return { path: writeStoreFull(store), was_current: wasCurrent };
171
+ }
172
+ /**
173
+ * Delete the persisted config file entirely. Idempotent — no error if the
174
+ * file never existed. Returns the path it targeted. Used by
175
+ * `pane agent logout --all` and `removeProfile` when it drains the last
176
+ * profile.
59
177
  */
60
178
  export function clearStore() {
61
179
  const path = storePath();
62
180
  rmSync(path, { force: true });
63
181
  return path;
64
182
  }
183
+ /**
184
+ * Resolve which profile to load from the store, given the optional selector
185
+ * (`--profile` flag or `PANE_PROFILE` env). Returns `null` if no profile
186
+ * matches — i.e. the caller should fall through to env / default-URL
187
+ * resolution. Throws if `selector` was explicit (truthy) and not found, so
188
+ * a typo in `--profile dev` doesn't silently fall back to the wrong relay.
189
+ */
190
+ export function resolveProfile(store, selector) {
191
+ if (selector !== undefined && selector !== "") {
192
+ const p = store.profiles[selector];
193
+ if (p === undefined) {
194
+ const known = Object.keys(store.profiles).sort().join(", ") || "(none)";
195
+ throw new Error(`profile '${selector}' does not exist (known: ${known}) — run 'pane config list'`);
196
+ }
197
+ return { name: selector, profile: p };
198
+ }
199
+ if (store.currentProfile !== undefined) {
200
+ const p = store.profiles[store.currentProfile];
201
+ if (p !== undefined)
202
+ return { name: store.currentProfile, profile: p };
203
+ }
204
+ return null;
205
+ }
package/dist/upgrade.js CHANGED
@@ -40,7 +40,7 @@ export function detectInstallMethod(entryPath) {
40
40
  }
41
41
  // npx caches the package under ~/Library/Caches/_npx (macOS) or
42
42
  // ~/.npm/_npx (Linux) and runs it from a node_modules inside that dir.
43
- // Surface this distinctly from a real vendored install: with an npx
43
+ // Pane this distinctly from a real vendored install: with an npx
44
44
  // execution there is no project package.json owning the version and no
45
45
  // global to upgrade — the user runs `npx @paneui/cli@<version>` each
46
46
  // time, so the right answer is "ask the human / re-run with a newer
package/dist/version.js CHANGED
@@ -1,11 +1,11 @@
1
1
  // Single source of truth for the CLI version string.
2
2
  //
3
3
  // - `pane --version` prints this verbatim.
4
- // - Every PaneClient construction passes it as `cliVersion`, which surfaces
4
+ // - Every PaneClient construction passes it as `cliVersion`, which panes
5
5
  // as the `x-pane-cli-version` header on every relay request — drives the
6
6
  // relay's version-skew check (HTTP 426 `cli_upgrade_required`).
7
7
  //
8
8
  // Keep this in lockstep with packages/cli/package.json's `version` field;
9
9
  // they're consulted in different places (here for the runtime header,
10
10
  // package.json for npm publish + dependency resolution).
11
- export const VERSION = "0.0.9";
11
+ export const VERSION = "0.0.10";
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@paneui/cli",
3
- "version": "0.0.9",
4
- "description": "Command-line client for the Pane relay: create surfaces, inspect state, send and watch events.",
3
+ "version": "0.0.10",
4
+ "description": "Command-line client for the Pane relay: create panes, inspect state, send and watch events.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "keywords": [
@@ -41,10 +41,12 @@
41
41
  "test:unit": "vitest run"
42
42
  },
43
43
  "dependencies": {
44
- "@paneui/core": "^0.0.9"
44
+ "@paneui/core": "^0.0.10",
45
+ "qrcode-terminal": "^0.12.0"
45
46
  },
46
47
  "devDependencies": {
47
48
  "@types/node": "^25.9.1",
49
+ "@types/qrcode-terminal": "^0.12.2",
48
50
  "typescript": "^6.0.3",
49
51
  "vitest": "^4.1.6"
50
52
  }
@@ -1,118 +0,0 @@
1
- // `pane surface` — the central noun of pane: open, observe, send to, and
2
- // close a surface.
3
- //
4
- // A surface is one *use* of an template: an open URL the human(s) interact
5
- // with, plus an event log the agent reads and appends to. Every other noun
6
- // (template, attachment, key, taste, feedback) exists in service of surfaces.
7
- //
8
- // This file is a thin dispatcher — each verb's actual logic lives in its own
9
- // file (create.ts, state.ts, send.ts, watch.ts, delete.ts). The verb runners
10
- // expect the surface id at positionals[0]; we slice off our own verb before
11
- // delegating so they don't need to know they're being called via `surface`.
12
- import { runCreate } from "./create.js";
13
- import { runState } from "./state.js";
14
- import { runSend } from "./send.js";
15
- import { runWatch } from "./watch.js";
16
- import { runDelete } from "./delete.js";
17
- import { runList, listHelp } from "./list.js";
18
- import { runParticipant, participantHelp } from "./participant.js";
19
- import { fail } from "../output.js";
20
- export const sessionHelp = `pane surface — open, observe, send to, and close surfaces
21
-
22
- A surface is one use of an template: an open URL the human(s) interact with,
23
- plus an event log the agent reads and appends to.
24
-
25
- Usage:
26
- pane surface <verb> [options]
27
-
28
- Verbs:
29
- create Create a surface (POST /v1/surfaces). Prints surface_id,
30
- urls, tokens, expires_at.
31
- list Enumerate YOUR agent's surfaces. The recovery primitive
32
- for "I dropped the create response" — surfaces are
33
- listable, but participant tokens are stored hashed and
34
- CANNOT be recovered. Use 'participant new' to mint a
35
- fresh URL.
36
- show <id> Non-blocking snapshot: surface metadata + event log.
37
- Supports --wait <secs> for relay-side long-polling.
38
- send <id> Emit an agent event into a surface.
39
- watch <id> Stream a surface's events as JSON-lines on stdout
40
- (long-lived; the building block for pipe-readers).
41
- delete <id> Close/delete a surface (DELETE /v1/surfaces/:id).
42
- participant List / mint / revoke participant URLs on an existing
43
- <list|new|revoke> surface. 'list' returns the participant ids you need
44
- for 'revoke'; 'new' replaces the destructive 'delete
45
- + recreate' workaround for a lost URL; 'revoke'
46
- invalidates one URL without touching the surface.
47
-
48
- Run \`pane surface <verb> --help\` for verb-specific options.`;
49
- /**
50
- * Build a new ParsedArgs with the leading positional (the verb) stripped.
51
- * The downstream verb runners (runState / runSend / runWatch / runDelete)
52
- * read the surface id at positionals[0], so we hand them an args object that
53
- * looks exactly like the pre-restructure invocation.
54
- */
55
- function shiftPositionals(args) {
56
- // Propagate danglingValueFlags too — otherwise the leaf runner's
57
- // assertKnownFlags can't tell that the user wrote `--title` without a
58
- // value, and falls through to a less-useful downstream error.
59
- const out = {
60
- positionals: args.positionals.slice(1),
61
- flags: args.flags,
62
- bools: args.bools,
63
- };
64
- if (args.danglingValueFlags !== undefined) {
65
- out.danglingValueFlags = args.danglingValueFlags;
66
- }
67
- return out;
68
- }
69
- export async function runSession(args) {
70
- const verb = args.positionals[0];
71
- // `pane surface participant --help` (verb-level help on the participant
72
- // sub-noun, with no further sub-verb). The general --help pre-empt in
73
- // index.ts only fires when no positional follows the noun; here a
74
- // positional ("participant") is present, so the sub-noun must own its own
75
- // --help routing.
76
- if (verb === "participant" &&
77
- args.bools.has("help") &&
78
- args.positionals.length === 1) {
79
- process.stdout.write(participantHelp + "\n");
80
- return;
81
- }
82
- // `pane surface list --help` — same pattern.
83
- if (verb === "list" &&
84
- args.bools.has("help") &&
85
- args.positionals.length === 1) {
86
- process.stdout.write(listHelp + "\n");
87
- return;
88
- }
89
- const inner = shiftPositionals(args);
90
- switch (verb) {
91
- case "create":
92
- await runCreate(inner);
93
- break;
94
- case "list":
95
- await runList(inner);
96
- break;
97
- case "show":
98
- await runState(inner);
99
- break;
100
- case "send":
101
- await runSend(inner);
102
- break;
103
- case "watch":
104
- await runWatch(inner);
105
- break;
106
- case "delete":
107
- await runDelete(inner);
108
- break;
109
- case "participant":
110
- await runParticipant(inner);
111
- break;
112
- case undefined:
113
- fail("missing verb — usage: pane surface <create|list|show|send|watch|delete|participant> (run 'pane surface --help')", "invalid_args");
114
- break;
115
- default:
116
- fail(`unknown surface verb '${verb}' — expected create|list|show|send|watch|delete|participant (run 'pane surface --help')`, "invalid_args");
117
- }
118
- }