@tokentop/ttop 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +273 -0
  3. package/bin/ttop.js +49 -0
  4. package/package.json +68 -0
  5. package/src/agents/aggregator.ts +176 -0
  6. package/src/agents/costing.ts +74 -0
  7. package/src/agents/index.ts +12 -0
  8. package/src/agents/types.ts +71 -0
  9. package/src/cli.ts +167 -0
  10. package/src/config/schema.test.ts +166 -0
  11. package/src/config/schema.ts +165 -0
  12. package/src/demo/simulator.ts +745 -0
  13. package/src/plugins/agents/index.ts +2 -0
  14. package/src/plugins/auth-sources.ts +186 -0
  15. package/src/plugins/lifecycle.ts +177 -0
  16. package/src/plugins/loader.test.ts +195 -0
  17. package/src/plugins/loader.ts +282 -0
  18. package/src/plugins/notification-bus.ts +153 -0
  19. package/src/plugins/notifications/index.ts +2 -0
  20. package/src/plugins/notifications/terminal-bell.ts +66 -0
  21. package/src/plugins/notifications/visual-flash.ts +57 -0
  22. package/src/plugins/npm-installer.ts +164 -0
  23. package/src/plugins/plugin-context-factory.ts +67 -0
  24. package/src/plugins/plugin-host.ts +205 -0
  25. package/src/plugins/providers/anthropic.ts +325 -0
  26. package/src/plugins/providers/antigravity.ts +309 -0
  27. package/src/plugins/providers/codex.ts +250 -0
  28. package/src/plugins/providers/gemini.ts +391 -0
  29. package/src/plugins/providers/github-copilot.ts +194 -0
  30. package/src/plugins/providers/index.ts +12 -0
  31. package/src/plugins/providers/minimax.ts +223 -0
  32. package/src/plugins/providers/openai-api.ts +250 -0
  33. package/src/plugins/providers/opencode-zen.ts +125 -0
  34. package/src/plugins/providers/perplexity.ts +177 -0
  35. package/src/plugins/providers/zai.ts +225 -0
  36. package/src/plugins/registry.ts +251 -0
  37. package/src/plugins/sandbox-guard.test.ts +191 -0
  38. package/src/plugins/sandbox-guard.ts +155 -0
  39. package/src/plugins/sandbox.ts +138 -0
  40. package/src/plugins/themes/catppuccin-latte.ts +45 -0
  41. package/src/plugins/themes/catppuccin-mocha.ts +45 -0
  42. package/src/plugins/themes/claude-code.ts +45 -0
  43. package/src/plugins/themes/dracula.ts +45 -0
  44. package/src/plugins/themes/github-light.ts +45 -0
  45. package/src/plugins/themes/gruvbox-dark.ts +45 -0
  46. package/src/plugins/themes/gruvbox-light.ts +45 -0
  47. package/src/plugins/themes/index.ts +15 -0
  48. package/src/plugins/themes/kanagawa.ts +45 -0
  49. package/src/plugins/themes/nord.ts +45 -0
  50. package/src/plugins/themes/one-dark.ts +45 -0
  51. package/src/plugins/themes/opencode.ts +47 -0
  52. package/src/plugins/themes/rose-pine-dawn.ts +45 -0
  53. package/src/plugins/themes/rose-pine.ts +45 -0
  54. package/src/plugins/themes/solarized-light.ts +45 -0
  55. package/src/plugins/themes/tokyo-night.ts +49 -0
  56. package/src/plugins/types/agent.ts +108 -0
  57. package/src/plugins/types/base.ts +267 -0
  58. package/src/plugins/types/index.ts +19 -0
  59. package/src/plugins/types/notification.ts +51 -0
  60. package/src/plugins/types/provider.ts +233 -0
  61. package/src/plugins/types/theme.ts +70 -0
  62. package/src/plugins/update-checker.ts +177 -0
  63. package/src/pricing/estimator.test.ts +181 -0
  64. package/src/pricing/estimator.ts +80 -0
  65. package/src/pricing/fallback.test.ts +71 -0
  66. package/src/pricing/fallback.ts +62 -0
  67. package/src/pricing/index.ts +66 -0
  68. package/src/pricing/models-dev.ts +130 -0
  69. package/src/storage/database.ts +613 -0
  70. package/src/storage/db.ts +64 -0
  71. package/src/storage/index.ts +66 -0
  72. package/src/storage/migrations/index.ts +189 -0
  73. package/src/storage/paths.ts +24 -0
  74. package/src/storage/repos/agentSessions.ts +210 -0
  75. package/src/storage/repos/providerSnapshots.ts +116 -0
  76. package/src/storage/repos/usageEvents.ts +334 -0
  77. package/src/storage/types.ts +288 -0
  78. package/src/tui/App.tsx +381 -0
  79. package/src/tui/components/CommandPalette.tsx +166 -0
  80. package/src/tui/components/DebugConsole.tsx +161 -0
  81. package/src/tui/components/DebugPanel.tsx +660 -0
  82. package/src/tui/components/FooterHints.tsx +22 -0
  83. package/src/tui/components/GhostProviderCard.tsx +45 -0
  84. package/src/tui/components/Header.tsx +125 -0
  85. package/src/tui/components/HelpOverlay.tsx +45 -0
  86. package/src/tui/components/InlineGauge.tsx +34 -0
  87. package/src/tui/components/InlineSparkline.tsx +78 -0
  88. package/src/tui/components/KpiStrip.tsx +246 -0
  89. package/src/tui/components/LimitGauge.tsx +186 -0
  90. package/src/tui/components/ModalBackdrop.tsx +42 -0
  91. package/src/tui/components/ProviderAggregateStrip.tsx +200 -0
  92. package/src/tui/components/ProviderCard.tsx +309 -0
  93. package/src/tui/components/ProviderDetailPanel.tsx +237 -0
  94. package/src/tui/components/ProviderLimitsPanel.tsx +308 -0
  95. package/src/tui/components/ProvidersList.tsx +380 -0
  96. package/src/tui/components/SessionDetailsDrawer.tsx +317 -0
  97. package/src/tui/components/SessionsTable.tsx +401 -0
  98. package/src/tui/components/SettingsModal.tsx +587 -0
  99. package/src/tui/components/SidebarBreakdown.tsx +87 -0
  100. package/src/tui/components/Skeleton.tsx +102 -0
  101. package/src/tui/components/SmartSidebar.tsx +644 -0
  102. package/src/tui/components/Sparkline.tsx +180 -0
  103. package/src/tui/components/Spinner.tsx +36 -0
  104. package/src/tui/components/StatusBar.tsx +96 -0
  105. package/src/tui/components/ThemePicker.tsx +234 -0
  106. package/src/tui/components/Toast.tsx +61 -0
  107. package/src/tui/components/UsageGauge.tsx +80 -0
  108. package/src/tui/components/index.ts +5 -0
  109. package/src/tui/contexts/AgentSessionContext.tsx +307 -0
  110. package/src/tui/contexts/ConfigContext.tsx +141 -0
  111. package/src/tui/contexts/DashboardRuntimeContext.tsx +102 -0
  112. package/src/tui/contexts/DemoModeContext.tsx +60 -0
  113. package/src/tui/contexts/DrawerContext.tsx +42 -0
  114. package/src/tui/contexts/InputContext.tsx +30 -0
  115. package/src/tui/contexts/LogContext.tsx +150 -0
  116. package/src/tui/contexts/PluginContext.tsx +424 -0
  117. package/src/tui/contexts/RealTimeActivityContext.tsx +119 -0
  118. package/src/tui/contexts/StorageContext.tsx +233 -0
  119. package/src/tui/contexts/ThemeContext.tsx +95 -0
  120. package/src/tui/contexts/TimeWindowContext.tsx +158 -0
  121. package/src/tui/contexts/ToastContext.tsx +40 -0
  122. package/src/tui/contexts/index.ts +5 -0
  123. package/src/tui/createApp.tsx +47 -0
  124. package/src/tui/debug/captureFrame.ts +262 -0
  125. package/src/tui/debug/snapshot.tsx +644 -0
  126. package/src/tui/driver/assertions.ts +279 -0
  127. package/src/tui/driver/cli.ts +717 -0
  128. package/src/tui/driver/coverage.ts +204 -0
  129. package/src/tui/driver/demo-snapshot.ts +66 -0
  130. package/src/tui/driver/diff.ts +166 -0
  131. package/src/tui/driver/driver.ts +270 -0
  132. package/src/tui/driver/index.ts +53 -0
  133. package/src/tui/driver/recorder.ts +303 -0
  134. package/src/tui/driver/test-workflow.ts +115 -0
  135. package/src/tui/driver/test.ts +47 -0
  136. package/src/tui/hooks/index.ts +20 -0
  137. package/src/tui/hooks/useAnimatedValue.ts +113 -0
  138. package/src/tui/hooks/useDashboardKeyboard.ts +341 -0
  139. package/src/tui/hooks/useDashboardState.ts +116 -0
  140. package/src/tui/hooks/useEmaActivity.ts +223 -0
  141. package/src/tui/hooks/useEntranceAnimation.ts +62 -0
  142. package/src/tui/hooks/useExitAnimation.ts +137 -0
  143. package/src/tui/hooks/usePulse.ts +138 -0
  144. package/src/tui/hooks/useSafeRenderer.ts +24 -0
  145. package/src/tui/hooks/useValueFlash.ts +111 -0
  146. package/src/tui/index.tsx +50 -0
  147. package/src/tui/utils/providerColor.ts +32 -0
  148. package/src/tui/views/Dashboard.tsx +340 -0
  149. package/src/tui/views/HistoricalTrendsView.tsx +1431 -0
  150. package/src/tui/views/ProjectsView.tsx +1277 -0
  151. package/src/tui/views/RealTimeDashboard.tsx +393 -0
  152. package/src/tui/views/SettingsView.tsx +465 -0
  153. package/src/tui/views/index.ts +2 -0
  154. package/src/types/global.d.ts +5 -0
  155. package/src/types/opentui-test-utils.d.ts +18 -0
  156. package/src/utils/clipboard.ts +118 -0
  157. package/src/utils/currency.test.ts +106 -0
  158. package/src/utils/currency.ts +79 -0
  159. package/src/version.ts +1 -0
