@jhytabest/plashboard 0.1.7 → 0.1.9

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
@@ -9,6 +9,7 @@ This plugin manages dashboard templates, scheduled fill runs, validation, and at
9
9
  ```bash
10
10
  openclaw plugins install @jhytabest/plashboard
11
11
  openclaw plugins enable plashboard
12
+ sudo systemctl restart openclaw-gateway
12
13
  openclaw plugins doctor
13
14
  ```
14
15
 
@@ -18,7 +19,21 @@ openclaw plugins doctor
18
19
  openclaw plugins update plashboard
19
20
  ```
20
21
 
21
- ## Minimal Config
22
+ ## Zero-Config First Run
23
+
24
+ No manual config is required for first use. Defaults are safe:
25
+ - `fill_provider=openclaw`
26
+ - `openclaw_fill_agent_id=main`
27
+ - automatic init on service start
28
+ - automatic starter template seed when template store is empty
29
+
30
+ In chat, run:
31
+
32
+ ```text
33
+ /plashboard quickstart <what this dashboard should focus on>
34
+ ```
35
+
36
+ ## Optional Config
22
37
 
23
38
  Add to `openclaw.json`:
24
39
 
@@ -35,6 +50,7 @@ Add to `openclaw.json`:
35
50
  "default_retry_count": 1,
36
51
  "retry_backoff_seconds": 20,
37
52
  "session_timeout_seconds": 90,
53
+ "auto_seed_template": true,
38
54
  "fill_provider": "openclaw",
39
55
  "openclaw_fill_agent_id": "main",
40
56
  "display_profile": {
@@ -59,6 +75,9 @@ Use `fill_provider: "command"` only if you need a custom external runner.
59
75
 
60
76
  ```text
61
77
  /plashboard setup [openclaw [agent_id]|mock|command <fill_command>]
78
+ /plashboard quickstart <description>
79
+ /plashboard doctor [local_url] [https_port] [repo_dir]
80
+ /plashboard web-guide [local_url] [repo_dir]
62
81
  /plashboard expose-guide [local_url] [https_port]
63
82
  /plashboard expose-check [local_url] [https_port]
64
83
  /plashboard init
@@ -74,8 +93,15 @@ Use `fill_provider: "command"` only if you need a custom external runner.
74
93
  Recommended first run:
75
94
 
76
95
  ```text
77
- /plashboard setup openclaw
78
- /plashboard init
96
+ /plashboard quickstart "Focus on service health, priorities, blockers, and next actions."
97
+ ```
98
+
99
+ If `quickstart` returns web/exposure warnings:
100
+
101
+ ```text
102
+ /plashboard web-guide
103
+ /plashboard expose-guide
104
+ /plashboard doctor
79
105
  ```
80
106
 
81
107
  Tailscale helper flow:
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "plashboard",
3
3
  "name": "Plashboard",
4
- "version": "0.1.7",
4
+ "version": "0.1.9",
5
5
  "description": "Template-driven dashboard runtime with scheduled OpenClaw fills and safe publish.",
6
6
  "entry": "./src/index.ts",
7
7
  "skills": ["./skills/plashboard-admin"],
@@ -15,6 +15,7 @@
15
15
  "default_retry_count": { "type": "integer", "minimum": 0, "maximum": 5, "default": 1 },
16
16
  "retry_backoff_seconds": { "type": "integer", "minimum": 1, "maximum": 300, "default": 20 },
17
17
  "session_timeout_seconds": { "type": "integer", "minimum": 10, "maximum": 600, "default": 90 },
18
+ "auto_seed_template": { "type": "boolean", "default": true },
18
19
  "fill_provider": { "type": "string", "enum": ["command", "mock", "openclaw"], "default": "openclaw" },
19
20
  "fill_command": { "type": "string" },
20
21
  "openclaw_fill_agent_id": { "type": "string", "default": "main" },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jhytabest/plashboard",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "private": false,
5
5
  "description": "Plashboard OpenClaw plugin runtime",
6
6
  "license": "MIT",
@@ -20,7 +20,10 @@ Always use plugin tools:
20
20
  - `plashboard_setup`
21
21
  - `plashboard_exposure_guide`
22
22
  - `plashboard_exposure_check`
23
+ - `plashboard_web_guide`
24
+ - `plashboard_doctor`
23
25
  - `plashboard_init`
26
+ - `plashboard_quickstart`
24
27
  - `plashboard_template_create`
25
28
  - `plashboard_template_update`
26
29
  - `plashboard_template_list`
@@ -39,7 +42,10 @@ Always use plugin tools:
39
42
  - Never ask the model to generate full dashboard structure when filling values.
40
43
 
41
44
  ## Command Shortcuts
45
+ - `/plashboard quickstart <description>`
42
46
  - `/plashboard setup [openclaw [agent_id]|mock|command <fill_command>]`
47
+ - `/plashboard doctor [local_url] [https_port] [repo_dir]`
48
+ - `/plashboard web-guide [local_url] [repo_dir]`
43
49
  - `/plashboard expose-guide [local_url] [https_port]`
44
50
  - `/plashboard expose-check [local_url] [https_port]`
45
51
  - `/plashboard init`
package/src/config.ts CHANGED
@@ -22,6 +22,16 @@ function asString(value: unknown, fallback: string): string {
22
22
  return typeof value === 'string' && value.trim() ? value : fallback;
23
23
  }
