@majkapp/plugin-kit 3.2.1 → 3.3.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,623 @@
1
+ # Functions
2
+
3
+ Functions are the core of your plugin. They define callable operations with strongly-typed inputs and outputs.
4
+
5
+ ## Basic Function
6
+
7
+ ```typescript
8
+ // src/index.ts
9
+ import { definePlugin } from '@majkapp/plugin-kit';
10
+
11
+ const plugin = definePlugin('my-plugin', 'My Plugin', '1.0.0')
12
+ .pluginRoot(__dirname)
13
+
14
+ .function('health', {
15
+ // REQUIRED: 2-3 sentence description for LLM context
16
+ description: 'Check plugin health status. Returns current status and timestamp.',
17
+
18
+ // REQUIRED: JSON Schema for input validation
19
+ input: {
20
+ type: 'object',
21
+ properties: {},
22
+ additionalProperties: false // Reject unknown properties
23
+ },
24
+
25
+ // REQUIRED: JSON Schema for output validation
26
+ output: {
27
+ type: 'object',
28
+ properties: {
29
+ status: { type: 'string', enum: ['ok', 'error'] },
30
+ timestamp: { type: 'string', format: 'date-time' }
31
+ },
32
+ required: ['status', 'timestamp']
33
+ },
34
+
35
+ // REQUIRED: Handler function
36
+ handler: async (input, ctx) => {
37
+ // ctx.logger - scoped logging (automatically includes plugin name)
38
+ ctx.logger.info('Health check requested');
39
+
40
+ // Return MUST match output schema or validation fails
41
+ return {
42
+ status: 'ok',
43
+ timestamp: new Date().toISOString()
44
+ };
45
+ },
46
+
47
+ // OPTIONAL: Tags for organization and discovery
48
+ tags: ['monitoring', 'health']
49
+ });
50
+ ```
51
+
52
+ Test it:
53
+
54
+ ```typescript
55
+ // tests/plugin/functions/unit/health.test.js
56
+ import { test, invoke, mock } from '@majkapp/plugin-test';
57
+ import assert from 'assert';
58
+
59
+ test('health check returns ok status', async () => {
60
+ const context = mock().build();
61
+ const result = await invoke('health', {}, { context });
62
+
63
+ assert.strictEqual(result.status, 'ok');
64
+ assert.ok(result.timestamp);
65
+ });
66
+ ```
67
+
68
+ ## Function with Input Parameters
69
+
70
+ ```typescript
71
+ // src/index.ts
72
+ .function('getAnalytics', {
73
+ description: 'Get analytics for specified time period. Returns user activity metrics.',
74
+
75
+ input: {
76
+ type: 'object',
77
+ properties: {
78
+ period: {
79
+ type: 'string',
80
+ enum: ['24h', '7d', '30d'],
81
+ description: 'Time period for analytics'
82
+ },
83
+ includeDetails: {
84
+ type: 'boolean',
85
+ default: false,
86
+ description: 'Include detailed breakdown'
87
+ }
88
+ },
89
+ required: ['period'], // period is required, includeDetails is optional
90
+ additionalProperties: false
91
+ },
92
+
93
+ output: {
94
+ type: 'object',
95
+ properties: {
96
+ period: { type: 'string' },
97
+ metrics: {
98
+ type: 'object',
99
+ properties: {
100
+ activeUsers: { type: 'number' },
101
+ totalSessions: { type: 'number' },
102
+ avgDuration: { type: 'number' }
103
+ },
104
+ required: ['activeUsers', 'totalSessions', 'avgDuration']
105
+ },
106
+ details: {
107
+ type: 'array',
108
+ items: {
109
+ type: 'object',
110
+ properties: {
111
+ date: { type: 'string' },
112
+ count: { type: 'number' }
113
+ }
114
+ }
115
+ }
116
+ },
117
+ required: ['period', 'metrics']
118
+ },
119
+
120
+ handler: async (input, ctx) => {
121
+ ctx.logger.info(`Analytics requested for ${input.period}`);
122
+
123
+ // Access plugin storage (key-value store scoped to your plugin)
124
+ const sessions = await ctx.storage.get('sessions') || [];
125
+
126
+ // Calculate metrics (best practice: move to separate file)
127
+ const now = Date.now();
128
+ const periodMs = input.period === '24h' ? 86400000 :
129
+ input.period === '7d' ? 604800000 : 2592000000;
130
+
131
+ const recentSessions = sessions.filter(s =>
132
+ now - s.timestamp < periodMs
133
+ );
134
+
135
+ const uniqueUsers = new Set(recentSessions.map(s => s.userId)).size;
136
+
137
+ const result = {
138
+ period: input.period,
139
+ metrics: {
140
+ activeUsers: uniqueUsers,
141
+ totalSessions: recentSessions.length,
142
+ avgDuration: recentSessions.length > 0
143
+ ? Math.round(recentSessions.reduce((sum, s) => sum + s.duration, 0) / recentSessions.length)
144
+ : 0
145
+ }
146
+ };
147
+
148
+ // Conditionally include details based on input
149
+ if (input.includeDetails) {
150
+ result.details = calculateDailyBreakdown(recentSessions);
151
+ }
152
+
153
+ return result;
154
+ },
155
+
156
+ tags: ['analytics', 'reporting']
157
+ });
158
+ ```
159
+
160
+ Test with different inputs:
161
+
162
+ ```typescript
163
+ // tests/plugin/functions/unit/analytics.test.js
164
+ import { test, invoke, mock } from '@majkapp/plugin-test';
165
+ import assert from 'assert';
166
+
167
+ test('getAnalytics returns metrics for 7d period', async () => {
168
+ const context = mock()
169
+ .storage({
170
+ sessions: [
171
+ { userId: 'user1', duration: 300, timestamp: Date.now() - 86400000 }, // 1 day ago
172
+ { userId: 'user2', duration: 450, timestamp: Date.now() - 172800000 }, // 2 days ago
173
+ { userId: 'user1', duration: 600, timestamp: Date.now() - 259200000 } // 3 days ago
174
+ ]
175
+ })
176
+ .build();
177
+
178
+ const result = await invoke('getAnalytics', { period: '7d' }, { context });
179
+
180
+ assert.strictEqual(result.period, '7d');
181
+ assert.strictEqual(result.metrics.activeUsers, 2); // user1 and user2
182
+ assert.strictEqual(result.metrics.totalSessions, 3);
183
+ assert.strictEqual(result.metrics.avgDuration, 450); // (300+450+600)/3
184
+ });
185
+
186
+ test('getAnalytics includes details when requested', async () => {
187
+ const context = mock()
188
+ .storage({ sessions: [] })
189
+ .build();
190
+
191
+ const result = await invoke('getAnalytics', {
192
+ period: '24h',
193
+ includeDetails: true
194
+ }, { context });
195
+
196
+ assert.ok(result.details);
197
+ assert.ok(Array.isArray(result.details));
198
+ });
199
+ ```
200
+
201
+ ## Best Practice: Decouple Business Logic
202
+
203
+ **ALWAYS separate core logic from plugin code.** Makes testing easier and logic reusable.
204
+
205
+ ```typescript
206
+ // src/core/analytics.ts - Pure business logic, NO plugin dependencies
207
+ export interface Session {
208
+ userId: string;
209
+ duration: number;
210
+ timestamp: number;
211
+ }
212
+
213
+ export interface AnalyticsResult {
214
+ period: string;
215
+ metrics: {
216
+ activeUsers: number;
217
+ totalSessions: number;
218
+ avgDuration: number;
219
+ };
220
+ }
221
+
222
+ // Pure function - testable with plain Jest/Vitest
223
+ export function calculateAnalytics(
224
+ sessions: Session[],
225
+ period: '24h' | '7d' | '30d'
226
+ ): AnalyticsResult {
227
+ const now = Date.now();
228
+ const periodMs = {
229
+ '24h': 86400000,
230
+ '7d': 604800000,
231
+ '30d': 2592000000
232
+ }[period];
233
+
234
+ const recentSessions = sessions.filter(s =>
235
+ now - s.timestamp < periodMs
236
+ );
237
+
238
+ const uniqueUsers = new Set(recentSessions.map(s => s.userId)).size;
239
+ const totalDuration = recentSessions.reduce((sum, s) => sum + s.duration, 0);
240
+
241
+ return {
242
+ period,
243
+ metrics: {
244
+ activeUsers: uniqueUsers,
245
+ totalSessions: recentSessions.length,
246
+ avgDuration: recentSessions.length > 0
247
+ ? Math.round(totalDuration / recentSessions.length)
248
+ : 0
249
+ }
250
+ };
251
+ }
252
+ ```
253
+
254
+ ```typescript
255
+ // tests/core/analytics.test.js - Test with plain Jest (faster, no plugin overhead)
256
+ import { calculateAnalytics } from '../src/core/analytics';
257
+ import { describe, it, expect } from 'vitest';
258
+
259
+ describe('calculateAnalytics', () => {
260
+ it('calculates metrics for 7d period', () => {
261
+ const sessions = [
262
+ { userId: 'user1', duration: 300, timestamp: Date.now() - 86400000 },
263
+ { userId: 'user2', duration: 450, timestamp: Date.now() - 172800000 },
264
+ { userId: 'user1', duration: 600, timestamp: Date.now() - 259200000 }
265
+ ];
266
+
267
+ const result = calculateAnalytics(sessions, '7d');
268
+
269
+ expect(result.metrics.activeUsers).toBe(2);
270
+ expect(result.metrics.totalSessions).toBe(3);
271
+ expect(result.metrics.avgDuration).toBe(450);
272
+ });
273
+
274
+ it('filters out old sessions', () => {
275
+ const sessions = [
276
+ { userId: 'user1', duration: 300, timestamp: Date.now() - 86400000 }, // 1 day ago - included
277
+ { userId: 'user2', duration: 450, timestamp: Date.now() - 172800000 }, // 2 days ago - excluded
278
+ ];
279
+
280
+ const result = calculateAnalytics(sessions, '24h');
281
+
282
+ expect(result.metrics.activeUsers).toBe(1);
283
+ expect(result.metrics.totalSessions).toBe(1);
284
+ });
285
+ });
286
+ ```
287
+
288
+ ```typescript
289
+ // src/index.ts - Plugin function is thin wrapper
290
+ import { calculateAnalytics } from './core/analytics';
291
+
292
+ const plugin = definePlugin('my-plugin', 'My Plugin', '1.0.0')
293
+ .pluginRoot(__dirname)
294
+
295
+ .function('getAnalytics', {
296
+ description: 'Get analytics for specified time period',
297
+ input: { /* schema */ },
298
+ output: { /* schema */ },
299
+ handler: async (input, ctx) => {
300
+ // Plugin layer: Get data from storage
301
+ const sessions = await ctx.storage.get('sessions') || [];
302
+
303
+ // Business logic: Pure computation (easy to test)
304
+ const result = calculateAnalytics(sessions, input.period);
305
+
306
+ // Plugin layer: Log and return
307
+ ctx.logger.info(`Analytics calculated for ${input.period}`, result);
308
+ return result;
309
+ }
310
+ });
311
+ ```
312
+
313
+ ## Accessing MAJK APIs
314
+
315
+ Use `ctx.majk` to access MAJK's conversation, todo, project, and teammate APIs.
316
+
317
+ ```typescript
318
+ .function('getConversationSummary', {
319
+ description: 'Get summary of a specific conversation with recent messages',
320
+
321
+ input: {
322
+ type: 'object',
323
+ properties: {
324
+ conversationId: { type: 'string' }
325
+ },
326
+ required: ['conversationId'],
327
+ additionalProperties: false
328
+ },
329
+
330
+ output: {
331
+ type: 'object',
332
+ properties: {
333
+ id: { type: 'string' },
334
+ title: { type: 'string' },
335
+ messageCount: { type: 'number' },
336
+ lastActivity: { type: 'string' }
337
+ },
338
+ required: ['id', 'title', 'messageCount']
339
+ },
340
+
341
+ handler: async (input, ctx) => {
342
+ // Access MAJK conversation API
343
+ const conversation = await ctx.majk.conversations.get(input.conversationId);
344
+
345
+ if (!conversation) {
346
+ ctx.logger.warn(`Conversation not found: ${input.conversationId}`);
347
+ throw new Error(`Conversation ${input.conversationId} not found`);
348
+ }
349
+
350
+ ctx.logger.info(`Retrieved conversation: ${conversation.title}`);
351
+
352
+ return {
353
+ id: conversation.id,
354
+ title: conversation.title || 'Untitled',
355
+ messageCount: conversation.messages?.length || 0,
356
+ lastActivity: conversation.updatedAt || conversation.createdAt
357
+ };
358
+ },
359
+
360
+ tags: ['conversations']
361
+ });
362
+ ```
363
+
364
+ Available `ctx.majk` APIs:
365
+
366
+ ```typescript
367
+ // Conversations
368
+ await ctx.majk.conversations.list();
369
+ await ctx.majk.conversations.get(id);
370
+
371
+ // Todos
372
+ await ctx.majk.todos.list();
373
+ await ctx.majk.todos.get(id);
374
+
375
+ // Projects
376
+ await ctx.majk.projects.list();
377
+ await ctx.majk.projects.get(id);
378
+
379
+ // Teammates
380
+ await ctx.majk.teammates.list();
381
+ await ctx.majk.teammates.get(id);
382
+
383
+ // MCP Servers
384
+ await ctx.majk.mcpServers.list();
385
+ await ctx.majk.mcpServers.get(id);
386
+
387
+ // Event Bus
388
+ const subscription = await ctx.majk.eventBus.subscribeAll((event) => {
389
+ console.log('Event:', event);
390
+ });
391
+ // Later: subscription.unsubscribe();
392
+ ```
393
+
394
+ ## Mutation Functions
395
+
396
+ Functions that modify state (create, update, delete) are **mutations**.
397
+
398
+ ```typescript
399
+ .function('clearEvents', {
400
+ description: 'Clear all logged events from storage',
401
+
402
+ input: {
403
+ type: 'object',
404
+ properties: {},
405
+ additionalProperties: false
406
+ },
407
+
408
+ output: {
409
+ type: 'object',
410
+ properties: {
411
+ success: { type: 'boolean' },
412
+ clearedCount: { type: 'number' }
413
+ },
414
+ required: ['success', 'clearedCount']
415
+ },
416
+
417
+ handler: async (input, ctx) => {
418
+ const events = await ctx.storage.get('events') || [];
419
+ const count = events.length;
420
+
421
+ // Clear storage
422
+ await ctx.storage.set('events', []);
423
+
424
+ ctx.logger.info(`Cleared ${count} events`);
425
+
426
+ return {
427
+ success: true,
428
+ clearedCount: count
429
+ };
430
+ },
431
+
432
+ tags: ['mutations', 'events']
433
+ })
434
+ ```
435
+
436
+ Generated React hook will be a **mutation hook** (call `.mutate()` manually):
437
+
438
+ ```typescript
439
+ // ui/src/EventsPage.tsx
440
+ import { useClearEvents } from './generated/hooks';
441
+
442
+ export function EventsPage() {
443
+ const { mutate: clearEvents, loading } = useClearEvents();
444
+
445
+ return (
446
+ <button
447
+ onClick={() => clearEvents({})}
448
+ disabled={loading}
449
+ data-majk-id="clearEventsButton"
450
+ data-majk-state={loading ? 'loading' : 'idle'}
451
+ >
452
+ {loading ? 'Clearing...' : 'Clear Events'}
453
+ </button>
454
+ );
455
+ }
456
+ ```
457
+
458
+ ## UI Logging Function (Best Practice)
459
+
460
+ **Always include a `uiLog` function** for debugging frontend issues.
461
+
462
+ ```typescript
463
+ .function('uiLog', {
464
+ description: 'Log messages from UI for debugging. Allows frontend to send logs to backend.',
465
+
466
+ input: {
467
+ type: 'object',
468
+ properties: {
469
+ level: {
470
+ type: 'string',
471
+ enum: ['debug', 'info', 'warn', 'error'],
472
+ description: 'Log level'
473
+ },
474
+ component: {
475
+ type: 'string',
476
+ description: 'React component name'
477
+ },
478
+ message: {
479
+ type: 'string',
480
+ description: 'Log message'
481
+ },
482
+ data: {
483
+ type: 'object',
484
+ description: 'Additional context data'
485
+ }
486
+ },
487
+ required: ['level', 'component', 'message'],
488
+ additionalProperties: false
489
+ },
490
+
491
+ output: {
492
+ type: 'object',
493
+ properties: {
494
+ success: { type: 'boolean' }
495
+ },
496
+ required: ['success']
497
+ },
498
+
499
+ handler: async (input, ctx) => {
500
+ const prefix = `[UI:${input.component}]`;
501
+ const message = `${prefix} ${input.message}`;
502
+
503
+ // Route to appropriate log level
504
+ switch (input.level) {
505
+ case 'debug':
506
+ ctx.logger.debug(message, input.data);
507
+ break;
508
+ case 'info':
509
+ ctx.logger.info(message, input.data);
510
+ break;
511
+ case 'warn':
512
+ ctx.logger.warn(message, input.data);
513
+ break;
514
+ case 'error':
515
+ ctx.logger.error(message, input.data);
516
+ break;
517
+ }
518
+
519
+ return { success: true };
520
+ },
521
+
522
+ tags: ['debugging', 'ui']
523
+ })
524
+ ```
525
+
526
+ Use in React:
527
+
528
+ ```typescript
529
+ import { useUiLog } from './generated/hooks';
530
+ import { useEffect } from 'react';
531
+
532
+ export function DashboardPage() {
533
+ const { mutate: uiLog } = useUiLog();
534
+
535
+ // Log component lifecycle
536
+ useEffect(() => {
537
+ uiLog({
538
+ level: 'info',
539
+ component: 'DashboardPage',
540
+ message: 'Component mounted'
541
+ });
542
+
543
+ return () => {
544
+ uiLog({
545
+ level: 'info',
546
+ component: 'DashboardPage',
547
+ message: 'Component unmounted'
548
+ });
549
+ };
550
+ }, [uiLog]);
551
+
552
+ // Log errors
553
+ const handleClick = async () => {
554
+ try {
555
+ await someOperation();
556
+ } catch (error) {
557
+ uiLog({
558
+ level: 'error',
559
+ component: 'DashboardPage',
560
+ message: 'Operation failed',
561
+ data: { error: error.message, stack: error.stack }
562
+ });
563
+ }
564
+ };
565
+
566
+ return <div>...</div>;
567
+ }
568
+ ```
569
+
570
+ ## Error Handling
571
+
572
+ ```typescript
573
+ .function('riskyOperation', {
574
+ description: 'Performs an operation that might fail',
575
+
576
+ input: { /* schema */ },
577
+ output: { /* schema */ },
578
+
579
+ handler: async (input, ctx) => {
580
+ try {
581
+ // Attempt operation
582
+ const result = await performOperation(input);
583
+
584
+ ctx.logger.info('Operation succeeded', { result });
585
+ return result;
586
+
587
+ } catch (error) {
588
+ // Log error with context
589
+ ctx.logger.error('Operation failed', {
590
+ error: error.message,
591
+ stack: error.stack,
592
+ input
593
+ });
594
+
595
+ // Re-throw or return error response
596
+ throw new Error(`Operation failed: ${error.message}`);
597
+ }
598
+ }
599
+ })
600
+ ```
601
+
602
+ ## Function Tags
603
+
604
+ Tags help organize and discover functions.
605
+
606
+ ```typescript
607
+ .function('health', { /* ... */, tags: ['monitoring', 'health'] })
608
+ .function('getAnalytics', { /* ... */, tags: ['analytics', 'reporting'] })
609
+ .function('clearEvents', { /* ... */, tags: ['mutations', 'events'] })
610
+ .function('uiLog', { /* ... */, tags: ['debugging', 'ui'] })
611
+ ```
612
+
613
+ Tags are used by:
614
+ - Service grouping (see SERVICES.md)
615
+ - Function discovery
616
+ - Documentation organization
617
+
618
+ ## Next Steps
619
+
620
+ Run `npx @majkapp/plugin-kit --hooks` - Generated React hooks reference
621
+ Run `npx @majkapp/plugin-kit --context` - Complete ctx API reference
622
+ Run `npx @majkapp/plugin-kit --services` - Group functions into services
623
+ Run `npx @majkapp/plugin-kit --testing` - Test your functions