@cremini/skillpack 1.1.9 → 1.2.1

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.
@@ -0,0 +1,244 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/runtime/registry.ts
4
+ import crypto from "crypto";
5
+ import fs from "fs";
6
+ import os from "os";
7
+ import path from "path";
8
+ var SKILLPACK_HOME = path.join(os.homedir(), ".skillpack");
9
+ var LEGACY_REGISTRY_FILE = path.join(SKILLPACK_HOME, "registry.json");
10
+ var REGISTRY_DIR = path.join(SKILLPACK_HOME, "registry.d");
11
+ var migrationChecked = false;
12
+ function getRegistryPath() {
13
+ ensureRegistryReady();
14
+ return REGISTRY_DIR;
15
+ }
16
+ function ensureHomeDir() {
17
+ if (!fs.existsSync(SKILLPACK_HOME)) {
18
+ fs.mkdirSync(SKILLPACK_HOME, { recursive: true });
19
+ }
20
+ }
21
+ function ensureRegistryDir() {
22
+ ensureHomeDir();
23
+ if (!fs.existsSync(REGISTRY_DIR)) {
24
+ fs.mkdirSync(REGISTRY_DIR, { recursive: true });
25
+ }
26
+ }
27
+ function canonicalizeDir(dir) {
28
+ const resolved = path.resolve(dir);
29
+ try {
30
+ return fs.realpathSync(resolved);
31
+ } catch {
32
+ return resolved;
33
+ }
34
+ }
35
+ function hashDir(dir) {
36
+ return crypto.createHash("md5").update(canonicalizeDir(dir)).digest("hex");
37
+ }
38
+ function getEntryPathForCanonicalDir(dir) {
39
+ return path.join(REGISTRY_DIR, `${hashDir(dir)}.json`);
40
+ }
41
+ function getEntryPath(dir) {
42
+ ensureRegistryReady();
43
+ return getEntryPathForCanonicalDir(canonicalizeDir(dir));
44
+ }
45
+ function listEntryFiles() {
46
+ ensureRegistryReady();
47
+ return fs.readdirSync(REGISTRY_DIR).filter((file) => file.endsWith(".json")).sort().map((file) => path.join(REGISTRY_DIR, file));
48
+ }
49
+ function readEntryFile(filePath) {
50
+ try {
51
+ const raw = fs.readFileSync(filePath, "utf-8");
52
+ const data = JSON.parse(raw);
53
+ if (typeof data?.dir !== "string" || typeof data?.name !== "string" || typeof data?.version !== "string" || typeof data?.port !== "number" || typeof data?.pid !== "number" && data?.pid !== null || data?.status !== "running" && data?.status !== "stopped") {
54
+ return null;
55
+ }
56
+ return {
57
+ dir: canonicalizeDir(data.dir),
58
+ name: data.name,
59
+ version: data.version,
60
+ port: data.port,
61
+ pid: data.pid,
62
+ status: data.status,
63
+ startedAt: data.startedAt,
64
+ stoppedAt: data.stoppedAt,
65
+ updatedAt: data.updatedAt
66
+ };
67
+ } catch {
68
+ return null;
69
+ }
70
+ }
71
+ function createTmpPath(entryPath) {
72
+ const suffix = `${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}`;
73
+ return `${entryPath}.tmp.${suffix}`;
74
+ }
75
+ function writeEntryFile(entry) {
76
+ ensureRegistryReady();
77
+ const normalized = {
78
+ ...entry,
79
+ dir: canonicalizeDir(entry.dir),
80
+ updatedAt: entry.updatedAt ?? (/* @__PURE__ */ new Date()).toISOString()
81
+ };
82
+ const entryPath = getEntryPathForCanonicalDir(normalized.dir);
83
+ const tmpPath = createTmpPath(entryPath);
84
+ fs.writeFileSync(tmpPath, JSON.stringify(normalized, null, 2), "utf-8");
85
+ fs.renameSync(tmpPath, entryPath);
86
+ }
87
+ function migrateLegacyRegistryIfNeeded() {
88
+ if (migrationChecked) {
89
+ return;
90
+ }
91
+ migrationChecked = true;
92
+ ensureRegistryDir();
93
+ if (!fs.existsSync(LEGACY_REGISTRY_FILE)) {
94
+ return;
95
+ }
96
+ if (listEntryFiles().length > 0) {
97
+ return;
98
+ }
99
+ try {
100
+ const raw = fs.readFileSync(LEGACY_REGISTRY_FILE, "utf-8");
101
+ const data = JSON.parse(raw);
102
+ const packs = Array.isArray(data?.packs) ? data.packs : [];
103
+ for (const pack of packs) {
104
+ try {
105
+ writeEntryFile({
106
+ ...pack,
107
+ dir: canonicalizeDir(pack.dir),
108
+ updatedAt: pack.updatedAt ?? pack.stoppedAt ?? pack.startedAt ?? (/* @__PURE__ */ new Date()).toISOString()
109
+ });
110
+ } catch {
111
+ }
112
+ }
113
+ fs.renameSync(LEGACY_REGISTRY_FILE, `${LEGACY_REGISTRY_FILE}.legacy`);
114
+ } catch (err) {
115
+ console.warn(" [Registry] Failed to migrate legacy registry.json:", err);
116
+ }
117
+ }
118
+ function ensureRegistryReady() {
119
+ ensureRegistryDir();
120
+ migrateLegacyRegistryIfNeeded();
121
+ }
122
+ function entriesEqual(a, b) {
123
+ return a.dir === b.dir && a.name === b.name && a.version === b.version && a.port === b.port && a.pid === b.pid && a.status === b.status && a.startedAt === b.startedAt && a.stoppedAt === b.stoppedAt;
124
+ }
125
+ function readEntry(dir) {
126
+ ensureRegistryReady();
127
+ return readEntryFile(getEntryPath(dir));
128
+ }
129
+ function writeEntry(entry) {
130
+ writeEntryFile(entry);
131
+ }
132
+ function deleteEntry(dir) {
133
+ ensureRegistryReady();
134
+ const entryPath = getEntryPath(dir);
135
+ if (fs.existsSync(entryPath)) {
136
+ fs.unlinkSync(entryPath);
137
+ }
138
+ }
139
+ function readRegistry() {
140
+ return { packs: readAll() };
141
+ }
142
+ function writeRegistry(data) {
143
+ ensureRegistryReady();
144
+ const nextPaths = /* @__PURE__ */ new Set();
145
+ for (const pack of data.packs) {
146
+ const normalized = {
147
+ ...pack,
148
+ dir: canonicalizeDir(pack.dir),
149
+ updatedAt: pack.updatedAt ?? (/* @__PURE__ */ new Date()).toISOString()
150
+ };
151
+ const entryPath = getEntryPathForCanonicalDir(normalized.dir);
152
+ nextPaths.add(entryPath);
153
+ writeEntryFile(normalized);
154
+ }
155
+ for (const existingPath of listEntryFiles()) {
156
+ if (!nextPaths.has(existingPath)) {
157
+ fs.unlinkSync(existingPath);
158
+ }
159
+ }
160
+ }
161
+ function register(opts) {
162
+ try {
163
+ const now = (/* @__PURE__ */ new Date()).toISOString();
164
+ const entry = {
165
+ dir: canonicalizeDir(opts.dir),
166
+ name: opts.name,
167
+ version: opts.version,
168
+ port: opts.port,
169
+ pid: process.pid,
170
+ status: "running",
171
+ startedAt: now,
172
+ updatedAt: now
173
+ };
174
+ writeEntryFile(entry);
175
+ console.log(` [Registry] Registered "${opts.name}" (pid ${process.pid})`);
176
+ } catch (err) {
177
+ console.warn(" [Registry] Failed to register:", err);
178
+ }
179
+ }
180
+ function deregister(dir, pid) {
181
+ try {
182
+ const entry = readEntry(dir);
183
+ if (!entry || entry.pid !== pid) {
184
+ return;
185
+ }
186
+ const now = (/* @__PURE__ */ new Date()).toISOString();
187
+ writeEntryFile({
188
+ ...entry,
189
+ pid: null,
190
+ status: "stopped",
191
+ stoppedAt: now,
192
+ updatedAt: now
193
+ });
194
+ console.log(` [Registry] Deregistered "${entry.name}"`);
195
+ } catch (err) {
196
+ console.warn(" [Registry] Failed to deregister:", err);
197
+ }
198
+ }
199
+ function readAll() {
200
+ return listEntryFiles().map((entryPath) => readEntryFile(entryPath)).filter((entry) => entry !== null);
201
+ }
202
+ function isPidAlive(pid) {
203
+ try {
204
+ process.kill(pid, 0);
205
+ return true;
206
+ } catch {
207
+ return false;
208
+ }
209
+ }
210
+ function validateEntries() {
211
+ const entries = readAll();
212
+ const now = (/* @__PURE__ */ new Date()).toISOString();
213
+ for (const entry of entries) {
214
+ if (entry.status === "running" && entry.pid !== null && !isPidAlive(entry.pid)) {
215
+ writeEntryFile({
216
+ ...entry,
217
+ pid: null,
218
+ status: "stopped",
219
+ stoppedAt: now,
220
+ updatedAt: now
221
+ });
222
+ }
223
+ }
224
+ const nextEntries = readAll();
225
+ if (entries.length === nextEntries.length && entries.every((entry, index) => entriesEqual(entry, nextEntries[index]))) {
226
+ return entries;
227
+ }
228
+ return nextEntries;
229
+ }
230
+ export {
231
+ canonicalizeDir,
232
+ deleteEntry,
233
+ deregister,
234
+ getEntryPath,
235
+ getRegistryPath,
236
+ isPidAlive,
237
+ readAll,
238
+ readEntry,
239
+ readRegistry,
240
+ register,
241
+ validateEntries,
242
+ writeEntry,
243
+ writeRegistry
244
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cremini/skillpack",
3
- "version": "1.1.9",
3
+ "version": "1.2.1",
4
4
  "description": "Pack AI Skills into Local Agents",
5
5
  "type": "module",
6
6
  "repository": {
@@ -65,4 +65,4 @@
65
65
  "tsup": "^8.5.1",
66
66
  "typescript": "^5.9.3"
67
67
  }
68
- }
68
+ }
package/web/index.html CHANGED
@@ -39,12 +39,6 @@
39
39
  <ul id="skills-list"></ul>
