dsh-cds-theme 0.0.0-stage → 0.1.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,4 @@
1
+ # dsh bundle patch: inserts this plugin into a profile's layer stack.
2
+ - insert:
3
+ - id: dsh-cds-theme
4
+ name: 'dsh-cds-theme'
package/icon.svg ADDED
@@ -0,0 +1,7 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36" width="36" height="36" role="img" aria-label="Claude Design System theme">
2
+ <title>Claude Design System theme</title>
3
+ <!-- Two offset surfaces: the CDS layering idea, read as a silhouette at 36px. -->
4
+ <rect x="5.5" y="7.5" width="15" height="15" rx="4.5" fill="#8f8880" fill-opacity="0.45"/>
5
+ <rect x="15.5" y="13.5" width="15" height="15" rx="4.5" fill="#d97757"/>
6
+ <circle cx="23" cy="21" r="2.6" fill="#ffffff" fill-opacity="0.9"/>
7
+ </svg>
package/lib/index.js ADDED
@@ -0,0 +1,79 @@
1
+ /**
2
+ * dsh-cds-theme Host half.
3
+ *
4
+ * The theme itself is a pure browser concern: every token and rule it owns is
5
+ * applied by the Client half. This Host entry exists because a bundle patch
6
+ * inserts a Loader row, and that row resolves the package's main entry.
7
+ *
8
+ * ONE SEAM, owned by the Host: a `webserver/index-inject` global carrying the
9
+ * absolute `file://` fonts base, computed here from `import.meta.url` so the
10
+ * committed client artifact carries no path.
11
+ *
12
+ * AND ONE ROUTE, also owned by the Host: the long-paste store
13
+ * (`paste-route.js`). A long paste must become a real file in the Session
14
+ * workspace before the composer can render it as an `@path` reference chip,
15
+ * and only the Host can write files — the browser cannot. The route lives in
16
+ * its own module, with its own fence and its own tests; this entry calls its
17
+ * installer and nothing else.
18
+ *
19
+ * NO FONT ROUTE, AND THAT IS DELIBERATE. This plugin used to register six
20
+ * `webServer` routes serving `/dsh-cds-theme/fonts/<file>`. They are gone
21
+ * because **the branch that would use them cannot be built**:
22
+ *
23
+ * - `client/src/font-assets.js` imports the six woff2 binaries, and
24
+ * `tools/build-client.mjs` sets `loader: { '.woff2': 'dataurl' }`, so every
25
+ * `@font-face` `src` in the shipped artifact is an inlined `data:` URL.
26
+ * Verified against the built bundle: six `data:font/woff2` URLs and zero
27
+ * route URLs in the very CSS the app injects.
28
+ * - `fontSourcesFor()` only falls back to the route when a face is NOT in the
29
+ * inline table. That happens only in a plain Node ESM run (the unit tests),
30
+ * where `.woff2` cannot be imported — and a Node run serves no HTTP fonts.
31
+ * - A build that LACKS the binaries does not produce a route-based bundle; it
32
+ * fails outright (`esbuild` cannot resolve the import), as verified. So there
33
+ * is no shipped configuration in which the route is the live path.
34
+ * - And the route could not have served the desktop shell anyway: that
35
+ * document's origin is `dsh-app://app`, where a route-relative URL resolves
36
+ * into the app protocol handler and 404s.
37
+ *
38
+ * Keeping six dead HTTP routes alive would be pure attack surface for zero
39
+ * rendered glyphs. The `file://` global stays because it is the fallback the
40
+ * source-run path uses.
41
+ *
42
+ * The seam does not start anything at import time, and it lives in a plain
43
+ * Cordis event listener the carrier owns.
44
+ */
45
+
46
+ import path from 'node:path'
47
+ import { fileURLToPath, pathToFileURL } from 'node:url'
48
+
49
+ import { PASTE_PATH, installPasteRoute } from './paste-route.js'
50
+
51
+ const FONTS_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'fonts')
52
+
53
+ /** Mirrors `FONT_BASE_GLOBAL` in `client/src/fonts.js`. */
54
+ export const FONT_BASE_GLOBAL = '__CDS_THEME_FONT_BASE__'
55
+
56
+ /** Absolute `file://` base of the shipped fonts, with a trailing slash. */
57
+ export const FONT_BASE_URL = pathToFileURL(FONTS_DIR).href + '/'
58
+
59
+ export { PASTE_PATH }
60
+
61
+ export const name = 'dsh-cds-theme'
62
+
63
+ /**
64
+ * Publish the local fonts base into the document, and mount the paste store.
65
+ *
66
+ * The listener needs no service: it is a plain Cordis event the carrier fires
67
+ * while rendering the page. The paste route needs a web server and degrades to
68
+ * "long pastes stay plain text" when the composition has none.
69
+ */
70
+ export function apply(ctx) {
71
+ ctx.on('webserver/index-inject', (table) => {
72
+ table.push({
73
+ kind: 'global',
74
+ name: FONT_BASE_GLOBAL,
75
+ value: FONT_BASE_URL,
76
+ })
77
+ })
78
+ installPasteRoute(ctx)
79
+ }
@@ -0,0 +1,333 @@
1
+ /**
2
+ * Host route: turn a pasted long text into a real file under the session
3
+ * workspace, and answer with the `@relative` mention the composer should carry.
4
+ *
5
+ * WHY A ROUTE. The renderer cannot write files (`ctx.fs` is a Host service; the
6
+ * desktop shell exposes no filesystem bridge to the page). A same-origin POST is
7
+ * the smallest Host seam that also works in both supported surfaces: the plain
8
+ * Web GUI document (`http://127.0.0.1:<port>`, same origin) and the Desktop
9
+ * shell (`dsh-app://app`, whose protocol handler forwards every non-asset path
10
+ * to the Host's own listener, see `lib/main.js` `forwardWebRequest`).
11
+ *
12
+ * THE FENCE. This route writes to disk, so it is reachable only by our own
13
+ * client: the listener's own authority is the reference, `sec-fetch-site` and
14
+ * `origin` must be absent or same-origin when present (the Desktop forwarder
15
+ * strips both, which is why they are optional rather than required), and a
16
+ * custom non-safelisted header must be present — no `<img>`, `<script>`,
17
+ * `<form>`, navigation, or cross-origin `fetch` can set one without a CORS
18
+ * preflight this server never answers. The same fence ships in
19
+ * `plugins/system-fonts/lib/font-route.js` and `plugins/usage-stats`.
20
+ *
21
+ * THE WORKSPACE. The Client sends only a Session id; the Host resolves the
22
+ * absolute workspace itself (`sessions` for a live Session,
23
+ * `sessionController.inspect` for a persisted one) and writes under
24
+ * `<cwd>/.dsh-paste/`. A browser-supplied path never reaches `fs`.
25
+ */
26
+
27
+ import { PASTE_DIR, MAX_PASTE_BYTES, writePasteFile, formatPasteMention } from './paste-store.js'
28
+
29
+ /** Route the client POSTs one converted paste to. */
30
+ export const PASTE_PATH = '/dsh-cds-theme/paste'
31
+
32
+ /** The marker every legitimate request carries (must match `client/src/paste.js`). */
33
+ export const MARKER_HEADER = 'x-dsh-cds-theme'
34
+ export const MARKER_VALUE = '1'
35
+
36
+ /** Refuse a request body larger than this (JSON envelope included). */
37
+ export const MAX_BODY_BYTES = MAX_PASTE_BYTES + 64 * 1024
38
+
39
+ function sendJson(response, status, body) {
40
+ const text = JSON.stringify(body)
41
+ response.writeHead(status, {
42
+ 'content-type': 'application/json; charset=utf-8',
43
+ 'content-length': Buffer.byteLength(text),
44
+ 'cache-control': 'no-store',
45
+ 'x-content-type-options': 'nosniff',
46
+ })
47
+ response.end(text)
48
+ }
49
+
50
+ // ------------------------------------------------------------ request fence
51
+
52
+ function headerOf(request, name) {
53
+ const headers = request && request.headers
54
+ if (!headers || typeof headers !== 'object') return undefined
55
+ const value = headers[name]
56
+ if (Array.isArray(value)) return value.length === 1 ? String(value[0]) : undefined
57
+ return typeof value === 'string' ? value : undefined
58
+ }
59
+
60
+ function authorityOf(text, scheme) {
61
+ if (typeof text !== 'string' || text === '') return null
62
+ const absolute = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(text)
63
+ try {
64
+ const url = new URL(absolute ? text : (scheme || 'http:') + '//' + text)
65
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') return null
66
+ return url.host.toLowerCase()
67
+ } catch (error) {
68
+ return null
69
+ }
70
+ }
71
+
72
+ function hostnameOf(authority) {
73
+ const at = authority.lastIndexOf(':')
74
+ return at === -1 ? authority : authority.slice(0, at)
75
+ }
76
+
77
+ function portOf(authority) {
78
+ const at = authority.lastIndexOf(':')
79
+ return at === -1 ? '' : authority.slice(at + 1)
80
+ }
81
+
82
+ function isLoopbackAuthority(authority) {
83
+ const host = hostnameOf(authority)
84
+ return host === 'localhost' || host === '127.0.0.1' || host === '[::1]'
85
+ }
86
+
87
+ /**
88
+ * Decide whether a request may write one paste file.
89
+ *
90
+ * Every branch fails CLOSED; the reference authority is the listener's own
91
+ * origin (`ctx.webServer.getOrigin()`), whose Host header a DNS rebinding
92
+ * attacker cannot forge. A not-yet-known origin falls back to the strictest
93
+ * loopback rule instead of opening up.
94
+ *
95
+ * @param request - node:http request (only `headers` is read).
96
+ * @param origin - the listener's own `http://host:port`, or null when unknown.
97
+ * @returns null when the request is ours, else a short machine-readable reason.
98
+ */
99
+ export function refuseRequest(request, origin) {
100
+ const host = headerOf(request, 'host')
101
+ if (host === undefined) return 'no-host'
102
+ const authority = authorityOf(host, 'http:')
103
+ if (authority === null) return 'bad-host'
104
+
105
+ const own = origin === null || origin === undefined ? null : authorityOf(String(origin), 'http:')
106
+ if (own === null) {
107
+ if (!isLoopbackAuthority(authority)) return 'foreign-host'
108
+ } else if (authority !== own) {
109
+ const sameLoopbackPeer = isLoopbackAuthority(authority)
110
+ && isLoopbackAuthority(own)
111
+ && portOf(authority) === portOf(own)
112
+ if (!sameLoopbackPeer) return 'foreign-host'
113
+ }
114
+
115
+ const site = headerOf(request, 'sec-fetch-site')
116
+ if (site !== undefined && site !== 'same-origin' && site !== 'none') return 'cross-site'
117
+
118
+ const requestOrigin = headerOf(request, 'origin')
119
+ if (requestOrigin !== undefined && authorityOf(requestOrigin, 'http:') !== authority) return 'foreign-origin'
120
+
121
+ if (headerOf(request, MARKER_HEADER) !== MARKER_VALUE) return 'missing-marker'
122
+
123
+ return null
124
+ }
125
+
126
+ // -------------------------------------------------------------- body reader
127
+
128
+ /**
129
+ * Read a bounded JSON body. The Client sends one small object; anything else is
130
+ * a bug or an attack, and neither deserves streaming.
131
+ *
132
+ * @param request - node:http request.
133
+ * @param limit - byte cap.
134
+ * @returns the parsed value.
135
+ * @throws Error with `code === 'TOO_LARGE'` past the cap, or a parse error.
136
+ */
137
+ export function readJsonBody(request, limit = MAX_BODY_BYTES) {
138
+ return new Promise((resolve, reject) => {
139
+ const chunks = []
140
+ let total = 0
141
+ let failed = false
142
+ const fail = (error) => {
143
+ if (failed) return
144
+ failed = true
145
+ reject(error)
146
+ }
147
+ request.on('data', (chunk) => {
148
+ if (failed) return
149
+ total += chunk.length
150
+ if (total > limit) {
151
+ const error = new Error('paste: request body too large')
152
+ error.code = 'TOO_LARGE'
153
+ fail(error)
154
+ return
155
+ }
156
+ chunks.push(chunk)
157
+ })
158
+ request.on('error', fail)
159
+ request.on('end', () => {
160
+ if (failed) return
161
+ try {
162
+ resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')))
163
+ } catch (error) {
164
+ fail(error)
165
+ }
166
+ })
167
+ })
168
+ }
169
+
170
+ // ------------------------------------------------------------ workspace
171
+
172
+ /**
173
+ * Resolve one Session's absolute workspace without activating an Agent.
174
+ *
175
+ * @param ctx - host context carrying `sessions` and/or `sessionController`.
176
+ * @param sessionId - durable Session identity from the Client.
177
+ * @returns the absolute cwd, or null when the Session is unknown/workspaceless.
178
+ */
179
+ export async function sessionWorkspace(ctx, sessionId) {
180
+ const sessions = typeof ctx.get === 'function' ? ctx.get('sessions') : ctx.sessions
181
+ const attached = sessions !== undefined && sessions !== null && typeof sessions.get === 'function'
182
+ ? sessions.get(sessionId)
183
+ : undefined
184
+ const live = attached === undefined || attached === null ? undefined : attached.header
185
+ if (live !== undefined && live !== null && typeof live.cwd === 'string') return live.cwd
186
+
187
+ const controller = typeof ctx.get === 'function' ? ctx.get('sessionController') : ctx.sessionController
188
+ if (controller !== undefined && controller !== null && typeof controller.inspect === 'function') {
189
+ const inspection = await controller.inspect(sessionId)
190
+ const meta = inspection === undefined || inspection === null ? undefined : inspection.meta
191
+ if (meta !== undefined && meta !== null && typeof meta.cwd === 'string') return meta.cwd
192
+ }
193
+ return null
194
+ }
195
+
196
+ // -------------------------------------------------------------- the route
197
+
198
+ /**
199
+ * The paste store route.
200
+ *
201
+ * @param options.resolveWorkspace - `(ctx, sessionId) => Promise<string|null>`,
202
+ * injectable so the route is testable without a session store.
203
+ * @param options.origin - returns the listener's own origin (or null).
204
+ * @param options.store - `writePasteFile`-shaped writer, injectable for tests.
205
+ */
206
+ export function createPasteRoute(ctx, options = {}) {
207
+ const resolve = typeof options.resolveWorkspace === 'function'
208
+ ? options.resolveWorkspace
209
+ : (hostCtx, sessionId) => sessionWorkspace(hostCtx, sessionId)
210
+ const originOf = typeof options.origin === 'function' ? options.origin : () => null
211
+ const store = typeof options.store === 'function' ? options.store : writePasteFile
212
+
213
+ function listenerOrigin() {
214
+ try {
215
+ const value = originOf()
216
+ return typeof value === 'string' && value !== '' ? value : null
217
+ } catch (error) {
218
+ return null
219
+ }
220
+ }
221
+
222
+ return {
223
+ kind: 'exact',
224
+ path: PASTE_PATH,
225
+ handler: async (request, response) => {
226
+ if (request.method !== 'POST') {
227
+ response.writeHead(405, { allow: 'POST' })
228
+ response.end()
229
+ return
230
+ }
231
+ // Refuse before reading a body: a refused request must cost nothing.
232
+ const refused = refuseRequest(request, listenerOrigin())
233
+ if (refused !== null) {
234
+ sendJson(response, 403, { error: 'refused', reason: refused })
235
+ return
236
+ }
237
+
238
+ let body
239
+ try {
240
+ body = await readJsonBody(request)
241
+ } catch (error) {
242
+ const tooLarge = error && error.code === 'TOO_LARGE'
243
+ sendJson(response, tooLarge ? 413 : 400, {
244
+ ok: false,
245
+ reason: tooLarge ? 'too-large' : 'bad-json',
246
+ })
247
+ return
248
+ }
249
+ if (body === null || typeof body !== 'object') {
250
+ sendJson(response, 400, { ok: false, reason: 'bad-body' })
251
+ return
252
+ }
253
+ const sessionId = typeof body.sessionId === 'string' ? body.sessionId : ''
254
+ const name = typeof body.name === 'string' ? body.name : ''
255
+ const text = typeof body.text === 'string' ? body.text : ''
256
+ if (sessionId === '' || name === '' || text === '') {
257
+ sendJson(response, 400, { ok: false, reason: 'bad-body' })
258
+ return
259
+ }
260
+ if (Buffer.byteLength(text, 'utf8') > MAX_PASTE_BYTES) {
261
+ sendJson(response, 413, { ok: false, reason: 'too-large' })
262
+ return
263
+ }
264
+
265
+ let cwd
266
+ try {
267
+ cwd = await resolve(ctx, sessionId)
268
+ } catch (error) {
269
+ cwd = null
270
+ }
271
+ if (typeof cwd !== 'string' || cwd === '') {
272
+ // No workspace (unknown Session, a session-less composer): the Client
273
+ // falls back to an ordinary text paste. Never an error the user sees.
274
+ sendJson(response, 409, { ok: false, reason: 'no-workspace' })
275
+ return
276
+ }
277
+
278
+ try {
279
+ const written = await store({ cwd, name, text })
280
+ const mention = formatPasteMention(written.relativePath)
281
+ if (mention === null) {
282
+ sendJson(response, 422, { ok: false, reason: 'unrepresentable-path' })
283
+ return
284
+ }
285
+ sendJson(response, 200, {
286
+ ok: true,
287
+ fileName: written.fileName,
288
+ relativePath: written.relativePath,
289
+ mention,
290
+ })
291
+ } catch (error) {
292
+ sendJson(response, 500, {
293
+ ok: false,
294
+ reason: 'write-failed',
295
+ error: error instanceof Error ? error.message : String(error),
296
+ })
297
+ }
298
+ },
299
+ }
300
+ }
301
+
302
+ /**
303
+ * Mount the route once the profile composes a web server.
304
+ *
305
+ * `ctx.inject` waits for the service, so a composition without a web server (a
306
+ * headless run) simply never mounts the route and the Client's paste falls back
307
+ * to plain text — the feature degrades, the plugin never fails to load.
308
+ *
309
+ * @param ctx - host context of this plugin.
310
+ */
311
+ export function installPasteRoute(ctx) {
312
+ if (ctx === undefined || ctx === null || typeof ctx.inject !== 'function') return
313
+ ctx.inject(['webServer'], (hostCtx) => {
314
+ const webServer = hostCtx.webServer
315
+ if (webServer === undefined || typeof webServer.register !== 'function') {
316
+ console.warn('dsh-cds-theme: the webServer service exposes no register(); long pastes stay plain text')
317
+ return
318
+ }
319
+ const origin = () => {
320
+ try {
321
+ return typeof webServer.getOrigin === 'function' ? webServer.getOrigin() : null
322
+ } catch (error) {
323
+ return null
324
+ }
325
+ }
326
+ hostCtx.effect(
327
+ () => webServer.register(createPasteRoute(hostCtx, { origin })),
328
+ 'dsh-cds-theme: paste store route',
329
+ )
330
+ })
331
+ }
332
+
333
+ export { PASTE_DIR }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Long-paste scratch store: the file half of "a long paste becomes an `@path`".
3
+ *
4
+ * WHY A FILE ON DISK. A pasted FILE is the only thing the composer renders as
5
+ * a compact chip: the host's paste/drop intake (verified in the installed
6
+ * `@deepseek-ai/dsh-client-ui-conversation` bundle, the `PASTE_COMMAND` handler
7
+ * around `registerComposerKeymap`) asks the desktop bridge for the real path of
8
+ * every pasted `File`:
9
+ *
10
+ * const path = bridge?.pathFor(file) ?? ''
11
+ * if (path === '' || (!directory && isImageMediaType(file.type))) {
12
+ * uploads.push(file) // → 240×64 upload card
13
+ * continue
14
+ * }
15
+ * const mention = formatFileMention({ path: relativizeToCwd(path, cwd), kind: 'file' }, false)
16
+ * references.push({ source: 'reference', ref: mention, … }) // → @-reference chip
17
+ * …
18
+ * shell.addFiles(references, []) // → insertFileReferences → chip node
19
+ *
20
+ * `bridge.pathFor` is `electron.webUtils.getPathForFile`, which only answers for
21
+ * files the OS handed to the renderer; a `File` built in JavaScript always gets
22
+ * `''`. So a plugin cannot synthesize the bridge answer — it must make the path
23
+ * REAL: write the text to disk here, and let the Client insert the `@relative`
24
+ * reference itself (see `client/src/paste.js`).
25
+ *
26
+ * WHERE. `<session workspace>/.dsh-paste/`, one file per paste, named by the
27
+ * Client (timestamped, locale-shaped) and never overwritten: the open uses
28
+ * `wx` and a numbered suffix retries a name collision. The directory is a
29
+ * visible dot-directory so the user can find and delete these files, and so
30
+ * `@.dsh-paste/…` resolves like any other workspace-relative mention.
31
+ *
32
+ * WHAT IS RETURNED. Only the workspace-relative path (`/` separators) and the
33
+ * file name. The absolute path never leaves the Host: it is a filesystem-layout
34
+ * disclosure the browser has no use for.
35
+ */
36
+
37
+ import { mkdir, open, unlink } from 'node:fs/promises'
38
+ import path from 'node:path'
39
+
40
+ /** Directory (relative to the session workspace) holding converted pastes. */
41
+ export const PASTE_DIR = '.dsh-paste'
42
+
43
+ /** Refuse a paste whose UTF-8 body exceeds this. */
44
+ export const MAX_PASTE_BYTES = 4 * 1024 * 1024
45
+
46
+ /** Longest file name accepted from the Client, in characters. */
47
+ export const MAX_PASTE_NAME = 180
48
+
49
+ /** Same reserved-device rule the Client applies (Windows `CON.txt` …). */
50
+ const RESERVED_NAME = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i
51
+
52
+ /**
53
+ * Make a client-supplied name safe as one path component.
54
+ *
55
+ * The Client already sanitizes (`client/src/paste.js`), but the Host must not
56
+ * trust a browser string that becomes a file name: this strips separators,
57
+ * Windows-rejected characters, control characters, leading/trailing dots and
58
+ * spaces, reserved device names, and caps the length. An unusable name becomes
59
+ * `pasted-text.txt`.
60
+ */
61
+ export function sanitizePasteName(name) {
62
+ let value = String(name === null || name === undefined ? '' : name)
63
+ .replace(/[<>:"/\\|?*\u0000-\u001f]/g, '-')
64
+ .replace(/-{2,}/g, '-')
65
+ .replace(/^[\s.]+|[\s.]+$/g, '')
66
+ if (value === '') value = 'pasted-text.txt'
67
+ if (RESERVED_NAME.test(value)) value = '_' + value
68
+ if (value.length > MAX_PASTE_NAME) value = value.slice(0, MAX_PASTE_NAME)
69
+ return value
70
+ }
71
+
72
+ /**
73
+ * The composer's mention spelling for a workspace-relative path, in the host's
74
+ * own grammar (`formatFileMention` in ui-conversation): a path with whitespace
75
+ * is quoted, a quote/control character makes the token unrepresentable.
76
+ *
77
+ * @returns the `@path` / `@"path"` token, or null when the grammar cannot carry it.
78
+ */
79
+ export function formatPasteMention(relativePath) {
80
+ const normalized = String(relativePath === null || relativePath === undefined ? '' : relativePath)
81
+ .replace(/\\/g, '/')
82
+ if (normalized === '') return null
83
+ if (/[\u0000-\u001f\u007f-\u009f"]/u.test(normalized)) return null
84
+ return /\s/u.test(normalized) ? '@"' + normalized + '"' : '@' + normalized
85
+ }
86
+
87
+ /**
88
+ * Write one paste under `<cwd>/.dsh-paste/`, never overwriting an existing file.
89
+ *
90
+ * @param cwd - absolute session workspace root.
91
+ * @param name - requested file name (sanitized here).
92
+ * @param text - the pasted plain text, written as UTF-8.
93
+ * @returns `{ fileName, relativePath }` with `/` separators.
94
+ * @throws when the directory cannot be created or every candidate name exists.
95
+ */
96
+ export async function writePasteFile({ cwd, name, text }) {
97
+ if (typeof cwd !== 'string' || cwd === '' || !path.isAbsolute(cwd)) {
98
+ throw new Error('paste: the session workspace is not an absolute path')
99
+ }
100
+ const directory = path.join(cwd, PASTE_DIR)
101
+ await mkdir(directory, { recursive: true })
102
+
103
+ const safe = sanitizePasteName(name)
104
+ const extension = path.extname(safe)
105
+ const stem = extension === '' ? safe : safe.slice(0, safe.length - extension.length)
106
+
107
+ for (let attempt = 1; attempt <= 100; attempt += 1) {
108
+ const fileName = attempt === 1 ? safe : stem + '-' + attempt + extension
109
+ const target = path.join(directory, fileName)
110
+ let handle
111
+ try {
112
+ handle = await open(target, 'wx')
113
+ } catch (error) {
114
+ if (error && error.code === 'EEXIST') continue
115
+ throw error
116
+ }
117
+ try {
118
+ await handle.writeFile(text, 'utf8')
119
+ } catch (error) {
120
+ // A half-written file is worse than no file: the name would be taken by
121
+ // an empty scratch the user cannot distinguish from a real paste.
122
+ await handle.close().catch(() => {})
123
+ await unlink(target).catch(() => {})
124
+ throw error
125
+ }
126
+ await handle.close()
127
+ return { fileName, relativePath: PASTE_DIR + '/' + fileName }
128
+ }
129
+ throw new Error('paste: every candidate file name is taken')
130
+ }
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Claude Design System Theme",
4
+ "description": "Re-maps the interface onto CDS: warm-gray surfaces, clay brand accent, dual-ring focus and organic motion, with component-level refinement of the composer, sidebar, message rows and code blocks."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Claude Design System 主题",
4
+ "description": "把界面重映射到 CDS:暖灰表面、陶土橙品牌色、双环焦点与有机动效,并对输入框、侧栏、消息行与代码块做组件级细化。"
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,6 +1,69 @@
1
1
  {
2
2
  "name": "dsh-cds-theme",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "DeepSeek Harness 的 Claude Design System 主题插件:把宿主的 --dsw-* 令牌整体重映射到 CDS 的暖灰表面阶梯、陶土橙品牌色、双环焦点与有机动效,并对输入框、侧栏、消息行、代码块做组件级细化。明暗两套配色,配色/圆角/阴影走 ctx.theme.overrideTokens,组件细化走插件自有样式表。 / Claude Design System theme for DeepSeek Harness: remaps the host's --dsw-* tokens onto CDS warm-gray surfaces, clay brand accent, dual-ring focus and organic motion, then refines the composer, sidebar, message rows and code blocks at the component level.",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "icon": "./icon.svg",
8
+ "license": "MIT",
9
+ "author": "HwFee <1744567281@qq.com>",
10
+ "publishConfig": {
11
+ "access": "public",
12
+ "registry": "https://registry.npmjs.org"
13
+ },
14
+ "engines": {
15
+ "node": "^22.19.0 || >=24.0.0"
16
+ },
17
+ "keywords": [
18
+ "deepseek",
19
+ "deepseek-harness",
20
+ "dsh",
21
+ "dsh-plugin",
22
+ "theme",
23
+ "claude",
24
+ "design-system",
25
+ "cds",
26
+ "appearance"
27
+ ],
28
+ "dsh": {
29
+ "bundle": {
30
+ "patch": "./cordis.patch.yml"
31
+ },
32
+ "client": {
33
+ "inject": [
34
+ "@deepseek-ai/dsh-client-ui-renderer",
35
+ "@deepseek-ai/dsh-client-ui-settings-general"
36
+ ],
37
+ "platform": "web",
38
+ "immediately": true
39
+ }
40
+ },
41
+ "exports": {
42
+ ".": {
43
+ "default": "./lib/index.js"
44
+ },
45
+ "./client": "./client/client.js",
46
+ "./cordis.patch.yml": "./cordis.patch.yml",
47
+ "./locale/*.json": "./locale/*.json",
48
+ "./icon": "./icon.svg",
49
+ "./package.json": "./package.json"
50
+ },
51
+ "files": [
52
+ "lib",
53
+ "client/client.js",
54
+ "locale",
55
+ "assets",
56
+ "cordis.patch.yml",
57
+ "icon.svg",
58
+ "README.md"
59
+ ],
60
+ "scripts": {
61
+ "build": "node tools/build-client.mjs",
62
+ "build:check": "node tools/build-client.mjs --check",
63
+ "test": "node tools/build-client.mjs --check && node test/tokens.test.mjs && node test/css.test.mjs && node test/bridge.test.mjs && node test/fonts.test.mjs && node test/typography.test.mjs && node test/hero.test.mjs && node test/markdown.test.mjs && node test/locale.test.mjs && node test/section.test.mjs && node test/thememode.test.mjs && node test/navicon.test.mjs && node test/host.test.mjs && node test/styles.test.mjs && node test/master-switch.test.mjs && node test/paste.test.mjs && node test/new-session-layout.test.mjs && node test/prefer-cjk.test.mjs && node test/ripple.test.mjs",
64
+ "verify": "node tools/verify.mjs"
65
+ },
66
+ "devDependencies": {
67
+ "esbuild": "0.25.10"
68
+ }
69
+ }