@aexol/opencode-wizard 0.3.0 → 0.3.2

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
@@ -50,7 +50,9 @@ Catalog discovery uses the backend-issued plugin session token stored at `~/.con
50
50
 
51
51
  Call `opencode_wizard_published_skills_fetch` without `skill` or `skills` to manually bootstrap plugin login if needed and return catalog-only discovery output for the current directory scope.
52
52
 
53
- No-arg discovery returns published skill summaries, assignment counts split into `global` and `project`, policy metadata, and no markdown bodies. Existing `skill` and `skills` calls still fetch one or more full skill body/detail payloads by slug, artifact name, or skill name.
53
+ No-arg discovery returns published skill summaries, assignment counts split into `global`, `project`, `user`, and `other`, policy metadata, and no markdown bodies. Existing `skill` and `skills` calls still fetch one or more full skill body/detail payloads by slug, artifact name, or skill name.
54
+
55
+ Use `opencode_wizard_published_skill_preference_set` for non-TUI preference actions (`install`, `uninstall`, `ignore`, `unignore`) against the same server-backed preference API used by the TUI overlay.
54
56
 
55
57
  `GLOBAL_CONTEXT` skills are active context skills and are not meant to be installed per project. `PROJECT_INSTALLABLE` skills are gallery/installable skills that may be attached globally or to a workspace/path through assignment records; those assignments remain the source of truth for what is active in a catalog response.
56
58
 
@@ -0,0 +1,4 @@
1
+ import type http from 'node:http';
2
+ export declare const escapeHtml: (value: string) => string;
3
+ export declare const renderOAuthCallbackPage: (statusCode: number, title: string, message: string) => string;
4
+ export declare const sendOAuthCallbackHtmlResponse: (response: http.ServerResponse, statusCode: number, title: string, message: string) => void;
@@ -0,0 +1,192 @@
1
+ export const escapeHtml = value => {
2
+ return value.replace(/[&<>'"]/g, character => {
3
+ const replacements = {
4
+ '&': '&amp;',
5
+ '<': '&lt;',
6
+ '>': '&gt;',
7
+ "'": '&#39;',
8
+ '"': '&quot;'
9
+ };
10
+ return replacements[character] ?? character;
11
+ });
12
+ };
13
+ export const renderOAuthCallbackPage = (statusCode, title, message) => {
14
+ const escapedTitle = escapeHtml(title);
15
+ const escapedMessage = escapeHtml(message);
16
+ const isSuccess = statusCode >= 200 && statusCode < 300;
17
+ const pageState = isSuccess ? 'success' : statusCode === 404 ? 'not-found' : 'error';
18
+ const cardTitle = isSuccess ? 'Authorization successful' : statusCode === 404 ? 'Callback not found' : 'Authorization failed';
19
+ const escapedCardTitle = escapeHtml(cardTitle);
20
+ const eyebrow = isSuccess ? 'Authorization complete' : statusCode === 404 ? 'Callback route not found' : 'Authorization needs attention';
21
+ const actionText = isSuccess ? 'This window will close automatically in a moment. You can also close it now and return to OpenCode.' : 'You can close this window and return to OpenCode to try again.';
22
+ const autoCloseScript = isSuccess ? `<script>
23
+ window.setTimeout(() => window.close(), 2000);
24
+ </script>` : '';
25
+ const stateIcon = isSuccess ? '<path d="M7 12.5l3.1 3.1L17.5 8" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/>' : statusCode === 404 ? '<path d="M10.5 17a6.5 6.5 0 1 0 0-13 6.5 6.5 0 0 0 0 13Z" stroke="currentColor" stroke-width="2.2"/><path d="m15.5 15.5 4 4" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"/>' : '<path d="M12 7v6" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"/><path d="M12 17.2v.1" stroke="currentColor" stroke-width="3.2" stroke-linecap="round"/>';
26
+ return `<!doctype html>
27
+ <html lang="en">
28
+ <head>
29
+ <meta charset="utf-8">
30
+ <meta name="viewport" content="width=device-width, initial-scale=1">
31
+ <meta name="color-scheme" content="light dark">
32
+ <title>${escapedTitle}</title>
33
+ <style>
34
+ :root {
35
+ color-scheme: light dark;
36
+ --page-bg: #f2efe7;
37
+ --page-ink: #211d18;
38
+ --muted: #6c6258;
39
+ --panel: rgba(255, 252, 245, 0.82);
40
+ --panel-border: rgba(78, 66, 52, 0.16);
41
+ --success: #167848;
42
+ --error: #ba3329;
43
+ --not-found: #986614;
44
+ --glow: rgba(22, 120, 72, 0.18);
45
+ }
46
+
47
+ @media (prefers-color-scheme: dark) {
48
+ :root {
49
+ --page-bg: #12100d;
50
+ --page-ink: #f7efe2;
51
+ --muted: #b8aa98;
52
+ --panel: rgba(30, 26, 22, 0.78);
53
+ --panel-border: rgba(255, 244, 224, 0.14);
54
+ --success: #71e0a6;
55
+ --error: #ff897e;
56
+ --not-found: #f7c96f;
57
+ --glow: rgba(113, 224, 166, 0.2);
58
+ }
59
+ }
60
+
61
+ * {
62
+ box-sizing: border-box;
63
+ }
64
+
65
+ body {
66
+ min-height: 100vh;
67
+ margin: 0;
68
+ display: grid;
69
+ place-items: center;
70
+ padding: 24px;
71
+ overflow: hidden;
72
+ background:
73
+ radial-gradient(circle at 18% 18%, var(--glow), transparent 34rem),
74
+ radial-gradient(circle at 82% 12%, rgba(209, 142, 72, 0.18), transparent 30rem),
75
+ linear-gradient(135deg, var(--page-bg), color-mix(in srgb, var(--page-bg) 76%, #000 24%));
76
+ color: var(--page-ink);
77
+ font-family: ui-rounded, "SF Pro Rounded", "Segoe UI", system-ui, sans-serif;
78
+ }
79
+
80
+ body::before {
81
+ content: "";
82
+ position: fixed;
83
+ inset: -20%;
84
+ pointer-events: none;
85
+ background-image:
86
+ linear-gradient(rgba(128, 104, 74, 0.08) 1px, transparent 1px),
87
+ linear-gradient(90deg, rgba(128, 104, 74, 0.08) 1px, transparent 1px);
88
+ background-size: 42px 42px;
89
+ mask-image: radial-gradient(circle at center, black, transparent 68%);
90
+ }
91
+
92
+ main {
93
+ position: relative;
94
+ width: min(100%, 560px);
95
+ padding: clamp(28px, 7vw, 56px);
96
+ border: 1px solid var(--panel-border);
97
+ border-radius: 32px;
98
+ background: var(--panel);
99
+ box-shadow: 0 24px 90px rgba(0, 0, 0, 0.24);
100
+ text-align: center;
101
+ backdrop-filter: blur(18px) saturate(1.2);
102
+ }
103
+
104
+ .mark {
105
+ width: 72px;
106
+ height: 72px;
107
+ margin: 0 auto 24px;
108
+ display: grid;
109
+ place-items: center;
110
+ border-radius: 24px;
111
+ color: var(--state-color);
112
+ background: color-mix(in srgb, var(--state-color) 16%, transparent);
113
+ box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--state-color) 28%, transparent);
114
+ }
115
+
116
+ [data-state="success"] { --state-color: var(--success); }
117
+ [data-state="error"] { --state-color: var(--error); }
118
+ [data-state="not-found"] { --state-color: var(--not-found); }
119
+
120
+ .eyebrow {
121
+ margin: 0 0 10px;
122
+ color: var(--state-color);
123
+ font-size: 0.78rem;
124
+ font-weight: 800;
125
+ letter-spacing: 0.14em;
126
+ text-transform: uppercase;
127
+ }
128
+
129
+ h1 {
130
+ margin: 0;
131
+ font-size: clamp(2rem, 7vw, 3.35rem);
132
+ line-height: 0.95;
133
+ letter-spacing: -0.06em;
134
+ }
135
+
136
+ .message {
137
+ margin: 22px auto 0;
138
+ max-width: 38rem;
139
+ color: var(--muted);
140
+ font-size: clamp(1rem, 2.5vw, 1.1rem);
141
+ line-height: 1.65;
142
+ }
143
+
144
+ .next-step {
145
+ margin: 26px 0 0;
146
+ padding: 14px 18px;
147
+ border-radius: 999px;
148
+ background: color-mix(in srgb, var(--state-color) 12%, transparent);
149
+ color: var(--page-ink);
150
+ font-size: 0.94rem;
151
+ line-height: 1.5;
152
+ }
153
+
154
+ @media (max-width: 520px) {
155
+ body {
156
+ padding: 16px;
157
+ }
158
+
159
+ main {
160
+ border-radius: 24px;
161
+ }
162
+
163
+ .next-step {
164
+ border-radius: 18px;
165
+ }
166
+ }
167
+ </style>
168
+ </head>
169
+ <body>
170
+ <main data-state="${pageState}" aria-labelledby="callback-title">
171
+ <div class="mark" aria-hidden="true">
172
+ <svg width="34" height="34" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
173
+ ${stateIcon}
174
+ </svg>
175
+ </div>
176
+ <p class="eyebrow">${eyebrow}</p>
177
+ <h1 id="callback-title">${escapedCardTitle}</h1>
178
+ <p class="message">${escapedMessage}</p>
179
+ <p class="next-step">${actionText}</p>
180
+ </main>
181
+ ${autoCloseScript}
182
+ </body>
183
+ </html>`;
184
+ };
185
+ export const sendOAuthCallbackHtmlResponse = (response, statusCode, title, message) => {
186
+ response.writeHead(statusCode, {
187
+ 'content-type': 'text/html; charset=utf-8',
188
+ 'cache-control': 'no-store'
189
+ });
190
+ response.end(renderOAuthCallbackPage(statusCode, title, message));
191
+ };
192
+ //# sourceMappingURL=oauth-callback-page.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"names":["escapeHtml","value","replace","character","replacements","renderOAuthCallbackPage","statusCode","title","message","escapedTitle","escapedMessage","isSuccess","pageState","cardTitle","escapedCardTitle","eyebrow","actionText","autoCloseScript","stateIcon","sendOAuthCallbackHtmlResponse","response","writeHead","end"],"sources":["../src/oauth-callback-page.ts"],"sourcesContent":["import type http from 'node:http';\n\nexport const escapeHtml = (value: string): string => {\n return value.replace(/[&<>'\"]/g, (character) => {\n const replacements: Record<string, string> = {\n '&': '&amp;',\n '<': '&lt;',\n '>': '&gt;',\n \"'\": '&#39;',\n '\"': '&quot;',\n };\n\n return replacements[character] ?? character;\n });\n};\n\nexport const renderOAuthCallbackPage = (statusCode: number, title: string, message: string): string => {\n const escapedTitle = escapeHtml(title);\n const escapedMessage = escapeHtml(message);\n const isSuccess = statusCode >= 200 && statusCode < 300;\n const pageState = isSuccess ? 'success' : statusCode === 404 ? 'not-found' : 'error';\n const cardTitle = isSuccess\n ? 'Authorization successful'\n : statusCode === 404\n ? 'Callback not found'\n : 'Authorization failed';\n const escapedCardTitle = escapeHtml(cardTitle);\n const eyebrow = isSuccess\n ? 'Authorization complete'\n : statusCode === 404\n ? 'Callback route not found'\n : 'Authorization needs attention';\n const actionText = isSuccess\n ? 'This window will close automatically in a moment. You can also close it now and return to OpenCode.'\n : 'You can close this window and return to OpenCode to try again.';\n const autoCloseScript = isSuccess\n ? `<script>\n window.setTimeout(() => window.close(), 2000);\n </script>`\n : '';\n const stateIcon = isSuccess\n ? '<path d=\"M7 12.5l3.1 3.1L17.5 8\" stroke=\"currentColor\" stroke-width=\"2.4\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>'\n : statusCode === 404\n ? '<path d=\"M10.5 17a6.5 6.5 0 1 0 0-13 6.5 6.5 0 0 0 0 13Z\" stroke=\"currentColor\" stroke-width=\"2.2\"/><path d=\"m15.5 15.5 4 4\" stroke=\"currentColor\" stroke-width=\"2.2\" stroke-linecap=\"round\"/>'\n : '<path d=\"M12 7v6\" stroke=\"currentColor\" stroke-width=\"2.4\" stroke-linecap=\"round\"/><path d=\"M12 17.2v.1\" stroke=\"currentColor\" stroke-width=\"3.2\" stroke-linecap=\"round\"/>';\n\n return `<!doctype html>\n<html lang=\"en\">\n <head>\n <meta charset=\"utf-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n <meta name=\"color-scheme\" content=\"light dark\">\n <title>${escapedTitle}</title>\n <style>\n :root {\n color-scheme: light dark;\n --page-bg: #f2efe7;\n --page-ink: #211d18;\n --muted: #6c6258;\n --panel: rgba(255, 252, 245, 0.82);\n --panel-border: rgba(78, 66, 52, 0.16);\n --success: #167848;\n --error: #ba3329;\n --not-found: #986614;\n --glow: rgba(22, 120, 72, 0.18);\n }\n\n @media (prefers-color-scheme: dark) {\n :root {\n --page-bg: #12100d;\n --page-ink: #f7efe2;\n --muted: #b8aa98;\n --panel: rgba(30, 26, 22, 0.78);\n --panel-border: rgba(255, 244, 224, 0.14);\n --success: #71e0a6;\n --error: #ff897e;\n --not-found: #f7c96f;\n --glow: rgba(113, 224, 166, 0.2);\n }\n }\n\n * {\n box-sizing: border-box;\n }\n\n body {\n min-height: 100vh;\n margin: 0;\n display: grid;\n place-items: center;\n padding: 24px;\n overflow: hidden;\n background:\n radial-gradient(circle at 18% 18%, var(--glow), transparent 34rem),\n radial-gradient(circle at 82% 12%, rgba(209, 142, 72, 0.18), transparent 30rem),\n linear-gradient(135deg, var(--page-bg), color-mix(in srgb, var(--page-bg) 76%, #000 24%));\n color: var(--page-ink);\n font-family: ui-rounded, \"SF Pro Rounded\", \"Segoe UI\", system-ui, sans-serif;\n }\n\n body::before {\n content: \"\";\n position: fixed;\n inset: -20%;\n pointer-events: none;\n background-image:\n linear-gradient(rgba(128, 104, 74, 0.08) 1px, transparent 1px),\n linear-gradient(90deg, rgba(128, 104, 74, 0.08) 1px, transparent 1px);\n background-size: 42px 42px;\n mask-image: radial-gradient(circle at center, black, transparent 68%);\n }\n\n main {\n position: relative;\n width: min(100%, 560px);\n padding: clamp(28px, 7vw, 56px);\n border: 1px solid var(--panel-border);\n border-radius: 32px;\n background: var(--panel);\n box-shadow: 0 24px 90px rgba(0, 0, 0, 0.24);\n text-align: center;\n backdrop-filter: blur(18px) saturate(1.2);\n }\n\n .mark {\n width: 72px;\n height: 72px;\n margin: 0 auto 24px;\n display: grid;\n place-items: center;\n border-radius: 24px;\n color: var(--state-color);\n background: color-mix(in srgb, var(--state-color) 16%, transparent);\n box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--state-color) 28%, transparent);\n }\n\n [data-state=\"success\"] { --state-color: var(--success); }\n [data-state=\"error\"] { --state-color: var(--error); }\n [data-state=\"not-found\"] { --state-color: var(--not-found); }\n\n .eyebrow {\n margin: 0 0 10px;\n color: var(--state-color);\n font-size: 0.78rem;\n font-weight: 800;\n letter-spacing: 0.14em;\n text-transform: uppercase;\n }\n\n h1 {\n margin: 0;\n font-size: clamp(2rem, 7vw, 3.35rem);\n line-height: 0.95;\n letter-spacing: -0.06em;\n }\n\n .message {\n margin: 22px auto 0;\n max-width: 38rem;\n color: var(--muted);\n font-size: clamp(1rem, 2.5vw, 1.1rem);\n line-height: 1.65;\n }\n\n .next-step {\n margin: 26px 0 0;\n padding: 14px 18px;\n border-radius: 999px;\n background: color-mix(in srgb, var(--state-color) 12%, transparent);\n color: var(--page-ink);\n font-size: 0.94rem;\n line-height: 1.5;\n }\n\n @media (max-width: 520px) {\n body {\n padding: 16px;\n }\n\n main {\n border-radius: 24px;\n }\n\n .next-step {\n border-radius: 18px;\n }\n }\n </style>\n </head>\n <body>\n <main data-state=\"${pageState}\" aria-labelledby=\"callback-title\">\n <div class=\"mark\" aria-hidden=\"true\">\n <svg width=\"34\" height=\"34\" viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n ${stateIcon}\n </svg>\n </div>\n <p class=\"eyebrow\">${eyebrow}</p>\n <h1 id=\"callback-title\">${escapedCardTitle}</h1>\n <p class=\"message\">${escapedMessage}</p>\n <p class=\"next-step\">${actionText}</p>\n </main>\n ${autoCloseScript}\n </body>\n</html>`;\n};\n\nexport const sendOAuthCallbackHtmlResponse = (\n response: http.ServerResponse,\n statusCode: number,\n title: string,\n message: string,\n) => {\n response.writeHead(statusCode, {\n 'content-type': 'text/html; charset=utf-8',\n 'cache-control': 'no-store',\n });\n response.end(renderOAuthCallbackPage(statusCode, title, message));\n};\n"],"mappings":"AAEA,OAAO,MAAMA,UAAU,GAAIC,KAAa,IAAa;EACnD,OAAOA,KAAK,CAACC,OAAO,CAAC,UAAU,EAAGC,SAAS,IAAK;IAC9C,MAAMC,YAAoC,GAAG;MAC3C,GAAG,EAAE,OAAO;MACZ,GAAG,EAAE,MAAM;MACX,GAAG,EAAE,MAAM;MACX,GAAG,EAAE,OAAO;MACZ,GAAG,EAAE;IACP,CAAC;IAED,OAAOA,YAAY,CAACD,SAAS,CAAC,IAAIA,SAAS;EAC7C,CAAC,CAAC;AACJ,CAAC;AAED,OAAO,MAAME,uBAAuB,GAAGA,CAACC,UAAkB,EAAEC,KAAa,EAAEC,OAAe,KAAa;EACrG,MAAMC,YAAY,GAAGT,UAAU,CAACO,KAAK,CAAC;EACtC,MAAMG,cAAc,GAAGV,UAAU,CAACQ,OAAO,CAAC;EAC1C,MAAMG,SAAS,GAAGL,UAAU,IAAI,GAAG,IAAIA,UAAU,GAAG,GAAG;EACvD,MAAMM,SAAS,GAAGD,SAAS,GAAG,SAAS,GAAGL,UAAU,KAAK,GAAG,GAAG,WAAW,GAAG,OAAO;EACpF,MAAMO,SAAS,GAAGF,SAAS,GACvB,0BAA0B,GAC1BL,UAAU,KAAK,GAAG,GAChB,oBAAoB,GACpB,sBAAsB;EAC5B,MAAMQ,gBAAgB,GAAGd,UAAU,CAACa,SAAS,CAAC;EAC9C,MAAME,OAAO,GAAGJ,SAAS,GACrB,wBAAwB,GACxBL,UAAU,KAAK,GAAG,GAChB,0BAA0B,GAC1B,+BAA+B;EACrC,MAAMU,UAAU,GAAGL,SAAS,GACxB,qGAAqG,GACrG,gEAAgE;EACpE,MAAMM,eAAe,GAAGN,SAAS,GAC7B;AACN;AACA,cAAc,GACR,EAAE;EACN,MAAMO,SAAS,GAAGP,SAAS,GACvB,4HAA4H,GAC5HL,UAAU,KAAK,GAAG,GAChB,gMAAgM,GAChM,4KAA4K;EAElL,OAAO;AACT;AACA;AACA;AACA;AACA;AACA,aAAaG,YAAY;AACzB;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,wBAAwBG,SAAS;AACjC;AACA;AACA,YAAYM,SAAS;AACrB;AACA;AACA,2BAA2BH,OAAO;AAClC,gCAAgCD,gBAAgB;AAChD,2BAA2BJ,cAAc;AACzC,6BAA6BM,UAAU;AACvC;AACA,MAAMC,eAAe;AACrB;AACA,QAAQ;AACR,CAAC;AAED,OAAO,MAAME,6BAA6B,GAAGA,CAC3CC,QAA6B,EAC7Bd,UAAkB,EAClBC,KAAa,EACbC,OAAe,KACZ;EACHY,QAAQ,CAACC,SAAS,CAACf,UAAU,EAAE;IAC7B,cAAc,EAAE,0BAA0B;IAC1C,eAAe,EAAE;EACnB,CAAC,CAAC;EACFc,QAAQ,CAACE,GAAG,CAACjB,uBAAuB,CAACC,UAAU,EAAEC,KAAK,EAAEC,OAAO,CAAC,CAAC;AACnE,CAAC","ignoreList":[]}
package/dist/server.js CHANGED
@@ -7,6 +7,7 @@ import { execFile } from 'node:child_process';
7
7
  import { promisify } from 'node:util';
