@parley-im/parley 0.1.2 → 0.2.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/cli.js CHANGED
@@ -6,17 +6,19 @@ import { fileURLToPath } from "node:url";
6
6
  import { loadConfig, paths, cleanEnv } from "./config.js";
7
7
  import { Store } from "./db.js";
8
8
  import { ensureServer, stopServer } from "./daemon.js";
9
+ import { enableAutostart, disableAutostart } from "./autostart.js";
10
+ import { VERSION } from "./version.js";
9
11
  import { readState, linkFor, dashboardUrl, isServerUp } from "./state.js";
10
12
  import { transcript, summaryLine } from "./transcript.js";
11
13
  import { startSession, endSession, deleteSession, setReplyLimit } from "./control.js";
12
14
  import { login, readCredentials, clearCredentials, relay as registry } from "./account.js";
13
15
  const HELP = `parley: someone chats with a read-only Claude or Codex running in this project, on this
14
- computer, and you get everything they said. Good for feedback from testers, or any interview.
16
+ computer, and you get everything they said. Good for feedback from guests, or any interview.
15
17
 
16
18
  If you're an agent and the user asks for "a parley" (or to get someone's take on this project):
17
19
 
18
20
  1. From the project folder, make one and give the user the printed link to send:
19
- parley new --tester "Sam" --brief-file - <<'BRIEF'
21
+ parley new --guest "Sam" --brief-file - <<'BRIEF'
20
22
  What this is and how they'll try it, what the user most wants to learn, known issues to
21
23
  skip. Written to the interviewer, in the terms a user sees (no file paths).
22
24
  BRIEF
@@ -28,7 +30,7 @@ With the parley MCP tools (create_session, list_sessions, get_transcript, end_se
28
30
  use those instead; they do the same.
29
31
 
30
32
  Commands
31
- parley new --tester NAME (--brief TEXT | --brief-file FILE|-) Make a session here; prints its link
33
+ parley new --guest NAME (--brief TEXT | --brief-file FILE|-) Make a session here; prints its link
32
34
  [--dir PATH] [--greeting TEXT] [--agent claude|codex] [--model MODEL] [--replies N]
33
35
  parley list [--ended | --all] [--dir PATH] Sessions, with new-message counts and replies used
34
36
  parley show ID [--new] Transcript as Markdown; --new for what's unread
@@ -38,8 +40,10 @@ Commands
38
40
  parley login | logout | whoami Link this computer to parley.im, or unlink it
39
41
  parley start | stop | status The background server (holds the parley.im connection)
40
42
  parley panel Open the control panel (localhost)
41
- parley install Add the parley MCP server to Claude Code and Codex
43
+ parley install Add parley's MCP tools to Claude Code and Codex; start at login
44
+ parley uninstall Undo install (your conversations stay)
42
45
  parley serve | mcp Run the server in the foreground | MCP over stdio
46
+ parley --version
43
47
 
44
48
  Conversations and screenshots live in each project's .parley folder (kept out of git), with a
45
49
  readable transcript.md per session. Settings and your parley.im login live in ${paths.home}.`;
@@ -93,7 +97,7 @@ async function main() {
93
97
  return;
94
98
  }
95
99
  case "stop": {
96
- console.log((await stopServer()) ? "Stopped." : "Not running.");
100
+ console.log((await stopServer(config)) ? "Stopped." : "Not running.");
97
101
  return;
98
102
  }
