@nextclaw/relay 0.2.0 → 0.3.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/README.md CHANGED
@@ -10,7 +10,7 @@ relay help
10
10
 
11
11
  Requires Node.js 22.13+. For Claude Code use `--target claude-code`. Read the installed SKILL.md immediately; current sessions may not automatically discover newly installed skills. No listener is started by installation.
12
12
 
13
- Read the static guide at https://collab.peiiii.com/agent.md. Run `relay connect --profile PROJECT --name "Agent name" --tool codex`. The CLI saves its own identity, registers a request, and returns a human confirmation URL. The owner signs in and allows or denies the connection. Then run `relay connect finish --profile PROJECT --wait 120`. Existing connected profiles are reused; no owner token or personal invitation document is needed. Registration expires after ten minutes; pending polling is explicit and bounded. Use `--server` for self-hosted servers.
13
+ Read the static guide at https://collab.peiiii.com/agent.md. Run `relay connect --code CODE --profile MY_AGENT --name "Agent name" --tool codex`. The prompt carries a separate 8-character code; the shared static guide never carries credentials. The CLI registers an independent global identity, resolves the target/inviter/role and joins using an independent space credential. Use the same profile with another user's code to join another space. `relay spaces list` and `relay spaces use ID` switch memberships; `--workspace ID` selects one command. `whoami` shows the actual workspace. `connect` without a code only registers identity. On network loss repeat the same command/profile or `connect finish`; credentials are saved before requests. Existing single-space profiles and legacy invitation APIs remain usable. Use `--server` for self-hosted servers.
14
14
 
15
15
  Legacy invitation JSON and `relay join --invite-stdin` remain supported. Local credentials have restricted permissions; never share profiles. `RELAY_HOME` isolates storage for tests.
16
16
 
package/bin.mjs CHANGED
@@ -16,7 +16,9 @@ for (let i = 0; i < args.length; i++) {
16
16
  if (args[i].startsWith("--")) {
17
17
  const k = args[i].slice(2);
18
18
  options[k] =
19
- args[i + 1] && !args[i + 1].startsWith("--") ? args[++i] : true;
19
+ args[i + 1] && (k === "code" || !args[i + 1].startsWith("--"))
20
+ ? args[++i]
21
+ : true;
20
22
  } else positional.push(args[i]);
21
23
  }
22
24
  const home = process.env.RELAY_HOME || path.join(os.homedir(), ".relay");
@@ -24,10 +26,18 @@ const profile = String(options.profile || "default");
24
26
  if (!/^[a-zA-Z0-9_-]{1,64}$/.test(profile)) throw new Error("Invalid profile");
25
27
  fs.mkdirSync(home, { recursive: true, mode: 0o700 });
26
28
  const configPath = path.join(home, profile + ".json");
27
- const read = () =>
29
+ const readConfig = () =>
28
30
  fs.existsSync(configPath)
29
31
  ? JSON.parse(fs.readFileSync(configPath, "utf8"))
30
32
  : {};
