@ljwei-stak/dsh-compositor 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,298 @@
1
+ /**
2
+ * Agent tool definitions contributed to Harness while the plugin is loaded
3
+ * (only when the host provides the `tools` service). Plain objects in the
4
+ * shape the tool registry accepts; no writes outside .comp packages and the
5
+ * configured checkout, no network, no subprocesses.
6
+ */
7
+ import { mkdir, rm, writeFile } from 'node:fs/promises'
8
+ import { basename, join, resolve } from 'node:path'
9
+ import { loadRepoDocs, publicDocs } from './comp-docs.mjs'
10
+ import { resolveCompPath, readGitState } from './comp-paths.mjs'
11
+ import { cleanPackage, autoFixManifest, newLayerId, readManifest, summarizeManifest, writeManifestAtomic } from './comp-project.mjs'
12
+ import { encodePngGray, encodePngRgba, generateGrayPixels, generatePixels } from './comp-png.mjs'
13
+ import { validateComp } from './comp-validate.mjs'
14
+ import { renderPreview } from './comp-preview.mjs'
15
+
16
+ const JSON_OUTPUT = { schema: { type: 'object', additionalProperties: true }, render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 1) }] }
17
+ const pathParam = { type: 'string', description: 'Absolute path of the .comp folder (or its manifest.json).' }
18
+
19
+ function asObject(args) {
20
+ return args && typeof args === 'object' && !Array.isArray(args) ? args : {}
21
+ }
22
+
23
+ function textArg(value) {
24
+ return typeof value === 'string' && value.trim() ? value.trim() : null
25
+ }
26
+
27
+ async function guardExecute(run) {
28
+ try {
29
+ return await run()
30
+ } catch (error) {
31
+ return { ok: false, error: String(error?.message ?? error) }
32
+ }
33
+ }
34
+
35
+ /** A .comp folder that may not have a manifest yet (brand-new project). */
36
+ async function resolvePackageDir(input) {
37
+ const path = resolve(textArg(input) ?? '')
38
+ if (!path) throw new Error('path is required')
39
+ return { compDir: basename(path).toLowerCase() === 'manifest.json' ? resolve(path, '..') : path }
40
+ }
41
+
42
+ function docGuide(repoInfo) {
43
+ return [
44
+ `The Compositor checkout used as the source of truth: ${repoInfo.repoDir}`,
45
+ repoInfo.git?.commit ? `git ${repoInfo.git.commit}${repoInfo.git.branch ? ` (${repoInfo.git.branch})` : ''}${repoInfo.git.subject ? ` — ${repoInfo.git.subject}` : ''}` : 'not a git checkout (no .git found)',
46
+ 'Ask the user to run `git pull` there when they want the newest upstream format changes.',
47
+ ].join('\n')
48
+ }
49
+
50
+ export function buildCompositorTools({ config } = {}) {
51
+ const repoDir = () => config.repoDir?.trim() || 'F:\\everyAI\\dsh-Compositor\\Compositor'
52
+ const maxChars = () => Number(config.docsMaxChars) > 0 ? Number(config.docsMaxChars) : 200_000
53
+
54
+ return [
55
+ {
56
+ name: 'compositor_repo',
57
+ description:
58
+ 'Report the local Compositor (github.com/robbietilton/Compositor) checkout this plugin reads its format knowledge from: git commit, which docs were found, the parsed blend-mode list, adjustment kinds, max manifest version and package limits. Compositor is a macOS image editor whose .comp projects are folders of PNG layers plus manifest.json; this plugin follows the synced checkout, so its knowledge matches whatever version the user pulled. Use this first to see how fresh the checkout is.',
59
+ parameters: { type: 'object', additionalProperties: false, properties: {} },
60
+ output: JSON_OUTPUT,
61
+ timeoutMs: 10_000,
62
+ async execute() {
63
+ return guardExecute(async () => {
64
+ const docs = await loadRepoDocs(repoDir())
65
+ const info = publicDocs(docs)
66
+ return { ok: true, ...info, guide: docGuide(info) }
67
+ })
68
+ },
69
+ },
70
+ {
71
+ name: 'compositor_docs',
72
+ description:
73
+ "Read the .comp format documentation straight from the synced Compositor checkout (never a frozen copy): file 'writing' = docs/writing-comp-files.md (how to build/edit projects: package layout, naming rules, atomic writes, masks, adjustment layers), file 'format' = docs/project-format.md (the full version history 1–13, groups, text, effects, guides, limits). Read 'writing' before creating your first .comp project.",
74
+ parameters: {
75
+ type: 'object', additionalProperties: false,
76
+ properties: {
77
+ file: { type: 'string', enum: ['writing', 'format', 'both'], description: "Which doc to return (default 'writing')." },
78
+ },
79
+ },
80
+ output: JSON_OUTPUT,
81
+ timeoutMs: 10_000,
82
+ async execute(args) {
83
+ return guardExecute(async () => {
84
+ const file = asObject(args).file ?? 'writing'
85
+ const docs = await loadRepoDocs(repoDir())
86
+ const pick = {}
87
+ if (file === 'writing' || file === 'both') pick.writing = docs.texts.writing
88
+ if (file === 'format' || file === 'both') pick.format = docs.texts.format
89
+ const limit = maxChars()
90
+ let total = Object.values(pick).reduce((sum, text) => sum + (text?.length ?? 0), 0)
91
+ const truncated = total > limit
92
+ if (truncated) for (const key of Object.keys(pick)) pick[key] = (pick[key] ?? '').slice(0, Math.floor(limit / Object.keys(pick).length))
93
+ return {
94
+ ok: true,
95
+ repo: { dir: docs.repoDir, git: docs.git },
96
+ vocab: { blendModes: docs.vocab.blendModes, adjustmentKinds: docs.vocab.adjustmentKinds, maxVersion: docs.vocab.maxVersion, limits: docs.vocab.limits },
97
+ docs: pick,
98
+ ...(truncated ? { truncated: true, note: `text capped at ${limit} chars — read the file directly for the rest` } : {}),
99
+ }
100
+ })
101
+ },
102
+ },
103
+ {
104
+ name: 'compositor_validate',
105
+ description:
106
+ 'Validate a .comp package the way Compositor will judge it: manifest JSON, layer/file naming (<layer id>.png, <layer id>.mask.png), exact blend-mode spellings, group tree (cycles, depth, contiguity), clipping-mask chains, adjustment settings and numeric ranges, version gates, image formats (RGBA PNG, 8-bit gray masks, 16-bit needs v12, EXR needs v13), package limits, unreferenced images. The app refuses a broken file silently — the canvas just does not update — so validate before and after every write. A manifest newer than the checkout docs only produces warnings (forward compatible).',
107
+ parameters: {
108
+ type: 'object', additionalProperties: false, required: ['path'],
109
+ properties: { path: pathParam },
110
+ },
111
+ output: JSON_OUTPUT,
112
+ timeoutMs: 60_000,
113
+ async execute(args) {
114
+ return guardExecute(async () => {
115
+ const target = await resolveCompPath(asObject(args).path)
116
+ const docs = await loadRepoDocs(repoDir())
117
+ const result = await validateComp(target, docs)
118
+ return { ok: result.ok, ...result }
119
+ })
120
+ },
121
+ },
122
+ {
123
+ name: 'compositor_manifest',
124
+ description:
125
+ 'Read a .comp manifest without dumping megabytes: canvas size, version, and one line per layer (kind, name, visibility, opacity, blend, group, transform, files) bottom-to-top. Returns the full manifest object only when it is small; pass layerIndex to get one layer record verbatim. Use this to plan edits; edit by calling compositor_write_manifest with the updated object.',
126
+ parameters: {
127
+ type: 'object', additionalProperties: false, required: ['path'],
128
+ properties: {
129
+ path: pathParam,
130
+ layerIndex: { type: 'integer', description: 'Return just this layer record (0-based, bottom of the stack).' },
131
+ },
132
+ },
133
+ output: JSON_OUTPUT,
134
+ timeoutMs: 15_000,
135
+ async execute(args) {
136
+ return guardExecute(async () => {
137
+ const value = asObject(args)
138
+ const target = await resolveCompPath(value.path)
139
+ const { manifest, text } = await readManifest(target)
140
+ return { ok: true, manifestPath: target.manifestPath, ...summarizeManifest(manifest, text, { layerIndex: Number.isInteger(value.layerIndex) ? value.layerIndex : null }) }
141
+ })
142
+ },
143
+ },
144
+ {
145
+ name: 'compositor_write_manifest',
146
+ description:
147
+ 'Write a .comp manifest the safe way: fills in missing layer ids (fresh UUIDs) and normalizes imageFile/maskFile to <id>.png/<id>.mask.png, validates the result against the package, and only then writes it atomically (temp file + rename) so the open Compositor app never sees a half-written manifest. Works on a brand-new folder (no manifest yet) as well as an existing project. Write order for a project being watched: PNGs into images/ FIRST (compositor_layer_png), then this call. Refuses to write when validation fails and returns the problems. removeUnused also deletes images no longer referenced plus the stale QuickLook folder.',
148
+ parameters: {
149
+ type: 'object', additionalProperties: false, required: ['path', 'manifest'],
150
+ properties: {
151
+ path: pathParam,
152
+ manifest: { type: 'object', description: 'The complete manifest object to write (layers bottom-to-top).' },
153
+ fixIds: { type: 'boolean', description: 'Generate UUIDs for layers without ids (default true).' },
154
+ fixFilenames: { type: 'boolean', description: 'Normalize imageFile/maskFile naming (default true).' },
155
+ removeUnused: { type: 'boolean', description: 'Also delete unreferenced images and QuickLook/ (default false).' },
156
+ skipValidation: { type: 'boolean', description: 'Write even when validation fails (default false — avoid).' },
157
+ defaultVersion: { type: 'integer', description: 'Version used when the manifest has none (default 11).' },
158
+ },
159
+ },
160
+ output: JSON_OUTPUT,
161
+ timeoutMs: 30_000,
162
+ async execute(args, exec) {
163
+ return guardExecute(async () => {
164
+ const value = asObject(args)
165
+ if (!value.manifest || typeof value.manifest !== 'object') return { ok: false, error: 'manifest object is required' }
166
+ const { compDir } = await resolvePackageDir(value.path)
167
+ const docs = await loadRepoDocs(repoDir())
168
+ const { manifest, fixes } = autoFixManifest(value.manifest, {
169
+ fixIds: value.fixIds !== false,
170
+ fixFilenames: value.fixFilenames !== false,
171
+ defaultVersion: Number.isInteger(value.defaultVersion) ? value.defaultVersion : 11,
172
+ })
173
+ // Validate the new manifest as it would sit on disk: stage it in a
174
+ // temp file inside the package (invisible to the watching app,
175
+ // which only reloads manifest.json), run the full validator, remove.
176
+ const imagesDir = join(compDir, 'images')
177
+ await mkdir(imagesDir, { recursive: true })
178
+ const stagingPath = join(compDir, `.manifest.check.${process.pid}.${Date.now()}.json.tmp`)
179
+ await writeFile(stagingPath, JSON.stringify(manifest, null, 2))
180
+ let validation
181
+ try {
182
+ validation = await validateComp({ compDir, manifestPath: stagingPath, imagesDir }, docs)
183
+ } finally {
184
+ await rm(stagingPath, { force: true })
185
+ }
186
+ if (!validation.ok && value.skipValidation !== true) {
187
+ return { ok: false, written: false, fixes, validation, note: 'nothing was written; fix the problems or pass skipValidation: true deliberately' }
188
+ }
189
+ const target = { compDir, manifestPath: join(compDir, 'manifest.json'), imagesDir }
190
+ await writeManifestAtomic(target, manifest)
191
+ let cleaned = null
192
+ if (value.removeUnused === true) cleaned = await cleanPackage(target, manifest, { apply: true })
193
+ return { ok: true, written: true, manifestPath: target.manifestPath, fixes, validation, cleaned }
194
+ })
195
+ },
196
+ },
197
+ {
198
+ name: 'compositor_layer_png',
199
+ description:
200
+ 'Create a layer image or mask PNG inside a .comp package, named exactly <id>.png or <id>.mask.png as the format requires: solid colors, linear gradients (angle in degrees) or radial gradients, any size. kind "image" writes 8-bit RGBA (colors are [r,g,b,(a)] 0–255 or #rrggbb); kind "mask" writes 8-bit grayscale (from/to are gray levels 0–255; white reveals, black hides). Returns the id and filename to reference in the manifest. For anything painterly, generate the PNG with your own image tools instead — this tool is for flat fills, gradients and masks.',
201
+ parameters: {
202
+ type: 'object', additionalProperties: false, required: ['path', 'width', 'height'],
203
+ properties: {
204
+ path: { type: 'string', description: 'Absolute path of the .comp folder (created if missing, manifest not required yet).' },
205
+ id: { type: 'string', description: 'Existing layer id to name the file after; a fresh uppercase UUID is generated when omitted.' },
206
+ kind: { type: 'string', enum: ['image', 'mask'], description: 'Layer image (RGBA) or mask (grayscale); default image.' },
207
+ width: { type: 'integer', description: 'Pixel width.' },
208
+ height: { type: 'integer', description: 'Pixel height.' },
209
+ mode: { type: 'string', enum: ['solid', 'linear', 'radial'], description: 'Fill mode (default solid).' },
210
+ color: { type: 'object', description: 'Solid color: [r,g,b,(a)] 0–255 or "#rrggbb".' },
211
+ from: { type: 'object', description: 'Gradient start (color array, or gray level for masks).' },
212
+ to: { type: 'object', description: 'Gradient end.' },
213
+ angle: { type: 'number', description: 'Linear gradient angle in degrees (0 = rightwards, 90 = downwards); default 90.' },
214
+ center: { type: 'array', description: 'Radial center [x, y]; default the middle.' },
215
+ radius: { type: 'number', description: 'Radial radius in pixels; default the farthest corner.' },
216
+ },
217
+ },
218
+ output: JSON_OUTPUT,
219
+ timeoutMs: 60_000,
220
+ async execute(args) {
221
+ return guardExecute(async () => {
222
+ const value = asObject(args)
223
+ const width = Number(value.width), height = Number(value.height)
224
+ if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1) return { ok: false, error: 'width and height must be positive integers' }
225
+ if (width > 30000 || height > 30000) return { ok: false, error: 'images are limited to 30,000 pixels per side' }
226
+ const { compDir } = await resolvePackageDir(value.path)
227
+ const id = textArg(value.id) ?? newLayerId()
228
+ if (!/^[0-9A-Fa-f-]{36}$/.test(id)) return { ok: false, error: `id "${id}" is not a UUID` }
229
+ const normalizedId = id.toUpperCase()
230
+ const kind = value.kind === 'mask' ? 'mask' : 'image'
231
+ const options = {
232
+ width, height,
233
+ mode: ['solid', 'linear', 'radial'].includes(value.mode) ? value.mode : 'solid',
234
+ color: value.color ?? undefined, from: value.from ?? undefined, to: value.to ?? undefined,
235
+ angle: Number(value.angle) || 90, center: Array.isArray(value.center) ? value.center : undefined, radius: Number(value.radius) || undefined,
236
+ }
237
+ const imagesDir = join(compDir, 'images')
238
+ await mkdir(imagesDir, { recursive: true })
239
+ const fileName = kind === 'mask' ? `${normalizedId}.mask.png` : `${normalizedId}.png`
240
+ const buffer = kind === 'mask' ? encodePngGray(width, height, generateGrayPixels(options)) : encodePngRgba(width, height, generatePixels(options))
241
+ const filePath = join(imagesDir, fileName)
242
+ await writeFile(filePath, buffer)
243
+ return { ok: true, id: normalizedId, kind, file: fileName, path: filePath, width, height, bytes: buffer.length, nextStep: `reference it in the manifest as ${kind === 'mask' ? 'maskFile' : 'imageFile'} and write with compositor_write_manifest` }
244
+ })
245
+ },
246
+ },
247
+ {
248
+ name: 'compositor_preview',
249
+ description:
250
+ 'Flatten a .comp project to a PNG you can look at without the macOS app: transforms, opacity (folders pass through), raster and folder masks, unlinked maskPlacement, clipping masks and all 24 blend modes, rendered in pure JavaScript from the decoded layer PNGs. Adjustment layers and layer effects are NOT rendered and are listed in "skipped" — treat the result as a layout/compositing check, not a color check. Writes the PNG next to the .comp folder (default <name>.preview.png). Needs the optional sharp package; reports clearly when it is missing.',
251
+ parameters: {
252
+ type: 'object', additionalProperties: false, required: ['path'],
253
+ properties: {
254
+ path: pathParam,
255
+ out: { type: 'string', description: 'Output PNG path (default: sibling <compname>.preview.png, outside the package).' },
256
+ maxWidth: { type: 'integer', description: 'Downscale to at most this width (default 1600).' },
257
+ },
258
+ },
259
+ output: JSON_OUTPUT,
260
+ timeoutMs: 120_000,
261
+ async execute(args, exec) {
262
+ return guardExecute(async () => {
263
+ const value = asObject(args)
264
+ const target = await resolveCompPath(value.path)
265
+ const { manifest } = await readManifest(target)
266
+ const result = await renderPreview(target, manifest, { maxWidth: Number.isInteger(value.maxWidth) ? value.maxWidth : 1600, signal: exec?.signal })
267
+ if (!result.ok) return result
268
+ const out = textArg(value.out) ?? `${target.compDir}.preview.png`
269
+ await writeFile(out, result.png)
270
+ return { ok: true, out, width: result.width, height: result.height, canvas: result.canvas, skipped: result.skipped, warnings: result.warnings, bytes: result.png.length }
271
+ })
272
+ },
273
+ },
274
+ {
275
+ name: 'compositor_clean',
276
+ description:
277
+ 'Remove what a .comp package no longer needs: image files in images/ that the manifest does not reference, and the QuickLook folder (the docs say to delete it after every change so the macOS Finder preview does not go stale). Dry-run by default — it lists what it would remove; pass apply: true to delete.',
278
+ parameters: {
279
+ type: 'object', additionalProperties: false, required: ['path'],
280
+ properties: {
281
+ path: pathParam,
282
+ apply: { type: 'boolean', description: 'Actually delete (default false: dry-run).' },
283
+ },
284
+ },
285
+ output: JSON_OUTPUT,
286
+ timeoutMs: 30_000,
287
+ async execute(args) {
288
+ return guardExecute(async () => {
289
+ const value = asObject(args)
290
+ const target = await resolveCompPath(value.path)
291
+ const { manifest } = await readManifest(target)
292
+ const result = await cleanPackage(target, manifest, { apply: value.apply === true })
293
+ return { ok: true, ...result, note: value.apply === true ? undefined : 'dry run — pass apply: true to delete' }
294
+ })
295
+ },
296
+ },
297
+ ]
298
+ }