24
24
 
25
+ function asBoolean(value: unknown, fallback: boolean): boolean {
26
+ if (typeof value === 'boolean') return value;
27
+ if (typeof value === 'string') {
28
+ const normalized = value.trim().toLowerCase();
29
+ if (normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on') return true;
30
+ if (normalized === 'false' || normalized === '0' || normalized === 'no' || normalized === 'off') return false;
31
+ }
32
+ return fallback;
33
+ }
34
+
25
35
  function asObject(value: unknown): Record<string, unknown> {
26
36
  return value && typeof value === 'object' && !Array.isArray(value)
27
37
  ? (value as Record<string, unknown>)
@@ -65,6 +75,7 @@ export function resolveConfig(api: unknown): PlashboardConfig {
65
75
  default_retry_count: Math.max(0, Math.floor(asNumber(raw.default_retry_count, 1))),
66
76
  retry_backoff_seconds: Math.max(1, Math.floor(asNumber(raw.retry_backoff_seconds, 20))),
67
77
  session_timeout_seconds: Math.max(10, Math.floor(asNumber(raw.session_timeout_seconds, 90))),
78
+ auto_seed_template: asBoolean(raw.auto_seed_template, true),
68
79
  fill_provider: fillProvider,
69
80
  fill_command: typeof raw.fill_command === 'string' ? raw.fill_command : undefined,
70
81
  openclaw_fill_agent_id: asString(raw.openclaw_fill_agent_id, 'main'),
@@ -37,6 +37,7 @@ function config(overrides: Partial<PlashboardConfig>): PlashboardConfig {
37
37
  default_retry_count: 0,
38
38
  retry_backoff_seconds: 1,
39
39
  session_timeout_seconds: 30,
40
+ auto_seed_template: false,
40
41
  fill_provider: 'mock',
41
42
  fill_command: undefined,
42
43
  openclaw_fill_agent_id: 'main',
package/src/plugin.ts CHANGED
@@ -90,6 +90,7 @@ type SetupParams = {
90
90
  fill_provider?: 'mock' | 'command' | 'openclaw';
91
91
  fill_command?: string;
92
92
  openclaw_fill_agent_id?: string;
93
+ auto_seed_template?: boolean;
93
94
  data_dir?: string;
94
95
  scheduler_tick_seconds?: number;
95
96
  session_timeout_seconds?: number;
@@ -101,12 +102,30 @@ type SetupParams = {
101
102
  layout_safety_margin_px?: number;
102
103
  };
103
104
 
105
+ type QuickstartParams = {
106
+ description?: string;
107
+ template_id?: string;
108
+ template_name?: string;
109
+ every_minutes?: number;
110
+ activate?: boolean;
111
+ run_now?: boolean;
112
+ };
113
+
104
114
  type ExposureParams = {
105
115
  local_url?: string;
106
116
  tailscale_https_port?: number;
107
117
  dashboard_output_path?: string;
108
118
  };
109
119
 
120
+ type WebGuideParams = {
121
+ local_url?: string;
122
+ repo_dir?: string;
123
+ };
124
+
125
+ type DoctorParams = ExposureParams & {
126
+ repo_dir?: string;
127
+ };
128
+
110
129
  type CommandExecResult = {
111
130
  ok: boolean;
112
131
  stdout: string;
@@ -220,6 +239,40 @@ async function buildExposureGuide(resolvedConfig: ReturnType<typeof resolveConfi
220
239
  } satisfies ToolResponse<Record<string, unknown>>;
221
240
  }
222
241
 
242
+ function deriveRepoDir(raw?: string): string {
243
+ const value = (raw || '').trim();
244
+ return value || '/opt/plashboard';
245
+ }
246
+
247
+ async function buildWebGuide(resolvedConfig: ReturnType<typeof resolveConfig>, params: WebGuideParams = {}) {
248
+ const localUrl = normalizeLocalUrl(params.local_url);
249
+ const repoDir = deriveRepoDir(params.repo_dir);
250
+ const dashboardPath = resolvedConfig.dashboard_output_path;
251
+
252
+ return {
253
+ ok: true,
254
+ errors: [],
255
+ data: {
256
+ local_url: localUrl,
257
+ repo_dir: repoDir,
258
+ dashboard_output_path: dashboardPath,
259
+ commands: [
260
+ `git clone https://github.com/jhytabest/plashboard.git ${repoDir} || true`,
261
+ `git -C ${repoDir} pull --ff-only`,
262
+ `docker compose -f ${repoDir}/docker-compose.yml up -d`,
263
+ `docker ps --format "{{.Names}} {{.Ports}}" | grep -E "plash-web|18888"`,
264
+ `curl -I ${localUrl}`,
265
+ `curl -I ${localUrl}/healthz`
266
+ ],
267
+ notes: [
268
+ 'Plashboard writes dashboard JSON; a local web server must serve the UI and /data/dashboard.json.',
269
+ 'The bundled docker-compose stack exposes nginx at 127.0.0.1:18888 by default.',
270
+ 'If you host UI differently, update local_url in expose-check/expose-guide.'
271
+ ]
272
+ }
273
+ } satisfies ToolResponse<Record<string, unknown>>;
274
+ }
275
+
223
276
  async function runExposureCheck(resolvedConfig: ReturnType<typeof resolveConfig>, params: ExposureParams = {}) {
224
277
  const localUrl = normalizeLocalUrl(params.local_url);
225
278
  const httpsPort = normalizePort(asNumber(params.tailscale_https_port), 8444);
@@ -357,6 +410,13 @@ async function runSetup(api: UnknownApi, resolvedConfig: ReturnType<typeof resol
357
410
  || asString(currentPluginConfig.fill_command)
358
411
  || asString(resolvedConfig.fill_command)
359
412
  ).trim();
413
+ const selectedAutoSeed = (
414
+ typeof params.auto_seed_template === 'boolean'
415
+ ? params.auto_seed_template
416
+ : typeof currentPluginConfig.auto_seed_template === 'boolean'
417
+ ? currentPluginConfig.auto_seed_template
418
+ : resolvedConfig.auto_seed_template
419
+ );
360
420
  const selectedAgentId = (
361
421
  params.openclaw_fill_agent_id
362
422
  || asString(currentPluginConfig.openclaw_fill_agent_id)
@@ -397,6 +457,7 @@ async function runSetup(api: UnknownApi, resolvedConfig: ReturnType<typeof resol
397
457
  )
398
458
  ),
399
459
  fill_provider: selectedProvider,
460
+ auto_seed_template: selectedAutoSeed,
400
461
  display_profile: displayProfile
401
462
  };
402
463
 
@@ -438,6 +499,7 @@ async function runSetup(api: UnknownApi, resolvedConfig: ReturnType<typeof resol
438
499
  fill_provider: selectedProvider,
439
500
  fill_command: selectedProvider === 'command' ? selectedCommand : undefined,
440
501
  openclaw_fill_agent_id: selectedProvider === 'openclaw' ? selectedAgentId : undefined,
502
+ auto_seed_template: selectedAutoSeed,
441
503
  data_dir: nextPluginConfig.data_dir,
442
504
  scheduler_tick_seconds: nextPluginConfig.scheduler_tick_seconds,
443
505
  session_timeout_seconds: nextPluginConfig.session_timeout_seconds,
@@ -450,6 +512,101 @@ async function runSetup(api: UnknownApi, resolvedConfig: ReturnType<typeof resol
450
512
  } satisfies ToolResponse<Record<string, unknown>>;
451
513
  }
452
514
 
515
+ async function runQuickstart(
516
+ runtime: PlashboardRuntime,
517
+ resolvedConfig: ReturnType<typeof resolveConfig>,
518
+ params: QuickstartParams = {}
519
+ ): Promise<ToolResponse<Record<string, unknown>>> {
520
+ const quickstart = await runtime.quickstart(params);
521
+ const exposure = await runExposureCheck(resolvedConfig, {});
522
+ const guide = await buildExposureGuide(resolvedConfig, {});
523
+ const webGuide = await buildWebGuide(resolvedConfig, {});
524
+
525
+ const warnings: string[] = [];
526
+ if (!exposure.ok) {
527
+ warnings.push(...exposure.errors);
528
+ }
529
+
530
+ return {
531
+ ok: quickstart.ok,
532
+ errors: quickstart.errors,
533
+ data: {
534
+ ...(quickstart.data || {}),
535
+ postcheck: {
536
+ local_url: exposure.data?.local_url,
537
+ local_url_ok: exposure.data?.local_url_ok,
538
+ tailscale_port_configured: exposure.data?.tailscale_port_configured,
539
+ dashboard_exists: exposure.data?.dashboard_exists
540
+ },
541
+ warnings,
542
+ next_steps: warnings.length
543
+ ? [
544
+ 'run /plashboard web-guide and execute its commands',
545
+ 'run /plashboard expose-guide and apply tailscale mapping',
546
+ 'run /plashboard doctor'
547
+ ]
548
+ : [
549
+ 'dashboard generation is working',
550
+ 'run /plashboard doctor for full readiness check'
551
+ ],
552
+ exposure_guide: guide.data,
553
+ web_guide: webGuide.data
554
+ }
555
+ };
556
+ }
557
+
558
+ async function runDoctor(
559
+ runtime: PlashboardRuntime,
560
+ resolvedConfig: ReturnType<typeof resolveConfig>,
561
+ params: DoctorParams = {}
562
+ ): Promise<ToolResponse<Record<string, unknown>>> {
563
+ const status = await runtime.status();
564
+ const exposure = await runExposureCheck(resolvedConfig, params);
565
+ const exposureGuide = await buildExposureGuide(resolvedConfig, params);
566
+ const webGuide = await buildWebGuide(resolvedConfig, params);
567
+
568
+ const issues: string[] = [];
569
+ const statusData = status.data;
570
+ const templateCount = Number(statusData?.template_count ?? 0);
571
+ const activeTemplateId = statusData?.active_template_id || null;
572
+
573
+ if (!status.ok) issues.push(...status.errors);
574
+ if (templateCount === 0) issues.push('no templates exist; run /plashboard quickstart "<description>"');
575
+ if (!activeTemplateId) issues.push('no active template; activate one with /plashboard activate <template-id>');
576
+ if (exposure.data?.dashboard_exists !== true) {
577
+ issues.push(`dashboard output missing at ${resolvedConfig.dashboard_output_path}`);
578
+ }
579
+ if (exposure.data?.local_url_ok !== true) {
580
+ issues.push(`local dashboard server is not reachable at ${String(exposure.data?.local_url || 'http://127.0.0.1:18888')}`);
581
+ }
582
+ if (exposure.data?.tailscale_status_ok !== true) {
583
+ issues.push('tailscale serve status failed');
584
+ } else if (exposure.data?.tailscale_port_configured !== true) {
585
+ issues.push(`tailscale serve mapping missing for port ${String(exposure.data?.tailscale_https_port || 8444)}`);
586
+ }
587
+
588
+ const ready = issues.length === 0;
589
+ return {
590
+ ok: ready,
591
+ errors: issues,
592
+ data: {
593
+ ready,
594
+ status: statusData,
595
+ exposure: exposure.data,
596
+ exposure_guide: exposureGuide.data,
597
+ web_guide: webGuide.data,
598
+ next_steps: ready
599
+ ? ['dashboard runtime + web exposure look healthy']
600
+ : [
601
+ 'run /plashboard quickstart "<description>" if no templates exist',
602
+ 'run /plashboard web-guide and start local UI server',
603
+ 'run /plashboard expose-guide and apply tailscale mapping',
604
+ 're-run /plashboard doctor'
605
+ ]
606
+ }
607
+ };
608
+ }
609
+
453
610
  export function registerPlashboardPlugin(api: UnknownApi): void {
454
611
  const config = resolveConfig(api);
455
612
  const runtimeCommand = api.runtime?.system?.runCommandWithTimeout;
@@ -528,6 +685,40 @@ export function registerPlashboardPlugin(api: UnknownApi): void {
528
685
  toToolResult(await runExposureCheck(config, params))
529
686
  });
530
687
 
688
+ api.registerTool?.({
689
+ name: 'plashboard_web_guide',
690
+ description: 'Return exact commands to start the local plashboard web UI server.',
691
+ optional: true,
692
+ parameters: {
693
+ type: 'object',
694
+ properties: {
695
+ local_url: { type: 'string' },
696
+ repo_dir: { type: 'string' }
697
+ },
698
+ additionalProperties: false
699
+ },
700
+ execute: async (_toolCallId: unknown, params: WebGuideParams = {}) =>
701
+ toToolResult(await buildWebGuide(config, params))
702
+ });
703
+
704
+ api.registerTool?.({
705
+ name: 'plashboard_doctor',
706
+ description: 'Run full plashboard readiness checks (templates, local UI, and tailscale mapping).',
707
+ optional: true,
708
+ parameters: {
709
+ type: 'object',
710
+ properties: {
711
+ local_url: { type: 'string' },
712
+ tailscale_https_port: { type: 'number' },
713
+ dashboard_output_path: { type: 'string' },
714
+ repo_dir: { type: 'string' }
715
+ },
716
+ additionalProperties: false
717
+ },
718
+ execute: async (_toolCallId: unknown, params: DoctorParams = {}) =>
719
+ toToolResult(await runDoctor(runtime, config, params))
720
+ });
721
+
531
722
  api.registerTool?.({
532
723
  name: 'plashboard_setup',
533
724
  description: 'Bootstrap or update plashboard plugin configuration in openclaw.json.',
@@ -538,6 +729,7 @@ export function registerPlashboardPlugin(api: UnknownApi): void {
538
729
  fill_provider: { type: 'string', enum: ['mock', 'command', 'openclaw'] },
539
730
  fill_command: { type: 'string' },
540
731
  openclaw_fill_agent_id: { type: 'string' },
732
+ auto_seed_template: { type: 'boolean' },
541
733
  data_dir: { type: 'string' },
542
734
  scheduler_tick_seconds: { type: 'number' },
543
735
  session_timeout_seconds: { type: 'number' },
@@ -566,6 +758,26 @@ export function registerPlashboardPlugin(api: UnknownApi): void {
566
758
  execute: async () => toToolResult(await runtime.init())
567
759
  });
568
760
 
761
+ api.registerTool?.({
762
+ name: 'plashboard_quickstart',
763
+ description: 'Create a first dashboard template from a short description, activate it, and run it once.',
764
+ optional: true,
765
+ parameters: {
766
+ type: 'object',
767
+ properties: {
768
+ description: { type: 'string' },
769
+ template_id: { type: 'string' },
770
+ template_name: { type: 'string' },
771
+ every_minutes: { type: 'number' },
772
+ activate: { type: 'boolean' },
773
+ run_now: { type: 'boolean' }
774
+ },
775
+ additionalProperties: false
776
+ },
777
+ execute: async (_toolCallId: unknown, params: QuickstartParams = {}) =>
778
+ toToolResult(await runQuickstart(runtime, config, params))
779
+ });
780
+
569
781
  api.registerTool?.({
570
782
  name: 'plashboard_template_create',
571
783
  description: 'Create a new dashboard template.',
@@ -769,6 +981,28 @@ export function registerPlashboardPlugin(api: UnknownApi): void {
769
981
  })
770
982
  );
771
983
  }
984
+ if (cmd === 'web-guide') {
985
+ const localUrl = rest.find((token) => token.startsWith('http://') || token.startsWith('https://'));
986
+ const repoDir = rest.find((token) => token.startsWith('/'));
987
+ return toCommandResult(
988
+ await buildWebGuide(config, {
989
+ local_url: localUrl,
990
+ repo_dir: repoDir
991
+ })
992
+ );
993
+ }
994
+ if (cmd === 'doctor') {
995
+ const localUrl = rest.find((token) => token.startsWith('http://') || token.startsWith('https://'));
996
+ const portToken = rest.find((token) => /^[0-9]+$/.test(token));
997
+ const repoDir = rest.find((token) => token.startsWith('/'));
998
+ return toCommandResult(
999
+ await runDoctor(runtime, config, {
1000
+ local_url: localUrl,
1001
+ tailscale_https_port: portToken ? Number(portToken) : undefined,
1002
+ repo_dir: repoDir
1003
+ })
1004
+ );
1005
+ }
772
1006
  if (cmd === 'setup') {
773
1007
  const mode = asString(rest[0]).toLowerCase();
774
1008
  const fillProvider = mode === 'command' || mode === 'mock' || mode === 'openclaw' ? mode : undefined;
@@ -782,6 +1016,10 @@ export function registerPlashboardPlugin(api: UnknownApi): void {
782
1016
  })
783
1017
  );
784
1018
  }
1019
+ if (cmd === 'quickstart') {
1020
+ const description = rest.join(' ').trim() || undefined;
1021
+ return toCommandResult(await runQuickstart(runtime, config, { description }));
1022
+ }
785
1023
  if (cmd === 'init') return toCommandResult(await runtime.init());
786
1024
  if (cmd === 'status') return toCommandResult(await runtime.status());
787
1025
  if (cmd === 'list') return toCommandResult(await runtime.templateList());
@@ -806,7 +1044,7 @@ export function registerPlashboardPlugin(api: UnknownApi): void {
806
1044
  return toCommandResult({
807
1045
  ok: false,
808
1046
  errors: [
809
- 'unknown command. supported: setup [openclaw [agent_id]|mock|command <fill_command>], expose-guide [local_url] [https_port], expose-check [local_url] [https_port], init, status, list, activate <id>, delete <id>, copy <src> <new-id> [new-name] [activate], run <id>, set-display <width> <height> <top> <bottom>'
1047
+ 'unknown command. supported: setup [openclaw [agent_id]|mock|command <fill_command>], quickstart <description>, doctor [local_url] [https_port] [repo_dir], web-guide [local_url] [repo_dir], expose-guide [local_url] [https_port], expose-check [local_url] [https_port], init, status, list, activate <id>, delete <id>, copy <src> <new-id> [new-name] [activate], run <id>, set-display <width> <height> <top> <bottom>'
810
1048
  ]
811
1049
  });
812
1050
  }
@@ -51,7 +51,7 @@ function template(id: string): DashboardTemplate {
51
51
  };
52
52
  }
53
53
 
54
- async function setupRuntime() {
54
+ async function setupRuntime(overrides: Partial<PlashboardConfig> = {}) {
55
55
  const root = await mkdtemp(join(tmpdir(), 'plashboard-test-'));
56
56
  const config: PlashboardConfig = {
57
57
  data_dir: root,
@@ -61,6 +61,7 @@ async function setupRuntime() {
61
61
  default_retry_count: 0,
62
62
  retry_backoff_seconds: 1,
63
63
  session_timeout_seconds: 30,
64
+ auto_seed_template: false,
64
65
  fill_provider: 'mock',
65
66
  fill_command: undefined,
66
67
  python_bin: 'python3',
@@ -75,7 +76,8 @@ async function setupRuntime() {
75
76
  safe_side_px: 28,
76
77
  layout_safety_margin_px: 24
77
78
  },
78
- model_defaults: {}
79
+ model_defaults: {},
80
+ ...overrides
79
81
  };
80
82
 
81
83
  const runtime = new PlashboardRuntime(config);
@@ -160,4 +162,41 @@ describe('PlashboardRuntime', () => {
160
162
  await rm(root, { recursive: true, force: true });
161
163
  }
162
164
  });
165
+
166
+ it('auto-seeds a starter template when enabled', async () => {
167
+ const { runtime, root } = await setupRuntime({ auto_seed_template: true });
168
+ try {
169
+ const list = await runtime.templateList();
170
+ expect(list.ok).toBe(true);
171
+ const templates = list.data?.templates ?? [];
172
+ expect(templates.length).toBe(1);
173
+ expect(String(templates[0]?.id)).toBe('starter');
174
+
175
+ const status = await runtime.status();
176
+ expect(status.ok).toBe(true);
177
+ expect(status.data?.active_template_id).toBe('starter');
178
+ } finally {
179
+ await rm(root, { recursive: true, force: true });
180
+ }
181
+ });
182
+
183
+ it('quickstart creates, activates, and runs once', async () => {
184
+ const { runtime, root, config } = await setupRuntime({ auto_seed_template: false });
185
+ try {
186
+ const quickstart = await runtime.quickstart({
187
+ description: 'Focus on homelab health, priorities, blockers, and next actions.'
188
+ });
189
+ expect(quickstart.ok).toBe(true);
190
+ const templateId = String(quickstart.data?.template_id || '');
191
+ expect(templateId).toBeTruthy();
192
+ expect(quickstart.data?.active_template_id).toBe(templateId);
193
+ expect(quickstart.data?.run_status).toBe('success');
194
+
195
+ const published = JSON.parse(await readFile(config.dashboard_output_path, 'utf8')) as Record<string, unknown>;
196
+ expect(published.version).toBe('3.0');
197
+ expect(typeof published.generated_at).toBe('string');
198
+ } finally {
199
+ await rm(root, { recursive: true, force: true });
200
+ }
201
+ });
163
202
  });
package/src/runtime.ts CHANGED
@@ -31,6 +31,25 @@ const NOOP_LOGGER: Logger = {
31
31
  error: () => {}
32
32
  };
33
33
 
34
+ function clampInt(value: number, min: number, max: number): number {
35
+ return Math.max(min, Math.min(max, Math.floor(value)));
36
+ }
37
+
38
+ function normalizeTemplateId(input: string): string {
39
+ const cleaned = input
40
+ .trim()
41
+ .toLowerCase()
42
+ .replace(/[^a-z0-9_-]+/g, '-')
43
+ .replace(/^-+/, '')
44
+ .replace(/-+$/, '')
45
+ .replace(/--+/g, '-');
46
+
47
+ if (!cleaned) return '';
48
+ const limited = cleaned.slice(0, 64);
49
+ if (!/^[a-z0-9]/.test(limited)) return '';
50
+ return limited;
51
+ }
52
+
34
53
  function resolveLastAttemptMs(state: PlashboardState, templateId: string): number | null {
35
54
  const runState = state.template_runs[templateId];
36
55
  if (!runState) return null;
@@ -100,32 +119,48 @@ export class PlashboardRuntime {
100
119
  await this.paths.ensure();
101
120
  await this.loadState();
102
121
 
103
- const templates = await this.templateStore.list();
104
122
  const state = await this.loadState();
123
+ let templates = await this.templateStore.list();
124
+ let autoSeededTemplateId: string | null = null;
125
+ let autoSeededSource: 'live_dashboard' | 'starter' | null = null;
105
126
 
106
127
  if (!state.display_profile) {
107
128
  state.display_profile = this.config.display_profile;
108
129
  await this.saveState(state);
109
130
  }
110
131
 
111
- if (!templates.length) {
112
- const seeded = await this.seedDefaultTemplate();
113
- if (seeded) {
114
- const next = await this.templateStore.list();
115
- if (!state.active_template_id && next.length) {
116
- state.active_template_id = next[0].id;
117
- await this.saveState(state);
118
- }
132
+ if (!templates.length && this.config.auto_seed_template) {
133
+ autoSeededTemplateId = await this.seedTemplateFromLiveDashboard();
134
+ autoSeededSource = autoSeededTemplateId ? 'live_dashboard' : null;
135
+
136
+ if (!autoSeededTemplateId) {
137
+ autoSeededTemplateId = await this.seedStarterTemplate({
138
+ preferredId: 'starter',
139
+ name: 'Starter Dashboard',
140
+ description: 'A first-run dashboard that summarizes priorities, risks, and next actions.',
141
+ everyMinutes: 15
142
+ });
143
+ autoSeededSource = 'starter';
119
144
  }
120
145
  }
121
146
 
147
+ templates = await this.templateStore.list();
148
+ if (!state.active_template_id && templates.length) {
149
+ state.active_template_id = autoSeededTemplateId || templates[0].id;
150
+ await this.saveState(state);
151
+ }
152
+
122
153
  return {
123
154
  ok: true,
124
155
  errors: [],
125
156
  data: {
126
157
  data_dir: this.paths.dataDir,
127
158
  dashboard_output_path: this.paths.liveDashboardPath,
128
- scheduler_tick_seconds: this.config.scheduler_tick_seconds
159
+ scheduler_tick_seconds: this.config.scheduler_tick_seconds,
160
+ template_count: templates.length,
161
+ active_template_id: state.active_template_id,
162
+ auto_seeded_template_id: autoSeededTemplateId,
163
+ auto_seeded_source: autoSeededSource
129
164
  }
130
165
  };
131
166
  }
@@ -333,6 +368,88 @@ export class PlashboardRuntime {
333
368
  };
334
369
  }
335
370
 
371
+ async quickstart(params: {
372
+ description?: string;
373
+ template_id?: string;
374
+ template_name?: string;
375
+ every_minutes?: number;
376
+ activate?: boolean;
377
+ run_now?: boolean;
378
+ } = {}): Promise<ToolResponse<Record<string, unknown>>> {
379
+ const description = (params.description || '').trim()
380
+ || 'Create a high-signal operational dashboard with concise summaries and actionable updates.';
381
+ const everyMinutes = clampInt(
382
+ Number.isFinite(params.every_minutes) ? Number(params.every_minutes) : 15,
383
+ 1,
384
+ 24 * 60
385
+ );
386
+
387
+ const requestedId = (params.template_id || '').trim();
388
+ if (requestedId && !/^[a-z0-9][a-z0-9_-]{0,63}$/.test(requestedId)) {
389
+ return { ok: false, errors: ['template_id is invalid'] };
390
+ }
391
+
392
+ const resolvedTemplateId = requestedId
393
+ ? await this.allocateTemplateId(requestedId)
394
+ : await this.allocateTemplateId('quickstart');
395
+
396
+ const templateName = (params.template_name || '').trim() || 'Quickstart Dashboard';
397
+ const template = this.buildStarterTemplate({
398
+ id: resolvedTemplateId,
399
+ name: templateName,
400
+ description,
401
+ everyMinutes
402
+ });
403
+
404
+ const createResult = await this.templateCreate(template);
405
+ if (!createResult.ok) return createResult;
406
+
407
+ const errors: string[] = [];
408
+ let activeTemplateId: string | null = null;
409
+
410
+ const shouldActivate = params.activate !== false;
411
+ if (shouldActivate) {
412
+ const activateResult = await this.templateActivate(resolvedTemplateId);
413
+ if (!activateResult.ok) {
414
+ errors.push(...activateResult.errors.map((entry) => `activate: ${entry}`));
415
+ } else {
416
+ activeTemplateId = activateResult.data?.active_template_id || null;
417
+ }
418
+ } else {
419
+ const state = await this.loadState();
420
+ activeTemplateId = state.active_template_id;
421
+ }
422
+
423
+ let runStatus: string | null = null;
424
+ let published = false;
425
+ let runErrors: string[] = [];
426
+ const shouldRunNow = params.run_now !== false;
427
+ if (shouldRunNow) {
428
+ const runResult = await this.runNow(resolvedTemplateId);
429
+ runStatus = String(runResult.data?.status || '');
430
+ published = Boolean(runResult.data?.published);
431
+ runErrors = runResult.errors;
432
+ if (!runResult.ok) {
433
+ errors.push(...runResult.errors.map((entry) => `run: ${entry}`));
434
+ }
435
+ }
436
+
437
+ return {
438
+ ok: errors.length === 0,
439
+ errors,
440
+ data: {
441
+ template_id: resolvedTemplateId,
442
+ template_name: templateName,
443
+ schedule_every_minutes: everyMinutes,
444
+ active_template_id: activeTemplateId,
445
+ run_now: shouldRunNow,
446
+ run_status: runStatus,
447
+ run_published: published,
448
+ run_errors: runErrors
449
+ }
450
+ };
451
+ }
452
+
336
453
  async status(): Promise<ToolResponse<RuntimeStatus>> {
337
454
  const state = await this.loadState();
338
455
  const templates = await this.templateStore.list();
@@ -571,18 +688,19 @@ export class PlashboardRuntime {
571
688
  return state.display_profile || this.config.display_profile;
572
689
  }
573
690
 
574
- private async seedDefaultTemplate(): Promise<boolean> {
691
+ private async seedTemplateFromLiveDashboard(): Promise<string | null> {
575
692
  try {
576
693
  await access(this.paths.liveDashboardPath, fsConstants.R_OK);
577
694
  } catch {
578
- return false;
695
+ return null;
579
696
  }
580
697
 
581
698
  const text = await readFile(this.paths.liveDashboardPath, 'utf8');
582
699
  const dashboard = JSON.parse(text) as Record<string, unknown>;
700
+ const templateId = await this.allocateTemplateId('default');
583
701
 
584
702
  const template: DashboardTemplate = {
585
- id: 'default',
703
+ id: templateId,
586
704
  name: 'Default Dashboard Template',
587
705
  enabled: true,
588
706
  schedule: {
@@ -602,7 +720,217 @@ export class PlashboardRuntime {
602
720
  };
603
721
 
604
722
  await this.templateStore.upsert(template);
605
- return true;
723
+ return templateId;
724
+ }
725
+
726
+ private async seedStarterTemplate(params: {
727
+ preferredId: string;
728
+ name: string;
729
+ description: string;
730
+ everyMinutes: number;
731
+ }): Promise<string> {
732
+ const id = await this.allocateTemplateId(params.preferredId);
733
+ const template = this.buildStarterTemplate({
734
+ id,
735
+ name: params.name,
736
+ description: params.description,
737
+ everyMinutes: clampInt(params.everyMinutes, 1, 24 * 60)
738
+ });
739
+
740
+ const validationErrors = await this.validateTemplate(template);
741
+ if (validationErrors.length) {
742
+ throw new Error(`starter template validation failed: ${validationErrors.join('; ')}`);
743
+ }
744
+
745
+ await this.templateStore.upsert(template);
746
+ return id;
747
+ }
748
+
749
+ private async allocateTemplateId(preferredId: string): Promise<string> {
750
+ const normalized = normalizeTemplateId(preferredId) || 'starter';
751
+ const existing = await this.templateStore.get(normalized);
752
+ if (!existing) return normalized;
753
+
754
+ const base = normalized.slice(0, 58);
755
+ for (let i = 2; i <= 99; i += 1) {
756
+ const candidate = `${base}-${i}`;
757
+ const found = await this.templateStore.get(candidate);
758
+ if (!found) return candidate;
759
+ }
760
+ return `${base}-${Date.now()}`.slice(0, 64);
761
+ }
762
+
763
+ private buildStarterTemplate(params: {
764
+ id: string;
765
+ name: string;
766
+ description: string;
767
+ everyMinutes: number;
768
+ }): DashboardTemplate {
769
+ const description = params.description.trim();
770
+ return {
771
+ id: params.id,
772
+ name: params.name,
773
+ enabled: true,
774
+ schedule: {
775
+ mode: 'interval',
776
+ every_minutes: params.everyMinutes,
777
+ timezone: this.config.timezone
778
+ },
779
+ base_dashboard: {
780
+ title: params.name,
781
+ summary: 'Preparing summary…',
782
+ ui: {
783
+ timezone: this.config.timezone
784
+ },
785
+ sections: [
786
+ {
787
+ id: 'overview',
788
+ label: 'Overview',
789
+ cards: [
790
+ {
791
+ id: 'pulse',
792
+ title: 'System Pulse',
793
+ description: 'Waiting for first run…',
794
+ long_description: 'This card is filled by OpenClaw on each run.'
795
+ },
796
+ {
797
+ id: 'priorities',
798
+ title: 'Top Priorities',
799
+ description: 'Waiting for first run…',
800
+ long_description: 'This card highlights what matters most right now.'
801
+ }
802
+ ]
803
+ },
804
+ {
805
+ id: 'operations',
806
+ label: 'Operations',
807
+ cards: [
808
+ {
809
+ id: 'risks',
810
+ title: 'Risks to Watch',
811
+ description: 'Waiting for first run…'
812
+ },
813
+ {
814
+ id: 'blockers',
815
+ title: 'Potential Blockers',
816
+ description: 'Waiting for first run…'
817
+ }
818
+ ]
819
+ },
820
+ {
821
+ id: 'actions',
822
+ label: 'Actions',
823
+ cards: [
824
+ {
825
+ id: 'next_steps',
826
+ title: 'Next Actions',
827
+ description: 'Waiting for first run…'
828
+ },
829
+ {
830
+ id: 'questions',
831
+ title: 'Open Questions',
832
+ description: 'Waiting for first run…'
833
+ }
834
+ ]
835
+ }
836
+ ],
837
+ alerts: []
838
+ },
839
+ fields: [
840
+ {
841
+ id: 'summary',
842
+ pointer: '/summary',
843
+ type: 'string',
844
+ prompt: 'Write one concise dashboard summary (max 260 chars).',
845
+ required: true,
846
+ constraints: { max_len: 260 }
847
+ },
848
+ {
849
+ id: 'pulse',
850
+ pointer: '/sections/0/cards/0/description',
851
+ type: 'string',
852
+ prompt: 'Describe current pulse/status in one short paragraph.',
853
+ required: true,
854
+ constraints: { max_len: 280 }
855
+ },
856
+ {
857
+ id: 'pulse_details',
858
+ pointer: '/sections/0/cards/0/long_description',
859
+ type: 'string',
860
+ prompt: 'Provide richer context behind the pulse status with concrete facts.',
861
+ required: true,
862
+ constraints: { max_len: 900 }
863
+ },
864
+ {
865
+ id: 'priorities',
866
+ pointer: '/sections/0/cards/1/description',
867
+ type: 'string',
868
+ prompt: 'State the top priorities right now as a compact sentence.',
869
+ required: true,
870
+ constraints: { max_len: 280 }
871
+ },
872
+ {
873
+ id: 'priorities_details',
874
+ pointer: '/sections/0/cards/1/long_description',
875
+ type: 'string',
876
+ prompt: 'Explain why these priorities matter and what outcome is expected.',
877
+ required: true,
878
+ constraints: { max_len: 900 }
879
+ },
880
+ {
881
+ id: 'risks',
882
+ pointer: '/sections/1/cards/0/description',
883
+ type: 'string',
884
+ prompt: 'Describe the most relevant risks and their likely impact.',
885
+ required: true,
886
+ constraints: { max_len: 320 }
887
+ },
888
+ {
889
+ id: 'blockers',
890
+ pointer: '/sections/1/cards/1/description',
891
+ type: 'string',
892
+ prompt: 'List active blockers and the dependency needed to unblock.',
893
+ required: true,
894
+ constraints: { max_len: 320 }
895
+ },
896
+ {
897
+ id: 'next_steps',
898
+ pointer: '/sections/2/cards/0/description',
899
+ type: 'string',
900
+ prompt: 'Provide immediate next steps with owners/time hints if possible.',
901
+ required: true,
902
+ constraints: { max_len: 320 }
903
+ },
904
+ {
905
+ id: 'questions',
906
+ pointer: '/sections/2/cards/1/description',
907
+ type: 'string',
908
+ prompt: 'List open questions that need decisions soon.',
909
+ required: true,
910
+ constraints: { max_len: 320 }
911
+ }
912
+ ],
913
+ context: {
914
+ dashboard_prompt: `Dashboard objective: ${description}`,
915
+ section_prompts: {
916
+ overview: 'Summarize high-level status and priorities for quick scanning.',
917
+ operations: 'Highlight operational risks and blockers with practical impact.',
918
+ actions: 'Focus on actionable next steps and unresolved decision points.'
919
+ },
920
+ card_prompts: {
921
+ pulse: `Context: ${description}. Keep it factual and concise.`,
922
+ priorities: 'Surface the top priorities and expected outcomes.',
923
+ risks: 'Call out risks that deserve active monitoring.',
924
+ blockers: 'Describe blockers and what would resolve them.',
925
+ next_steps: 'Give concrete next actions.',
926
+ questions: 'Capture open questions requiring a decision.'
927
+ }
928
+ },
929
+ run: {
930
+ retry_count: this.config.default_retry_count,
931
+ repair_attempts: 1
932
+ }
933
+ };
606
934
  }
607
935
 
608
936
  private async writeRenderedSnapshot(templateId: string, payload: Record<string, unknown>): Promise<void> {
package/src/types.ts CHANGED
@@ -23,6 +23,7 @@ export interface PlashboardConfig {
23
23
  default_retry_count: number;
24
24
  retry_backoff_seconds: number;
25
25
  session_timeout_seconds: number;
26
+ auto_seed_template: boolean;
26
27
  fill_provider: 'command' | 'mock' | 'openclaw';
27
28
  fill_command?: string;
28
29
  openclaw_fill_agent_id?: string;