@hyperspell/openclaw-hyperspell 0.7.1 → 0.8.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
@@ -193,70 +193,6 @@ Enable the graph tools by using `hyperspell_network_scan`, `hyperspell_network_w
193
193
 
194
194
  ---
195
195
 
196
- ## SommeliAgent 🍷
197
-
198
- > *"Your playlist says more about your palate than you'd like to admit, darling."*
199
- > — A Linea, Hyperspell's hidden sommelier
200
-
201
- An opinionated AI sommelier living inside your memory plugin. She reads your Spotify listening habits, judges them (affectionately), and recommends wines that match the person your music says you are — not the person you think you are.
202
-
203
- 215 wines. 26 countries. Zero tolerance for boring recommendations.
204
-
205
- **Requires:** `uv` ([install](https://docs.astral.sh/uv/))
206
-
207
- ### Setup
208
-
209
- 1. Create a Spotify app at https://developer.spotify.com/dashboard (redirect URI: `http://localhost:8888/callback`)
210
- 2. Set environment variables: `SPOTIFY_CLIENT_ID` and `SPOTIFY_CLIENT_SECRET`
211
- 3. Authenticate: `/wine-auth`
212
-
213
- Or skip all that and try demo mode: `/wine demo`
214
-
215
- ### Slash Commands
216
-
217
- #### `/wine [options]`
218
-
219
- Get wine recommendations. Options can be combined:
220
-
221
- ```
222
- /wine — recommendations from your Spotify
223
- /wine demo — use demo profile (no Spotify needed)
224
- /wine red premium — only red wines, premium price range
225
- /wine white 5 — 5 white wine recommendations
226
- /wine demo profile — show full music-to-wine mapping
227
- ```
228
-
229
- #### `/wine-auth`
230
-
231
- Connect your Spotify account.
232
-
233
- #### `/wine-rate <wine-id> <1-5> [notes]`
234
-
235
- Rate a recommendation to improve future suggestions.
236
-
237
- ```
238
- /wine-rate red-it-001 5 "Incredible tannins, paired perfectly with my Radiohead phase"
239
- ```
240
-
241
- #### `/wine-history`
242
-
243
- View your rating history and derived taste preferences.
244
-
245
- ### AI Tools
246
-
247
- The plugin also registers tools the AI can use autonomously:
248
-
249
- - **hyperspell_sommelier** - Get wine recommendations (with full personality instructions)
250
- - **hyperspell_sommelier_rate** - Rate wines
251
-
252
- ### How It Works
253
-
254
- Your Spotify audio features (energy, valence, complexity, acousticness, tempo) are mapped to wine dimensions (body, sweetness, tannin, acidity, complexity, fruitiness, earthiness, spiciness). The cross-domain mapping is entertainment-first — the comedy comes from a sharp, opinionated sommelier voice diagnosing your personality through your questionable music taste.
255
-
256
- The sommelier is A Linea — she lives inside Hyperspell and has strong opinions about both your playlist and your palate. She chose Alexander McQueen as her fashion house, so expect the wine recommendations to have a similar aesthetic: beautiful, a little dark, and never boring.
257
-
258
- See `sommeliagent/references/cross-domain-mappings.md` for the full methodology. 🖤
259
-
260
196
  ## Troubleshooting
261
197
 
262
198
  ### "No relevant memories found"
package/index.ts CHANGED
@@ -1,7 +1,6 @@
1
1
  import type { OpenClawPluginApi } from "openclaw/plugin-sdk"
2
2
  import { HyperspellClient } from "./client.ts"
3
3
  import { registerCommands } from "./commands/slash.ts"
4
- import { registerWineCommands } from "./commands/wine.ts"
5
4
  import { registerCliCommands } from "./commands/setup.ts"
6
5
  import { parseConfig, hyperspellConfigSchema, getWorkspaceDir } from "./config.ts"
7
6
  import { buildAutoContextHandler } from "./hooks/auto-context.ts"
@@ -10,7 +9,6 @@ import { buildFileSyncHandler, syncMemoriesOnStartup } from "./hooks/memory-sync
10
9
  import { initLogger } from "./logger.ts"
11
10
  import { registerRememberTool } from "./tools/remember.ts"
12
11
  import { registerSearchTool } from "./tools/search.ts"
13
- import { registerSommelierTool, registerSommelierRateTool } from "./tools/sommelier.ts"
14
12
  import { registerNetworkTools } from "./graph/index.ts"
15
13
 
