@every-env/spiral-cli 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -3
- package/package.json +1 -1
- package/src/auth.ts +93 -8
- package/src/cli.ts +94 -28
- package/src/config.ts +8 -0
package/README.md
CHANGED
|
@@ -6,15 +6,31 @@ A command-line interface for interacting with the Spiral API from your terminal.
|
|
|
6
6
|
|
|
7
7
|
### Prerequisites
|
|
8
8
|
|
|
9
|
-
- macOS (Safari cookie extraction)
|
|
9
|
+
- macOS (Safari/Chrome/Firefox cookie extraction)
|
|
10
10
|
- [Bun](https://bun.sh/) >= 1.1.0
|
|
11
11
|
- Full Disk Access for terminal (macOS Sonoma+)
|
|
12
12
|
|
|
13
|
+
### Install from npm (recommended)
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
# Install globally
|
|
17
|
+
bun add -g @every-env/spiral-cli
|
|
18
|
+
|
|
19
|
+
# Or run directly without installing
|
|
20
|
+
bunx @every-env/spiral-cli chat "Write a tweet about AI"
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
After installation, the `spiral` command is available globally:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
spiral chat "Your prompt here"
|
|
27
|
+
```
|
|
28
|
+
|
|
13
29
|
### Install from source
|
|
14
30
|
|
|
15
31
|
```bash
|
|
16
|
-
git clone
|
|
17
|
-
cd spiral-cli
|
|
32
|
+
git clone https://github.com/EveryInc/spiral-next.git
|
|
33
|
+
cd spiral-next/spiral-cli
|
|
18
34
|
bun install
|
|
19
35
|
```
|
|
20
36
|
|
package/package.json
CHANGED
package/src/auth.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { getCookies } from "@steipete/sweet-cookie";
|
|
2
|
+
import { config } from "./config";
|
|
2
3
|
import { AuthenticationError } from "./types";
|
|
3
4
|
|
|
4
5
|
const SPIRAL_DOMAIN = "app.writewithspiral.com";
|
|
5
6
|
const SPIRAL_URL = `https://${SPIRAL_DOMAIN}`;
|
|
7
|
+
const PAT_PREFIX = "spiral_sk_";
|
|
6
8
|
|
|
7
9
|
// Supported browsers for cookie extraction
|
|
8
10
|
const SUPPORTED_BROWSERS = ["safari", "chrome", "firefox"] as const;
|
|
@@ -92,7 +94,80 @@ export async function extractSpiralAuth(): Promise<string> {
|
|
|
92
94
|
}
|
|
93
95
|
|
|
94
96
|
/**
|
|
95
|
-
*
|
|
97
|
+
* Check if a token is a PAT (Personal Access Token)
|
|
98
|
+
*/
|
|
99
|
+
function isPAT(token: string): boolean {
|
|
100
|
+
return token.startsWith(PAT_PREFIX);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Get stored PAT from config
|
|
105
|
+
*/
|
|
106
|
+
export function getStoredPAT(): string | null {
|
|
107
|
+
const auth = config.get("auth");
|
|
108
|
+
return auth?.token || null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Store PAT in config
|
|
113
|
+
*/
|
|
114
|
+
export function storePAT(token: string): void {
|
|
115
|
+
if (!isPAT(token)) {
|
|
116
|
+
throw new AuthenticationError(
|
|
117
|
+
`Invalid API key format. Keys should start with "${PAT_PREFIX}"`,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
config.set("auth", {
|
|
122
|
+
token,
|
|
123
|
+
tokenPrefix: `${token.substring(0, 16)}...`,
|
|
124
|
+
createdAt: new Date().toISOString(),
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// Clear any cached JWT
|
|
128
|
+
clearTokenCache();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Clear stored PAT
|
|
133
|
+
*/
|
|
134
|
+
export function clearStoredPAT(): void {
|
|
135
|
+
config.delete("auth");
|
|
136
|
+
clearTokenCache();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Get auth status info
|
|
141
|
+
*/
|
|
142
|
+
export function getAuthStatus(): {
|
|
143
|
+
method: "pat" | "browser" | "env" | "none";
|
|
144
|
+
tokenPrefix?: string;
|
|
145
|
+
createdAt?: string;
|
|
146
|
+
} {
|
|
147
|
+
// Check env first
|
|
148
|
+
if (process.env.SPIRAL_TOKEN) {
|
|
149
|
+
const token = process.env.SPIRAL_TOKEN;
|
|
150
|
+
return {
|
|
151
|
+
method: "env",
|
|
152
|
+
tokenPrefix: isPAT(token) ? `${token.substring(0, 16)}...` : "JWT token",
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Check stored PAT
|
|
157
|
+
const auth = config.get("auth");
|
|
158
|
+
if (auth?.token) {
|
|
159
|
+
return {
|
|
160
|
+
method: "pat",
|
|
161
|
+
tokenPrefix: auth.tokenPrefix,
|
|
162
|
+
createdAt: auth.createdAt,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return { method: "none" };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Get valid auth token (from env, stored PAT, cache, or browser)
|
|
96
171
|
* @security Uses in-memory cache, re-extracts on expiry
|
|
97
172
|
*/
|
|
98
173
|
export async function getAuthToken(): Promise<string> {
|
|
@@ -105,25 +180,35 @@ export async function getAuthToken(): Promise<string> {
|
|
|
105
180
|
return envToken;
|
|
106
181
|
}
|
|
107
182
|
|
|
108
|
-
// 1. Check
|
|
183
|
+
// 1. Check for stored PAT (long-lived, doesn't expire)
|
|
184
|
+
const storedPAT = getStoredPAT();
|
|
185
|
+
if (storedPAT) {
|
|
186
|
+
if (process.env.DEBUG) {
|
|
187
|
+
console.debug("Using stored PAT from config");
|
|
188
|
+
}
|
|
189
|
+
return storedPAT;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// 2. Check in-memory cache for JWT (0ms vs 50-200ms disk access)
|
|
109
193
|
if (tokenCache && !isTokenExpired(tokenCache.token)) {
|
|
110
194
|
return tokenCache.token;
|
|
111
195
|
}
|
|
112
196
|
|
|
113
|
-
//
|
|
197
|
+
// 3. Extract fresh JWT token from browser cookies
|
|
114
198
|
const token = await extractSpiralAuth();
|
|
115
199
|
|
|
116
|
-
//
|
|
200
|
+
// 4. Check if JWT token is expired
|
|
117
201
|
if (isTokenExpired(token)) {
|
|
118
202
|
throw new AuthenticationError(
|
|
119
203
|
"Session token has expired.\n\n" +
|
|
120
|
-
"To
|
|
121
|
-
"
|
|
122
|
-
"
|
|
204
|
+
"To fix this, either:\n" +
|
|
205
|
+
" 1. Run `spiral auth login` and enter your API key\n" +
|
|
206
|
+
" 2. Open https://app.writewithspiral.com in your browser and refresh\n\n" +
|
|
207
|
+
"Get an API key at: https://app.writewithspiral.com → Account → API Keys",
|
|
123
208
|
);
|
|
124
209
|
}
|
|
125
210
|
|
|
126
|
-
//
|
|
211
|
+
// 5. Cache for future calls in this process
|
|
127
212
|
tokenCache = { token, expiresAt: getTokenExpiry(token) };
|
|
128
213
|
return token;
|
|
129
214
|
}
|
package/src/cli.ts
CHANGED
|
@@ -2,13 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
import * as readline from "node:readline";
|
|
4
4
|
import { parseArgs } from "node:util";
|
|
5
|
+
import { password } from "@inquirer/prompts";
|
|
5
6
|
import chalk from "chalk";
|
|
6
7
|
import { marked } from "marked";
|
|
7
8
|
import { markedTerminal } from "marked-terminal";
|
|
8
9
|
import ora from "ora";
|
|
9
10
|
import { fetchConversations, fetchMessages, getApiBaseUrl, streamChat } from "./api";
|
|
10
11
|
import { formatAttachmentsSummary, processAttachments } from "./attachments";
|
|
11
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
clearStoredPAT,
|
|
14
|
+
clearTokenCache,
|
|
15
|
+
getAuthStatus,
|
|
16
|
+
getAuthToken,
|
|
17
|
+
sanitizeError,
|
|
18
|
+
storePAT,
|
|
19
|
+
} from "./auth";
|
|
12
20
|
import { config } from "./config";
|
|
13
21
|
import {
|
|
14
22
|
editDraft,
|
|
@@ -806,47 +814,104 @@ async function historyCommand(conversationId?: string): Promise<void> {
|
|
|
806
814
|
*/
|
|
807
815
|
async function authCommand(action?: string): Promise<void> {
|
|
808
816
|
switch (action) {
|
|
809
|
-
case "
|
|
817
|
+
case "login": {
|
|
818
|
+
console.log(theme.heading("\nSpiral CLI Login\n"));
|
|
819
|
+
console.log(
|
|
820
|
+
theme.dim("Get your API key at: https://app.writewithspiral.com → Account → API Keys\n"),
|
|
821
|
+
);
|
|
822
|
+
|
|
810
823
|
try {
|
|
811
|
-
const
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
);
|
|
819
|
-
} else {
|
|
820
|
-
console.log(theme.success("Authenticated"));
|
|
821
|
-
if (process.env.DEBUG) {
|
|
822
|
-
console.log(theme.dim(`Token: ${token.slice(0, 20)}...`));
|
|
823
|
-
}
|
|
824
|
+
const apiKey = await password({
|
|
825
|
+
message: "Enter your API key:",
|
|
826
|
+
mask: "•",
|
|
827
|
+
});
|
|
828
|
+
|
|
829
|
+
if (!apiKey || !apiKey.trim()) {
|
|
830
|
+
console.log(theme.error("No API key provided"));
|
|
831
|
+
process.exit(EXIT_CODES.AUTH_ERROR);
|
|
824
832
|
}
|
|
833
|
+
|
|
834
|
+
storePAT(apiKey.trim());
|
|
835
|
+
console.log(theme.success("\n✓ Logged in successfully!"));
|
|
836
|
+
console.log(theme.dim("Your API key has been saved securely.\n"));
|
|
825
837
|
} catch (error) {
|
|
826
|
-
if (
|
|
827
|
-
console.log(
|
|
828
|
-
JSON.stringify({
|
|
829
|
-
status: "unauthenticated",
|
|
830
|
-
error: (error as Error).message,
|
|
831
|
-
}),
|
|
832
|
-
);
|
|
838
|
+
if ((error as Error).message?.includes("spiral_sk_")) {
|
|
839
|
+
console.log(theme.error((error as Error).message));
|
|
833
840
|
} else {
|
|
834
|
-
console.log(theme.error(
|
|
841
|
+
console.log(theme.error("Login cancelled"));
|
|
835
842
|
}
|
|
836
843
|
process.exit(EXIT_CODES.AUTH_ERROR);
|
|
837
844
|
}
|
|
838
845
|
break;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
case "logout": {
|
|
849
|
+
const status = getAuthStatus();
|
|
850
|
+
if (status.method === "pat") {
|
|
851
|
+
clearStoredPAT();
|
|
852
|
+
console.log(theme.success("Logged out. API key removed."));
|
|
853
|
+
} else if (status.method === "env") {
|
|
854
|
+
console.log(
|
|
855
|
+
theme.warning("Using SPIRAL_TOKEN environment variable. Unset it to log out."),
|
|
856
|
+
);
|
|
857
|
+
} else {
|
|
858
|
+
console.log(theme.info("Not logged in with an API key."));
|
|
859
|
+
}
|
|
860
|
+
break;
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
case "status": {
|
|
864
|
+
const status = getAuthStatus();
|
|
865
|
+
|
|
866
|
+
if (values.json) {
|
|
867
|
+
console.log(JSON.stringify(status));
|
|
868
|
+
return;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
switch (status.method) {
|
|
872
|
+
case "env":
|
|
873
|
+
console.log(theme.success("Authenticated via SPIRAL_TOKEN environment variable"));
|
|
874
|
+
console.log(theme.dim(`Token: ${status.tokenPrefix}`));
|
|
875
|
+
break;
|
|
876
|
+
case "pat":
|
|
877
|
+
console.log(theme.success("Authenticated with API key"));
|
|
878
|
+
console.log(theme.dim(`Key: ${status.tokenPrefix}`));
|
|
879
|
+
if (status.createdAt) {
|
|
880
|
+
console.log(theme.dim(`Saved: ${new Date(status.createdAt).toLocaleDateString()}`));
|
|
881
|
+
}
|
|
882
|
+
break;
|
|
883
|
+
case "none":
|
|
884
|
+
// Try browser fallback
|
|
885
|
+
try {
|
|
886
|
+
const token = await getAuthToken();
|
|
887
|
+
console.log(theme.success("Authenticated via browser session"));
|
|
888
|
+
console.log(theme.dim(`Token: ${token.slice(0, 20)}...`));
|
|
889
|
+
} catch {
|
|
890
|
+
console.log(theme.warning("Not authenticated"));
|
|
891
|
+
console.log(
|
|
892
|
+
theme.dim("\nRun `spiral auth login` to authenticate with an API key."),
|
|
893
|
+
);
|
|
894
|
+
}
|
|
895
|
+
break;
|
|
896
|
+
}
|
|
897
|
+
break;
|
|
898
|
+
}
|
|
899
|
+
|
|
839
900
|
case "clear":
|
|
901
|
+
clearStoredPAT();
|
|
840
902
|
clearTokenCache();
|
|
841
|
-
console.log(
|
|
842
|
-
theme.info("Token cache cleared. Re-login by visiting https://app.writewithspiral.com"),
|
|
843
|
-
);
|
|
903
|
+
console.log(theme.info("All credentials cleared."));
|
|
844
904
|
break;
|
|
905
|
+
|
|
845
906
|
default:
|
|
846
907
|
console.log(`
|
|
847
908
|
${theme.heading("Auth Commands:")}
|
|
909
|
+
spiral auth login Login with API key (recommended)
|
|
910
|
+
spiral auth logout Remove stored API key
|
|
848
911
|
spiral auth status Check authentication status
|
|
849
|
-
spiral auth clear Clear
|
|
912
|
+
spiral auth clear Clear all stored credentials
|
|
913
|
+
|
|
914
|
+
${theme.dim("Get your API key at: https://app.writewithspiral.com → Account → API Keys")}
|
|
850
915
|
`);
|
|
851
916
|
}
|
|
852
917
|
}
|
|
@@ -863,7 +928,8 @@ ${theme.heading("Usage:")}
|
|
|
863
928
|
spiral chat [--session <id>] [--new] Start interactive chat
|
|
864
929
|
spiral sessions [--json] [--limit N] List sessions
|
|
865
930
|
spiral history <session-id> [--json] View session history
|
|
866
|
-
spiral auth
|
|
931
|
+
spiral auth login Login with API key
|
|
932
|
+
spiral auth [status|logout|clear] Manage authentication
|
|
867
933
|
|
|
868
934
|
${theme.heading("Content Management:")}
|
|
869
935
|
spiral styles [--json] List writing styles
|
package/src/config.ts
CHANGED
|
@@ -25,6 +25,13 @@ export interface SpiralPreferences {
|
|
|
25
25
|
toolCallVerbosity?: "minimal" | "normal" | "verbose";
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
// Auth credentials
|
|
29
|
+
export interface AuthCredentials {
|
|
30
|
+
token: string; // Personal Access Token (PAT)
|
|
31
|
+
tokenPrefix: string; // For display (spiral_sk_abc...)
|
|
32
|
+
createdAt: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
28
35
|
// Main config schema
|
|
29
36
|
export interface SpiralConfig {
|
|
30
37
|
currentWorkspaceId?: string;
|
|
@@ -33,6 +40,7 @@ export interface SpiralConfig {
|
|
|
33
40
|
drafts: Record<string, LocalDraft>;
|
|
34
41
|
notes: NoteItem[];
|
|
35
42
|
preferences: SpiralPreferences;
|
|
43
|
+
auth?: AuthCredentials;
|
|
36
44
|
}
|
|
37
45
|
|
|
38
46
|
// Initialize conf with defaults
|