99
103
  case "status": {
@@ -111,7 +115,7 @@ async function main() {
111
115
  args: rest,
112
116
  options: {
113
117
  dir: { type: "string" },
114
- tester: { type: "string" },
118
+ guest: { type: "string" },
115
119
  brief: { type: "string" },
116
120
  "brief-file": { type: "string" },
117
121
  greeting: { type: "string" },
@@ -123,22 +127,25 @@ async function main() {
123
127
  // "-" reads the brief from stdin, so a long one can come in a heredoc.
124
128
  const file = values["brief-file"];
125
129
  const brief = file ? readFileSync(file === "-" ? 0 : file, "utf8") : values.brief;
126
- if (!values.tester || !brief)
127
- fail("Need --tester and --brief (or --brief-file).");
130
+ if (!values.guest || !brief)
131
+ fail("Need --guest and --brief (or --brief-file).");
128
132
  if (values.agent && values.agent !== "claude" && values.agent !== "codex")
129
133
  fail("--agent is claude or codex.");
134
+ const replies = values.replies === undefined ? null : Number(values.replies);
135
+ if (replies !== null && !(Number.isInteger(replies) && replies > 0))
136
+ fail("--replies is a whole number above zero.");
130
137
  const store = new Store();
131
138
  const made = await startSession(store, config, {
132
139
  projectDir: resolve(values.dir ?? process.cwd()),
133
- tester: values.tester,
140
+ guest: values.guest,
134
141
  brief,
135
142
  greeting: values.greeting,
136
- agent: values.agent ?? "claude",
143
+ agent: values.agent ?? null,
137
144
  model: values.model,
138
- replyLimit: values.replies ? Number(values.replies) : null,
145
+ replyLimit: replies,
139
146
  }).catch((error) => fail(error.message));
140
147
  const { session } = made;
141
- console.log(`Session ${session.id} for ${session.tester} in ${session.project_dir}`);
148
+ console.log(`Session ${session.id} for ${session.guest} in ${session.project_dir}`);
142
149
  console.log(`Link: ${linkFor(session, config)}`);
143
150
  console.log(`Replies allowed: ${session.reply_limit}`);
144
151
  console.log(`Watch: ${dashboardUrl(config, session)}`);
@@ -197,11 +204,12 @@ async function main() {
197
204
  if (session.status !== "open")
198
205
  return console.log(`${session.id} had already ended.`);
199
206
  await endSession(config, store, session.id);
200
- console.log(`Ended ${session.id} (${session.tester} on ${session.project_name}). Its link no longer works.`);
207
+ console.log(`Ended ${session.id} (${session.guest} on ${session.project_name}). Its link no longer works.`);
201
208
  }
202
209
  else {
203
210
  await deleteSession(config, store, session.id);
204
- console.log(`Deleted ${session.id} (${session.tester} on ${session.project_name}) and its screenshots.`);
211
+ console.log(`Deleted ${session.id} (${session.guest} on ${session.project_name}) and its screenshots.`);
212
+ console.log(`${session.agent === "codex" ? "Codex" : "Claude Code"} keeps its own log of the conversation (in ${session.agent === "codex" ? "~/.codex/sessions" : "~/.claude/projects"}); delete it there too if you want it gone everywhere.`);
205
213
  }
206
214
  return;
207
215
  }
@@ -212,6 +220,13 @@ async function main() {
212
220
  }
213
221
  case "install":
214
222
  return install(config);
223
+ case "uninstall":
224
+ return uninstall(config);
225
+ case "version":
226
+ case "--version":
227
+ case "-v":
228
+ console.log(VERSION);
229
+ return;
215
230
  case undefined:
216
231
  case "help":
217
232
  case "--help":
@@ -222,7 +237,7 @@ async function main() {
222
237
  fail(`Unknown command: ${command}\n\n${HELP}`);
223
238
  }
224
239
  }
225
- function install(config) {
240
+ async function install(config) {
226
241
  const launch = [process.execPath, "--no-warnings", CLI_PATH, "mcp"];
227
242
  const env = cleanEnv();
228
243
  const run = (bin, args) => {
@@ -248,8 +263,34 @@ function install(config) {
248
263
  }
249
264
  else
250
265
  console.log("Codex: not found, skipped.");
266
+ // The server holds your links; it should come back after a restart.
267
+ await stopServer(config);
268
+ console.log(`Server: ${enableAutostart(CLI_PATH)}`);
269
+ if (!(await ensureServer(config)))
270
+ console.log(`The server didn't come up. See ${paths.log}`);
251
271
  console.log(`\nStart a new Claude or Codex session in any project and ask it to make a parley for someone.`);
252
272
  }
273
+ // Undoes `parley install`. Your conversations stay in each project's .parley
274
+ // folder, and settings and login in ~/.parley.
275
+ async function uninstall(config) {
276
+ const run = (bin, args) => {
277
+ try {
278
+ execFileSync(bin, args, { env: cleanEnv(), stdio: "pipe" });
279
+ return true;
280
+ }
281
+ catch {
282
+ return false;
283
+ }
284
+ };
285
+ if (config.claude.bin.startsWith("/") && run(config.claude.bin, ["mcp", "remove", "--scope", "user", "parley"]))
286
+ console.log("Claude Code: removed the parley MCP server.");
287
+ if (config.codex.bin.startsWith("/") && run(config.codex.bin, ["mcp", "remove", "parley"]))
288
+ console.log("Codex: removed the parley MCP server.");
289
+ console.log(disableAutostart() ?? "Wasn't set to start at login.");
290
+ if (await stopServer(config))
291
+ console.log("Stopped the server.");
292
+ console.log(`\nYour conversations are still in each project's .parley folder, and settings and login in ${paths.home}.`);
293
+ }
253
294
  async function printRelay(config) {
254
295
  const creds = readCredentials(config);
255
296
  if (!creds)
@@ -270,4 +311,9 @@ function fail(message) {
270
311
  console.error(message);
271
312
  process.exit(1);
272
313
  }
273
- main().catch((error) => fail(String(error?.stack ?? error)));
314
+ // Mistakes in how parley was called get a sentence, not a stack trace.
315
+ main().catch((error) => {
316
+ if (typeof error?.code === "string" && error.code.startsWith("ERR_PARSE_ARGS"))
317
+ fail(`${error.message}\nRun parley on its own for help.`);
318
+ fail(process.env.PARLEY_DEBUG ? String(error?.stack ?? error) : String(error?.message ?? error));
319
+ });
package/dist/config.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync, accessSync, constants } from "node:fs";
2
2
  import { homedir } from "node:os";
3
- import { join, delimiter } from "node:path";
3
+ import { join, delimiter, basename } from "node:path";
4
4
  export const HOME = process.env.PARLEY_HOME ?? join(homedir(), ".parley");
5
5
  // Settings, your parley.im login and the server's state. Conversations live
6
6
  // in each project's own .parley folder (see db.ts); projects.json lists them.
@@ -13,6 +13,41 @@ export const paths = {
13
13
  // Your computer's link to a parley.im account, from `parley login`.
14
14
  credentials: join(HOME, "credentials.json"),
15
15
  };
16
+ export const BLOCKED_BY_DEFAULT = [
17
+ // Environment files and other places secrets usually live.
18
+ "**/.env",
19
+ "**/.env.*",
20
+ "**/.dev.vars",
21
+ "**/secrets/**",
22
+ "**/secrets.json",
23
+ "**/secrets.yml",
24
+ "**/secrets.yaml",
25
+ "**/credentials.json",
26
+ "**/*.tfstate",
27
+ "**/*.tfvars",
28
+ // Keys and certificates.
29
+ "**/*.pem",
30
+ "**/*.key",
31
+ "**/*.p12",
32
+ "**/*.pfx",
33
+ "**/*.jks",
34
+ "**/*.keystore",
35
+ "**/id_rsa*",
36
+ "**/id_dsa*",
37
+ "**/id_ecdsa*",
38
+ "**/id_ed25519*",
39
+ // Tool logins.
40
+ "**/.ssh/**",
41
+ "**/.aws/**",
42
+ "**/.gnupg/**",
43
+ "**/.kube/**",
44
+ "**/.docker/**",
45
+ "**/.npmrc",
46
+ "**/.pypirc",
47
+ "**/.netrc",
48
+ "**/.git-credentials",
49
+ "**/.git/config",
50
+ ];
16
51
  const defaults = {
17
52
  relayUrl: "https://parley.im",
18
53
  port: 4747,
@@ -21,6 +56,7 @@ const defaults = {
21
56
  replyLimit: 100,
22
57
  claude: { bin: "claude", model: null, effort: null },
23
58
  codex: { bin: "codex", model: null, effort: null },
59
+ interviewer: { readConversations: false, blocked: BLOCKED_BY_DEFAULT, allowHomeFolder: false },
24
60
  };
25
61
  export function ensureHome() {
26
62
  mkdirSync(paths.home, { recursive: true });
@@ -40,18 +76,20 @@ export function loadConfig() {
40
76
  replyLimit: pick("replyLimit"),
41
77
  claude: { ...defaults.claude, ...stored.claude },
42
78
  codex: { ...defaults.codex, ...stored.codex },
79
+ interviewer: { ...defaults.interviewer, ...stored.interviewer },
43
80
  };
44
81
  // The server is often started from inside an MCP server with a thin PATH,
45
- // so binaries are pinned to absolute paths the first time they are found.
46
- let changed = !existsSync(paths.config) || Object.keys(stored).some((key) => !(key in defaults));
82
+ // so binaries are pinned to absolute paths the first time they are found,
83
+ // and found again if they move (say, from npm to a native installer).
84
+ let changed = !existsSync(paths.config) || Object.keys(stored).some((key) => !(key in defaults)) || !stored.interviewer;
47
85
  for (const [get, set] of [
48
86
  [() => config.claude.bin, (v) => (config.claude.bin = v)],
49
87
  [() => config.codex.bin, (v) => (config.codex.bin = v)],
50
88
  ]) {
51
89
  const current = get();
52
- if (current.startsWith("/"))
90
+ if (current.startsWith("/") && existsSync(current))
53
91
  continue;
54
- const found = findBin(current);
92
+ const found = findBin(basename(current));
55
93
  if (found) {
56
94
  set(found);
57
95
  changed = true;
@@ -84,13 +122,15 @@ export function findBin(name) {
84
122
  }
85
123
  return null;
86
124
  }
87
- // Agents run as you, on your own subscription. Anything inherited from a
88
- // host session (Claude Code's own env, API keys, proxies) is dropped so the
89
- // CLIs fall back to your normal login.
125
+ // Agents run as you, on your own subscription. What a host session (Claude
126
+ // Code itself, an MCP client) put in the environment is dropped, API keys
127
+ // included, so the CLIs fall back to your normal login. Settings that choose
128
+ // where that login lives (a config folder, Bedrock, Vertex, a proxy) stay.
129
+ const KEEP = new Set(["CLAUDE_CONFIG_DIR", "CLAUDE_CODE_USE_BEDROCK", "CLAUDE_CODE_USE_VERTEX", "ANTHROPIC_BASE_URL", "ANTHROPIC_VERTEX_PROJECT_ID"]);
90
130
  export function cleanEnv() {
91
131
  const env = {};
92
132
  for (const [key, value] of Object.entries(process.env)) {
93
- if (/^(CLAUDE|ANTHROPIC_|MCP_)/.test(key))
133
+ if (/^(CLAUDE|ANTHROPIC_|MCP_)/.test(key) && !KEEP.has(key))
94
134
  continue;
95
135
  env[key] = value;
96
136
  }
package/dist/control.js CHANGED
@@ -1,28 +1,34 @@
1
- import { existsSync, statSync } from "node:fs";
2
- import { isAbsolute, resolve } from "node:path";
1
+ import { existsSync, realpathSync, statSync } from "node:fs";
2
+ import { isAbsolute } from "node:path";
3
3
  import { isServerUp } from "./state.js";
4
4
  import { readCredentials, relay as registry } from "./account.js";
5
5
  import { ensureServer } from "./daemon.js";
6
+ import { agentProblem, checkProjectFolder, defaultAgent } from "./access.js";
6
7
  // The one way sessions get made, from the CLI, MCP tools or your control panel.
7
8
  export function createSession(store, config, input) {
8
9
  if (!isAbsolute(input.projectDir))
9
10
  throw new Error(`Use the full path of the project folder, not ${input.projectDir}.`);
10
- const projectDir = resolve(input.projectDir);
11
- if (!existsSync(projectDir) || !statSync(projectDir).isDirectory())
12
- throw new Error(`Not a folder: ${projectDir}`);
13
- const tester = input.tester.trim();
11
+ if (!existsSync(input.projectDir) || !statSync(input.projectDir).isDirectory())
12
+ throw new Error(`Not a folder: ${input.projectDir}`);
13
+ const projectDir = realpathSync(input.projectDir);
14
+ checkProjectFolder(projectDir, config);
15
+ const guest = input.guest.trim();
14
16
  const brief = input.brief.trim();
15
- if (!tester)
17
+ if (!guest)
16
18
  throw new Error("Say who the session is for.");
17
19
  if (!brief)
18
20
  throw new Error("Write a brief for the interviewer.");
19
21
  const replyLimit = input.replyLimit ?? config.replyLimit;
20
22
  if (!Number.isInteger(replyLimit) || replyLimit < 1)
21
23
  throw new Error("The reply limit has to be a whole number above zero.");
22
- return store.createSession({ ...input, projectDir, tester, brief, replyLimit });
24
+ const agent = input.agent ?? defaultAgent(config);
25
+ const problem = agentProblem(config, agent);
26
+ if (problem)
27
+ throw new Error(problem);
28
+ return store.createSession({ ...input, projectDir, guest, brief, replyLimit, agent });
23
29
  }
24
30
  // Changes go through the server when it's running, so a reply in progress
25
- // stops, any open tester page updates at once, and parley.im hears about it.
31
+ // stops, any open guest page updates at once, and parley.im hears about it.
26
32
  async function viaServer(config, path, body) {
27
33
  const res = await fetch(`http://localhost:${config.dashboardPort}${path}`, {
28
34
  method: "POST",
@@ -39,7 +45,7 @@ async function viaServer(config, path, body) {
39
45
  export async function startSession(store, config, input) {
40
46
  if (await ensureServer(config)) {
41
47
  const made = await viaServer(config, "/api/sessions", input);
42
- return { session: store.session(made.id), shared: made.shared, serverUp: true };
48
+ return { session: made.session ?? store.session(made.id), shared: made.shared, serverUp: true };
43
49
  }
44
50
  const session = createSession(store, config, input);
45
51
  return { session, shared: false, serverUp: false };
package/dist/daemon.js CHANGED
@@ -1,15 +1,30 @@
1
1
  import { spawn } from "node:child_process";
2
- import { openSync } from "node:fs";
2
+ import { openSync, rmSync } from "node:fs";
3
3
  import { join, dirname } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { paths, cleanEnv } from "./config.js";
6
- import { readState, isServerUp } from "./state.js";
6
+ import { readState, serverHealth } from "./state.js";
7
+ import { VERSION, compareVersions } from "./version.js";
7
8
  // cli.ts in a checkout, cli.js once built for npm.
8
9
  const CLI = join(dirname(fileURLToPath(import.meta.url)), import.meta.url.endsWith(".ts") ? "cli.ts" : "cli.js");
9
10
  // Starts the server in the background if it isn't running, and waits until
10
- // it answers. It holds this computer's connection to parley.im.
11
+ // it answers. It holds this computer's connection to parley.im. A server from
12
+ // an older parley is replaced; one newer than this process means this process
13
+ // is the stale one (an MCP server started before an update).
11
14
  export async function ensureServer(config, waitMs = 20_000) {
12
- if (!(await isServerUp(config))) {
15
+ const running = await serverHealth(config);
16
+ if (running?.version && running.version !== VERSION) {
17
+ if (compareVersions(running.version, VERSION) > 0) {
18
+ throw new Error(`parley was updated to ${running.version}, but this process is still ${VERSION}. Restart this Claude Code or Codex session (or the parley command) to use the new version.`);
19
+ }
20
+ await stopServer(config);
21
+ await waitUntilDown(config);
22
+ }
23
+ else if (running && !running.version) {
24
+ await stopServer(config);
25
+ await waitUntilDown(config);
26
+ }
27
+ if (!(await serverHealth(config))) {
13
28
  const log = openSync(paths.log, "a");
14
29
  const child = spawn(process.execPath, ["--no-warnings", CLI, "serve"], {
15
30
  detached: true,
@@ -22,18 +37,27 @@ export async function ensureServer(config, waitMs = 20_000) {
22
37
  const deadline = Date.now() + waitMs;
23
38
  while (Date.now() < deadline) {
24
39
  const state = readState();
25
- if (state && (await isServerUp(config)))
40
+ if (state && (await serverHealth(config)))
26
41
  return state;
27
42
  await new Promise((r) => setTimeout(r, 400));
28
43
  }
29
- return (await isServerUp(config)) ? readState() : null;
44
+ return (await serverHealth(config)) ? readState() : null;
30
45
  }
31
- export async function stopServer() {
32
- const state = readState();
33
- if (!state)
46
+ async function waitUntilDown(config, waitMs = 10_000) {
47
+ const deadline = Date.now() + waitMs;
48
+ while (Date.now() < deadline && (await serverHealth(config)))
49
+ await new Promise((r) => setTimeout(r, 200));
50
+ }
51
+ // Stops the server, but only a process that answers as parley's server: the
52
+ // pid in server.json may be stale and belong to something else by now.
53
+ export async function stopServer(config) {
54
+ const running = await serverHealth(config);
55
+ if (!running) {
56
+ rmSync(paths.state, { force: true });
34
57
  return false;
58
+ }
35
59
  try {
36
- process.kill(state.pid, "SIGTERM");
60
+ process.kill(running.pid, "SIGTERM");
37
61
  return true;
38
62
  }
39
63
  catch {