@clubnet/seedclub 0.2.28 → 0.2.30

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
@@ -28,13 +28,11 @@ seedclub
28
28
 
29
29
  ### First run
30
30
 
31
- On a fresh install, the normal setup flow is:
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
36
-
37
- After that, run `/seedclub` to open the main Seed Club menu.
31
+ 1. Run `seedclub`
32
+ 2. Complete Seed Club sign-in when the browser opens
33
+ 3. Run `/login`
34
+ 4. Run `/model`
35
+ 5. Open `/seedclub`
38
36
 
39
37
  ### Alternative: curl | bash
40
38
 
@@ -42,24 +40,17 @@ After that, run `/seedclub` to open the main Seed Club menu.
42
40
  curl -fsSL https://raw.githubusercontent.com/seedclub/seedclub-agent/main/install.sh | bash
43
41
  ```
44
42
 
45
- ### Internal install auth
46
-
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
- ```
43
+ ### Package access
52
44
 
53
- Then `npm install -g @clubnet/seedclub` and `seedclub update` work.
45
+ `@clubnet/seedclub` is a public npm package. Install access is open; runtime access is enforced inside the app.
54
46
 
55
47
  ## Core workflow
56
48
 
57
- The normal interactive flow is:
58
-
59
49
  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
50
+ 2. Complete Seed Club sign-in when the browser opens
51
+ 3. Complete `/login` and `/model` if this is your first run
52
+ 4. Open `/seedclub`
53
+ 5. Choose the workflow you need
63
54
 
64
55
  ## Commands
65
56
 
@@ -74,227 +65,6 @@ The normal interactive flow is:
74
65
 
75
66
  Natural-language transcript retrieval is also supported (no slash command required). Examples: `download vibhu transcripts from 11am`, `i need transcripts for all guests on 11am last week`. Seed Club will run metadata-first export confirmation and then write VTT files.
76
67
 
