@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 +11 -241
- package/assets/extensions/seedclub/auth.ts +6 -21
- package/assets/extensions/seedclub/commands/seedclub.ts +2 -5
- package/assets/extensions/seedclub/gate-state.ts +85 -0
- package/assets/extensions/seedclub/index.ts +348 -67
- package/assets/extensions/seedclub-ui/welcome.ts +133 -82
- package/bin/cli.js +2 -3
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -28,13 +28,11 @@ seedclub
|
|
|
28
28
|
|
|
29
29
|
### First run
|
|
30
30
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
###
|
|
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
|
-
|
|
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
|
|
61
|
-
3.
|
|
62
|
-
4.
|
|
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
|
|
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
|
|
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<
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
const
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
210
|
-
|
|
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
|
|
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, {
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
-
|
|
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, {
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
440
|
-
|
|
441
|
-
const
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
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
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
tuiRef?.requestRender();
|
|
539
|
+
uiState.ready = true;
|
|
540
|
+
ctx.ui.setEditorText("");
|
|
541
|
+
tuiRef?.requestRender();
|
|
542
|
+
});
|
|
543
|
+
};
|
|
489
544
|
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
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
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
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
|
|
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("
|
|
19
|
-
console.error(" npm
|
|
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.
|
|
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": "
|
|
11
|
+
"access": "public"
|
|
12
12
|
},
|
|
13
13
|
"bin": {
|
|
14
14
|
"seedclub": "bin/cli.js"
|