40
40
  </div>
41
41
 
42
- <div class="sidebar-settings-section">
43
- <button id="open-settings-btn" class="settings-trigger-btn">
44
- <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="btn-icon"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>
45
- Settings
46
- </button>
47
- </div>
48
42
  </aside>
49
43
 
50
44
  <main id="chat-area" class="mode-welcome">
@@ -71,59 +65,6 @@
71
65
  </main>
72
66
  </div>
73
67
 
74
- <!-- Settings Dialog -->
75
- <dialog id="settings-dialog" class="settings-modal">
76
- <div class="settings-modal-header">
77
- <h2>Settings</h2>
78
- <button id="close-settings-btn" class="close-btn" aria-label="Close">
79
- <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
80
- </button>
81
- </div>
82
- <div class="settings-modal-body">
83
- <div class="settings-sections">
84
- <!-- General Section -->
85
- <div class="settings-section">
86
- <h3 class="section-title">General</h3>
87
- <div class="form-group">
88
- <label>Provider</label>
89
- <div class="provider-select-wrapper">
90
- <select id="provider-select">
91
- <option value="openai">OpenAI</option>
92
- <option value="anthropic">Anthropic</option>
93
- </select>
94
- </div>
95
- </div>
96
- <div class="form-group">
97
- <label>API Key</label>
98
- <input type="password" id="api-key-input" placeholder="sk-..." class="form-input" />
99
- </div>
100
- </div>
101
-
102
- <!-- IM Bots Section -->
103
- <div class="settings-section">
104
- <h3 class="section-title">IM Bots</h3>
105
- <div class="form-group">
106
- <label>Telegram Bot Token</label>
107
- <input type="password" id="telegram-token-input" placeholder="123456:ABC-DEF..." class="form-input" />
108
- </div>
109
- <div class="form-group">
110
- <label>Slack Bot Token</label>
111
- <input type="password" id="slack-bot-token-input" placeholder="xoxb-..." class="form-input" />
112
- </div>
113
- <div class="form-group">
114
- <label>Slack App Token</label>
115
- <input type="password" id="slack-app-token-input" placeholder="xapp-..." class="form-input" />
116
- </div>
117
- </div>
118
- </div>
119
- </div>
120
- <div class="settings-modal-footer">
121
- <p id="key-status" class="status-text"></p>
122
- <button id="restart-service-btn" class="secondary-btn" hidden>Restart Service</button>
123
- <button id="save-settings-btn" class="primary-btn">Save Settings</button>
124
- </div>
125
- </dialog>
126
-
127
68
  <!-- API Key Dialog -->