@@ -0,0 +1,2 @@
1
+ export { default as opencodeAgentPlugin } from '@tokentop/agent-opencode';
2
+ export { default as claudeCodeAgentPlugin } from '@tokentop/agent-claude-code';
@@ -0,0 +1,186 @@
1
+ import * as fs from 'fs/promises';
2
+ import * as path from 'path';
3
+ import * as os from 'os';
4
+ import type { PluginPermissions } from './types/base.ts';
5
+ import type { AuthSources, OpenCodeAuthEntry } from './types/provider.ts';
6
+ import { PluginPermissionError } from './types/base.ts';
7
+
8
+ const OPENCODE_AUTH_PATH = path.join(os.homedir(), '.local/share/opencode/auth.json');
9
+ const OPENCODE_CONFIG_PATH = path.join(os.homedir(), '.config/opencode/opencode.json');
10
+
11
+ function expandHome(filePath: string): string {
12
+ if (filePath.startsWith('~')) {
13
+ return path.join(os.homedir(), filePath.slice(1));
14
+ }
15
+ return filePath;
16
+ }
17
+
18
+ function isPathAllowed(filePath: string, permissions: PluginPermissions): boolean {
19
+ const allowedPaths = permissions.filesystem?.paths;
20
+ if (!allowedPaths || allowedPaths.length === 0) return true;
21
+
22
+ const normalized = expandHome(filePath);
23
+ return allowedPaths.some((allowed: string) => {
24
+ const normalizedAllowed = expandHome(allowed);
25
+ return normalized.startsWith(normalizedAllowed);
26
+ });
27
+ }
28
+
29
+ function isEnvVarAllowed(name: string, permissions: PluginPermissions): boolean {
30
+ if (!permissions.env?.read) return false;
31
+ const allowedVars = permissions.env.vars;
32
+ if (!allowedVars || allowedVars.length === 0) return true;
33
+ return allowedVars.includes(name);
34
+ }
35
+
36
+ async function readJsonFile<T>(filePath: string): Promise<T | null> {
37
+ try {
38
+ const content = await fs.readFile(filePath, 'utf-8');
39
+ return JSON.parse(content) as T;
40
+ } catch {
41
+ return null;
42
+ }
43
+ }
44
+
45
+ interface OpenCodeConfig {
46
+ provider?: Record<string, {
47
+ name?: string;
48
+ key?: string;
49
+ options?: {
50
+ apiKey?: string;
51
+ [key: string]: unknown;
52
+ };
53
+ }>;
54
+ }
55
+
56
+ function resolveEnvValue(value: string | undefined): string | undefined {
57
+ if (!value) return undefined;
58
+ const envMatch = value.match(/^\{env:(\w+)\}$/);
59
+ if (envMatch?.[1]) {
60
+ return process.env[envMatch[1]];
61
+ }
62
+ return value;
63
+ }
64
+
65
+ export function createAuthSources(pluginId: string, permissions: PluginPermissions): AuthSources {
66
+ return {
67
+ env: {
68
+ get(name: string): string | undefined {
69
+ if (!isEnvVarAllowed(name, permissions)) {
70
+ throw new PluginPermissionError(
71
+ pluginId,
72
+ 'env',
73
+ `Environment variable "${name}" not in allowlist`
74
+ );
75
+ }
76
+ return process.env[name];
77
+ },
78
+ },
79
+
80
+ files: {
81
+ async readText(filePath: string): Promise<string | null> {
82
+ const resolved = expandHome(filePath);
83
+ if (!permissions.filesystem?.read) {
84
+ throw new PluginPermissionError(
85
+ pluginId,
86
+ 'filesystem',
87
+ 'Filesystem read access not permitted'
88
+ );
89
+ }
90
+ if (!isPathAllowed(filePath, permissions)) {
91
+ throw new PluginPermissionError(
92
+ pluginId,
93
+ 'filesystem',
94
+ `Path "${filePath}" not in allowlist`
95
+ );
96
+ }
97
+ try {
98
+ return await fs.readFile(resolved, 'utf-8');
99
+ } catch {
100
+ return null;
101
+ }
102
+ },
103
+
104
+ async readJson<T = unknown>(filePath: string): Promise<T | null> {
105
+ const resolved = expandHome(filePath);
106
+ if (!permissions.filesystem?.read) {
107
+ throw new PluginPermissionError(
108
+ pluginId,
109
+ 'filesystem',
110
+ 'Filesystem read access not permitted'
111
+ );
112
+ }
113
+ if (!isPathAllowed(filePath, permissions)) {
114
+ throw new PluginPermissionError(
115
+ pluginId,
116
+ 'filesystem',
117
+ `Path "${filePath}" not in allowlist`
118
+ );
119
+ }
120
+ try {
121
+ const content = await fs.readFile(resolved, 'utf-8');
122
+ return JSON.parse(content) as T;
123
+ } catch {
124
+ return null;
125
+ }
126
+ },
127
+
128
+ async exists(filePath: string): Promise<boolean> {
129
+ const resolved = expandHome(filePath);
130
+ if (!permissions.filesystem?.read) {
131
+ throw new PluginPermissionError(
132
+ pluginId,
133
+ 'filesystem',
134
+ 'Filesystem read access not permitted'
135
+ );
136
+ }
137
+ if (!isPathAllowed(filePath, permissions)) {
138
+ throw new PluginPermissionError(
139
+ pluginId,
140
+ 'filesystem',
141
+ `Path "${filePath}" not in allowlist`
142
+ );
143
+ }
144
+ try {
145
+ await fs.access(resolved);
146
+ return true;
147
+ } catch {
148
+ return false;
149
+ }
150
+ },
151
+ },
152
+
153
+ opencode: {
154
+ async getProviderEntry(key: string): Promise<OpenCodeAuthEntry | null> {
155
+ const authData = await readJsonFile<Record<string, OpenCodeAuthEntry>>(OPENCODE_AUTH_PATH);
156
+ if (authData) {
157
+ const entry = authData[key];
158
+ if (entry) return entry;
159
+ }
160
+
161
+ const config = await readJsonFile<OpenCodeConfig>(OPENCODE_CONFIG_PATH);
162
+ if (config?.provider) {
163
+ const providerConfig = config.provider[key];
164
+ if (providerConfig) {
165
+ const apiKey = resolveEnvValue(providerConfig.key) ??
166
+ resolveEnvValue(providerConfig.options?.apiKey as string);
167
+ if (apiKey) {
168
+ return {
169
+ type: 'api',
170
+ key: apiKey,
171
+ };
172
+ }
173
+ }
174
+ }
175
+
176
+ return null;
177
+ },
178
+ },
179
+
180
+ platform: {
181
+ os: process.platform as 'darwin' | 'linux' | 'win32',
182
+ homedir: os.homedir(),
183
+ arch: process.arch,
184
+ },
185
+ };
186
+ }
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Plugin lifecycle manager — orchestrates initialize/start/stop/destroy hooks.
3
+ *
4
+ * Maintains per-plugin state and ensures hooks are called in order, wrapped
5
+ * with safeInvoke for error isolation and circuit breaker protection.
6
+ */
7
+
8
+ import type { BasePlugin, PluginLifecycleContext, PluginLogger } from './types/base.ts';
9
+ import { pluginRegistry } from './registry.ts';
10
+ import { safeInvoke } from './plugin-host.ts';
11
+ import { createPluginLogger } from './sandbox.ts';
12
+ import { runInPluginGuard } from './sandbox-guard.ts';
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // State machine
16
+ // ---------------------------------------------------------------------------
17
+
18
+ export type PluginLifecycleState =
19
+ | 'loaded'
20
+ | 'initialized'
21
+ | 'started'
22
+ | 'stopped'
23
+ | 'destroyed'
24
+ | 'failed';
25
+
26
+ interface PluginLifecycleEntry {
27
+ pluginId: string;
28
+ state: PluginLifecycleState;
29
+ config: Record<string, unknown>;
30
+ logger: PluginLogger;
31
+ }
32
+
33
+ const HOOK_TIMEOUT_MS = 5_000;
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Manager
37
+ // ---------------------------------------------------------------------------
38
+
39
+ class PluginLifecycleManager {
40
+ private entries = new Map<string, PluginLifecycleEntry>();
41
+
42
+ private getOrCreate(plugin: BasePlugin): PluginLifecycleEntry {
43
+ let entry = this.entries.get(plugin.id);
44
+ if (!entry) {
45
+ entry = {
46
+ pluginId: plugin.id,
47
+ state: 'loaded',
48
+ config: { ...(plugin.defaultConfig ?? {}) },
49
+ logger: createPluginLogger(plugin.id),
50
+ };
51
+ this.entries.set(plugin.id, entry);
52
+ }
53
+ return entry;
54
+ }
55
+
56
+ private makeCtx(entry: PluginLifecycleEntry): PluginLifecycleContext {
57
+ return { config: entry.config, logger: entry.logger };
58
+ }
59
+
60
+ private async invokeHook(
61
+ plugin: BasePlugin,
62
+ hook: 'initialize' | 'start' | 'stop' | 'destroy',
63
+ ): Promise<boolean> {
64
+ const fn = plugin[hook];
65
+ if (!fn) return true;
66
+
67
+ const entry = this.getOrCreate(plugin);
68
+ const ctx = this.makeCtx(entry);
69
+
70
+ const result = await safeInvoke(plugin.id, hook, () =>
71
+ runInPluginGuard(plugin.id, plugin.permissions, () =>
72
+ Promise.race([
73
+ fn.call(plugin, ctx),
74
+ new Promise<never>((_, reject) =>
75
+ setTimeout(() => reject(new Error(`${hook} timed out after ${HOOK_TIMEOUT_MS}ms`)), HOOK_TIMEOUT_MS),
76
+ ),
77
+ ]),
78
+ ),
79
+ );
80
+
81
+ if (!result.ok) {
82
+ entry.logger.error(`Lifecycle hook "${hook}" failed: ${result.error.message}`);
83
+ return false;
84
+ }
85
+ return true;
86
+ }
87
+
88
+ async initializeAll(): Promise<void> {
89
+ const plugins = pluginRegistry.getAllPlugins();
90
+
91
+ for (const plugin of plugins) {
92
+ const entry = this.getOrCreate(plugin);
93
+ if (entry.state !== 'loaded') continue;
94
+
95
+ const ok = await this.invokeHook(plugin, 'initialize');
96
+ entry.state = ok ? 'initialized' : 'failed';
97
+ }
98
+ }
99
+
100
+ async startAll(): Promise<void> {
101
+ const plugins = pluginRegistry.getAllPlugins();
102
+
103
+ for (const plugin of plugins) {
104
+ const entry = this.getOrCreate(plugin);
105
+ if (entry.state !== 'initialized') continue;
106
+
107
+ const ok = await this.invokeHook(plugin, 'start');
108
+ entry.state = ok ? 'started' : 'failed';
109
+ }
110
+ }
111
+
112
+ async stopAll(): Promise<void> {
113
+ const plugins = [...pluginRegistry.getAllPlugins()].reverse();
114
+
115
+ for (const plugin of plugins) {
116
+ const entry = this.entries.get(plugin.id);
117
+ if (!entry || entry.state !== 'started') continue;
118
+
119
+ const ok = await this.invokeHook(plugin, 'stop');
120
+ entry.state = ok ? 'stopped' : 'failed';
121
+ }
122
+ }
123
+
124
+ async destroyAll(): Promise<void> {
125
+ const plugins = [...pluginRegistry.getAllPlugins()].reverse();
126
+
127
+ for (const plugin of plugins) {
128
+ const entry = this.entries.get(plugin.id);
129
+ if (!entry) continue;
130
+ if (entry.state === 'destroyed') continue;
131
+
132
+ await this.invokeHook(plugin, 'destroy');
133
+ entry.state = 'destroyed';
134
+ }
135
+ }
136
+
137
+ async notifyConfigChange(
138
+ pluginId: string,
139
+ newConfig: Record<string, unknown>,
140
+ ): Promise<void> {
141
+ const plugin = pluginRegistry.getAllPlugins().find((p) => p.id === pluginId);
142
+ if (!plugin?.onConfigChange) return;
143
+
144
+ const entry = this.entries.get(pluginId);
145
+ if (!entry || entry.state === 'destroyed' || entry.state === 'failed') return;
146
+
147
+ entry.config = { ...newConfig };
148
+
149
+ const ctx = this.makeCtx(entry);
150
+ await safeInvoke(pluginId, 'onConfigChange', () =>
151
+ runInPluginGuard(pluginId, plugin.permissions, () =>
152
+ Promise.resolve(plugin.onConfigChange!(newConfig, ctx)),
153
+ ),
154
+ );
155
+ }
156
+
157
+ getState(pluginId: string): PluginLifecycleState | undefined {
158
+ return this.entries.get(pluginId)?.state;
159
+ }
160
+
161
+ /**
162
+ * Update the stored config for a plugin (e.g. from user settings).
163
+ * Does NOT fire onConfigChange — call notifyConfigChange for that.
164
+ */
165
+ setPluginConfig(pluginId: string, config: Record<string, unknown>): void {
166
+ const entry = this.entries.get(pluginId);
167
+ if (entry) {
168
+ entry.config = { ...config };
169
+ }
170
+ }
171
+
172
+ reset(): void {
173
+ this.entries.clear();
174
+ }
175
+ }
176
+
177
+ export const pluginLifecycle = new PluginLifecycleManager();
@@ -0,0 +1,195 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { validatePlugin } from './loader.ts';
3
+ import { CURRENT_API_VERSION } from './types/base.ts';
4
+
5
+ const validPlugin = {
6
+ id: 'test-plugin',
7
+ type: 'notification',
8
+ name: 'Test Plugin',
9
+ version: '1.0.0',
10
+ apiVersion: CURRENT_API_VERSION,
11
+ permissions: {},
12
+ initialize: async () => {},
13
+ notify: async () => {},
14
+ };
15
+
16
+ function hasError(errors: string[], fragment: string): boolean {
17
+ return errors.some((error) => error.includes(fragment));
18
+ }
19
+
20
+ describe('validatePlugin', () => {
21
+ test('accepts a valid minimal plugin object', async () => {
22
+ const result = await validatePlugin(validPlugin);
23
+
24
+ expect(result.valid).toBe(true);
25
+ expect(result.errors).toHaveLength(0);
26
+ });
27
+
28
+ test('rejects plugin missing id', async () => {
29
+ const plugin = {
30
+ ...validPlugin,
31
+ id: '',
32
+ };
33
+
34
+ const result = await validatePlugin(plugin);
35
+ expect(result.valid).toBe(false);
36
+ expect(hasError(result.errors, 'non-empty string "id"')).toBe(true);
37
+ });
38
+
39
+ test('rejects plugin with invalid id format', async () => {
40
+ const plugin = {
41
+ ...validPlugin,
42
+ id: 'Bad Plugin',
43
+ };
44
+
45
+ const result = await validatePlugin(plugin);
46
+ expect(result.valid).toBe(false);
47
+ expect(hasError(result.errors, 'kebab-case')).toBe(true);
48
+ });
49
+
50
+ test('rejects plugin missing type', async () => {
51
+ const plugin = {
52
+ id: 'test-plugin',
53
+ name: 'Test Plugin',
54
+ version: '1.0.0',
55
+ apiVersion: CURRENT_API_VERSION,
56
+ permissions: {},
57
+ };
58
+
59
+ const result = await validatePlugin(plugin);
60
+ expect(result.valid).toBe(false);
61
+ expect(hasError(result.errors, 'must be one of')).toBe(true);
62
+ });
63
+
64
+ test('rejects plugin with invalid type', async () => {
65
+ const plugin = {
66
+ ...validPlugin,
67
+ type: 'invalid',
68
+ };
69
+
70
+ const result = await validatePlugin(plugin);
71
+ expect(result.valid).toBe(false);
72
+ expect(hasError(result.errors, 'provider, agent, theme, notification')).toBe(true);
73
+ });
74
+
75
+ test('rejects plugin missing name', async () => {
76
+ const plugin = {
77
+ ...validPlugin,
78
+ name: '',
79
+ };
80
+
81
+ const result = await validatePlugin(plugin);
82
+ expect(result.valid).toBe(false);
83
+ expect(hasError(result.errors, 'non-empty string "name"')).toBe(true);
84
+ });
85
+
86
+ test('rejects plugin missing version', async () => {
87
+ const plugin = {
88
+ ...validPlugin,
89
+ version: '',
90
+ };
91
+
92
+ const result = await validatePlugin(plugin);
93
+ expect(result.valid).toBe(false);
94
+ expect(hasError(result.errors, 'valid semver "version"')).toBe(true);
95
+ });
96
+
97
+ test('rejects plugin with invalid version format', async () => {
98
+ const plugin = {
99
+ ...validPlugin,
100
+ version: 'v1',
101
+ };
102
+
103
+ const result = await validatePlugin(plugin);
104
+ expect(result.valid).toBe(false);
105
+ expect(hasError(result.errors, 'valid semver "version"')).toBe(true);
106
+ });
107
+
108
+ test('rejects plugin missing permissions', async () => {
109
+ const plugin = {
110
+ id: 'test-plugin',
111
+ type: 'notification',
112
+ name: 'Test Plugin',
113
+ version: '1.0.0',
114
+ apiVersion: CURRENT_API_VERSION,
115
+ };
116
+
117
+ const result = await validatePlugin(plugin);
118
+ expect(result.valid).toBe(false);
119
+ expect(hasError(result.errors, 'declare "permissions" object')).toBe(true);
120
+ });
121
+
122
+ test('rejects plugin with wrong apiVersion', async () => {
123
+ const plugin = {
124
+ ...validPlugin,
125
+ apiVersion: CURRENT_API_VERSION + 1,
126
+ };
127
+
128
+ const result = await validatePlugin(plugin);
129
+ expect(result.valid).toBe(false);
130
+ expect(hasError(result.errors, `expected ${CURRENT_API_VERSION}`)).toBe(true);
131
+ });
132
+
133
+ test('rejects provider plugin missing auth', async () => {
134
+ const providerPlugin = {
135
+ ...validPlugin,
136
+ type: 'provider',
137
+ fetchUsage: async () => [],
138
+ };
139
+
140
+ const result = await validatePlugin(providerPlugin);
141
+ expect(result.valid).toBe(false);
142
+ expect(hasError(result.errors, 'declare "auth" object')).toBe(true);
143
+ });
144
+
145
+ test('accepts valid provider plugin with auth and fetchUsage', async () => {
146
+ const providerPlugin = {
147
+ ...validPlugin,
148
+ type: 'provider',
149
+ auth: {
150
+ discover: async () => undefined,
151
+ isConfigured: () => true,
152
+ },
153
+ fetchUsage: async () => [],
154
+ };
155
+
156
+ const result = await validatePlugin(providerPlugin);
157
+ expect(result.valid).toBe(true);
158
+ });
159
+
160
+ test('rejects theme plugin missing colors object', async () => {
161
+ const themePlugin = {
162
+ ...validPlugin,
163
+ type: 'theme',
164
+ colorScheme: 'dark',
165
+ };
166
+
167
+ const result = await validatePlugin(themePlugin);
168
+ expect(result.valid).toBe(false);
169
+ expect(hasError(result.errors, '"colors" object')).toBe(true);
170
+ });
171
+
172
+ test('rejects theme plugin missing colorScheme', async () => {
173
+ const themePlugin = {
174
+ ...validPlugin,
175
+ type: 'theme',
176
+ colors: { primary: '#fff' },
177
+ };
178
+
179
+ const result = await validatePlugin(themePlugin);
180
+ expect(result.valid).toBe(false);
181
+ expect(hasError(result.errors, 'colorScheme')).toBe(true);
182
+ });
183
+
184
+ test('accepts valid theme plugin', async () => {
185
+ const themePlugin = {
186
+ ...validPlugin,
187
+ type: 'theme',
188
+ colorScheme: 'dark',
189
+ colors: { primary: '#fff' },
190
+ };
191
+
192
+ const result = await validatePlugin(themePlugin);
193
+ expect(result.valid).toBe(true);
194
+ });
195
+ });