@clubnet/seedclub 0.2.28 → 0.2.29

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
@@ -30,9 +30,10 @@ seedclub
30
30
 
31
31
  On a fresh install, the normal setup flow is:
32
32
 
33
- 1. `/login` to sign in to a model provider such as Anthropic, OpenAI, or Gemini
34
- 2. `/model` to choose the model you want to use
35
- 3. `/connect` to connect your Seed Club account
33
+ 1. Run `seedclub`
34
+ 2. Seed Club auth opens automatically
35
+ 3. `/login` to sign in to a model provider such as Anthropic, OpenAI, or Gemini
36
+ 4. `/model` to choose the model you want to use
36
37
 
37
38
  After that, run `/seedclub` to open the main Seed Club menu.
38
39
 
@@ -42,24 +43,19 @@ After that, run `/seedclub` to open the main Seed Club menu.
42
43
  curl -fsSL https://raw.githubusercontent.com/seedclub/seedclub-agent/main/install.sh | bash
43
44
  ```
44
45
 
45
- ### Internal install auth
46
+ ### Package access
46
47
 
47
- `@clubnet/seedclub` is currently a private npm package. This auth is only for installing or updating the package from npm. It is separate from `/login` and `/connect` inside the app.
48
-
49
- ```bash
50
- npm login
51
- ```
52
-
53
- Then `npm install -g @clubnet/seedclub` and `seedclub update` work.
48
+ `@clubnet/seedclub` is a public npm package. Install access is open; runtime access is enforced by Seed Club auth inside the app.
54
49
 
55
50
  ## Core workflow
56
51
 
57
52
  The normal interactive flow is:
58
53
 
59
54
  1. Start the app with `seedclub`
60
- 2. Complete `/login`, `/model`, and `/connect` if this is your first run
61
- 3. Open `/seedclub`
62
- 4. Choose the CRM, meetings, media, recordings, or transcript workflow you need
55
+ 2. Complete Seed Club auth when the browser opens
56
+ 3. Complete `/login` and `/model` if this is your first run
57
+ 4. Open `/seedclub`
58
+ 5. Choose the CRM, meetings, media, recordings, or transcript workflow you need
63
59
 
64
60
  ## Commands
65
61
 
@@ -78,15 +74,21 @@ Natural-language transcript retrieval is also supported (no slash command requir
78
74
 
79
75
  There are two separate auth layers in the product:
80
76
 
81
- 1. Model auth: `/login`
82
- This signs you into the LLM provider you want the agent to use.
83
- 2. Seed Club auth: `/connect`
77
+ 1. Seed Club auth: auto-start on launch or `/connect`
84
78
  This connects the CLI to your Seed Club account so Seed Club tools and commands can read and write account data.
79
+ 2. Model auth: `/login`
80
+ This signs you into the LLM provider you want the agent to use.
85
81
  3. Personal calendar connect: `/connect-calendar`
86
82
  This connects a Google Calendar to your Seed Club account for booking and availability workflows. It is only needed if you want the agent to schedule using your personal calendar.
87
83
 
84
+ Seed Club auth is the first gate. Until it succeeds, normal harness usage is blocked and only the setup commands are available.
85
+
88
86
  `/seedclub` is the main entry point for Seed Club actions. If you are not connected yet, it will start the Seed Club connect flow automatically.
89
87
 
88
+ CLI auth now prefers an authorization-code exchange:
89
+ - browser callback: `http://127.0.0.1:<port>/callback?code=...&state=...`
90
+ - CLI exchange: `POST ${SEEDCLUB_API_URL}/auth/cli/exchange` with `{ code, state }`
91
+
90
92
  Power-user env overrides:
91
93
 