128
69
  <dialog id="apikey-dialog" class="settings-modal compact-modal">
129
70
  <div class="settings-modal-header">
@@ -140,12 +81,38 @@
140
81
  <select id="apikey-provider-select">
141
82
  <option value="openai">OpenAI</option>
142
83
  <option value="anthropic">Anthropic</option>
84
+ <option value="google">Google (Gemini)</option>
85
+ <option value="openai-codex">OpenAI Codex</option>
143
86
  </select>
144
87
  </div>
145
88
  </div>
146
- <div class="form-group">
147
- <label>API Key</label>
148
- <input type="password" id="apikey-input" placeholder="sk-..." class="form-input" />
89
+ <!-- API Key Section -->
90
+ <div id="apikey-apikey-section">
91
+ <div class="form-group">
92
+ <label>API Key</label>
93
+ <input type="password" id="apikey-input" placeholder="sk-..." class="form-input" />
94
+ </div>
95
+ <div class="form-group" id="apikey-baseurl-group">
96
+ <label>Custom Base URL <span class="label-hint">(optional)</span></label>
97
+ <input type="text" id="apikey-baseurl-input" placeholder="https://api.example.com/v1" class="form-input" />
98
+ </div>
99
+ </div>
100
+
101
+ <!-- OAuth Section -->
102
+ <div id="apikey-oauth-section" style="display: none;">
103
+ <div class="oauth-status-card">
104
+ <div id="oauth-status-indicator" class="oauth-disconnected">
105
+ <span class="oauth-status-dot"></span>
106
+ <span id="oauth-status-text">Not Connected</span>
107
+ </div>
108
+ <p class="oauth-hint">Login with your ChatGPT account to use Codex subscription.</p>
109
+ </div>
110
+ <button id="oauth-login-btn" class="primary-btn oauth-btn">
111
+ Login with ChatGPT
112
+ </button>
113
+ <button id="oauth-logout-btn" class="secondary-btn oauth-btn" style="display: none;">
114
+ Logout
115
+ </button>
149
116
  </div>