8
8
  import { URL, fileURLToPath } from 'node:url';
9
9
  import { resolveBackendOriginFromValues } from './config.js';
10
+ import { sendOAuthCallbackHtmlResponse } from './oauth-callback-page.js';
10
11
  import { deleteFileIfExists, readJsonFile, writePrivateJsonFile } from './storage.js';
11
12
  const execFileAsync = promisify(execFile);
12
13
  const MODULE_FILE_PATH = fileURLToPath(import.meta.url);
@@ -54,7 +55,7 @@ const statusPathLoginBootstrap = {
54
55
  failedAt: null
55
56
  };
56
57
  const importOpencodePluginModule = new Function('specifier', 'return import(specifier)');
57
- export const AVAILABLE_PUBLISHED_SKILL_TOOLS = ['opencode_wizard_published_skills_fetch', 'opencode_wizard_status'];
58
+ export const AVAILABLE_PUBLISHED_SKILL_TOOLS = ['opencode_wizard_published_skills_fetch', 'opencode_wizard_published_skill_preference_set', 'opencode_wizard_status'];
58
59
  let publishedSkillPreferenceCacheVersion = 0;
59
60
  export const NATIVE_SKILLS_URL_COMPATIBILITY = {
60
61
  configKey: 'skills.urls',
@@ -516,6 +517,19 @@ const toIgnoredSkillSlug = value => {
516
517
  if (!normalized) return null;
517
518
  return normalized;
518
519
  };
520
+ const toPublishedSkillPreferenceAction = value => {
521
+ const normalized = value.trim().toLowerCase();
522
+ if (normalized === 'install' || normalized === 'uninstall' || normalized === 'ignore' || normalized === 'unignore') {
523
+ return normalized;
524
+ }
525
+ throw new Error('Published skill preference action must be one of: install, uninstall, ignore, unignore.');
526
+ };
527
+ const toPublishedSkillPreferenceScope = (value, defaultScope) => {
528
+ if (!value) return defaultScope;
529
+ const normalized = value.trim().toLowerCase();
530
+ if (normalized === 'global' || normalized === 'project') return normalized;
531
+ throw new Error('Published skill preferenceScope must be either global or project.');
532
+ };
519
533
  const getPublishedSkillIgnoreScopeKey = (resolution, payload) => {
520
534
  const workspaceSlug = payload?.workspace?.slug ?? resolution.fallbackWorkspaceSlug;
521
535
  if (workspaceSlug) return `workspace:${toWorkspaceSlug(workspaceSlug)}`;
@@ -858,8 +872,8 @@ export const buildSystemNote = (result, config, details) => {
858
872
  const projectSkills = catalog.skills.filter(skill => skill.contextKind === 'project' && !isUserPublishedSkillAssignment(skill.assignmentSource)).slice(0, 5).map(buildSkillCatalogLine);
859
873
  const userSkills = catalog.skills.filter(skill => isUserPublishedSkillAssignment(skill.assignmentSource)).slice(0, 5).map(buildSkillCatalogLine);
860
874
  const detailLines = details.slice(0, SYSTEM_NOTE_DETAIL_LIMIT).map(buildSkillDetailSnippetLine);
861
- const detailBlock = detailLines.length > 0 ? ` Loaded body snippets (capped):\n${truncateText(detailLines.join('\n'), SYSTEM_NOTE_DETAIL_CHAR_LIMIT)}` : '';
862
- return [result.fetchResult.payload.workspace ? `Prefer opencode-wizard backend-published fetched skill bodies for scoped/private wizard skills in workspace ${result.fetchResult.payload.workspace.slug}.` : 'Prefer opencode-wizard backend-published global fetched skill bodies; workspace-specific skills are unavailable because the workspace was not found.', `Current directory: ${result.directoryPath}.`, `Published skills for this scope: ${renderedSkillNames}${renderedCountSuffix}; counts: ${catalog.assignmentCounts.global} global, ${catalog.assignmentCounts.project} workspace, ${catalog.assignmentCounts.user} user, ${catalog.assignmentCounts.other} other (GLOBAL SCOPE: ${catalog.assignmentCounts.global}, PROJECT SCOPE: ${catalog.assignmentCounts.project}, USER SCOPE: ${catalog.assignmentCounts.user}).`, 'Wizard-published skills listed in this note/catalog are not necessarily native OpenCode skills; they may be private, scoped, or backend-published records that only the opencode-wizard fetch tool can load.', 'Use the native OpenCode skill tool only for local/native skills that appear in native available_skills; do not use it for wizard-private/scoped/backend-published skill slugs unless that same skill is also listed by native available_skills.', 'If a native skill call fails with an error like `Skill "diagnose" not found. Available skills: ...` and `diagnose` (or the requested slug/name) is wizard-listed here, treat that failure as evidence to call opencode_wizard_published_skills_fetch for the wizard skill body instead.', 'Use catalog whenToUse guidance to decide applicability; fetch full bodies with opencode_wizard_published_skills_fetch and prefer those fetched bodies for current scoped/private wizard guidance, preferring the `skills` field with comma/newline-separated identifiers for multiple bodies; `skill` is for one identifier but remains backward-compatible with delimited lists.', 'GLOBAL_CONTEXT skills are active context skills and are not project-installable; PROJECT_INSTALLABLE skills can be assigned to GLOBAL SCOPE, PROJECT SCOPE, or USER SCOPE; assignment rows decide which scope is active here.', globalSkills.length > 0 ? `GLOBAL SCOPE / Global context skills:\n${globalSkills.join('\n')}` : 'GLOBAL SCOPE / Global context skills: none.', projectSkills.length > 0 ? `PROJECT SCOPE / Project-scoped active skills:\n${projectSkills.join('\n')}` : 'PROJECT SCOPE / Project-scoped active skills: none.', userSkills.length > 0 ? `USER SCOPE / User-scoped active skills:\n${userSkills.join('\n')}` : 'USER SCOPE / User-scoped active skills: none.', detailBlock, 'Local/native sources can still complement wizard skills: .opencode/skills is source seed content, skills.urls is a public/static complement, and backend-published fetched bodies are preferred for private/scoped wizard guidance.', `Root source seed path remains seed/source content: ${config.rootSkillSeedPath}/**.`].filter(line => line.length > 0).join(' ');
875
+ const detailBlock = detailLines.length > 0 ? `Loaded body snippets (capped):\n${truncateText(detailLines.join('\n'), SYSTEM_NOTE_DETAIL_CHAR_LIMIT)}` : '';
876
+ return [result.fetchResult.payload.workspace ? `Workspace: ${result.fetchResult.payload.workspace.slug}.` : 'Workspace not found; workspace-scoped wizard skills are unavailable.', `Current directory: ${result.directoryPath}.`, `Active wizard skills: ${renderedSkillNames}${renderedCountSuffix}.`, `Counts: ${catalog.assignmentCounts.global} global, ${catalog.assignmentCounts.project} project, ${catalog.assignmentCounts.user} user, ${catalog.assignmentCounts.other} other.`, 'Wizard-listed skills are backend-published, not native OpenCode skills.', 'Use native skill tooling only for names in native available_skills.', 'When a wizard skill matches by whenToUse, fetch its current body with opencode_wizard_published_skills_fetch before using it.', 'Use `skills` for multiple wizard skill identifiers; use `skill` for one.', 'If native skill loading cannot find a wizard-listed skill, fetch it as a wizard skill instead.', 'Fetched wizard bodies are authoritative over local seed/native sources.', globalSkills.length > 0 ? `Global skills:\n${globalSkills.join('\n')}` : 'Global skills: none.', projectSkills.length > 0 ? `Project skills:\n${projectSkills.join('\n')}` : 'Project skills: none.', userSkills.length > 0 ? `User skills:\n${userSkills.join('\n')}` : 'User skills: none.', detailBlock].filter(Boolean).join(' ');
863
877
  };
864
878
  const toWorkspaceResolutionOutput = resolution => ({
865
879
  requestedDirectory: resolution.requestedDirectory,
@@ -1216,194 +1230,6 @@ const toCallbackServerStartError = error => {
1216
1230
  }
1217
1231
  return new Error('OAuth login cannot start because localhost:24953 is already in use. Another OpenCode login is likely in progress; finish it or close the other instance, then retry.');
1218
1232
  };
1219
- const escapeHtml = value => {
1220
- return value.replace(/[&<>'"]/g, character => {
1221
- const replacements = {
1222
- '&': '&amp;',
1223
- '<': '&lt;',
1224
- '>': '&gt;',
1225
- "'": '&#39;',
1226
- '"': '&quot;'
1227
- };
1228
- return replacements[character] ?? character;
1229
- });
1230
- };
1231
- const sendHtmlResponse = (response, statusCode, title, message) => {
1232
- const escapedTitle = escapeHtml(title);
1233
- const escapedMessage = escapeHtml(message);
1234
- const isSuccess = statusCode >= 200 && statusCode < 300;
1235
- const pageState = isSuccess ? 'success' : statusCode === 404 ? 'not-found' : 'error';
1236
- const cardTitle = isSuccess ? 'Authorization successful' : statusCode === 404 ? 'Callback not found' : 'Authorization failed';
1237
- const escapedCardTitle = escapeHtml(cardTitle);
1238
- const eyebrow = isSuccess ? 'Authorization complete' : statusCode === 404 ? 'Callback route not found' : 'Authorization needs attention';
1239
- const actionText = isSuccess ? 'This window will close automatically in a moment. You can also close it now and return to OpenCode.' : 'You can close this window and return to OpenCode to try again.';
1240
- const autoCloseScript = isSuccess ? `<script>
1241
- window.setTimeout(() => window.close(), 2000);
1242
- </script>` : '';
1243
- const stateIcon = isSuccess ? '<path d="M7 12.5l3.1 3.1L17.5 8" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/>' : statusCode === 404 ? '<path d="M10.5 17a6.5 6.5 0 1 0 0-13 6.5 6.5 0 0 0 0 13Z" stroke="currentColor" stroke-width="2.2"/><path d="m15.5 15.5 4 4" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"/>' : '<path d="M12 7v6" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"/><path d="M12 17.2v.1" stroke="currentColor" stroke-width="3.2" stroke-linecap="round"/>';
1244
- response.writeHead(statusCode, {
1245
- 'content-type': 'text/html; charset=utf-8',
1246
- 'cache-control': 'no-store'
1247
- });
1248
- response.end(`<!doctype html>
1249
- <html lang="en">
1250
- <head>
1251
- <meta charset="utf-8">
1252
- <meta name="viewport" content="width=device-width, initial-scale=1">
1253
- <meta name="color-scheme" content="light dark">
1254
- <title>${escapedTitle}</title>
1255
- <style>
1256
- :root {
1257
- color-scheme: light dark;
1258
- --page-bg: #f2efe7;
1259
- --page-ink: #211d18;
1260
- --muted: #6c6258;
1261
- --panel: rgba(255, 252, 245, 0.82);
1262
- --panel-border: rgba(78, 66, 52, 0.16);
1263
- --success: #167848;
1264
- --error: #ba3329;
1265
- --not-found: #986614;
1266
- --glow: rgba(22, 120, 72, 0.18);
1267
- }
1268
-
1269
- @media (prefers-color-scheme: dark) {
1270
- :root {
1271
- --page-bg: #12100d;
1272
- --page-ink: #f7efe2;
1273
- --muted: #b8aa98;
1274
- --panel: rgba(30, 26, 22, 0.78);
1275
- --panel-border: rgba(255, 244, 224, 0.14);
1276
- --success: #71e0a6;
1277
- --error: #ff897e;
1278
- --not-found: #f7c96f;
1279
- --glow: rgba(113, 224, 166, 0.2);
1280
- }
1281
- }
1282
-
1283
- * {
1284
- box-sizing: border-box;
1285
- }
1286
-
1287
- body {
1288
- min-height: 100vh;
1289
- margin: 0;
1290
- display: grid;
1291
- place-items: center;
1292
- padding: 24px;
1293
- overflow: hidden;
1294
- background:
1295
- radial-gradient(circle at 18% 18%, var(--glow), transparent 34rem),
1296
- radial-gradient(circle at 82% 12%, rgba(209, 142, 72, 0.18), transparent 30rem),
1297
- linear-gradient(135deg, var(--page-bg), color-mix(in srgb, var(--page-bg) 76%, #000 24%));
1298
- color: var(--page-ink);
1299
- font-family: ui-rounded, "SF Pro Rounded", "Segoe UI", system-ui, sans-serif;
1300
- }
1301
-
1302
- body::before {
1303
- content: "";
1304
- position: fixed;
1305
- inset: -20%;
1306
- pointer-events: none;
1307
- background-image:
1308
- linear-gradient(rgba(128, 104, 74, 0.08) 1px, transparent 1px),
1309
- linear-gradient(90deg, rgba(128, 104, 74, 0.08) 1px, transparent 1px);
1310
- background-size: 42px 42px;
1311
- mask-image: radial-gradient(circle at center, black, transparent 68%);
1312
- }
1313
-
1314
- main {
1315
- position: relative;
1316
- width: min(100%, 560px);
1317
- padding: clamp(28px, 7vw, 56px);
1318
- border: 1px solid var(--panel-border);
1319
- border-radius: 32px;
1320
- background: var(--panel);
1321
- box-shadow: 0 24px 90px rgba(0, 0, 0, 0.24);
1322
- text-align: center;
1323
- backdrop-filter: blur(18px) saturate(1.2);
1324
- }
1325
-
1326
- .mark {
1327
- width: 72px;
1328
- height: 72px;
1329
- margin: 0 auto 24px;
1330
- display: grid;
1331
- place-items: center;
1332
- border-radius: 24px;
1333
- color: var(--state-color);
1334
- background: color-mix(in srgb, var(--state-color) 16%, transparent);
1335
- box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--state-color) 28%, transparent);
1336
- }
1337
-
1338
- [data-state="success"] { --state-color: var(--success); }
1339
- [data-state="error"] { --state-color: var(--error); }
1340
- [data-state="not-found"] { --state-color: var(--not-found); }
1341
-
1342
- .eyebrow {
1343
- margin: 0 0 10px;
1344
- color: var(--state-color);
1345
- font-size: 0.78rem;
1346
- font-weight: 800;
1347
- letter-spacing: 0.14em;
1348
- text-transform: uppercase;
1349
- }
1350
-
1351
- h1 {
1352
- margin: 0;
1353
- font-size: clamp(2rem, 7vw, 3.35rem);
1354
- line-height: 0.95;
1355
- letter-spacing: -0.06em;
1356
- }
1357
-
1358
- .message {
1359
- margin: 22px auto 0;
1360
- max-width: 38rem;
1361
- color: var(--muted);
1362
- font-size: clamp(1rem, 2.5vw, 1.1rem);
1363
- line-height: 1.65;
1364
- }
1365
-
1366
- .next-step {
1367
- margin: 26px 0 0;
1368
- padding: 14px 18px;
1369
- border-radius: 999px;
1370
- background: color-mix(in srgb, var(--state-color) 12%, transparent);
1371
- color: var(--page-ink);
1372
- font-size: 0.94rem;
1373
- line-height: 1.5;
1374
- }
1375
-
1376
- @media (max-width: 520px) {
1377
- body {
1378
- padding: 16px;
1379
- }
1380
-
1381
- main {
1382
- border-radius: 24px;
1383
- }
1384
-
1385
- .next-step {
1386
- border-radius: 18px;
1387
- }
1388
- }
1389
- </style>
1390
- </head>
1391
- <body>
1392
- <main data-state="${pageState}" aria-labelledby="callback-title">
1393
- <div class="mark" aria-hidden="true">
1394
- <svg width="34" height="34" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
1395
- ${stateIcon}
1396
- </svg>
1397
- </div>
1398
- <p class="eyebrow">${eyebrow}</p>
1399
- <h1 id="callback-title">${escapedCardTitle}</h1>
1400
- <p class="message">${escapedMessage}</p>
1401
- <p class="next-step">${actionText}</p>
1402
- </main>
1403
- ${autoCloseScript}
1404
- </body>
1405
- </html>`);
1406
- };
1407
1233
  const startLocalCallbackServer = async ({
1408
1234
  expectedState,
1409
1235
  signal
@@ -1428,14 +1254,14 @@ const startLocalCallbackServer = async ({
1428
1254
  const server = http.createServer((request, response) => {
1429
1255
  const requestUrl = new URL(request.url ?? '/', OIDC_CALLBACK_ORIGIN);
1430
1256
  if (requestUrl.pathname !== OIDC_CALLBACK_PATH) {
1431
- sendHtmlResponse(response, 404, 'opencode-wizard plugin login', 'Unknown callback path.');
1257
+ sendOAuthCallbackHtmlResponse(response, 404, 'opencode-wizard plugin login', 'Unknown callback path.');
1432
1258
  return;
1433
1259
  }
1434
1260
  const error = requestUrl.searchParams.get('error');
1435
1261
  const errorDescription = requestUrl.searchParams.get('error_description');
1436
1262
  if (error) {
1437
1263
  const message = errorDescription ?? error;
1438
- sendHtmlResponse(response, 400, 'opencode-wizard plugin login failed', message);
1264
+ sendOAuthCallbackHtmlResponse(response, 400, 'opencode-wizard plugin login failed', message);
1439
1265
  finalize({
1440
1266
  status: 'error',
1441
1267
  message
@@ -1445,7 +1271,7 @@ const startLocalCallbackServer = async ({
1445
1271
  const state = requestUrl.searchParams.get('state');
1446
1272
  const code = requestUrl.searchParams.get('code');
1447
1273
  if (!state || state !== expectedState) {
1448
- sendHtmlResponse(response, 400, 'opencode-wizard plugin login failed', 'OAuth state did not match the login request.');
1274
+ sendOAuthCallbackHtmlResponse(response, 400, 'opencode-wizard plugin login failed', 'OAuth state did not match the login request.');
1449
1275
  finalize({
1450
1276
  status: 'error',
1451
1277
  message: 'OAuth state did not match the login request.'
@@ -1453,14 +1279,14 @@ const startLocalCallbackServer = async ({
1453
1279
  return;
1454
1280
  }
1455
1281
  if (!code) {
1456
- sendHtmlResponse(response, 400, 'opencode-wizard plugin login failed', 'OAuth callback did not include an authorization code.');
1282
+ sendOAuthCallbackHtmlResponse(response, 400, 'opencode-wizard plugin login failed', 'OAuth callback did not include an authorization code.');
1457
1283
  finalize({
1458
1284
  status: 'error',
1459
1285
  message: 'OAuth callback did not include an authorization code.'
1460
1286
  });
1461
1287
  return;
1462
1288
  }
1463
- sendHtmlResponse(response, 200, 'opencode-wizard plugin callback received', 'Callback received. OpenCode is finalizing the backend session now.');
1289
+ sendOAuthCallbackHtmlResponse(response, 200, 'opencode-wizard plugin callback received', 'Callback received. OpenCode is finalizing the backend session now.');
1464
1290
  finalize({
1465
1291
  status: 'success',
1466
1292
  code,
@@ -2473,6 +2299,9 @@ const OpencodeWizardSkillsPlugin = async input => {
2473
2299
  // Keep returning the safe missing-auth snapshot when interactive login is cancelled or fails.
2474
2300
  }
2475
2301
  }
2302
+ if (snapshot.status === 'ready') {
2303
+ await scheduleInteractivePresenceStart();
2304
+ }
2476
2305
  const metadata = toPluginStatusMetadata(snapshot);
2477
2306
  context.metadata({
2478
2307
  title: `opencode-wizard status: ${snapshot.status} / auth ${snapshot.authState.status}`,
@@ -2483,6 +2312,96 @@ const OpencodeWizardSkillsPlugin = async input => {
2483
2312
  metadata
2484
2313
  };
2485
2314
  };
2315
+ const executePublishedSkillPreferenceTool = async ({
2316
+ args,
2317
+ context
2318
+ }) => {
2319
+ const requestedDirectory = normalizeDirectoryArg(context.directory, args.directory);
2320
+ const directoryPath = normalizeRepositoryPath(workspacePath, requestedDirectory);
2321
+ lastInteractiveDirectoryPath = directoryPath;
2322
+ const requestedSkill = typeof args.skill === 'string' ? args.skill.trim() : '';
2323
+ const emitPreferenceOutcome = async event => {
2324
+ await emitActionEventForCurrentSession({
2325
+ event,
2326
+ directoryPath
2327
+ });
2328
+ };
2329
+ try {
2330
+ if (!requestedSkill) {
2331
+ throw new Error('Published skill preference tool requires a non-empty skill slug, artifact name, or skill name.');
2332
+ }
2333
+ if (typeof args.action !== 'string') {
2334
+ throw new Error('Published skill preference tool requires an action: install, uninstall, ignore, or unignore.');
2335
+ }
2336
+ const action = toPublishedSkillPreferenceAction(args.action);
2337
+ const catalogResult = await loadPublishedSkillCatalog({
2338
+ directory: requestedDirectory,
2339
+ useCache: true,
2340
+ signal: context.abort
2341
+ });
2342
+ if (!catalogResult.fetchResult.ok) {
2343
+ throw new Error(`Cannot resolve published skill preference target: ${catalogResult.fetchResult.message}`);
2344
+ }
2345
+ const selectableCatalogSkills = catalogResult.fetchResult.payload.catalogSkills.map(item => ({
2346
+ ...item,
2347
+ assignmentSource: 'CATALOG',
2348
+ assignmentType: 'PATH',
2349
+ scopePath: '',
2350
+ includeChildren: true
2351
+ }));
2352
+ const preferenceSelection = selectPublishedSkills({
2353
+ ...catalogResult.fetchResult.payload,
2354
+ skills: [...catalogResult.fetchResult.payload.skills, ...selectableCatalogSkills, ...catalogResult.fetchResult.payload.userPreferences.ignoredSkills]
2355
+ }, [requestedSkill]);
2356
+ const matchedSkill = preferenceSelection.selectedItems[0];
2357
+ if (!matchedSkill) {
2358
+ throw new Error(`Published skill preference target was not found for identifier: ${requestedSkill}.`);
2359
+ }
2360
+ const skillSlug = matchedSkill.skill.slug;
2361
+ const preferenceState = action === 'ignore' || action === 'unignore' ? await setPublishedSkillIgnored({
2362
+ worktree: input.worktree,
2363
+ directory: requestedDirectory,
2364
+ skillSlug,
2365
+ ignored: action === 'ignore',
2366
+ preferenceScope: toPublishedSkillPreferenceScope(args.preferenceScope, 'project')
2367
+ }) : await setPublishedSkillInstalled({
2368
+ worktree: input.worktree,
2369
+ directory: requestedDirectory,
2370
+ skillSlug,
2371
+ installed: action === 'install',
2372
+ preferenceScope: toPublishedSkillPreferenceScope(args.preferenceScope, 'project')
2373
+ });
2374
+ await scheduleInteractivePresenceStart();
2375
+ await emitPreferenceOutcome('PREFERENCE_SUCCESS');
2376
+ const metadata = {
2377
+ status: 'updated',
2378
+ skillSlug,
2379
+ action,
2380
+ directoryPath,
2381
+ ignoredSkillCount: preferenceState.ignoredSkillSlugs.length.toString()
2382
+ };
2383
+ context.metadata({
2384
+ title: `opencode-wizard published skill preference: ${action} ${skillSlug}`,
2385
+ metadata
2386
+ });
2387
+ return {
2388
+ output: JSON.stringify({
2389
+ pluginId: PLUGIN_ID,
2390
+ status: 'updated',
2391
+ requestedIdentifier: requestedSkill,
2392
+ skillSlug,
2393
+ action,
2394
+ requestedDirectoryPath: directoryPath,
2395
+ preferenceState,
2396
+ message: 'Published skill preference updated through the shared server-backed API; TUI views will reflect this after refresh.'
2397
+ }, null, 2),
2398
+ metadata
2399
+ };
2400
+ } catch (error) {
2401
+ await emitPreferenceOutcome('PREFERENCE_FAILED');
2402
+ throw error;
2403
+ }
2404
+ };
2486
2405
  return {
2487
2406
  tool: {
2488
2407
  opencode_wizard_published_skills_fetch: tool({
@@ -2500,6 +2419,21 @@ const OpencodeWizardSkillsPlugin = async input => {
2500
2419
  });
2501
2420
  }
2502
2421
  }),
2422
+ opencode_wizard_published_skill_preference_set: tool({
2423
+ description: 'Install, uninstall, ignore, or unignore a backend-published wizard skill for non-TUI workflows using the same shared server-backed preference API as the TUI overlay',
2424
+ args: {
2425
+ skill: tool.schema.string().describe('Published skill slug, artifact name, or skill name to update'),
2426
+ action: tool.schema.string().describe('Preference action: install, uninstall, ignore, or unignore'),
2427
+ preferenceScope: tool.schema.string().optional().describe('Preference scope for the action: project or global; defaults to project'),
2428
+ directory: tool.schema.string().optional().describe('Optional absolute or relative directory override')
2429
+ },
2430
+ async execute(args, context) {
2431
+ return executePublishedSkillPreferenceTool({
2432
+ args,
2433
+ context
2434
+ });
2435
+ }
2436
+ }),
2503
2437
  opencode_wizard_status: tool({
2504
2438
  description: 'Report opencode-wizard plugin status, bootstrap auth when missing, and return a safe auth summary without exposing tokens',
2505
2439
  args: {
@@ -2539,6 +2473,9 @@ const OpencodeWizardSkillsPlugin = async input => {
2539
2473
  return;
2540
2474
  }
2541
2475
  }
2476
+ if (publishedSkillsResult.fetchResult.ok) {
2477
+ await scheduleInteractivePresenceStart();
2478
+ }
2542
2479
  const filteredPublishedSkillsResult = await filterIgnoredPublishedSkills(config, publishedSkillsResult);
2543
2480
  const details = await loadSystemNoteDetails({
2544
2481
  publishedSkillsResult: filteredPublishedSkillsResult,