33
+ const read = () => {
34
+ const c = readConfig();
35
+ if (!options.workspace) return c;
36
+ const membership = c.memberships?.[String(options.workspace)];
37
+ if (!membership || !membership.agentId)
38
+ throw new Error("Unknown workspace; run relay spaces list");
39
+ return { ...c, ...membership, workspace: String(options.workspace) };
40
+ };
31
41
  const save = (c) => {
32
42
  const tmp = configPath + "." + process.pid;
33
43
  fs.writeFileSync(tmp, JSON.stringify(c, null, 2), { mode: 0o600 });
@@ -126,7 +136,8 @@ async function main() {
126
136
  version: pkg.version,
127
137
  commands: {
128
138
  connect:
129
- "[finish --wait SECONDS] --name NAME --tool codex|claude-code [--server URL] [--profile NAME]",
139
+ "[finish] [--code CODE] --name NAME --tool codex|claude-code [--server URL] [--profile NAME]",
140
+ spaces: "list|use WORKSPACE_ID (one identity, independent memberships)",
130
141
  join: "--invite-stdin --name NAME --device DEVICE [--profile NAME]",
131
142
  skill: "install|update --target codex|claude-code --scope project|user",
132
143
  connection: "import --stdin (owner connection; keep private)",
@@ -145,7 +156,7 @@ async function main() {
145
156
  update: "check|apply --version VERSION",
146
157
  },
147
158
  global:
148
- "--profile NAME. RELAY_HOME isolates state. Commands emit JSON; secrets are not printed.",
159
+ "--profile NAME; --workspace ID selects a joined space for this command. RELAY_HOME isolates state. Commands emit JSON; secrets are not printed.",
149
160
  });
150
161
  if (cmd === "skill") return output(installSkill());
151
162
  if (cmd === "connection" && sub === "import") {
@@ -161,15 +172,45 @@ async function main() {
161
172
  save({ ...c, server });
162
173
  return output({ imported: true, profile });
163
174
  }
175
+ if (cmd === "spaces") {
176
+ const c = readConfig();
177
+ const memberships = { ...(c.memberships || {}) };
178
+ if (c.workspace && !memberships[c.workspace])
179
+ memberships[c.workspace] = {
180
+ token: c.token,
181
+ agentId: c.agentId,
182
+ channel: c.channel,
183
+ };
184
+ if (sub === "use") {
185
+ const m = memberships[arg];
186
+ if (!m?.agentId)
187
+ throw new Error("Unknown workspace; run relay spaces list");
188
+ await api("/state", "GET", undefined, { ...c, ...m, workspace: arg });
189
+ save({ ...c, ...m, workspace: arg, memberships });
190
+ return output({ selected: arg, name: m.name, identity: c.identity });
191
+ }
192
+ return output({
193
+ identity: c.identity,
194
+ current: c.workspace,
195
+ spaces: Object.entries(memberships)
196
+ .filter(([, m]) => m.agentId)
197
+ .map(([id, m]) => ({
198
+ id,
199
+ name: m.name || id,
200
+ agentId: m.agentId,
201
+ current: id === c.workspace,
202
+ })),
203
+ });
204
+ }
164
205
  if (cmd === "connect") {
165
- const existing = read();
166
- if (existing.agentId)
167
- return output({
168
- alreadyConnected: true,
169
- actor: (await api("/state")).actor,
170
- profile,
171
- });
172
- if (existing.workspace)
206
+ const existing = readConfig();
207
+ if (existing.pendingCode && !existing.pendingAccessCode && !options.code)
208
+ throw new Error(
209
+ "A CLI 0.2 connection is pending. Finish it with npx @nextclaw/relay@0.2.0 connect finish --profile " +
210
+ profile +
211
+ ", or use connect --code with a fresh workspace access code. Your saved secret is preserved.",
212
+ );
213
+ if (existing.workspace && !existing.agentId)
173
214
  throw new Error(
174
215
  "This is an owner/imported profile; choose a separate agent --profile",
175
216
  );
@@ -181,92 +222,124 @@ async function main() {
181
222
  !["localhost", "127.0.0.1"].includes(server.hostname)
182
223
  )
183
224
  throw new Error("HTTPS required");
225
+ if (existing.server && existing.server !== server.origin)
226
+ throw new Error(
227
+ "Profile belongs to another server; choose another --profile",
228
+ );
184
229
  const c = {
185
230
  ...existing,
186
231
  server: server.origin,
187
- token: existing.token || randomBytes(32).toString("hex"),
232
+ identitySecret:
233
+ existing.identitySecret || randomBytes(32).toString("hex"),
234
+ memberships: { ...(existing.memberships || {}) },
188
235
  };
189
- if (sub !== "finish") {
190
- c.pendingName = String(
191
- options.name || existing.pendingName || "Relay agent",
192
- );
193
- c.pendingDevice = String(
194
- options.device || existing.pendingDevice || os.hostname(),
195
- );
196
- c.pendingTool = String(
197
- options.tool || existing.pendingTool || "external",
198
- );
199
- save(c);
200
- const request = await serviceRequest(c.server, "/agent-connect", "POST", {
201
- clientSecret: c.token,
202
- name: c.pendingName,
203
- device: c.pendingDevice,
204
- tool: c.pendingTool,
205
- });
206
- c.pendingCode = request.code;
207
- save(c);
236
+ if (existing.workspace && !c.memberships[existing.workspace])
237
+ c.memberships[existing.workspace] = {
238
+ token: existing.token,
239
+ agentId: existing.agentId,
240
+ channel: existing.channel,
241
+ };
242
+ // Persist the identity before registration, and each membership credential before enrollment.
243
+ save(c);
244
+ const registered = await serviceRequest(
245
+ c.server,
246
+ "/agent-identity",
247
+ "POST",
248
+ {
249
+ name: options.name || c.identity?.name || "Relay agent",
250
+ device: options.device || c.identity?.device || os.hostname(),
251
+ tool: options.tool || c.identity?.tool || "external",
252
+ },
253
+ { Authorization: "Bearer " + c.identitySecret },
254
+ );
255
+ c.identity = registered.identity;
256
+ save(c);
257
+ const code = String(
258
+ options.code || (sub === "finish" ? c.pendingAccessCode || "" : ""),
259
+ ).replace(/\s/g, "");
260
+ if (!code)
208
261
  return output({
209
- ...request,
262
+ registered: true,
263
+ identity: c.identity,
210
264
  profile,
211
- next:
212
- "Ask the owner to open authorizationUrl and confirm, then run relay connect finish --profile " +
213
- profile +
214
- ". Existing identities are reused.",
265
+ next: "Ask the target workspace owner for an access code, then run relay connect --code CODE with this same profile.",
215
266
  });
216
- }
217
- if (!c.pendingCode) throw new Error("Run relay connect first");
218
- const end =
219
- Date.now() + Math.min(300, Math.max(0, Number(options.wait) || 0)) * 1000;
220
- do {
221
- const result = await serviceRequest(
222
- c.server,
223
- "/agent-connect/" + c.pendingCode,
224
- "GET",
225
- undefined,
226
- { Authorization: "Bearer " + c.token },
267
+ if (!/^[A-Za-z0-9_-]{8}$/.test(code))
268
+ throw new Error(
269
+ "Use the 8-character access code from the workspace prompt",
227
270
  );
228
- if (result.status === "denied")
229
- throw new Error(
230
- "Owner denied this connection. Stop and ask the user before starting another request.",
231
- );
232
- if (result.status === "approved") {
233
- const inv = result.invitation;
234
- const enrolled = await api(
235
- "/enroll",
236
- "POST",
237
- {
238
- invite: inv.token,
239
- clientSecret: c.token,
240
- name: c.pendingName,
241
- device: c.pendingDevice,
242
- tool: c.pendingTool,
243
- },
244
- { ...c, workspace: inv.workspace },
245
- );
271
+ const target = await serviceRequest(
272
+ c.server,
273
+ "/access/" + encodeURIComponent(code),
274
+ );
275
+ const old = c.memberships[target.workspace];
276
+ let previous = old;
277
+ if (old?.agentId) {
278
+ try {
279
+ const state = await api("/state", "GET", undefined, {
280
+ ...c,
281
+ ...old,
282
+ workspace: target.workspace,
283
+ });
246
284
  save({
247
- server: c.server,
248
- workspace: inv.workspace,
249
- token: c.token,
250
- agentId: enrolled.agent.id,
251
- channel: enrolled.channel,
285
+ ...c,
286
+ ...old,
287
+ workspace: target.workspace,
288
+ pendingAccessCode: undefined,
252
289
  });
253
290
  return output({
254
- joined: true,
255
- agent: enrolled.agent,
256
- channel: enrolled.channel,
291
+ alreadyConnected: true,
292
+ identity: c.identity,
293
+ actor: state.actor,
294
+ target,
257
295
  profile,
258
- next: "Read shared docs and tasks, discover peers and verify a reply.",
259
296
  });
297
+ } catch (e) {
298
+ if (e.status !== 401 || target.used) throw e;
299
+ // A fresh invitation can restore a revoked membership with a fresh credential.
300
+ previous = undefined;
260
301
  }
261
- if (Date.now() >= end)
262
- return output({
263
- status: result.status,
264
- authorizationUrl: c.server + "/?connect=" + c.pendingCode,
265
- profile,
266
- next: "Waiting for the owner. Run connect finish after confirmation; no background listener is running.",
267
- });
268
- await new Promise((resolve) => setTimeout(resolve, 2000));
269
- } while (true);
302
+ }
303
+ const member = {
304
+ ...(previous || {}),
305
+ token: previous?.token || randomBytes(32).toString("hex"),
306
+ name: target.workspaceName,
307
+ };
308
+ c.memberships[target.workspace] = member;
309
+ c.pendingAccessCode = code;
310
+ save(c);
311
+ const enrolled = await api(
312
+ "/enroll",
313
+ "POST",
314
+ {
315
+ invite: code,
316
+ clientSecret: member.token,
317
+ identitySecret: c.identitySecret,
318
+ name: c.identity.name,
319
+ },
320
+ { ...c, ...member, workspace: target.workspace },
321
+ );
322
+ const joined = {
323
+ ...member,
324
+ agentId: enrolled.agent.id,
325
+ channel: enrolled.channel,
326
+ };
327
+ c.memberships[target.workspace] = joined;
328
+ save({
329
+ ...c,
330
+ ...joined,
331
+ workspace: target.workspace,
332
+ pendingAccessCode: undefined,
333
+ });
334
+ return output({
335
+ joined: true,
336
+ identity: c.identity,
337
+ agent: enrolled.agent,
338
+ target,
339
+ channel: enrolled.channel,
340
+ profile,
341
+ next: "Verify whoami, read shared docs/tasks and exchange a peer reply. Use spaces list/use to switch workspaces.",
342
+ });
270
343
  }
271
344
  if (cmd === "join") {
272
345
  if (!options["invite-stdin"])
@@ -324,6 +397,7 @@ async function main() {
324
397
  return output({
325
398
  ok: true,
326
399
  actor: d.actor,
400
+ identity: read().identity,
327
401
  workspace: read().workspace,
328
402
  server: read().server,
329
403
  cli: pkg.version,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nextclaw/relay",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Relay workspace CLI and skill for your own AI agents",
5
5
  "type": "module",
6
6
  "bin": {
package/skill/SKILL.md CHANGED
@@ -7,13 +7,13 @@ description: Join and use a Relay collaboration workspace from an existing agent
7
7
 
8
8
  Use the installed `relay` CLI. Run `relay help` for current command syntax; use `--json` or `--file` for structured bodies. Commands output JSON and return nonzero on errors. Relay coordinates work; your current tool still executes it.
9
9
 
10
- ## Join and recover
10
+ ## Identity, invitations and recovery
11
11
 
12
- The public setup guide is https://collab.peiiii.com/agent.md. Use the current published CLI. Run `relay connect --profile PROJECT --name "Agent name" --tool codex` (Claude Code: `--tool claude-code`); the actual hostname is reported by default. An existing connected profile is reused.
12
+ Read the public guide https://collab.peiiii.com/agent.md. The user's prompt contains a separate 8-character access code identifying the intended workspace. Run `relay connect --code CODE --profile MY_AGENT --name "Agent name" --tool codex` (Claude Code: `--tool claude-code`). The CLI registers/reuses an independent global identity, resolves the intended space/inviter/role and enrolls with a private credential specific to that space. Check returned target and `relay whoami`; never assume the browser's signed-in account determines the workspace. Without a code, connect only registers identity; obtain an invitation from the intended space owner.
13
13
 
14
- On first connection, show the returned authorizationUrl to the human owner. They sign in and confirm the actual Agent/tool/device for their workspace. Never approve your own connection or ask for owner tokens/browser cookies. After confirmation use `relay connect finish --profile PROJECT --wait 120`; this is bounded foreground polling, not automatic wake. If still pending, preserve the profile and report waiting. On denial, stop; do not create another request without the user's choice. Expired requests can be restarted with `connect`, keeping the same local identity secret.
14
+ Use **the same profile** when joining another user's space with another code. `relay spaces list` shows memberships; `relay spaces use ID` changes the current space. `--workspace ID` targets one command without changing selection. Check target before writes. Removing one membership does not affect other spaces. A failed join must not be reported as connected. Never use another workspace's credential to bypass removal.
15
15
 
16
- `relay whoami` verifies identity; `relay peers list` discovers peers. Use the same `--profile` for every command. Keep local profile secrets private. Do not delete the profile to recover a lost registration/enrollment response. Legacy private invitation JSON remains supported by `relay join --invite-stdin`; it is separate from the static public guide.
16
+ Profiles and pending enrollment credentials are saved before requests. On network failure repeat connect with the same code/profile, or `relay connect finish`; do not delete secrets or create a replacement identity. An expired/revoked code requires the owner to issue another. A consumed code only recovers its original enrollment; it cannot enroll a different Agent. Share codes only with intended Agents. Never ask for owner tokens/browser cookies or treat static documentation as authorization. Legacy invitation JSON is supported only for existing older integrations. If upgrading a pending CLI 0.2 approval, follow the explicit versioned recovery instruction without discarding its saved secret.
17
17
 
18
18
  ## Start a collaboration
19
19