92
94
  ```bash
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Token storage for Seed Club.
3
3
  *
4
- * Priority: SEEDCLUB_ACCESS_TOKEN / SEEDCLUB_TOKEN env var > stored token file.
4
+ * Priority: SEEDCLUB_ACCESS_TOKEN env var > stored token file.
5
5
  * Use /seedclub to connect.
6
6
  */
7
7
 
@@ -76,12 +76,7 @@ function tryReadStoredBasesSync(): StoredBases | null {
76
76
  }
77
77
 
78
78
  export function getApiBase(): string {
79
- if (
80
- process.env.SEEDCLUB_API_URL ||
81
- process.env.SEEDCLUB_API ||
82
- process.env.SEED_NETWORK_API
83
- )
84
- return process.env.SEEDCLUB_API_URL || process.env.SEEDCLUB_API || process.env.SEED_NETWORK_API!;
79
+ if (process.env.SEEDCLUB_API_URL) return process.env.SEEDCLUB_API_URL;
85
80
  if (shouldPreferLocalBases()) return LOCAL_API_BASE;
86
81
  const storedBases = tryReadStoredBasesSync();
87
82
  if (storedBases?.apiBase) return storedBases.apiBase;
@@ -90,12 +85,7 @@ export function getApiBase(): string {
90
85
  }
91
86
 
92
87
  export function getAuthBase(): string {
93
- if (
94
- process.env.SEEDCLUB_AUTH_URL ||
95
- process.env.SEEDCLUB_AUTH ||
96
- process.env.SEED_NETWORK_AUTH
97
- )
98
- return process.env.SEEDCLUB_AUTH_URL || process.env.SEEDCLUB_AUTH || process.env.SEED_NETWORK_AUTH!;
88
+ if (process.env.SEEDCLUB_AUTH_URL) return process.env.SEEDCLUB_AUTH_URL;
99
89
  if (shouldPreferLocalBases()) return LOCAL_AUTH_BASE;
100
90
  const storedBases = tryReadStoredBasesSync();
101
91
  if (storedBases?.authBase) return storedBases.authBase;
@@ -141,18 +131,14 @@ async function tryReadTokenFile(path: string): Promise<StoredToken | null> {
141
131
  if (
142
132
  stored.apiBase &&
143
133
  !shouldPreferLocalBases() &&
144
- !process.env.SEEDCLUB_API_URL &&
145
- !process.env.SEEDCLUB_API &&
146
- !process.env.SEED_NETWORK_API
134
+ !process.env.SEEDCLUB_API_URL
147
135
  ) {
148
136
  _cachedApiBase = stored.apiBase;
149
137
  }
150
138
  if (
151
139
  stored.authBase &&
152
140
  !shouldPreferLocalBases() &&
153
- !process.env.SEEDCLUB_AUTH_URL &&
154
- !process.env.SEEDCLUB_AUTH &&
155
- !process.env.SEED_NETWORK_AUTH
141
+ !process.env.SEEDCLUB_AUTH_URL
156
142
  ) {
157
143
  _cachedAuthBase = stored.authBase;
158
144
  }
@@ -189,8 +175,7 @@ export async function getStoredBases(): Promise<StoredBases | null> {
189
175
  }
190
176
 
191
177
  export async function getToken(): Promise<string | null> {
192
- if (process.env.SEEDCLUB_ACCESS_TOKEN || process.env.SEEDCLUB_TOKEN || process.env.SEED_NETWORK_TOKEN)
193
- return (process.env.SEEDCLUB_ACCESS_TOKEN || process.env.SEEDCLUB_TOKEN || process.env.SEED_NETWORK_TOKEN)!;
178
+ if (process.env.SEEDCLUB_ACCESS_TOKEN) return process.env.SEEDCLUB_ACCESS_TOKEN;
194
179
  const stored = await getStoredToken();
195
180
  return stored?.token ?? null;
196
181
  }
@@ -15,7 +15,7 @@ import {
15
15
  import { getCurrentUser, getSessionContext } from "../tools/utility.js";
16
16
 
17
17
  interface SeedclubDeps {
18
- connect: (args: string | undefined, ctx: any) => Promise<void>;
18
+ connect: (args: string | undefined, ctx: any) => Promise<boolean>;
19
19
  connectCalendar: (ctx: any) => Promise<void>;
20
20
  disconnect: (ctx: any) => Promise<void>;
21
21
  }
@@ -109,10 +109,7 @@ export function registerSeedclubCommand(pi: ExtensionAPI, deps: SeedclubDeps) {
109
109
  description: "Seed Club",
110
110
  handler: async (args, ctx) => {
111
111
  const stored = await getStoredToken();
112
- const hasEnvToken =
113
- !!process.env.SEEDCLUB_ACCESS_TOKEN ||
114
- !!process.env.SEEDCLUB_TOKEN ||
115
- !!process.env.SEED_NETWORK_TOKEN;
112
+ const hasEnvToken = !!process.env.SEEDCLUB_ACCESS_TOKEN;
116
113
  const isConnected = !!stored || hasEnvToken;
117
114
 
118
115
  if (!isConnected) {
@@ -0,0 +1,85 @@
1
+ export type SeedclubAuthGateStatus = "auth_required" | "auth_in_progress" | "auth_complete";
2
+
3
+ export interface SeedclubAuthGateState {
4
+ status: SeedclubAuthGateStatus;
5
+ authUrl: string | null;
6
+ message: string | null;
7
+ error: string | null;
8
+ }
9
+
10
+ export const AUTH_GATE_ALLOWED_COMMANDS = new Set([
11
+ "connect",
12
+ "seedclub",
13
+ "seedenv",
14
+ "commands",
15
+ "extensions",
16
+ ]);
17
+
18
+ const state: SeedclubAuthGateState = {
19
+ status: "auth_required",
20
+ authUrl: null,
21
+ message: "Seed Club sign-in is required before /login or /model.",
22
+ error: null,
23
+ };
24
+
25
+ const listeners = new Set<() => void>();
26
+
27
+ function emit() {
28
+ for (const listener of listeners) listener();
29
+ }
30
+
31
+ function setState(next: Partial<SeedclubAuthGateState>) {
32
+ Object.assign(state, next);
33
+ emit();
34
+ }
35
+
36
+ export function getAuthGateState(): SeedclubAuthGateState {
37
+ return { ...state };
38
+ }
39
+
40
+ export function subscribeToAuthGate(listener: () => void): () => void {
41
+ listeners.add(listener);
42
+ return () => listeners.delete(listener);
43
+ }
44
+
45
+ export function markAuthRequired(options?: { authUrl?: string | null; message?: string | null; error?: string | null }) {
46
+ setState({
47
+ status: "auth_required",
48
+ authUrl: options?.authUrl ?? state.authUrl,
49
+ message: options?.message ?? "Seed Club sign-in is required before /login or /model.",
50
+ error: options?.error ?? null,
51
+ });
52
+ }
53
+
54
+ export function markAuthInProgress(options?: { authUrl?: string | null; message?: string | null; error?: string | null }) {
55
+ setState({
56
+ status: "auth_in_progress",
57
+ authUrl: options?.authUrl ?? state.authUrl,
58
+ message: options?.message ?? "Opening your browser for Seed Club sign-in.",
59
+ error: options?.error ?? null,
60
+ });
61
+ }
62
+
63
+ export function markAuthComplete(message?: string | null) {
64
+ setState({
65
+ status: "auth_complete",
66
+ authUrl: null,
67
+ message: message ?? null,
68
+ error: null,
69
+ });
70
+ }
71
+
72
+ export function isAuthGateBlocking(): boolean {
73
+ return state.status !== "auth_complete";
74
+ }
75
+
76
+ export function getCommandName(text: string): string | null {
77
+ const trimmed = text.trim();
78
+ if (!trimmed.startsWith("/")) return null;
79
+ const token = trimmed.slice(1).split(/\s+/).find(Boolean);
80
+ return token ? token.toLowerCase() : null;
81
+ }
82
+
83
+ export function isAllowedDuringAuthGate(commandName: string | null): boolean {
84
+ return !!commandName && AUTH_GATE_ALLOWED_COMMANDS.has(commandName);
85
+ }
@@ -19,8 +19,19 @@ import { registerCrmTools } from "./tools/crm.js";
19
19
  import { registerMeetingTools } from "./tools/meetings.js";
20
20
  import { registerMediaTools } from "./tools/media.js";
21
21
  import registerBrandingGuard from "./branding.js";
22
+ import {
23
+ getCommandName,
24
+ isAllowedDuringAuthGate,
25
+ isAuthGateBlocking,
26
+ markAuthComplete,
27
+ markAuthInProgress,
28
+ markAuthRequired,
29
+ } from "./gate-state.js";
22
30
 
23
31
  export default function (pi: ExtensionAPI) {
32
+ const ENV_TOKEN_KEYS = ["SEEDCLUB_ACCESS_TOKEN"] as const;
33
+ let connectInFlight: Promise<boolean> | null = null;
34
+
24
35
  const formatSeedLabel = (name?: string, email?: string) => {
25
36
  const label = (name?.trim() || email?.trim() || "connected").replace(/\s+/g, " ");
26
37
  return label;
@@ -65,60 +76,176 @@ export default function (pi: ExtensionAPI) {
65
76
  registerTranscriptIntentInterceptor(pi);
66
77
  }
67
78
 
68
- // Show connection status on session start
69
- pi.on("session_start", async (_event, ctx) => {
70
- const stored = await getStoredToken();
79
+ function getPostAuthInstruction(ctx: any): string | null {
80
+ const hasProviderAuth = ctx.modelRegistry.getAvailable().length > 0;
81
+ const hasSelectedModel = !!ctx.model;
82
+ if (!hasProviderAuth) return "Next: /login, then /model.";
83
+ if (!hasSelectedModel) return "Next: /model.";
84
+ return null;
85
+ }
86
+
87
+ function clearSeedStatuses(ctx: any) {
88
+ ctx.ui.setStatus("seed", undefined);
89
+ ctx.ui.setStatus("seed-env", undefined);
90
+ ctx.ui.setStatus("seed-api", undefined);
91
+ ctx.ui.setStatus("seed-auth", undefined);
92
+ }
93
+
94
+ async function applyConnectedStatus(ctx: any, user: { name?: string | null; email?: string | null }) {
71
95
  const storedBases = await getStoredBases();
72
96
  const effectiveApiBase = getApiBase();
73
97
  const effectiveAuthBase = getAuthBase();
74
- if (stored) {
75
- const isDev = effectiveApiBase.includes("localhost") || effectiveApiBase.includes("127.0.0.1");
76
- const hasSeparateDevAuthBase =
77
- effectiveAuthBase !== effectiveApiBase &&
78
- (effectiveAuthBase.includes("localhost") || effectiveAuthBase.includes("127.0.0.1"));
79
- ctx.ui.setStatus("seed", formatSeedLabel(stored.name, stored.email));
80
- if (storedBases?.mode) ctx.ui.setStatus("seed-env", `env: ${storedBases.mode}`);
81
- if (isDev) ctx.ui.setStatus("seed-api", `dev: ${effectiveApiBase}`);
82
- if (hasSeparateDevAuthBase) ctx.ui.setStatus("seed-auth", `auth: ${effectiveAuthBase}`);
83
- } else if (process.env.SEEDCLUB_ACCESS_TOKEN || process.env.SEEDCLUB_TOKEN || process.env.SEED_NETWORK_TOKEN) {
84
- ctx.ui.setStatus("seed", "seed: connected (env)");
98
+ const isDev = effectiveApiBase.includes("localhost") || effectiveApiBase.includes("127.0.0.1");
99
+ const hasSeparateDevAuthBase =
100
+ effectiveAuthBase !== effectiveApiBase &&
101
+ (effectiveAuthBase.includes("localhost") || effectiveAuthBase.includes("127.0.0.1"));
102
+
103
+ ctx.ui.setStatus("seed", formatSeedLabel(user.name ?? undefined, user.email ?? undefined));
104
+ if (storedBases?.mode) ctx.ui.setStatus("seed-env", `env: ${storedBases.mode}`);
105
+ else ctx.ui.setStatus("seed-env", undefined);
106
+ if (isDev) ctx.ui.setStatus("seed-api", `dev: ${effectiveApiBase}`);
107
+ else ctx.ui.setStatus("seed-api", undefined);
108
+ if (hasSeparateDevAuthBase) ctx.ui.setStatus("seed-auth", `auth: ${effectiveAuthBase}`);
109
+ else ctx.ui.setStatus("seed-auth", undefined);
110
+ }
111
+
112
+ function getRuntimeEnvToken(): string | null {
113
+ for (const key of ENV_TOKEN_KEYS) {
114
+ const value = process.env[key];
115
+ if (value?.trim()) return value;
85
116
  }
117
+ return null;
118
+ }
119
+
120
+ function clearRuntimeEnvTokens() {
121
+ for (const key of ENV_TOKEN_KEYS) delete process.env[key];
122
+ }
123
+
124
+ async function validateCurrentCredential(ctx: any): Promise<{ name?: string | null; email?: string | null } | null> {
125
+ const envToken = getRuntimeEnvToken();
126
+ const stored = envToken ? null : await getStoredToken();
127
+ const token = envToken || stored?.token;
128
+ if (!token) return null;
129
+
130
+ markAuthInProgress({ message: "Checking Seed Club access..." });
131
+ setCachedToken(token, getApiBase());
132
+ const user = await getCurrentUser();
133
+ if ("error" in user) {
134
+ await clearCredentials();
135
+ if (envToken) clearRuntimeEnvTokens();
136
+ clearSeedStatuses(ctx);
137
+ return null;
138
+ }
139
+
140
+ await applyConnectedStatus(ctx, user);
141
+ return user;
142
+ }
143
+
144
+ async function ensureSeedclubAuthenticated(ctx: any): Promise<boolean> {
145
+ const existing = await validateCurrentCredential(ctx);
146
+ if (existing) {
147
+ markAuthComplete(getPostAuthInstruction(ctx));
148
+ return true;
149
+ }
150
+ return connect(undefined, ctx, { autoStart: true });
151
+ }
152
+
153
+ pi.on("session_start", (_event, ctx) => {
154
+ if (getRuntimeEnvToken()) {
155
+ markAuthInProgress({ message: "Checking Seed Club access..." });
156
+ }
157
+ void ensureSeedclubAuthenticated(ctx);
86
158
  });
87
159
 
88
160
  // --- Auth handlers ---
89
161
 
90
- async function connect(args: string | undefined, ctx: any) {
91
- const token = args?.trim();
92
- if (token) {
93
- if (!token) {
94
- ctx.ui.notify("Invalid token.", "error");
95
- return;
96
- }
97
- await verifyAndStore(token, ctx);
98
- return;
162
+ async function connect(args: string | undefined, ctx: any, options?: { autoStart?: boolean }) {
163
+ if (connectInFlight) {
164
+ ctx.ui.notify("Seed Club sign-in is already in progress.", "info");
165
+ return connectInFlight;
99
166
  }
100
167
 
101
- const apiBase = getApiBase();
102
- const authBase = getAuthBase();
103
- const port = await findAvailablePort();
104
- const state = randomBytes(16).toString("hex");
105
- const authUrl = `${authBase}/auth/cli/authorize?port=${port}&state=${state}`;
168
+ const run = async () => {
169
+ const trimmedArgs = args?.trim();
170
+ const isReset = trimmedArgs?.toLowerCase() === "reset";
171
+ const token = isReset ? undefined : trimmedArgs;
172
+ if (token) return verifyAndStore(token, ctx, { notifyOnSuccess: true });
173
+
174
+ const apiBase = getApiBase();
175
+ const authBase = getAuthBase();
176
+ const port = await findAvailablePort();
177
+ const state = randomBytes(16).toString("hex");
178
+ const authorizePath = `/auth/cli/authorize?port=${port}&state=${state}`;
179
+ const authUrl = isReset
180
+ ? new URL(`/auth/sign-out?redirect=${encodeURIComponent(authorizePath)}`, authBase.endsWith("/") ? authBase : `${authBase}/`).toString()
181
+ : new URL(authorizePath, authBase.endsWith("/") ? authBase : `${authBase}/`).toString();
182
+
183
+ if (isReset) {
184
+ await clearCredentials();
185
+ clearRuntimeEnvTokens();
186
+ clearSeedStatuses(ctx);
187
+ markAuthRequired({
188
+ authUrl: null,
189
+ message: "Seed Club sign-in is required before /login or /model.",
190
+ error: null,
191
+ });
192
+ }
106
193
 
107
- ctx.ui.notify("Opening browser to sign in...", "info");
194
+ markAuthInProgress({
195
+ authUrl,
196
+ message: isReset
197
+ ? "Resetting your Seed Club sign-in and opening the browser to switch accounts."
198
+ : options?.autoStart
199
+ ? "Seed Club sign-in is required before /login or /model. Opening your browser now."
200
+ : "Opening your browser for Seed Club sign-in.",
201
+ error: null,
202
+ });
203
+ ctx.ui.notify(isReset ? "Opening browser to switch Seed Club accounts..." : "Opening browser to sign in...", "info");
204
+
205
+ const opened = await openExternalUrl(pi, authUrl, ctx);
206
+ if (!opened) {
207
+ markAuthInProgress({
208
+ authUrl,
209
+ message: "Open the Seed Club auth link below to continue.",
210
+ error: "Browser launch failed. Open the auth URL manually.",
211
+ });
212
+ }
108
213
 
109
- openExternalUrl(pi, authUrl, ctx);
214
+ try {
215
+ const result = await waitForCallback(port, state, apiBase);
216
+ return verifyAndStore(result.token, ctx, {
217
+ emailHint: result.email,
218
+ notifyOnSuccess: true,
219
+ });
220
+ } catch (error) {
221
+ const message = error instanceof Error ? error.message : "Auth failed";
222
+ markAuthRequired({
223
+ authUrl,
224
+ message: "Seed Club sign-in is still required. Run /connect to retry.",
225
+ error: message,
226
+ });
227
+ ctx.ui.notify(message, "error");
228
+ return false;
229
+ }
230
+ };
110
231
 
232
+ connectInFlight = run();
111
233
  try {
112
- const result = await waitForCallback(port, state);
113
- await verifyAndStore(result.token, ctx, result.email);
114
- } catch (error) {
115
- ctx.ui.notify(error instanceof Error ? error.message : "Auth failed", "error");
234
+ return await connectInFlight;
235
+ } finally {
236
+ connectInFlight = null;
116
237
  }
117
238
  }
118
239
 
119
240
  async function disconnect(ctx: any) {
120
241
  await clearCredentials();
121
- ctx.ui.setStatus("seed", undefined);
242
+ clearRuntimeEnvTokens();
243
+ clearSeedStatuses(ctx);
244
+ markAuthRequired({
245
+ authUrl: null,
246
+ message: "Seed Club sign-in is required before /login or /model.",
247
+ error: null,
248
+ });
122
249
  ctx.ui.notify("Logged out", "info");
123
250
  }
124
251
 
@@ -192,23 +319,61 @@ export default function (pi: ExtensionAPI) {
192
319
  }
193
320
  }
194
321
 
195
- async function verifyAndStore(token: string, ctx: any, emailHint?: string) {
322
+ async function verifyAndStore(
323
+ token: string,
324
+ ctx: any,
325
+ options?: { emailHint?: string; notifyOnSuccess?: boolean },
326
+ ): Promise<boolean> {
196
327
  const apiBase = getApiBase();
197
328
  const authBase = getAuthBase();
198
- await storeToken(token, emailHint || "pending", apiBase, { authBase });
329
+ markAuthInProgress({ message: "Verifying Seed Club access...", error: null });
330
+ await storeToken(token, options?.emailHint || "pending", apiBase, { authBase });
199
331
  setCachedToken(token, apiBase);
200
332
 
201
333
  const result = await getCurrentUser();
202
334
  if ("error" in result) {
203
335
  await clearCredentials();
336
+ clearSeedStatuses(ctx);
337
+ markAuthRequired({
338
+ authUrl: null,
339
+ message: "Seed Club sign-in is still required. Run /connect to retry.",
340
+ error: `Token verification failed: ${result.error}`,
341
+ });
204
342
  ctx.ui.notify(`Token verification failed: ${result.error}`, "error");
205
- return;
343
+ return false;
206
344
  }
207
345
 
208
346
  await storeToken(token, result.email, apiBase, { authBase, name: result.name });
209
- ctx.ui.notify(`Connected as ${result.name || result.email}`, "info");
210
- ctx.ui.setStatus("seed", formatSeedLabel(result.name, result.email));
347
+ await applyConnectedStatus(ctx, result);
348
+ const nextStep = getPostAuthInstruction(ctx);
349
+ markAuthComplete(nextStep);
350
+ if (options?.notifyOnSuccess) {
351
+ const suffix = nextStep ? ` ${nextStep}` : "";
352
+ ctx.ui.notify(`Connected as ${result.name || result.email}.${suffix}`.trim(), "info");
353
+ }
354
+ return true;
211
355
  }
356
+
357
+ pi.on("input", async (event, ctx) => {
358
+ if (event.source !== "interactive" || !isAuthGateBlocking()) return;
359
+
360
+ const text = event.text.trim();
361
+ if (!text) return { action: "handled" as const };
362
+
363
+ const commandName = getCommandName(text);
364
+ if (commandName && isAllowedDuringAuthGate(commandName)) return;
365
+
366
+ if (commandName === "login" || commandName === "model") {
367
+ ctx.ui.notify("Connect to Seed Club first.", "info");
368
+ return { action: "handled" as const };
369
+ }
370
+
371
+ ctx.ui.notify(
372
+ "Connect to Seed Club first. Allowed now: /connect, /seedclub, /seedenv, /commands, /extensions.",
373
+ "info",
374
+ );
375
+ return { action: "handled" as const };
376
+ });
212
377
  }
213
378
 
214
379
  // --- Helpers ---
@@ -228,7 +393,84 @@ function findAvailablePort(): Promise<number> {
228
393
  });
229
394
  }
230
395
 
231
- function waitForCallback(port: number, state: string): Promise<{ token: string; email: string }> {
396
+ async function exchangeCliCode(
397
+ apiBase: string,
398
+ code: string,
399
+ state: string,
400
+ ): Promise<{ token: string; email: string; name?: string | null }> {
401
+ const url = new URL("/auth/cli/exchange", apiBase.endsWith("/") ? apiBase : `${apiBase}/`);
402
+ const response = await fetch(url.toString(), {
403
+ method: "POST",
404
+ headers: {
405
+ "Content-Type": "application/json",
406
+ Accept: "application/json",
407
+ },
408
+ body: JSON.stringify({ code, state }),
409
+ signal: AbortSignal.timeout(10_000),
410
+ });
411
+
412
+ const text = await response.text();
413
+ let data: any = {};
414
+ try {
415
+ data = text ? JSON.parse(text) : {};
416
+ } catch {
417
+ if (!response.ok) {
418
+ throw new Error(`CLI code exchange failed (${response.status}).`);
419
+ }
420
+ throw new Error("CLI code exchange returned invalid JSON.");
421
+ }
422
+
423
+ if (!response.ok) {
424
+ const message =
425
+ typeof data?.error === "string" && data.error.trim()
426
+ ? data.error.trim()
427
+ : `CLI code exchange failed (${response.status}).`;
428
+ throw new Error(message);
429
+ }
430
+
431
+ if (typeof data?.token !== "string" || !data.token.trim()) {
432
+ throw new Error("CLI code exchange returned no token.");
433
+ }
434
+
435
+ return {
436
+ token: data.token,
437
+ email: typeof data?.email === "string" && data.email.trim() ? data.email : "unknown",
438
+ name: typeof data?.name === "string" && data.name.trim() ? data.name : null,
439
+ };
440
+ }
441
+
442
+ async function verifyCliSessionContext(apiBase: string, token: string): Promise<void> {
443
+ const url = new URL("/session/context", apiBase.endsWith("/") ? apiBase : `${apiBase}/`);
444
+ const response = await fetch(url.toString(), {
445
+ method: "GET",
446
+ headers: {
447
+ Authorization: `Bearer ${token}`,
448
+ Accept: "application/json",
449
+ },
450
+ signal: AbortSignal.timeout(10_000),
451
+ });
452
+
453
+ const text = await response.text();
454
+ let data: any = {};
455
+ try {
456
+ data = text ? JSON.parse(text) : {};
457
+ } catch {
458
+ if (!response.ok) {
459
+ throw new Error(`Seed Club access verification failed (${response.status}).`);
460
+ }
461
+ throw new Error("Seed Club access verification returned invalid JSON.");
462
+ }
463
+
464
+ if (!response.ok) {
465
+ const message =
466
+ typeof data?.error === "string" && data.error.trim()
467
+ ? data.error.trim()
468
+ : `Seed Club access verification failed (${response.status}).`;
469
+ throw new Error(message);
470
+ }
471
+ }
472
+
473
+ function waitForCallback(port: number, state: string, apiBase: string): Promise<{ token: string; email: string }> {
232
474
  return new Promise((resolve, reject) => {
233
475
  const timeout = setTimeout(() => {
234
476
  server.close();
@@ -244,7 +486,12 @@ function waitForCallback(port: number, state: string): Promise<{ token: string;
244
486
  }
245
487
 
246
488
  const done = (status: number, body: string) => {
247
- res.writeHead(status, { "Content-Type": "text/html; charset=utf-8" });
489
+ res.writeHead(status, {
490
+ "Content-Type": "text/html; charset=utf-8",
491
+ "Cache-Control": "no-store, max-age=0",
492
+ Pragma: "no-cache",
493
+ Expires: "0",
494
+ });
248
495
  res.end(body);
249
496
  clearTimeout(timeout);
250
497
  server.close();
@@ -271,26 +518,44 @@ function waitForCallback(port: number, state: string): Promise<{ token: string;
271
518
  reject(new Error(error));
272
519
  return;
273
520
  }
274
- const token = url.searchParams.get("token");
275
- if (!token?.trim()) {
276
- done(400, renderCallbackPage({
277
- eyebrow: "Seed Club Auth",
278
- title: "No token was returned.",
279
- message: "Seed Club completed sign-in, but the local callback did not receive a usable token. Try /connect again.",
280
- status: "error",
281
- }));
282
- reject(new Error("Invalid token"));
283
- return;
284
- }
285
-
286
- const email = url.searchParams.get("email") || "unknown";
287
- done(200, renderCallbackPage({
288
- eyebrow: "Seed Club Auth",
289
- title: "You're connected.",
290
- message: `Signed in as ${escapeHtml(email)}. Your CLI session is active and the token has been handed off to the agent.`,
291
- status: "success",
292
- }));
293
- resolve({ token, email });
521
+ void (async () => {
522
+ try {
523
+ const code = url.searchParams.get("code");
524
+ if (!code?.trim()) {
525
+ done(400, renderCallbackPage({
526
+ eyebrow: "Seed Club Auth",
527
+ title: "No code was returned.",
528
+ message: "Seed Club completed sign-in, but the local callback did not receive a usable authorization code. Try /connect again.",
529
+ status: "error",
530
+ cleanUrlPath: "/callback",
531
+ }));
532
+ reject(new Error("Invalid authorization code"));
533
+ return;
534
+ }
535
+
536
+ const exchange = await exchangeCliCode(apiBase, code, state);
537
+ await verifyCliSessionContext(apiBase, exchange.token);
538
+ done(200, renderCallbackPage({
539
+ eyebrow: "Seed Club Auth",
540
+ title: "You're connected.",
541
+ message: `Signed in as ${escapeHtml(exchange.email)}. Seed Club access is verified and your CLI session is ready.`,
542
+ status: "success",
543
+ cleanUrlPath: "/callback",
544
+ }));
545
+ resolve({ token: exchange.token, email: exchange.email });
546
+ } catch (exchangeError) {
547
+ const message =
548
+ exchangeError instanceof Error ? exchangeError.message : "CLI code exchange failed.";
549
+ done(400, renderCallbackPage({
550
+ eyebrow: "Seed Club Auth",
551
+ title: "CLI token exchange failed.",
552
+ message,
553
+ status: "error",
554
+ cleanUrlPath: "/callback",
555
+ }));
556
+ reject(new Error(message));
557
+ }
558
+ })();
294
559
  });
295
560
 
296
561
  server.listen(port, "127.0.0.1");
@@ -301,13 +566,17 @@ function waitForCallback(port: number, state: string): Promise<{ token: string;
301
566
  });
302
567
  }
303
568
 
304
- function openExternalUrl(pi: ExtensionAPI, url: string, ctx: any) {
569
+ async function openExternalUrl(pi: ExtensionAPI, url: string, ctx: any): Promise<boolean> {
305
570
  const openCmd =
306
571
  process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
307
572
 
308
- pi.exec(openCmd, [url]).catch(() => {
573
+ try {
574
+ await pi.exec(openCmd, [url]);
575
+ return true;
576
+ } catch {
309
577
  ctx.ui.notify(`Open this link:\n${url}`, "info");
310
- });
578
+ return false;
579
+ }
311
580
  }
312
581
 
313
582
  function waitForCalendarCallback(
@@ -329,7 +598,12 @@ function waitForCalendarCallback(
329
598
  }
330
599
 
331
600
  const done = (status: number, body: string) => {
332
- res.writeHead(status, { "Content-Type": "text/html; charset=utf-8" });
601
+ res.writeHead(status, {
602
+ "Content-Type": "text/html; charset=utf-8",
603
+ "Cache-Control": "no-store, max-age=0",
604
+ Pragma: "no-cache",
605
+ Expires: "0",
606
+ });
333
607
  res.end(body);
334
608
  clearTimeout(timeout);
335
609
  server.close();
@@ -368,6 +642,7 @@ function waitForCalendarCallback(
368
642
  title: "Your calendar is connected.",
369
643
  message: `Connected ${escapeHtml(accountLabel || accountUsername || "your Google Calendar")} to your Seed Club account.`,
370
644
  status: "success",
645
+ cleanUrlPath: "/callback",
371
646
  }));
372
647
  resolve({
373
648
  accountId: url.searchParams.get("accountId"),
@@ -398,6 +673,7 @@ function renderCallbackPage(input: {
398
673
  title: string;
399
674
  message: string;
400
675
  status: "success" | "error";
676
+ cleanUrlPath?: string;
401
677
  }) {
402
678
  const palette =
403
679
  input.status === "success"
@@ -519,6 +795,11 @@ function renderCallbackPage(input: {
519
795
  }
520
796
  }
521
797
  </style>
798
+ <script>
799
+ try {
800
+ ${input.cleanUrlPath ? `window.history.replaceState(null, "", ${JSON.stringify(input.cleanUrlPath)});` : ""}
801
+ } catch {}
802
+ </script>
522
803
  </head>
523
804
  <body>
524
805
  <main class="shell">
@@ -526,7 +807,7 @@ function renderCallbackPage(input: {
526
807
  <p class="eyebrow">${escapeHtml(input.eyebrow)}</p>
527
808
  <h1>${escapeHtml(input.title)}</h1>
528
809
  <p>${escapeHtml(input.message)}</p>
529
- <div class="note">You can close this tab and return to your terminal.</div>
810
+ <div class="note">You can close this page and return to your terminal.</div>
530
811
  </main>
531
812
  </body>
532
813
  </html>`;