16
14
  export default {
@@ -34,11 +32,6 @@ export default {
34
32
  const rawConfig = api.pluginConfig as Record<string, unknown> | undefined;
35
33
  const hasConfig = rawConfig?.apiKey || process.env.HYPERSPELL_API_KEY;
36
34
 
37
- // SommeliAgent works independently of Hyperspell config — it just needs uv + Spotify
38
- registerSommelierTool(api)
39
- registerSommelierRateTool(api)
40
- registerWineCommands(api)
41
-
42
35
  if (!hasConfig) {
43
36
  api.logger.info(
44
37
  "hyperspell: not configured - run 'openclaw openclaw-hyperspell setup'",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperspell/openclaw-hyperspell",
3
- "version": "0.7.1",
3
+ "version": "0.8.0",
4
4
  "type": "module",
5
5
  "description": "OpenClaw Hyperspell memory plugin",
6
6
  "license": "MIT",
@@ -14,13 +14,12 @@
14
14
  "graph/",
15
15
  "lib/",
16
16
  "types/",
17
- "sommeliagent/",
18
17
  "openclaw.plugin.json",
19
18
  "README.md"
20
19
  ],
21
20
  "repository": {
22
21
  "type": "git",
23
- "url": "git+https://github.com/hyperspell/openclaw-hyperspell.git"
22
+ "url": "git+https://github.com/hyperspell/hyperspell-openclaw.git"
24
23
  },
25
24
  "keywords": [
26
25
  "openclaw",
@@ -28,9 +27,7 @@
28
27
  "memory",
29
28
  "rag",
30
29
  "ai",
31
- "plugin",
32
- "sommelier",
33
- "wine"
30
+ "plugin"
34
31
  ],
35
32
  "dependencies": {
36
33
  "@clack/prompts": "^1.0.0",
@@ -48,7 +45,8 @@
48
45
  "openclaw": {
49
46
  "extensions": [
50
47
  "./index.ts"
51
- ]
48
+ ],
49
+ "hooks": []
52
50
  },
53
51
  "devDependencies": {
54
52
  "typescript": "^5.9.3"
package/commands/wine.ts DELETED
@@ -1,164 +0,0 @@
1
- import { execFile } from "node:child_process"
2
- import { access, constants } from "node:fs/promises"
3
- import { join } from "node:path"
4
- import type { OpenClawPluginApi } from "openclaw/plugin-sdk"
5
- import { log } from "../logger.ts"
6
- import { runScript, SCRIPTS_DIR } from "../lib/run-script.ts"
7
-
8
- function parseWineArgs(argsStr: string): string[] {
9
- const args: string[] = []
10
- const parts = argsStr.trim().split(/\s+/)
11
-
12
- for (let i = 0; i < parts.length; i++) {
13
- const part = parts[i].toLowerCase()
14
- if (["red", "white", "rose", "orange", "sparkling", "dessert"].includes(part)) {
15
- args.push("--color", part)
16
- } else if (["budget", "mid", "premium", "luxury"].includes(part)) {
17
- args.push("--price", part)
18
- } else if (part === "demo") {
19
- args.push("--demo")
20
- } else if (part === "profile") {
21
- args.push("--profile")
22
- } else if (/^\d+$/.test(part)) {
23
- args.push("--count", String(Math.min(Number.parseInt(part), 10)))
24
- }
25
- }
26
-
27
- return args
28
- }
29
-
30
- export function registerWineCommands(api: OpenClawPluginApi): void {
31
- // /wine [color] [price] [count] [demo] [profile]
32
- api.registerCommand({
33
- name: "wine",
34
- description: "Get wine recommendations based on your Spotify listening habits",
35
- acceptsArgs: true,
36
- requireAuth: false,
37
- handler: async (ctx: { args?: string }) => {
38
- log.debug(`/wine command: "${ctx.args || ""}"`)
39
-
40
- // Check uv
41
- try {
42
- await new Promise<void>((resolve, reject) => {
43
- execFile("uv", ["--version"], { timeout: 5_000 }, (err) => {
44
- if (err) reject(err)
45
- else resolve()
46
- })
47
- })
48
- } catch {
49
- return { text: "SommeliAgent needs `uv` installed. Run: brew install uv" }
50
- }
51
-
52
- const userArgs = ctx.args?.trim() || ""
53
- const scriptArgs = parseWineArgs(userArgs)
54
- const isDemo = scriptArgs.includes("--demo")
55
-
56
- // Check Spotify auth (unless demo mode)
57
- if (!isDemo) {
58
- const tokenPath = join(
59
- process.env.HOME || process.env.USERPROFILE || "",
60
- ".sommeliagent",
61
- "token.json",
62
- )
63
- try {
64
- await access(tokenPath, constants.R_OK)
65
- } catch {
66
- return {
67
- text: `Spotify not connected. Set up:\n1. Create app at https://developer.spotify.com/dashboard\n2. Set SPOTIFY_CLIENT_ID and SPOTIFY_CLIENT_SECRET\n3. Run: uv run ${join(SCRIPTS_DIR, "auth.py")}\n\nOr try: /wine demo`,
68
- }
69
- }
70
- }
71
-
72
- try {
73
- const { stdout, stderr } = await runScript("recommend.py", scriptArgs)
74
-
75
- if (stderr) {
76
- log.debug(`/wine stderr: ${stderr}`)
77
- }
78
-
79
- return { text: stdout }
80
- } catch (err) {
81
- log.error("/wine failed", err)
82
- return {
83
- text: `SommeliAgent failed: ${err instanceof Error ? err.message : String(err)}\n\nTry: /wine demo`,
84
- }
85
- }
86
- },
87
- })
88
-
89
- // /wine-auth - Run Spotify OAuth
90
- api.registerCommand({
91
- name: "wine-auth",
92
- description: "Connect your Spotify account for wine recommendations",
93
- acceptsArgs: false,
94
- requireAuth: false,
95
- handler: async () => {
96
- log.debug("/wine-auth command")
97
-
98
- try {
99
- const { stdout } = await runScript("auth.py", [], 180_000)
100
- return { text: stdout }
101
- } catch (err) {
102
- log.error("/wine-auth failed", err)
103
- return {
104
- text: `Spotify auth failed: ${err instanceof Error ? err.message : String(err)}\n\nMake sure SPOTIFY_CLIENT_ID and SPOTIFY_CLIENT_SECRET are set.`,
105
- }
106
- }
107
- },
108
- })
109
-
110
- // /wine-rate <wine-id> <1-5> [notes]
111
- api.registerCommand({
112
- name: "wine-rate",
113
- description: "Rate a wine recommendation (improves future suggestions)",
114
- acceptsArgs: true,
115
- requireAuth: false,
116
- handler: async (ctx: { args?: string }) => {
117
- const parts = ctx.args?.trim().split(/\s+/) || []
118
- if (parts.length < 2) {
119
- return { text: 'Usage: /wine-rate <wine-id> <1-5> [notes]\nExample: /wine-rate red-it-001 5 "Incredible tannins"' }
120
- }
121
-
122
- const wineId = parts[0]
123
- const rating = Number.parseInt(parts[1])
124
- if (Number.isNaN(rating) || rating < 1 || rating > 5) {
125
- return { text: "Rating must be 1-5." }
126
- }
127
-
128
- const notes = parts.slice(2).join(" ")
129
- const args = ["--wine-id", wineId, "--rating", String(rating)]
130
- if (notes) args.push("--notes", notes)
131
-
132
- try {
133
- const { stdout } = await runScript("rate.py", args)
134
- return { text: stdout }
135
- } catch (err) {
136
- log.error("/wine-rate failed", err)
137
- return {
138
- text: `Failed to rate: ${err instanceof Error ? err.message : String(err)}`,
139
- }
140
- }
141
- },
142
- })
143
-
144
- // /wine-history - Show past ratings
145
- api.registerCommand({
146
- name: "wine-history",
147
- description: "View your wine rating history and taste profile",
148
- acceptsArgs: false,
149
- requireAuth: false,
150
- handler: async () => {
151
- log.debug("/wine-history command")
152
-
153
- try {
154
- const { stdout } = await runScript("history.py", [])
155
- return { text: stdout }
156
- } catch (err) {
157
- log.error("/wine-history failed", err)
158
- return {
159
- text: `Failed to load history: ${err instanceof Error ? err.message : String(err)}`,
160
- }
161
- }
162
- },
163
- })
164
- }
package/lib/run-script.ts DELETED
@@ -1,32 +0,0 @@
1
- import { execFile } from "node:child_process"
2
- import { dirname, join } from "node:path"
3
- import { fileURLToPath } from "node:url"
4
-
5
- const __dirname = dirname(fileURLToPath(import.meta.url))
6
- export const SCRIPTS_DIR = join(__dirname, "..", "sommeliagent", "scripts")
7
-
8
- const ALLOWED_SCRIPTS = new Set(["recommend.py", "rate.py", "auth.py", "history.py"])
9
-
10
- export function runScript(
11
- scriptName: string,
12
- args: string[],
13
- timeout: number = 30_000,
14
- ): Promise<{ stdout: string; stderr: string }> {
15
- if (!ALLOWED_SCRIPTS.has(scriptName)) {
16
- return Promise.reject(new Error(`Unknown script: ${scriptName}`))
17
- }
18
- return new Promise((resolve, reject) => {
19
- execFile(
20
- "uv",
21
- ["run", join(SCRIPTS_DIR, scriptName), ...args],
22
- { timeout, maxBuffer: 1024 * 512 },
23
- (error, stdout, stderr) => {
24
- if (error) {
25
- reject(new Error(`${scriptName} failed: ${stderr || error.message}`))
26
- } else {
27
- resolve({ stdout, stderr })
28
- }
29
- },
30
- )
31
- })
32
- }
@@ -1,63 +0,0 @@
1
- # Cross-Domain Mapping: Music → Wine
2
-
3
- The core thesis: sensory and aesthetic preferences transfer across domains. Someone who seeks complexity in music seeks complexity in wine. Someone drawn to dark, brooding sounds wants wines with tension and structure, not crowd-pleasers.
4
-
5
- ## Music → Wine Dimension Map
6
-
7
- | Music Signal | Wine Dimension | Logic |
8
- |---|---|---|
9
- | Tempo / energy | Body / weight | High energy → bold, full-bodied; ambient → light, delicate |
10
- | Complexity (jazz, prog, classical) | Complexity (terroir-driven, natural wines) | Listeners who tolerate/seek complexity want wines that challenge |
11
- | Valence (happy vs dark) | Sweetness / dryness spectrum | Bright pop → approachable, off-dry; minor key → dry, tannic, austere |
12
- | Acoustic vs electronic | Old World vs New World | Organic, analog sound → traditional winemaking; synthetic → modern, tech-forward wines |
13
- | Obscurity / niche taste | Obscurity of region/varietal | Niche listeners don't want grocery store Cab Sav |
14
- | Repetition tolerance | Familiarity preference | Loop-heavy listeners → reliable house pours; variety seekers → discovery |
15
- | Lyrical depth | Label story / terroir narrative | People who care about lyrics care about provenance |
16
-
17
- ## Mapping Algorithm
18
-
19
- ### Step 1: Music Profile Aggregation
20
-
21
- From Spotify's audio features API, we aggregate across a user's top 50 tracks:
22
- - **avgValence** (0-1): musical positiveness
23
- - **avgEnergy** (0-1): intensity and activity
24
- - **avgDanceability** (0-1): dance suitability
25
- - **avgAcousticness** (0-1): acoustic vs electronic
26
- - **avgTempo** (BPM): normalized to 0-1 range (/200)
27
- - **avgComplexity** (derived): instrumentalness × 0.3 + time signature variety × 0.3 + (1 - danceability) × 0.4
28
- - **obscurityScore** (derived): 1 - (average track popularity / 100)
29
-
30
- ### Step 2: Wine Dimension Translation
31
-
32
- Each wine dimension is a weighted average of music features:
33
-
34
- **Body** = energy × 0.5 + tempo × 0.3 + (1 - acousticness) × 0.2
35
- **Sweetness** = valence × 0.6 + danceability × 0.3 + (1 - complexity) × 0.1
36
- **Tannin** = (1 - valence) × 0.4 + complexity × 0.3 + energy × 0.3
37
- **Acidity** = complexity × 0.4 + (1 - valence) × 0.3 + obscurity × 0.3
38
- **Complexity** = complexity × 0.5 + obscurity × 0.3 + (1 - danceability) × 0.2
39
- **Fruitiness** = valence × 0.5 + energy × 0.3 + (1 - complexity) × 0.2
40
- **Earthiness** = acousticness × 0.4 + complexity × 0.3 + obscurity × 0.3
41
- **Spiciness** = energy × 0.4 + (1 - valence) × 0.3 + complexity × 0.3
42
-
43
- ### Step 3: Wine Scoring
44
-
45
- Each wine in the database has a profile with the same 8 dimensions. Match score = weighted cosine similarity between target profile and wine profile.
46
-
47
- Weights: body (1.5), sweetness (1.2), complexity (1.5), tannin (1.0), acidity (1.0), fruitiness (0.8), earthiness (0.8), spiciness (0.7).
48
-
49
- ### Step 4: Connection Generation
50
-
51
- For each recommended wine, the system identifies which specific music-wine connections are strongest and generates explanations.
52
-
53
- ## Expansion Signals (Future)
54
-
55
- - **Reading history** → intellectual complexity tolerance
56
- - **Food delivery** → direct cuisine/region pairing data
57
- - **Time of day / mood** → contextual recommendations
58
- - **Weather + location** → seasonal suggestions
59
- - **Instagram aesthetic** → visual preference → label/winemaking philosophy
60
-
61
- ## Philosophy
62
-
63
- The signal doesn't need to be scientifically rigorous. This is entertainment that gets smarter. The delight is in the explanation, not the accuracy. The feedback loop (ratings) quietly builds a genuine cross-domain taste database over time.
@@ -1,222 +0,0 @@
1
- # /// script
2
- # requires-python = ">=3.11"
3
- # dependencies = ["httpx"]
4
- # ///
5
- """
6
- Spotify OAuth flow for SommeliAgent.
7
- Opens browser, runs local callback server, saves token.
8
- """
9
-
10
- import html
11
- import http.server
12
- import json
13
- import os
14
- import secrets
15
- import stat
16
- import sys
17
- import threading
18
- import urllib.parse
19
- import webbrowser
20
- from pathlib import Path
21
-
22
- import httpx
23
-
24
- CONFIG_DIR = Path.home() / ".sommeliagent"
25
- TOKEN_FILE = CONFIG_DIR / "token.json"
26
- REDIRECT_URI = "http://localhost:8888/callback"
27
- SCOPES = "user-top-read user-read-recently-played"
28
-
29
-
30
- def get_credentials() -> tuple[str, str]:
31
- client_id = os.environ.get("SPOTIFY_CLIENT_ID", "")
32
- client_secret = os.environ.get("SPOTIFY_CLIENT_SECRET", "")
33
- if not client_id or not client_secret:
34
- print("Error: SPOTIFY_CLIENT_ID and SPOTIFY_CLIENT_SECRET must be set.", file=sys.stderr)
35
- print("Get them from https://developer.spotify.com/dashboard", file=sys.stderr)
36
- sys.exit(1)
37
- return client_id, client_secret
38
-
39
-
40
- def save_token(token_data: dict) -> None:
41
- CONFIG_DIR.mkdir(parents=True, exist_ok=True)
42
- tmp = TOKEN_FILE.with_suffix(".tmp")
43
- tmp.write_text(json.dumps(token_data, indent=2))
44
- tmp.chmod(stat.S_IRUSR | stat.S_IWUSR) # 600 — owner only
45
- tmp.replace(TOKEN_FILE)
46
- print(f"Token saved to {TOKEN_FILE}")
47
-
48
-
49
- def load_token() -> dict | None:
50
- if TOKEN_FILE.exists():
51
- return json.loads(TOKEN_FILE.read_text())
52
- return None
53
-
54
-
55
- def refresh_access_token(refresh_token: str, client_id: str, client_secret: str) -> dict:
56
- resp = httpx.post(
57
- "https://accounts.spotify.com/api/token",
58
- data={
59
- "grant_type": "refresh_token",
60
- "refresh_token": refresh_token,
61
- "client_id": client_id,
62
- "client_secret": client_secret,
63
- },
64
- )
65
- resp.raise_for_status()
66
- data = resp.json()
67
- if "refresh_token" not in data:
68
- data["refresh_token"] = refresh_token
69
- return data
70
-
71
-
72
- def get_access_token() -> str | None:
73
- """Get a valid access token, refreshing if needed. Returns None if unavailable."""
74
- import time as _time
75
-
76
- token_data = load_token()
77
-
78
- if token_data and "refresh_token" in token_data:
79
- # Return existing token if it hasn't expired yet
80
- expires_at = token_data.get("expires_at", 0)
81
- if expires_at and _time.time() < expires_at and token_data.get("access_token"):
82
- return token_data["access_token"]
83
-
84
- # Need to refresh — now we need credentials
85
- client_id, client_secret = get_credentials()
86
- try:
87
- refreshed = refresh_access_token(
88
- token_data["refresh_token"], client_id, client_secret
89
- )
90
- expires_in = refreshed.get("expires_in", 3600)
91
- refreshed["expires_at"] = _time.time() + expires_in
92
- save_token(refreshed)
93
- return refreshed["access_token"]
94
- except Exception as e:
95
- print(f"Token refresh failed: {e}", file=sys.stderr)
96
-
97
- return None
98
-
99
-
100
- def _make_callback_handler(expected_state: str):
101
- """Create a callback handler that validates the OAuth state parameter."""
102
-
103
- class CallbackHandler(http.server.BaseHTTPRequestHandler):
104
- auth_code: str | None = None
105
- error: str | None = None
106
-
107
- def do_GET(self):
108
- query = urllib.parse.urlparse(self.path).query
109
- params = urllib.parse.parse_qs(query)
110
-
111
- # Validate state to prevent CSRF
112
- received_state = params.get("state", [None])[0]
113
- if received_state != expected_state:
114
- self.send_response(400)
115
- self.send_header("Content-Type", "text/html")
116
- self.end_headers()
117
- self.wfile.write(b"<html><body><h1>Invalid state parameter</h1></body></html>")
118
- return
119
-
120
- if "code" in params:
121
- CallbackHandler.auth_code = params["code"][0]
122
- self.send_response(200)
123
- self.send_header("Content-Type", "text/html")
124
- self.end_headers()
125
- self.wfile.write(
126
- b"<html><body><h1>SommeliAgent connected!</h1>"
127
- b"<p>You can close this tab and return to your terminal.</p></body></html>"
128
- )
129
- elif "error" in params:
130
- CallbackHandler.error = params["error"][0]
131
- self.send_response(400)
132
- self.send_header("Content-Type", "text/html")
133
- self.end_headers()
134
- safe_error = html.escape(params["error"][0])
135
- self.wfile.write(f"<html><body><h1>Error: {safe_error}</h1></body></html>".encode())
136
- else:
137
- self.send_response(400)
138
- self.end_headers()
139
-
140
- def log_message(self, format, *args):
141
- pass
142
-
143
- return CallbackHandler
144
-
145
-
146
- def run_oauth_flow() -> None:
147
- client_id, client_secret = get_credentials()
148
- state = secrets.token_urlsafe(16)
149
-
150
- auth_url = (
151
- "https://accounts.spotify.com/authorize?"
152
- + urllib.parse.urlencode(
153
- {
154
- "response_type": "code",
155
- "client_id": client_id,
156
- "scope": SCOPES,
157
- "redirect_uri": REDIRECT_URI,
158
- "state": state,
159
- }
160
- )
161
- )
162
-
163
- handler_class = _make_callback_handler(state)
164
-
165
- try:
166
- server = http.server.HTTPServer(("localhost", 8888), handler_class)
167
- except OSError as e:
168
- print(f"Error: Could not start callback server on port 8888: {e}", file=sys.stderr)
169
- print("Make sure nothing else is using that port.", file=sys.stderr)
170
- sys.exit(1)
171
-
172
- # Handle up to 3 requests (in case of preflight/favicon/etc hitting first)
173
- def serve():
174
- for _ in range(3):
175
- server.handle_request()
176
- if handler_class.auth_code or handler_class.error:
177
- break
178
-
179
- server_thread = threading.Thread(target=serve, daemon=True)
180
- server_thread.start()
181
-
182
- print("Opening Spotify authorization in your browser...")
183
- print(f"If it doesn't open, visit: {auth_url}")
184
- webbrowser.open(auth_url)
185
-
186
- server_thread.join(timeout=120)
187
- server.server_close()
188
-
189
- if handler_class.error:
190
- print(f"Error: Spotify returned: {handler_class.error}", file=sys.stderr)
191
- sys.exit(1)
192
-
193
- if not handler_class.auth_code:
194
- print("Error: No authorization code received (timed out after 120s).", file=sys.stderr)
195
- sys.exit(1)
196
-
197
- # Exchange code for token
198
- resp = httpx.post(
199
- "https://accounts.spotify.com/api/token",
200
- data={
201
- "grant_type": "authorization_code",
202
- "code": handler_class.auth_code,
203
- "redirect_uri": REDIRECT_URI,
204
- "client_id": client_id,
205
- "client_secret": client_secret,
206
- },
207
- )
208
- resp.raise_for_status()
209
- token_data = resp.json()
210
- import time as _time
211
- token_data["expires_at"] = _time.time() + token_data.get("expires_in", 3600)
212
- save_token(token_data)
213
- print("Spotify connected successfully!")
214
-
215
-
216
- if __name__ == "__main__":
217
- token = get_access_token()
218
- if token:
219
- print("Already authenticated. Token refreshed successfully.")
220
- print(f"Token file: {TOKEN_FILE}")
221
- else:
222
- run_oauth_flow()