77
- ## Auth
78
-
79
- There are two separate auth layers in the product:
80
-
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`
84
- This connects the CLI to your Seed Club account so Seed Club tools and commands can read and write account data.
85
- 3. Personal calendar connect: `/connect-calendar`
86
- 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
-
88
- `/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
-
90
- Power-user env overrides:
91
-
92
- ```bash
93
- export SEEDCLUB_API_URL=http://localhost:3001
94
- export SEEDCLUB_AUTH_URL=http://localhost:3000
95
- export SEEDCLUB_ACCESS_TOKEN=<bearer-token>
96
- ```
97
-
98
- Production defaults are already built into the auth extension:
99
-
100
- ```bash
101
- SEEDCLUB_API_URL=https://api.seedclub.com
102
- SEEDCLUB_AUTH_URL=https://auth.seedclub.com
103
- ```
104
-
105
- You do not need to export those for normal use. Only set env vars when you want to override them for local/dev.
106
-
107
- ## How it works
108
-
109
- seedclub is an npm package (`@clubnet/seedclub`) that wraps [pi](https://github.com/badlogic/pi-mono) as a dependency. Installing the package globally gives you the `seedclub` command.
110
-
111
- The `seedclub` CLI wrapper does three things:
112
- 1. Sets `PI_CODING_AGENT_DIR` to point pi at `~/.seedclub/agent/`
113
- 2. Sets `PI_SKIP_VERSION_CHECK=1` so pi's update banner never shows
114
- 3. Spawns the pi binary from the package's own `node_modules/`
115
-
116
- A `postinstall` script runs after every install/update and sets up:
117
-
118
- ```
119
- ~/.seedclub/
120
- └── agent/
121
- ├── extensions/
122
- │ ├── seedclub/ ← core: auth, tools, commands
123
- │ └── seedclub-ui/ ← UI: welcome screen, update check
124
- ├── themes/
125
- │ ├── dark.json
126
- │ └── light.json
127
- ├── settings.json
128
- └── .seedclub-version
129
- ```
130
-
131
- This means:
132
- - Running `seedclub` → uses `~/.seedclub/agent/` config, package-local pi binary
133
- - Running `pi` (if installed separately) → uses `~/.pi/agent/` config, totally independent
134
- - **seedclub never modifies pi's installation**
135
-
136
- ## Version pinning
137
-
138
- seedclub pins versions in `package.json`:
139
-
140
- ```json
141
- {
142
- "version": "0.2.19",
143
- "dependencies": {
144
- "@mariozechner/pi-coding-agent": "0.65.2"
145
- }
146
- }
147
- ```
148
-
149
- Users never interact with pi's npm package directly. When they run `seedclub update`, it runs `npm install -g @clubnet/seedclub@latest`.
150
-
151
- ## Theme
152
-
153
- Themes live under `assets/theme/` and are installed to `~/.seedclub/agent/themes/` (currently `dark.json` and `light.json`).
154
-
155
- It's a standard pi theme with 51 color tokens. Edit it to change any visual aspect of the app:
156
-
157
- | Section | What it controls |
158
- |---|---|
159
- | **Core UI** | `accent`, `border`, `success`, `error`, `warning`, `muted`, `text` |
160
- | **Backgrounds** | User messages, tool boxes (pending/success/error), selection highlight |
161
- | **Markdown** | Headings, links, code blocks, quotes, list bullets |
162
- | **Syntax** | Comments, keywords, functions, strings, numbers, types |
163
- | **Thinking borders** | Editor border color per thinking level (off → xhigh) |
164
- | **Diffs** | Added/removed/context lines in tool output |
165
-
166
- The `vars` block at the top defines reusable colors (e.g. `brand: "#00C853"`) that are referenced throughout `colors`. To change the brand color, just update `vars.brand`.
167
-
168
- Seed Club keeps product-level names in `vars` (`editorBg`, `messageBg`, `successBg`, `errorBg`) and maps pi's required tokens to those names (`selectedBg`, `userMessageBg`, `toolPendingBg`, etc.). The CLI probes the terminal background with OSC 11 and sets `COLORFGBG` before pi starts when no explicit theme is configured, so light/dark defaults track the actual terminal window instead of pi's fallback.
169
-
170
- **Hot reload:** Edit the theme file while seedclub is running and it reloads instantly.
171
-
172
- Colors can be hex (`"#00C853"`), 256-color palette index (`242`), a reference to a `vars` entry (`"brand"`), or empty string (`""`) for the terminal default.
173
-
174
- ## Development
175
-
176
- ### Setup
177
-
178
- ```bash
179
- git clone https://github.com/seedclub/seedclub-agent.git
180
- cd seedclub-agent
181
-
182
- # Install deps, then install locally from the repo
183
- npm install
184
- npm install -g ./
185
- ```
186
-
187
- For day-to-day work, run `seedclub version` after installing to confirm your shell is using this repo's wrapper build.
188
-
189
- ### Repo structure
190
-
191
- ```
192
- seedclub/
193
- ├── package.json ← npm package definition (@clubnet/seedclub)
194
- ├── bin/cli.js ← Node.js CLI wrapper (the `seedclub` command)
195
- ├── postinstall.js ← runs after npm install (sets up ~/.seedclub/agent/)
196
- ├── install.sh ← curl | bash installer (just runs npm install -g)
197
- ├── README.md
198
- └── assets/
199
- ├── theme/
200
- │ ├── dark.json
201
- │ └── light.json
202
- └── extensions/
203
- ├── seedclub/ ← core extension source
204
- └── seedclub-ui/ ← UI extension source
205
- ```
206
-
207
- The `assets/` directory contains the canonical source for extensions and themes. The postinstall script copies these into `~/.seedclub/agent/`.
208
-
209
- ### Updating pi
210
-
211
- When a new pi version comes out, upgrading it for Seed Club users means shipping a new `@clubnet/seedclub` package that depends on the newer pi release.
212
-
213
- Recommended flow:
214
-
215
- 1. **Update and test locally:**
216
- ```bash
217
- npm install @mariozechner/pi-coding-agent@NEW_VERSION
218
- npm install -g ./
219
- seedclub version
220
- seedclub
221
- ```
222
-
223
- Verify the wrapper still launches, the Seed Club UI loads, and the core flows you care about still work.
224
-
225
- 2. **Commit the dependency change and bump the package version:**
226
- The pi version is carried by this package, so users only receive it once the Seed Club package version is bumped and released.
227
- ```json
228
- {
229
- "version": "0.3.0",
230
- "dependencies": {
231
- "@mariozechner/pi-coding-agent": "NEW_VERSION"
232
- }
233
- }
234
- ```
235
-
236
- 3. **Push the release:**
237
- ```bash
238
- git add -A
239
- git commit -m "bump pi to NEW_VERSION"
240
- npm version patch|minor|major
241
- git push --follow-tags
242
- ```
243
-
244
- 4. **Users pull the update:**
245
- Users get the newer pi runtime when they run:
246
- ```bash
247
- seedclub update
248
- ```
249
-
250
- A fresh `npm install -g @clubnet/seedclub` also installs the new packaged pi version.
251
-
252
- ### Updating extensions
253
-
254
- Extensions live in `assets/extensions/`. Edit them there, then:
255
-
256
- ```bash
257
- # Test locally
258
- npm install -g ./
259
- seedclub # test
260
-
261
- # When ready, bump package version and publish
262
- git add -A
263
- git commit -m "update extensions"
264
- npm version patch|minor|major
265
- git push --follow-tags
266
- ```
267
-
268
- For reproducible extension dependency installs, commit `assets/extensions/seedclub/package-lock.json` and keep it in sync when changing extension deps.
269
-
270
- ### Release process
271
-
272
- Publishing is now handled by npm trusted publishing from GitHub Actions.
273
-
274
- Workflow:
275
-
276
- 1. Create a branch and make changes
277
- 2. Merge to `main`
278
- 3. Pull `main`, run `npm install`, then `npm install -g ./`
279
- 4. Smoke test with `seedclub version` and, if relevant, a quick interactive run
280
- 5. Bump version: `npm version patch|minor|major`
281
- 6. Push commits + tags: `git push --follow-tags`
282
- 7. GitHub Actions publishes the tagged release to npm automatically via `.github/workflows/publish.yml`
283
-
284
- ### Trusted publishing
285
-
286
- The npm package is configured to publish from GitHub Actions, not from long-lived local npm tokens.
287
-
288
- Trusted publisher settings for `@clubnet/seedclub`:
289
-
290
- - Provider: GitHub Actions
291
- - Organization or user: `seedclub`
292
- - Repository: `seedclub-agent`
293
- - Workflow filename: `publish.yml`
294
- - Environment name: blank
295
-
296
- Once trusted publishing is working, npm package publishing access should stay on the stricter setting that disallows token-based publishing.
297
-
298
68
  ## Update
299
69
 
300
70
  ```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.30",
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"