150
117
  </div>
151
118
  </div>
@@ -1,8 +1,7 @@
1
1
  /**
2
2
  * API Key Dialog Module
3
3
  *
4
- * 负责 Model API Key 的配置管理。
5
- * 独立的 Dialog,从原 SettingDialog 的 API Key 部分拆分出来。
4
+ * 负责 Model 认证配置管理。支持 API Key 模式和 OAuth 模式切换。
6
5
  */
7
6
  import { state } from "./config.js";
8
7
  import { saveConfigData, restartRuntime } from "./api.js";
@@ -15,6 +14,14 @@ let saveBtn;
15
14
  let restartBtn;
16
15
  let providerSelect;
17
16
  let apiKeyInput;
17
+ let baseUrlInput;
18
+ let baseUrlGroup;
19
+ let apikeySection;
20
+ let oauthSection;
21
+ let oauthLoginBtn;
22
+ let oauthLogoutBtn;
23
+ let oauthStatusIndicator;
24
+ let oauthStatusText;
18
25
  let statusEl;
19
26
 
20
27
  // --- Public API ---
@@ -27,6 +34,14 @@ export function initApiKeyDialog() {
27
34
  restartBtn = document.getElementById("restart-apikey-btn");
28
35
  providerSelect = document.getElementById("apikey-provider-select");
29
36
  apiKeyInput = document.getElementById("apikey-input");
37
+ baseUrlInput = document.getElementById("apikey-baseurl-input");
38
+ baseUrlGroup = document.getElementById("apikey-baseurl-group");
39
+ apikeySection = document.getElementById("apikey-apikey-section");
40
+ oauthSection = document.getElementById("apikey-oauth-section");
41
+ oauthLoginBtn = document.getElementById("oauth-login-btn");
42
+ oauthLogoutBtn = document.getElementById("oauth-logout-btn");
43
+ oauthStatusIndicator = document.getElementById("oauth-status-indicator");
44
+ oauthStatusText = document.getElementById("oauth-status-text");
30
45
  statusEl = document.getElementById("apikey-status");
31
46
 
32
47
  if (!dialog) return;
@@ -44,7 +59,13 @@ export function initApiKeyDialog() {
44
59
  restartBtn.addEventListener("click", handleRestart);
45
60
  }
46
61
  if (providerSelect) {
47
- providerSelect.addEventListener("change", updatePlaceholder);
62
+ providerSelect.addEventListener("change", updateProviderUI);
63
+ }
64
+ if (oauthLoginBtn) {
65
+ oauthLoginBtn.addEventListener("click", handleOAuthLogin);
66
+ }
67
+ if (oauthLogoutBtn) {
68
+ oauthLogoutBtn.addEventListener("click", handleOAuthLogout);
48
69
  }
49
70
  }
