@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 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 <repo>
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@every-env/spiral-cli",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "CLI for Spiral API - create content from your terminal",
5
5
  "author": "Kieran Klaassen",
6
6
  "license": "MIT",
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
- * Get valid auth token (from env, cache, or browser)
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 in-memory cache first (0ms vs 50-200ms disk access)
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
- // 2. Extract fresh token from browser cookies
197
+ // 3. Extract fresh JWT token from browser cookies
114
198
  const token = await extractSpiralAuth();
115
199
 
116
- // 3. Check if token is expired
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 refresh: Open https://app.writewithspiral.com in your browser and refresh the page.\n" +
121
- "The CLI will automatically pick up the fresh token.\n\n" +
122
- "Tip: Keep a Spiral tab open while using the CLI for seamless token refresh.",
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
- // 4. Cache for future calls in this process
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 { clearTokenCache, getAuthToken, sanitizeError } from "./auth";
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 "status":
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 token = await getAuthToken();
812
- if (values.json) {
813
- console.log(
814
- JSON.stringify({
815
- status: "authenticated",
816
- token_preview: `${token.slice(0, 20)}...`,
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 (values.json) {
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(`Not authenticated: ${(error as Error).message}`));
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 cached token
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 [status|clear] Manage authentication
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