From b4885c900dee1e48a29b3e4298adf7689d338fa9 Mon Sep 17 00:00:00 2001 From: Andrew Harris Date: Fri, 19 Jun 2026 10:34:19 +0100 Subject: [PATCH 1/2] Add a skill to migrate dashboards Allows author to create a new dashboard using tiles from an LCP, and then migrate it back to LCP format --- .claude/skills/migrate-dashboard/SKILL.md | 107 +++++++++ .../references/migrated-example.json | 216 ++++++++++++++++++ .../references/source-example.json | 210 +++++++++++++++++ .../migrate-dashboard/scripts/normalize.js | 116 ++++++++++ 4 files changed, 649 insertions(+) create mode 100644 .claude/skills/migrate-dashboard/SKILL.md create mode 100644 .claude/skills/migrate-dashboard/references/migrated-example.json create mode 100644 .claude/skills/migrate-dashboard/references/source-example.json create mode 100644 .claude/skills/migrate-dashboard/scripts/normalize.js diff --git a/.claude/skills/migrate-dashboard/SKILL.md b/.claude/skills/migrate-dashboard/SKILL.md new file mode 100644 index 00000000..ac07dd79 --- /dev/null +++ b/.claude/skills/migrate-dashboard/SKILL.md @@ -0,0 +1,107 @@ +--- +name: migrate-dashboard +description: Migrate an exported platform dashboard JSON to plugin format. Use when the user pastes a dashboard JSON to convert, says "migrate this dashboard", or wants to add an exported dashboard to a plugin as default content. +metadata: + author: SquaredUp + version: "0.0.1" +--- + +# Migrate Dashboard to Plugin Format + +Convert a platform-exported dashboard JSON to plugin format by normalizing hardcoded IDs to template variables, then saving as plugin default content. + +**Announce at start:** "I'm using the migrate-dashboard skill." + +--- + +## Steps + +### 1. Locate plugin + +Identify the plugin and version from context, or ask the user. + +Working folder: `plugins///` + +If only one version exists, use it without asking. + +**Done when:** working folder is set. + +--- + +### 2. Receive dashboard JSON + +Ask the user to paste the exported dashboard JSON if not already provided. + +**Done when:** valid JSON with `"_type": "layout/grid"` is in hand. + +--- + +### 3. Normalize + +Run the normalization script to replace all hardcoded platform values with template variables: + +```bash +node .claude/skills/migrate-dashboard/scripts/normalize.js [plugins///defaultContent/scopes.json] [--scope-name "Scope Name"] +``` + +Pass `scopes.json` when the dashboard has tiles with `config.variables` or `config.scope` (perspective dashboard). The script reads `config.dataStream.name` from each tile to generate the correct `{{dataStreams.[name]}}` reference. + +If `scopes.json` has multiple entries, read it first and determine which scope applies. It may be obvious from the dashboard content (e.g. tile titles referencing "Agent" or "Organization"), but if not, ask the user before running the script. Pass the chosen scope via `--scope-name "Scope Name"`. Run once per scope if the dashboard mixes tiles from multiple scopes. + +Verify the output — check that: +- No `config-*` IDs remain (all → `{{configId}}`) +- No `space-*` IDs remain (all → `{{workspaceId}}`) +- All `config.dataStream.id` values are `{{dataStreams.[name]}}` form +- All `config.activePluginConfigIds` are `["{{configId}}"]` +- `config.scopes[]` entries with `ids_defaultScopeIds` bindings are removed +- Perspective tiles: `config.variables`, `config.scope.variable`, `config.scope.scope` are templatized + +**Done when:** no hardcoded platform IDs remain in any tile. + +--- + +### 4. Confirm title and timeframe + +Ask the user to confirm the dashboard title (suggest one derived from the content). + +Ask for timeframe. Default: `last24hours`. Options: + +`last1hour` · `last12hours` · `last24hours` · `last30days` · `thisMonth` · `thisQuarter` · `thisYear` · `lastMonth` · `lastQuarter` · `lastYear` · `none` + +**Done when:** title confirmed and timeframe chosen. + +--- + +### 5. Save + +Write to `plugins///defaultContent/.dash.json`: + +```json +{ + "name": "", + "schemaVersion": "1.5", + "timeframe": "", + "variables": [], + "dashboard": +} +``` + +For a perspective dashboard, set `"variables"` to the variable template from scopes.json, e.g.: + +```json +"variables": ["{{variables.[Algolia Index]}}"] +``` + +For a non-perspective dashboard, leave `"variables": []`. + +See [source-example.json](references/source-example.json) and [migrated-example.json](references/migrated-example.json) for a before/after reference. + +**Done when:** file written. + +--- + +### 6. Update manifest + +Ask where in `manifest.json` this dashboard should appear. Update `manifest.json` with the new entry at the specified position. + +**Done when:** `manifest.json` updated and order confirmed. diff --git a/.claude/skills/migrate-dashboard/references/migrated-example.json b/.claude/skills/migrate-dashboard/references/migrated-example.json new file mode 100644 index 00000000..3fc2edcb --- /dev/null +++ b/.claude/skills/migrate-dashboard/references/migrated-example.json @@ -0,0 +1,216 @@ +{ + "name": "Algolia Index", + "schemaVersion": "1.5", + "timeframe": "last24hours", + "variables": ["{{variables.[Algolia Index]}}"], + "dashboard": { + "_type": "layout/grid", + "version": 1, + "contents": [ + { + "static": false, + "w": 4, + "moved": false, + "x": 0, + "h": 4, + "i": "9b9fc2b9-c56d-47de-a4c4-d151e467a210", + "y": 6, + "z": 0, + "config": { + "timeframe": "none", + "variables": ["{{variables.[Algolia Index]}}"], + "dataStream": { + "pluginConfigId": "{{configId}}", + "id": "{{dataStreams.[properties]}}" + }, + "scope": { + "variable": "{{variables.[Algolia Index]}}", + "workspace": "{{workspaceId}}", + "scope": "{{scopes.[Algolia Indices]}}" + }, + "_type": "tile/data-stream", + "description": "", + "activePluginConfigIds": ["{{configId}}"], + "title": "Index Details", + "visualisation": { + "type": "data-stream-table", + "config": { + "data-stream-table": { + "transpose": true + } + } + } + } + }, + { + "static": false, + "w": 2, + "moved": false, + "x": 0, + "h": 4, + "i": "dfa41039-1657-4142-b6a5-16ac7abffb9d", + "y": 4, + "z": 0, + "config": { + "variables": ["{{variables.[Algolia Index]}}"], + "dataStream": { + "name": "searchCount", + "pluginConfigId": "{{configId}}", + "id": "{{dataStreams.[searchCount]}}", + "sort": { "by": [["date_byDay", "asc"]] }, + "group": { + "by": [["date", "byDay"]], + "aggregate": [{ "type": "sum", "names": ["count"] }] + } + }, + "scope": { + "variable": "{{variables.[Algolia Index]}}", + "workspace": "{{workspaceId}}", + "scope": "{{scopes.[Algolia Indices]}}" + }, + "_type": "tile/data-stream", + "description": "", + "activePluginConfigIds": ["{{configId}}"], + "title": "Search Volume", + "visualisation": { + "type": "data-stream-line-graph", + "config": { + "data-stream-line-graph": { + "yAxisLabel": "Searches", + "xAxisColumn": "date_byDay", + "showLegend": false, + "showYAxisLabel": true, + "seriesColumn": "none", + "showTrendLine": false, + "legendPosition": "bottom", + "yAxisColumn": ["count_sum"] + } + } + } + } + }, + { + "static": false, + "w": 2, + "moved": false, + "x": 2, + "h": 4, + "i": "09cc7897-4e96-44b3-9205-144fcf3ef5fb", + "y": 4, + "z": 0, + "config": { + "variables": ["{{variables.[Algolia Index]}}"], + "dataStream": { + "name": "noResultRate", + "pluginConfigId": "{{configId}}", + "id": "{{dataStreams.[noResultRate]}}", + "sort": { "by": [["date_byDay", "asc"]] }, + "group": { + "by": [["date", "byDay"]], + "aggregate": [{ "type": "mean", "names": ["rate"] }] + } + }, + "scope": { + "variable": "{{variables.[Algolia Index]}}", + "workspace": "{{workspaceId}}", + "scope": "{{scopes.[Algolia Indices]}}" + }, + "_type": "tile/data-stream", + "description": "", + "activePluginConfigIds": ["{{configId}}"], + "title": "No-Result Rate", + "visualisation": { + "type": "data-stream-line-graph", + "config": { + "data-stream-line-graph": { + "yAxisLabel": "No-Result Rate", + "xAxisColumn": "date_byDay", + "showLegend": false, + "showYAxisLabel": true, + "seriesColumn": "none", + "showTrendLine": false, + "legendPosition": "bottom", + "yAxisColumn": ["rate_mean"] + } + } + } + } + }, + { + "static": false, + "w": 2, + "moved": false, + "x": 0, + "h": 4, + "i": "1c816936-aafc-42dc-8096-10f0281a6e17", + "y": 0, + "z": 0, + "config": { + "variables": ["{{variables.[Algolia Index]}}"], + "dataStream": { + "name": "topSearches", + "pluginConfigId": "{{configId}}", + "id": "{{dataStreams.[topSearches]}}", + "sort": { "by": [["count", "desc"]] } + }, + "scope": { + "variable": "{{variables.[Algolia Index]}}", + "workspace": "{{workspaceId}}", + "scope": "{{scopes.[Algolia Indices]}}" + }, + "_type": "tile/data-stream", + "description": "", + "activePluginConfigIds": ["{{configId}}"], + "title": "Top Searches", + "visualisation": { + "type": "data-stream-table", + "config": { + "data-stream-table": { + "columnOrder": ["search", "count", "nbHits"], + "transpose": false + } + } + } + } + }, + { + "static": false, + "w": 2, + "moved": false, + "x": 2, + "h": 4, + "i": "1727a100-9c88-41cb-b157-c0f197b1b13e", + "y": 0, + "z": 0, + "config": { + "variables": ["{{variables.[Algolia Index]}}"], + "dataStream": { + "name": "noResultSearches", + "pluginConfigId": "{{configId}}", + "id": "{{dataStreams.[noResultSearches]}}", + "sort": { "by": [["count", "desc"]] } + }, + "scope": { + "variable": "{{variables.[Algolia Index]}}", + "workspace": "{{workspaceId}}", + "scope": "{{scopes.[Algolia Indices]}}" + }, + "_type": "tile/data-stream", + "description": "", + "activePluginConfigIds": ["{{configId}}"], + "title": "Searches With No Results", + "visualisation": { + "type": "data-stream-table", + "config": { + "data-stream-table": { + "columnOrder": ["search", "count", "withFilterCount"], + "transpose": false + } + } + } + } + } + ], + "columns": 4 + } +} diff --git a/.claude/skills/migrate-dashboard/references/source-example.json b/.claude/skills/migrate-dashboard/references/source-example.json new file mode 100644 index 00000000..7dd8c0a5 --- /dev/null +++ b/.claude/skills/migrate-dashboard/references/source-example.json @@ -0,0 +1,210 @@ +{ + "_type": "layout/grid", + "version": 1, + "contents": [ + { + "static": false, + "w": 4, + "moved": false, + "x": 0, + "h": 4, + "i": "9b9fc2b9-c56d-47de-a4c4-d151e467a210", + "y": 6, + "z": 0, + "config": { + "timeframe": "none", + "variables": ["var-J4pfoHDhrn2MRUQuPqv4"], + "dataStream": { + "pluginConfigId": "config-6PK2neo9yFh0rx3z80V2", + "id": "datastream-properties" + }, + "scope": { + "variable": "var-J4pfoHDhrn2MRUQuPqv4", + "workspace": "space-m86qNQgA8Mm39fbrpiNX", + "scope": "scope-mFB0VLVFCzanCpmI07nA" + }, + "_type": "tile/data-stream", + "description": "", + "activePluginConfigIds": ["config-6PK2neo9yFh0rx3z80V2"], + "title": "Index Details", + "visualisation": { + "type": "data-stream-table", + "config": { + "data-stream-table": { + "transpose": true + } + } + } + } + }, + { + "static": false, + "w": 2, + "moved": false, + "x": 0, + "h": 4, + "i": "dfa41039-1657-4142-b6a5-16ac7abffb9d", + "y": 4, + "z": 0, + "config": { + "variables": ["var-J4pfoHDhrn2MRUQuPqv4"], + "dataStream": { + "name": "searchCount", + "pluginConfigId": "config-6PK2neo9yFh0rx3z80V2", + "id": "datastream-GECbenNA3dhoyzEmkVrW", + "sort": { "by": [["date_byDay", "asc"]] }, + "group": { + "by": [["date", "byDay"]], + "aggregate": [{ "type": "sum", "names": ["count"] }] + } + }, + "scope": { + "variable": "var-J4pfoHDhrn2MRUQuPqv4", + "workspace": "space-m86qNQgA8Mm39fbrpiNX", + "scope": "scope-mFB0VLVFCzanCpmI07nA" + }, + "_type": "tile/data-stream", + "description": "", + "activePluginConfigIds": ["config-6PK2neo9yFh0rx3z80V2"], + "title": "Search Volume", + "visualisation": { + "type": "data-stream-line-graph", + "config": { + "data-stream-line-graph": { + "yAxisLabel": "Searches", + "xAxisColumn": "date_byDay", + "showLegend": false, + "showYAxisLabel": true, + "seriesColumn": "none", + "showTrendLine": false, + "legendPosition": "bottom", + "yAxisColumn": ["count_sum"] + } + } + } + } + }, + { + "static": false, + "w": 2, + "moved": false, + "x": 2, + "h": 4, + "i": "09cc7897-4e96-44b3-9205-144fcf3ef5fb", + "y": 4, + "z": 0, + "config": { + "variables": ["var-J4pfoHDhrn2MRUQuPqv4"], + "dataStream": { + "name": "noResultRate", + "pluginConfigId": "config-6PK2neo9yFh0rx3z80V2", + "id": "datastream-WZlGFhDNuEpIX3axqpcR", + "sort": { "by": [["date_byDay", "asc"]] }, + "group": { + "by": [["date", "byDay"]], + "aggregate": [{ "type": "mean", "names": ["rate"] }] + } + }, + "scope": { + "variable": "var-J4pfoHDhrn2MRUQuPqv4", + "workspace": "space-m86qNQgA8Mm39fbrpiNX", + "scope": "scope-mFB0VLVFCzanCpmI07nA" + }, + "_type": "tile/data-stream", + "description": "", + "activePluginConfigIds": ["config-6PK2neo9yFh0rx3z80V2"], + "title": "No-Result Rate", + "visualisation": { + "type": "data-stream-line-graph", + "config": { + "data-stream-line-graph": { + "yAxisLabel": "No-Result Rate", + "xAxisColumn": "date_byDay", + "showLegend": false, + "showYAxisLabel": true, + "seriesColumn": "none", + "showTrendLine": false, + "legendPosition": "bottom", + "yAxisColumn": ["rate_mean"] + } + } + } + } + }, + { + "static": false, + "w": 2, + "moved": false, + "x": 0, + "h": 4, + "i": "1c816936-aafc-42dc-8096-10f0281a6e17", + "y": 0, + "z": 0, + "config": { + "variables": ["var-J4pfoHDhrn2MRUQuPqv4"], + "dataStream": { + "name": "topSearches", + "pluginConfigId": "config-6PK2neo9yFh0rx3z80V2", + "id": "datastream-f7fhFvXjv82JKG5Np0lc", + "sort": { "by": [["count", "desc"]] } + }, + "scope": { + "variable": "var-J4pfoHDhrn2MRUQuPqv4", + "workspace": "space-m86qNQgA8Mm39fbrpiNX", + "scope": "scope-mFB0VLVFCzanCpmI07nA" + }, + "_type": "tile/data-stream", + "description": "", + "activePluginConfigIds": ["config-6PK2neo9yFh0rx3z80V2"], + "title": "Top Searches", + "visualisation": { + "type": "data-stream-table", + "config": { + "data-stream-table": { + "columnOrder": ["search", "count", "nbHits"], + "transpose": false + } + } + } + } + }, + { + "static": false, + "w": 2, + "moved": false, + "x": 2, + "h": 4, + "i": "1727a100-9c88-41cb-b157-c0f197b1b13e", + "y": 0, + "z": 0, + "config": { + "variables": ["var-J4pfoHDhrn2MRUQuPqv4"], + "dataStream": { + "name": "noResultSearches", + "pluginConfigId": "config-6PK2neo9yFh0rx3z80V2", + "id": "datastream-Y85PbGnycRrMZ6eH6bSw", + "sort": { "by": [["count", "desc"]] } + }, + "scope": { + "variable": "var-J4pfoHDhrn2MRUQuPqv4", + "workspace": "space-m86qNQgA8Mm39fbrpiNX", + "scope": "scope-mFB0VLVFCzanCpmI07nA" + }, + "_type": "tile/data-stream", + "description": "", + "activePluginConfigIds": ["config-6PK2neo9yFh0rx3z80V2"], + "title": "Searches With No Results", + "visualisation": { + "type": "data-stream-table", + "config": { + "data-stream-table": { + "columnOrder": ["search", "count", "withFilterCount"], + "transpose": false + } + } + } + } + } + ], + "columns": 4 +} diff --git a/.claude/skills/migrate-dashboard/scripts/normalize.js b/.claude/skills/migrate-dashboard/scripts/normalize.js new file mode 100644 index 00000000..f1b6ef63 --- /dev/null +++ b/.claude/skills/migrate-dashboard/scripts/normalize.js @@ -0,0 +1,116 @@ +#!/usr/bin/env node +// Usage: node normalize.js [scopes.json] [--scope-name "Scope Name"] +// Writes normalized dashboard JSON to stdout. +// Pass scopes.json for perspective dashboards (those with config.variables or config.scope). +// If scopes.json has multiple entries, --scope-name is required to select which one to apply. + +const fs = require('fs'); + +const args = process.argv.slice(2); +const scopeNameFlag = args.indexOf('--scope-name'); +const requestedScopeName = scopeNameFlag !== -1 ? args[scopeNameFlag + 1] : null; +const positional = args.filter((a, i) => { + if (a.startsWith('--')) return false; + if (scopeNameFlag !== -1 && i === scopeNameFlag + 1) return false; + return true; +}); + +const dashboardPath = positional[0]; +const scopesPath = positional[1]; + +if (!dashboardPath) { + process.stderr.write( + 'Usage: node normalize.js [scopes.json] [--scope-name "Scope Name"]\n' + ); + process.exit(1); +} + +const dashboard = JSON.parse(fs.readFileSync(dashboardPath, 'utf8')); +const allScopes = scopesPath ? JSON.parse(fs.readFileSync(scopesPath, 'utf8')) : null; + +let scopeEntry = null; +if (allScopes) { + if (allScopes.length === 1) { + scopeEntry = allScopes[0]; + } else if (requestedScopeName) { + scopeEntry = allScopes.find(s => s.name === requestedScopeName); + if (!scopeEntry) { + const available = allScopes.map(s => ` "${s.name}"`).join('\n'); + process.stderr.write( + `No scope named "${requestedScopeName}". Available:\n${available}\n` + ); + process.exit(1); + } + } else { + const available = allScopes.map(s => ` "${s.name}"`).join('\n'); + process.stderr.write( + `scopes.json has multiple entries — use --scope-name to select one:\n${available}\n` + ); + process.exit(1); + } +} + +const scopeName = scopeEntry?.name; +const variableName = scopeEntry?.variable?.name; + +function normalizeTile(tile) { + const config = structuredClone(tile.config); + if (!config) return tile; + + // Remove scope entries that pin to specific nodes via ids_defaultScopeIds + if (Array.isArray(config.scopes)) { + config.scopes = config.scopes.filter( + s => !(s.bindings && 'ids_defaultScopeIds' in s.bindings) + ); + if (config.scopes.length === 0) delete config.scopes; + } + + // Templatize dataStream + if (config.dataStream) { + if (config.dataStream.name) { + config.dataStream.id = `{{dataStreams.[${config.dataStream.name}]}}`; + } + if (config.dataStream.pluginConfigId) { + config.dataStream.pluginConfigId = '{{configId}}'; + } + } + + if (config.activePluginConfigIds) { + config.activePluginConfigIds = ['{{configId}}']; + } + + // Perspective fields + if (config.variables && variableName) { + config.variables = [`{{variables.[${variableName}]}}`]; + } + + if (config.scope) { + if (config.scope.variable && variableName) { + config.scope.variable = `{{variables.[${variableName}]}}`; + } + if (config.scope.workspace) { + config.scope.workspace = '{{workspaceId}}'; + } + if (config.scope.scope && scopeName) { + config.scope.scope = `{{scopes.[${scopeName}]}}`; + } + } + + return { ...tile, config }; +} + +// String-level pass: catch any remaining hardcoded IDs the structural pass may miss +// (e.g. inside scope.query strings) +function globalReplace(obj) { + let s = JSON.stringify(obj); + s = s.replace(/config-[A-Za-z0-9]+/g, '{{configId}}'); + s = s.replace(/space-[A-Za-z0-9]+/g, '{{workspaceId}}'); + return JSON.parse(s); +} + +const normalized = globalReplace({ + ...dashboard, + contents: (dashboard.contents || []).map(normalizeTile), +}); + +process.stdout.write(JSON.stringify(normalized, null, 2) + '\n'); From 2b650da90efc9cdc292103191e8139a90ec9e781 Mon Sep 17 00:00:00 2001 From: Andrew Harris Date: Fri, 19 Jun 2026 13:49:43 +0100 Subject: [PATCH 2/2] suggest sonnet model for migration skill --- .claude/skills/migrate-dashboard/SKILL.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.claude/skills/migrate-dashboard/SKILL.md b/.claude/skills/migrate-dashboard/SKILL.md index ac07dd79..bc4ac5dd 100644 --- a/.claude/skills/migrate-dashboard/SKILL.md +++ b/.claude/skills/migrate-dashboard/SKILL.md @@ -1,6 +1,7 @@ --- name: migrate-dashboard description: Migrate an exported platform dashboard JSON to plugin format. Use when the user pastes a dashboard JSON to convert, says "migrate this dashboard", or wants to add an exported dashboard to a plugin as default content. +model: sonnet metadata: author: SquaredUp version: "0.0.1" @@ -49,6 +50,7 @@ Pass `scopes.json` when the dashboard has tiles with `config.variables` or `conf If `scopes.json` has multiple entries, read it first and determine which scope applies. It may be obvious from the dashboard content (e.g. tile titles referencing "Agent" or "Organization"), but if not, ask the user before running the script. Pass the chosen scope via `--scope-name "Scope Name"`. Run once per scope if the dashboard mixes tiles from multiple scopes. Verify the output — check that: + - No `config-*` IDs remain (all → `{{configId}}`) - No `space-*` IDs remain (all → `{{workspaceId}}`) - All `config.dataStream.id` values are `{{dataStreams.[name]}}` form