@@ -4,11 +4,10 @@
4
4
  */
5
5
 
6
6
  import { execFileSync } from "node:child_process";
7
- import { existsSync } from "node:fs";
8
- import { homedir } from "node:os";
9
- import { basename, join } from "node:path";
7
+ import { basename } from "node:path";
10
8
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
11
9
  import { ApiError, NotConnectedError, api } from "../seedclub/api-client.js";
10
+ import { getAuthGateState, isAuthGateBlocking, subscribeToAuthGate } from "../seedclub/gate-state.js";
12
11
  import { uiState } from "./state.js";
13
12
 
14
13
  const BOLD = "\x1b[1m";
@@ -278,12 +277,6 @@ async function withTimeout<T>(promise: Promise<T>, ms: number, fallback: T): Pro
278
277
  }
279
278
  }
280
279
 
281
- function hasSeedConnection(): boolean {
282
- if (process.env.SEEDCLUB_ACCESS_TOKEN || process.env.SEEDCLUB_TOKEN || process.env.SEED_NETWORK_TOKEN) return true;
283
- const tokenPath = join(homedir(), ".config", "seedclub", "token");
284
- return existsSync(tokenPath);
285
- }
286
-
287
280
  function getTerminalTitle(): string {
288
281
  const cwd = basename(process.cwd());
289
282
  return cwd ? `⦿ - ${cwd}` : "⦿";
@@ -305,19 +298,47 @@ function formatQuote(q: MarketQuote, theme: ThemeLike): string {
305
298
 
306
299
  function renderSetupLines(setupHints: string[], theme: ThemeLike): string[] {
307
300
  return setupHints.flatMap((hint) => {
301
+ if (hint === "/connect") {
302
+ return [` ${theme.fg("text", "/connect")} ${theme.fg("dim", "sign in to Seed Club")}`];
303
+ }
308
304
  if (hint === "/login") {
309
305
  return [` ${theme.fg("text", "/login")} ${theme.fg("dim", "sign in with Anthropic, OpenAI, Gemini, or others")}`];
310
306
  }
311
307
  if (hint === "/model") {
312
308
  return [` ${theme.fg("text", "/model")} ${theme.fg("dim", "choose your model")}`];
313
309
  }
314
- if (hint === "/connect") {
315
- return [` ${theme.fg("text", "/connect")} ${theme.fg("dim", "to seeclub.com")}`];
316
- }
317
310
  return [` ${theme.fg("text", hint)}`];
318
311
  });
319
312
  }
320
313
 
314
+ function renderAuthGateLines(theme: ThemeLike): string[] {
315
+ const gate = getAuthGateState();
316
+ const lines = [
317
+ "",
318
+ renderTitle(theme),
319
+ "",
320
+ ` ${theme.fg("accent", "Secure access required")}`,
321
+ ` ${theme.fg("dim", gate.message || "Seed Club sign-in is required before /login or /model.")}`,
322
+ ];
323
+
324
+ if (gate.error) {
325
+ lines.push("");
326
+ lines.push(` ${theme.fg("error", gate.error)}`);
327
+ }
328
+
329
+ if (gate.authUrl) {
330
+ lines.push("");
331
+ lines.push(` ${theme.fg("text", "Auth URL")}`);
332
+ lines.push(` ${theme.fg("mdLink", gate.authUrl)}`);
333
+ }
334
+
335
+ lines.push("");
336
+ lines.push(` ${theme.fg("text", "/connect")} ${theme.fg("dim", "retry sign-in")}`);
337
+ lines.push(` ${theme.fg("text", "/commands")} ${theme.fg("dim", "list commands available during setup")}`);
338
+ lines.push("");
339
+ return lines;
340
+ }
341
+
321
342
  function renderTodayOn11amLines(today: TodayOn11am | null, theme: ThemeLike): string[] {
322
343
  if (!today || !today.guests.length) return [];
323
344
  const lines = [` ${theme.fg("text", "Today on 11AM")}`];
@@ -392,6 +413,10 @@ function renderCoinLoaderLines(frame: number, theme: ThemeLike): string[] {
392
413
  return [...lines, "", `${loadingIndent}${theme.fg("dim", loadingLabel)}`];
393
414
  }
394
415
 
416
+ function shouldShowCommandInList(name: string): boolean {
417
+ return !name.startsWith("skill:");
418
+ }
419
+
395
420
  export default function (pi: ExtensionAPI, options?: { enableFrame?: boolean }) {
396
421
  let headerLines: string[] = [
397
422
  "",
@@ -400,18 +425,13 @@ export default function (pi: ExtensionAPI, options?: { enableFrame?: boolean })
400
425
  ...renderCoinLoaderLines(0, PLAIN_THEME),
401
426
  "",
402
427
  ];
428
+ let unsubscribeAuthGate: (() => void) | undefined;
403
429
 
404
430
  pi.on("session_start", async (_event, ctx) => {
405
431
  if (!ctx.hasUI) return;
406
432
  applyTerminalTitle(ctx);
407
433
  uiState.ready = false;
408
- const hasAnyAuth = ctx.modelRegistry.getAvailable().length > 0;
409
- const hasSelectedModel = !!ctx.model;
410
- const connectedToSeed = hasSeedConnection();
411
- const setupHints: string[] = [];
412
- if (!hasAnyAuth) setupHints.push("/login");
413
- if (!hasSelectedModel) setupHints.push("/model");
414
- if (!connectedToSeed) setupHints.push("/connect");
434
+ unsubscribeAuthGate?.();
415
435
 
416
436
  let tuiRef: any = null;
417
437
  ctx.ui.setHeader((tui, theme) => {
@@ -436,77 +456,107 @@ export default function (pi: ExtensionAPI, options?: { enableFrame?: boolean })
436
456
  };
437
457
  });
438
458
 
439
- const setupLines = renderSetupLines(setupHints, ctx.ui.theme);
440
- let loaderFrame = 0;
441
- const renderLoadingHeader = () => {
442
- headerLines = [
443
- "",
444
- renderTitle(ctx.ui.theme),
445
- "",
446
- ...renderCoinLoaderLines(loaderFrame, ctx.ui.theme),
447
- "",
448
- ];
449
- };
450
- renderLoadingHeader();
451
- const loaderTimer = setInterval(() => {
452
- loaderFrame += 1;
459
+ let loadStarted = false;
460
+
461
+ const startReadyHeader = () => {
462
+ if (loadStarted) return;
463
+ loadStarted = true;
464
+ uiState.ready = false;
465
+
466
+ const setupHints: string[] = [];
467
+ const hasAnyAuth = ctx.modelRegistry.getAvailable().length > 0;
468
+ const hasSelectedModel = !!ctx.model;
469
+ if (!hasAnyAuth) setupHints.push("/login");
470
+ if (!hasSelectedModel) setupHints.push("/model");
471
+ const setupLines = renderSetupLines(setupHints, ctx.ui.theme);
472
+
473
+ let loaderFrame = 0;
474
+ const renderLoadingHeader = () => {
475
+ headerLines = [
476
+ "",
477
+ renderTitle(ctx.ui.theme),
478
+ "",
479
+ ...renderCoinLoaderLines(loaderFrame, ctx.ui.theme),
480
+ "",
481
+ ];
482
+ };
453
483
  renderLoadingHeader();
484
+ const loaderTimer = setInterval(() => {
485
+ loaderFrame += 1;
486
+ renderLoadingHeader();
487
+ tuiRef?.requestRender();
488
+ }, 120);
489
+ loaderTimer.unref?.();
454
490
  tuiRef?.requestRender();
455
- }, 120);
456
- loaderTimer.unref?.();
457
- tuiRef?.requestRender();
458
-
459
- const todayPromise = fetchTodayOn11am();
460
- void Promise.all([
461
- getData(),
462
- withTimeout(todayPromise, TODAY_PREFETCH_TIMEOUT_MS, null),
463
- ]).then(([{ weather, market }, todayOn11am]) => {
464
- clearInterval(loaderTimer);
465
- const renderReadyHeader = (today: TodayOn11am | null) => {
466
- const theme = ctx.ui.theme;
467
- const weatherLine = ` ${weather.icon} ${theme.fg("text", weather.temp)} ${theme.fg("dim", weather.condition)} ${theme.fg("dim", "·")} ${theme.fg("dim", weather.location)}`;
468
- const marketLine = ` ${market.map((quote) => formatQuote(quote, theme)).join(` ${theme.fg("dim", "·")} `)}`;
469
- const todayLines = renderTodayOn11amLines(today, theme);
470
- uiState.todayOn11am = today;
491
+
492
+ const todayPromise = fetchTodayOn11am();
493
+ void Promise.all([
494
+ getData(),
495
+ withTimeout(todayPromise, TODAY_PREFETCH_TIMEOUT_MS, null),
496
+ ]).then(([{ weather, market }, todayOn11am]) => {
497
+ clearInterval(loaderTimer);
498
+ const renderReadyHeader = (today: TodayOn11am | null) => {
499
+ const theme = ctx.ui.theme;
500
+ const weatherLine = ` ${weather.icon} ${theme.fg("text", weather.temp)} ${theme.fg("dim", weather.condition)} ${theme.fg("dim", "·")} ${theme.fg("dim", weather.location)}`;
501
+ const marketLine = ` ${market.map((quote) => formatQuote(quote, theme)).join(` ${theme.fg("dim", "·")} `)}`;
502
+ const todayLines = renderTodayOn11amLines(today, theme);
503
+ uiState.todayOn11am = today;
504
+ headerLines = [
505
+ "",
506
+ renderTitle(theme),
507
+ "",
508
+ weatherLine,
509
+ marketLine,
510
+ "",
511
+ ...todayLines,
512
+ ...(todayLines.length ? [""] : []),
513
+ ...setupLines,
514
+ "",
515
+ ];
516
+ };
517
+
518
+ renderReadyHeader(todayOn11am);
519
+ uiState.ready = true;
520
+ ctx.ui.setEditorText("");
521
+ tuiRef?.requestRender();
522
+
523
+ if (!todayOn11am?.guests.length) {
524
+ void todayPromise.then((freshToday) => {
525
+ if (!freshToday?.guests.length) return;
526
+ renderReadyHeader(freshToday);
527
+ tuiRef?.requestRender();
528
+ }).catch(() => {});
529
+ }
530
+ }).catch(() => {
531
+ clearInterval(loaderTimer);
471
532
  headerLines = [
472
533
  "",
473
- renderTitle(theme),
474
- "",
475
- weatherLine,
476
- marketLine,
534
+ renderTitle(ctx.ui.theme),
477
535
  "",
478
- ...todayLines,
479
- ...(todayLines.length ? [""] : []),
480
536
  ...setupLines,
481
537
  "",
482
538
  ];
483
- };
484
-
485
- renderReadyHeader(todayOn11am);
486
- uiState.ready = true;
487
- ctx.ui.setEditorText("");
488
- tuiRef?.requestRender();
539
+ uiState.ready = true;
540
+ ctx.ui.setEditorText("");
541
+ tuiRef?.requestRender();
542
+ });
543
+ };
489
544
 
490
- if (!todayOn11am?.guests.length) {
491
- void todayPromise.then((freshToday) => {
492
- if (!freshToday?.guests.length) return;
493
- renderReadyHeader(freshToday);
494
- tuiRef?.requestRender();
495
- }).catch(() => {});
545
+ const renderCurrentHeader = () => {
546
+ if (isAuthGateBlocking()) {
547
+ loadStarted = false;
548
+ uiState.todayOn11am = null;
549
+ headerLines = renderAuthGateLines(ctx.ui.theme);
550
+ uiState.ready = true;
551
+ ctx.ui.setEditorText("");
552
+ tuiRef?.requestRender();
553
+ return;
496
554
  }
497
- }).catch(() => {
498
- clearInterval(loaderTimer);
499
- headerLines = [
500
- "",
501
- renderTitle(ctx.ui.theme),
502
- "",
503
- ...setupLines,
504
- "",
505
- ];
506
- uiState.ready = true;
507
- ctx.ui.setEditorText("");
508
- tuiRef?.requestRender();
509
- });
555
+ startReadyHeader();
556
+ };
557
+
558
+ unsubscribeAuthGate = subscribeToAuthGate(renderCurrentHeader);
559
+ renderCurrentHeader();
510
560
  });
511
561
 
512
562
  pi.on("turn_start", (_event, ctx) => {
@@ -546,9 +596,10 @@ ${rows.join("\n")}`,
546
596
  pi.on("input", async (event, ctx) => {
547
597
  if (event.source !== "interactive") return;
548
598
  if (!uiState.ready) return { action: "handled" };
599
+ if (isAuthGateBlocking()) return;
549
600
  if (event.text.trim().startsWith("/")) return;
550
601
  if (ctx.model) return;
551
- ctx.ui.notify("Set up first: /login, then /model, then /connect.", "info");
602
+ ctx.ui.notify("Set up next: /login, then /model.", "info");
552
603
  return { action: "handled" };
553
604
  });
554
605
 
@@ -556,7 +607,7 @@ ${rows.join("\n")}`,
556
607
  description: "List all available commands",
557
608
  handler: async (_args, ctx) => {
558
609
  const theme = ctx.ui.theme;
559
- const commands = pi.getCommands();
610
+ const commands = pi.getCommands().filter((cmd) => shouldShowCommandInList(cmd.name));
560
611
  const lines = commands
561
612
  .sort((a, b) => a.name.localeCompare(b.name))
562
613
  .map((cmd) => ` ${theme.fg("accent", `/${cmd.name}`)} ${theme.fg("dim", cmd.description || "")}`)
package/bin/cli.js CHANGED
@@ -15,9 +15,8 @@ const SEEDCLUB_ENV_EXCLUDE = new Set(["SEEDCLUB_PI_MAIN"]);
15
15
 
16
16
  function printPrivateRegistryHint() {
17
17
  console.error("seedclub: install/update failed.");
18
- console.error("If npm reports a private package or permission error, run:");
19
- console.error(" npm login");
20
- console.error(" seedclub update");
18
+ console.error("Retry with:");
19
+ console.error(" npm install -g @clubnet/seedclub@latest");
21
20
  }
22
21
 
23
22
  function findPackageRoot(fromFile, expectedName) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubnet/seedclub",
3
- "version": "0.2.28",
3
+ "version": "0.2.29",
4
4
  "description": "A branded command-line agent wrapper around pi, with integrated Seed Club commands, tools, and app actions",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -8,7 +8,7 @@
8
8
  "url": "git+https://github.com/seedclub/seedclub-agent.git"
9
9
  },
10
10
  "publishConfig": {
11
- "access": "restricted"
11
+ "access": "public"
12
12
  },
13
13
  "bin": {
14
14
  "seedclub": "bin/cli.js"