50
71
 
@@ -54,12 +75,19 @@ export function initApiKeyDialog() {
54
75
  export function updateApiKeyButton() {
55
76
  if (!openBtn) return;
56
77
  const config = state.config;
57
- if (config && config.hasApiKey) {
78
+ const currentProvider = config?.provider || "openai";
79
+ const meta = config?.supportedProviders?.[currentProvider];
80
+
81
+ const connected = meta?.authType === "oauth"
82
+ ? config?.oauthConnected
83
+ : config?.hasApiKey;
84
+
85
+ if (connected) {
58
86
  openBtn.classList.add("connected");
59
- openBtn.querySelector(".action-btn-label").textContent = "API Key Configured";
87
+ openBtn.querySelector(".action-btn-label").textContent = "Model Configured";
60
88
  } else {
61
89
  openBtn.classList.remove("connected");
62
- openBtn.querySelector(".action-btn-label").textContent = "Provide Model API Key";
90
+ openBtn.querySelector(".action-btn-label").textContent = "Provide Model Auth";
63
91
  }
64
92
  }
65
93
 
@@ -83,10 +111,11 @@ function populateForm() {
83
111
  if (config.provider && providerSelect) {
84
112
  providerSelect.value = config.provider;
85
113
  }
86
- updatePlaceholder();
114
+ updateProviderUI();
87
115
 
88
116
  setStatus("", "");
89
117
 
118
+ // API Key & BaseURL
90
119
  if (config.hasApiKey && config.apiKey) {
91
120
  apiKeyInput.value = config.apiKey;
92
121
  } else if (config.hasApiKey) {
@@ -94,27 +123,64 @@ function populateForm() {
94
123
  } else {
95
124
  apiKeyInput.value = "";
96
125
  }
126
+ if (baseUrlInput) {
127
+ baseUrlInput.value = config.baseUrl || "";
128
+ }
129
+
130
+ // OAuth Status
131
+ if (config.oauthConnected) {
132
+ updateOAuthUI(true);
133
+ } else {
134
+ updateOAuthUI(false);
135
+ }
97
136
  }
98
137
 
99
- function updatePlaceholder() {
100
- if (!providerSelect || !apiKeyInput) return;
138
+ function updateProviderUI() {
139
+ if (!providerSelect) return;
101
140
  const p = providerSelect.value;
102
- if (p === "openai") apiKeyInput.placeholder = "sk-proj-...";
103
- else if (p === "anthropic") apiKeyInput.placeholder = "sk-ant-api03-...";
104
- else apiKeyInput.placeholder = "sk-...";
141
+ const meta = state.config?.supportedProviders?.[p];
142
+
143
+ if (!meta) return;
144
+
145
+ if (meta.authType === "oauth") {
146
+ // Show OAuth section, hide API Key section
147
+ if (apikeySection) apikeySection.style.display = "none";
148
+ if (oauthSection) oauthSection.style.display = "";
149
+ if (saveBtn) saveBtn.style.display = "none";
150
+ checkOAuthStatus();
151
+ } else {
152
+ // Show API Key section, hide OAuth section
153
+ if (apikeySection) apikeySection.style.display = "";
154
+ if (oauthSection) oauthSection.style.display = "none";
155
+ if (saveBtn) saveBtn.style.display = "";
156
+
157
+ // Toggle BaseURL input
158
+ if (baseUrlGroup) {
159
+ baseUrlGroup.style.display = meta.supportsBaseUrl ? "" : "none";
160
+ }
161
+
162
+ // Update placeholder
163
+ if (apiKeyInput) {
164
+ apiKeyInput.placeholder = meta.placeholder || "sk-...";
165
+ }
166
+ }
105
167
  }
106
168
 
107
169
  async function handleSave() {
108
170
  const key = apiKeyInput.value.trim();
109
171
  const provider = providerSelect.value;
172
+ const baseUrl = baseUrlInput.value.trim();
110
173
 
111
- if (!key) {
112
- setStatus("Please enter an API key", "error");
113
- return;
114
- }
174
+ // If OAuth is selected, we don't handle it here but it shouldn't happen as save button is hidden
175
+ const meta = state.config?.supportedProviders?.[provider];
176
+ if (meta?.authType === "oauth") return;
115
177
 
116
178
  const updates = { provider };
117
- if (key !== "***************************************************" && key !== state.config.apiKey) {
179
+ if (baseUrl !== state.config.baseUrl) {
180
+ updates.baseUrl = baseUrl;
181
+ }
182
+
183
+ if (key && key !== "***************************************************" && key !== state.config.apiKey) {
118
184
  updates.key = key;
119
185
  }
120
186
 
@@ -123,35 +189,21 @@ async function handleSave() {
123
189
  const res = await saveConfigData(updates);
124
190
 
125
191
  state.config.provider = res.provider;
192
+ state.config.baseUrl = res.baseUrl || "";
126
193
  if (updates.key) {
127
194
  state.config.hasApiKey = true;
128
195
  state.config.apiKey = updates.key;
129
196
  }
130
197
 
131
- if (state.config.hasApiKey && state.config.apiKey) {
132
- apiKeyInput.value = state.config.apiKey;
133
- } else if (state.config.hasApiKey) {
134
- apiKeyInput.value = "***************************************************";
135
- } else {
136
- apiKeyInput.value = "";
137
- }
138
-
139
- state.config.runtimeControl = res.runtimeControl;
198
+ populateForm();
140
199
  state.restartRequired = !!res.requiresRestart;
141
-
142
200
  updateApiKeyButton();
143
201
 
144
202
  if (res.requiresRestart) {
145
- setStatus(
146
- res.runtimeControl?.canManagedRestart
147
- ? "API key saved. Restart service to apply changes."
148
- : "API key saved. Restart the service manually to apply changes.",
149
- "warning",
150
- );
151
- updateRestartButton(!!res.runtimeControl?.canManagedRestart);
203
+ setStatus("Settings saved. Restart service to apply changes.", "warning");
204
+ updateRestartButton(true);
152
205
  } else {
153
- setStatus("API key saved successfully", "success");
154
- // 延迟关闭让用户看到成功消息
206
+ setStatus("Settings saved successfully", "success");
155
207
  setTimeout(() => close(), 1200);
156
208
  }
157
209
  } catch (err) {
@@ -161,6 +213,107 @@ async function handleSave() {
161
213
  }
162
214
  }
163
215
 
216
+ async function handleOAuthLogin() {
217
+ const provider = providerSelect.value;
218
+ oauthLoginBtn.disabled = true;
219
+ setStatus("Starting OAuth login...", "warning");
220
+
221
+ try {
222
+ // Ensure provider is saved first
223
+ await saveConfigData({ provider });
224
+
225
+ const res = await fetch("/api/oauth/login", {
226
+ method: "POST",
227
+ headers: { "Content-Type": "application/json" },
228
+ body: JSON.stringify({ provider }),
229
+ });
230
+ const data = await res.json();
231
+
232
+ if (data.authUrl) {
233
+ window.open(data.authUrl, "_blank");
234
+ setStatus("Waiting for authorization in browser...", "warning");
235
+ pollOAuthStatus();
236
+ } else {
237
+ setStatus("OAuth process started. Check terminal for URL if browser didn't open.", "warning");
238
+ pollOAuthStatus();
239
+ }
240
+ } catch (err) {
241
+ setStatus("Login failed: " + err.message, "error");
242
+ oauthLoginBtn.disabled = false;
243
+ }
244
+ }
245
+
246
+ async function handleOAuthLogout() {
247
+ const provider = providerSelect.value;
248
+ try {
249
+ await fetch("/api/oauth/logout", {
250
+ method: "POST",
251
+ headers: { "Content-Type": "application/json" },
252
+ body: JSON.stringify({ provider }),
253
+ });
254
+ updateOAuthUI(false);
255
+ state.config.oauthConnected = false;
256
+ updateApiKeyButton();
257
+ setStatus("Logged out successfully", "success");
258
+ } catch (err) {
259
+ setStatus("Logout failed: " + err.message, "error");
260
+ }
261
+ }
262
+
263
+ async function checkOAuthStatus() {
264
+ try {
265
+ const res = await fetch("/api/oauth/status");
266
+ const { connected } = await res.json();
267
+ updateOAuthUI(connected);
268
+ state.config.oauthConnected = connected;
269
+ updateApiKeyButton();
270
+ } catch (err) {
271
+ console.error("Failed to check OAuth status:", err);
272
+ }
273
+ }
274
+
275
+ function pollOAuthStatus() {
276
+ const interval = setInterval(async () => {
277
+ const res = await fetch("/api/oauth/status");
278
+ const { connected } = await res.json();
279
+ if (connected) {
280
+ clearInterval(interval);
281
+ updateOAuthUI(true);
282
+ state.config.oauthConnected = true;
283
+ state.restartRequired = true;
284
+ updateApiKeyButton();
285
+ setStatus("Connected successfully!", "success");
286
+ updateRestartButton(true);
287
+ }
288
+ }, 2000);
289
+
290
+ // Timeout after 5 minutes
291
+ setTimeout(() => clearInterval(interval), 300000);
292
+ }
293
+
294
+ function updateOAuthUI(connected) {
295
+ if (connected) {
296
+ if (oauthStatusIndicator) {
297
+ oauthStatusIndicator.classList.remove("oauth-disconnected");
298
+ oauthStatusIndicator.classList.add("oauth-connected");
299
+ }
300
+ if (oauthStatusText) oauthStatusText.textContent = "Connected";
301
+ if (oauthLoginBtn) oauthLoginBtn.style.display = "none";
302
+ if (oauthLogoutBtn) oauthLogoutBtn.style.display = "";
303
+ } else {
304
+ if (oauthStatusIndicator) {
305
+ oauthStatusIndicator.classList.add("oauth-disconnected");
306
+ oauthStatusIndicator.classList.remove("oauth-connected");
307
+ }
308
+ if (oauthStatusText) oauthStatusText.textContent = "Not Connected";
309
+ if (oauthLoginBtn) {
310
+ oauthLoginBtn.style.display = "";
311
+ oauthLoginBtn.disabled = false;
312
+ }
313
+ if (oauthLogoutBtn) oauthLogoutBtn.style.display = "none";
314
+ }
315
+ }
316
+
164
317
  async function handleRestart() {
165
318
  if (!restartBtn) return;
166
319