@meltstudio/meltctl 4.31.0 → 4.33.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.
@@ -117,6 +117,16 @@ description: >-
117
117
  This is a reference skill — it explains the process, not executes it.
118
118
  ---
119
119
 
120
+ `,
121
+ link: `---
122
+ user-invocable: true
123
+ description: >-
124
+ Connect and verify required integrations (ticket tracker, browser
125
+ testing). Use after melt-setup, when tools aren't working, or when
126
+ a skill reports missing MCP connections. Guides setup, verifies
127
+ each tool works, and updates AGENTS.md connection status.
128
+ ---
129
+
120
130
  `,
121
131
  };
122
132
  const OPENCODE_COMMAND_FRONTMATTER = {
@@ -174,6 +184,11 @@ description: Update Melt skills and standards to the latest version.
174
184
  description: Answer questions about the AI-First Development Playbook and team workflow.
175
185
  ---
176
186
 
187
+ `,
188
+ link: `---
189
+ description: Connect and verify required integrations (ticket tracker, browser testing).
190
+ ---
191
+
177
192
  `,
178
193
  };
179
194
  const GITIGNORE_ENTRIES = ['.env.local', '.claude/settings.local.json'];
@@ -321,6 +336,7 @@ export async function initCommand(options) {
321
336
  'security-audit',
322
337
  'update',
323
338
  'help',
339
+ 'link',
324
340
  ];
325
341
  // Shared files (skip on re-init)
326
342
  if (!isReInit) {
@@ -443,7 +459,7 @@ export async function initCommand(options) {
443
459
  }
444
460
  console.log();
445
461
  console.log(chalk.dim(' The setup skill will analyze your project and fill in AGENTS.md.'));
446
- console.log(chalk.dim(' Once it finishes, commit the changes to share with your team.'));
462
+ console.log(chalk.dim(' Then run /melt-link to connect your ticket tracker and browser testing tools.'));
447
463
  console.log();
448
464
  }
449
465
  console.log(chalk.green('Done!'));
@@ -36,6 +36,7 @@ const MOCK_TEMPLATES = {
36
36
  'workflows/security-audit.md': '# Security Audit Workflow\nSecurity check.',
37
37
  'workflows/update.md': '# Update Workflow\nUpdate skills.',
38
38
  'workflows/help.md': '# Help Workflow\nAnswer questions.',
39
+ 'workflows/link.md': '# Link Workflow\nConnect tools.',
39
40
  'claude-settings.json': '{"permissions":{"allow":["Read"],"deny":["Bash"]}}',
40
41
  'mcp-configs/base.json': '{"mcpServers":{"context7":{"command":"npx","args":["context7"]}}}',
41
42
  };
@@ -114,13 +115,13 @@ describe('initCommand', () => {
114
115
  // Verify ensureDir was called for skill directories
115
116
  const ensureDirCalls = fs.ensureDir.mock.calls;
116
117
  const skillDirs = ensureDirCalls.filter(c => c[0].includes('.claude/skills/melt-'));
117
- expect(skillDirs.length).toBeGreaterThanOrEqual(11);
118
+ expect(skillDirs.length).toBeGreaterThanOrEqual(12);
118
119
  });
119
120
  it('creates all 11 Claude skills', async () => {
120
121
  await initCommand({ claude: true });
121
122
  const writeCalls = fs.writeFile.mock.calls;
122
123
  const skillFiles = writeCalls.filter(c => c[0].includes('.claude/skills/melt-') && c[0].endsWith('SKILL.md'));
123
- expect(skillFiles.length).toBe(11);
124
+ expect(skillFiles.length).toBe(12);
124
125
  const skillNames = skillFiles.map(c => {
125
126
  const match = c[0].match(/\.claude\/skills\/melt-([^/]+)/);
126
127
  return match ? match[1] : '';
@@ -136,12 +137,13 @@ describe('initCommand', () => {
136
137
  expect(skillNames).toContain('security-audit');
137
138
  expect(skillNames).toContain('update');
138
139
  expect(skillNames).toContain('help');
140
+ expect(skillNames).toContain('link');
139
141
  });
140
142
  it('creates Cursor commands without frontmatter', async () => {
141
143
  await initCommand({ cursor: true });
142
144
  const writeCalls = fs.writeFile.mock.calls;
143
145
  const cursorFiles = writeCalls.filter(c => c[0].includes('.cursor/commands/melt-'));
144
- expect(cursorFiles.length).toBe(11);
146
+ expect(cursorFiles.length).toBe(12);
145
147
  // Cursor commands should NOT have frontmatter
146
148
  const setupCmd = cursorFiles.find(c => c[0].includes('melt-setup.md'));
147
149
  expect(setupCmd).toBeDefined();
@@ -152,7 +154,7 @@ describe('initCommand', () => {
152
154
  await initCommand({ opencode: true });
153
155
  const writeCalls = fs.writeFile.mock.calls;
154
156
  const opencodeFiles = writeCalls.filter(c => c[0].includes('.opencode/commands/melt-'));
155
- expect(opencodeFiles.length).toBe(11);
157
+ expect(opencodeFiles.length).toBe(12);
156
158
  // OpenCode commands should have shorter frontmatter
157
159
  const setupCmd = opencodeFiles.find(c => c[0].includes('melt-setup.md'));
158
160
  expect(setupCmd).toBeDefined();
package/dist/index.js CHANGED
@@ -16,6 +16,7 @@ import { feedbackCommand } from './commands/feedback.js';
16
16
  import { coinsCommand } from './commands/coins.js';
17
17
  import { auditSubmitCommand, auditListCommand, auditViewCommand } from './commands/audit.js';
18
18
  import { planSubmitCommand, planListCommand } from './commands/plan.js';
19
+ import { trackCommand } from './utils/analytics.js';
19
20
  // Read version from package.json
20
21
  const __filename = fileURLToPath(import.meta.url);
21
22
  const __dirname = dirname(__filename);
@@ -32,6 +33,7 @@ program
32
33
  .addHelpText('after', `
33
34
  ${chalk.bold('AI Skills')} ${chalk.dim('(run these in your AI coding tool, not the CLI):')}
34
35
  ${chalk.dim(' /melt-setup Analyze the project and customize AGENTS.md')}
36
+ ${chalk.dim(' /melt-link Connect and verify ticket tracker + browser testing')}
35
37
  ${chalk.dim(' /melt-plan Design an implementation approach before writing code')}
36
38
  ${chalk.dim(' /melt-validate Run the validation plan and verify end-to-end')}
37
39
  ${chalk.dim(' /melt-review Review changes against project standards')}
@@ -45,6 +47,20 @@ ${chalk.dim(' /melt-help Answer questions about the development pla
45
47
  `)
46
48
  .hook('preAction', async () => {
47
49
  await checkAndEnforceUpdate();
50
+ })
51
+ .hook('postAction', (_thisCommand, actionCommand) => {
52
+ try {
53
+ const parts = [];
54
+ let cmd = actionCommand;
55
+ while (cmd && cmd.name() !== 'meltctl') {
56
+ parts.unshift(cmd.name());
57
+ cmd = cmd.parent;
58
+ }
59
+ trackCommand(parts.join(' '), true);
60
+ }
61
+ catch {
62
+ // Analytics must never break the CLI
63
+ }
48
64
  });
49
65
  program
50
66
  .command('login')
@@ -64,6 +80,7 @@ const project = program
64
80
  .addHelpText('after', `
65
81
  ${chalk.dim('Related skills (run in your AI coding tool after init):')}
66
82
  ${chalk.dim(' /melt-setup Analyze the project and customize AGENTS.md')}
83
+ ${chalk.dim(' /melt-link Connect and verify ticket tracker + browser testing')}
67
84
  ${chalk.dim(' /melt-update Update Melt skills to the latest version')}
68
85
  `);
69
86
  project
@@ -183,6 +200,11 @@ program
183
200
  });
184
201
  program.parseAsync(process.argv).catch((error) => {
185
202
  const message = error instanceof Error ? error.message : 'An unexpected error occurred';
203
+ const commandName = process.argv
204
+ .slice(2)
205
+ .filter(a => !a.startsWith('-'))
206
+ .join(' ') || 'unknown';
207
+ trackCommand(commandName, false, message);
186
208
  console.error(chalk.red(message));
187
209
  process.exit(1);
188
210
  });
@@ -0,0 +1 @@
1
+ export declare function trackCommand(command: string, success: boolean, errorMessage?: string): Promise<void>;
@@ -0,0 +1,44 @@
1
+ import { readFileSync } from 'fs';
2
+ import { join, dirname } from 'path';
3
+ import { fileURLToPath } from 'url';
4
+ import { getStoredAuth, API_BASE } from './auth.js';
5
+ import { getGitRepository, getGitBranch, getProjectName } from './git.js';
6
+ const __filename = fileURLToPath(import.meta.url);
7
+ const __dirname = dirname(__filename);
8
+ function getVersion() {
9
+ try {
10
+ const pkg = JSON.parse(readFileSync(join(__dirname, '../../package.json'), 'utf8'));
11
+ return pkg.version ?? 'unknown';
12
+ }
13
+ catch {
14
+ return 'unknown';
15
+ }
16
+ }
17
+ export async function trackCommand(command, success, errorMessage) {
18
+ try {
19
+ const auth = await getStoredAuth();
20
+ if (!auth || new Date(auth.expiresAt) <= new Date()) {
21
+ return;
22
+ }
23
+ const repo = getGitRepository();
24
+ fetch(`${API_BASE}/events`, {
25
+ method: 'POST',
26
+ headers: {
27
+ Authorization: `Bearer ${auth.token}`,
28
+ 'Content-Type': 'application/json',
29
+ },
30
+ body: JSON.stringify({
31
+ command,
32
+ project: getProjectName(),
33
+ repository: repo?.slug ?? null,
34
+ branch: getGitBranch(),
35
+ version: getVersion(),
36
+ success,
37
+ errorMessage: errorMessage?.slice(0, 500) ?? null,
38
+ }),
39
+ }).catch(() => { });
40
+ }
41
+ catch {
42
+ // Analytics must never break the CLI
43
+ }
44
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,91 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ vi.mock('./auth.js', () => ({
3
+ getStoredAuth: vi.fn(),
4
+ API_BASE: 'https://api.test',
5
+ }));
6
+ vi.mock('./git.js', () => ({
7
+ getGitRepository: vi
8
+ .fn()
9
+ .mockReturnValue({ slug: 'org/repo', url: 'https://github.com/org/repo' }),
10
+ getGitBranch: vi.fn().mockReturnValue('main'),
11
+ getProjectName: vi.fn().mockReturnValue('test-project'),
12
+ }));
13
+ const mockFetch = vi.fn().mockResolvedValue({ ok: true });
14
+ vi.stubGlobal('fetch', mockFetch);
15
+ import { getStoredAuth } from './auth.js';
16
+ import { trackCommand } from './analytics.js';
17
+ beforeEach(() => {
18
+ vi.clearAllMocks();
19
+ });
20
+ describe('trackCommand', () => {
21
+ it('sends event when authenticated', async () => {
22
+ vi.mocked(getStoredAuth).mockResolvedValue({
23
+ token: 'test-token',
24
+ email: 'user@meltstudio.co',
25
+ expiresAt: new Date(Date.now() + 86400000).toISOString(),
26
+ });
27
+ await trackCommand('standup', true);
28
+ expect(mockFetch).toHaveBeenCalledWith('https://api.test/events', expect.objectContaining({
29
+ method: 'POST',
30
+ headers: expect.objectContaining({
31
+ Authorization: 'Bearer test-token',
32
+ }),
33
+ }));
34
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body);
35
+ expect(body.command).toBe('standup');
36
+ expect(body.success).toBe(true);
37
+ expect(body.project).toBe('test-project');
38
+ expect(body.repository).toBe('org/repo');
39
+ expect(body.branch).toBe('main');
40
+ });
41
+ it('does not send event when not authenticated', async () => {
42
+ vi.mocked(getStoredAuth).mockResolvedValue(undefined);
43
+ await trackCommand('standup', true);
44
+ expect(mockFetch).not.toHaveBeenCalled();
45
+ });
46
+ it('does not send event when token is expired', async () => {
47
+ vi.mocked(getStoredAuth).mockResolvedValue({
48
+ token: 'test-token',
49
+ email: 'user@meltstudio.co',
50
+ expiresAt: new Date(Date.now() - 86400000).toISOString(),
51
+ });
52
+ await trackCommand('standup', true);
53
+ expect(mockFetch).not.toHaveBeenCalled();
54
+ });
55
+ it('does not throw when fetch fails', async () => {
56
+ vi.mocked(getStoredAuth).mockResolvedValue({
57
+ token: 'test-token',
58
+ email: 'user@meltstudio.co',
59
+ expiresAt: new Date(Date.now() + 86400000).toISOString(),
60
+ });
61
+ mockFetch.mockRejectedValueOnce(new Error('Network error'));
62
+ await expect(trackCommand('standup', true)).resolves.not.toThrow();
63
+ });
64
+ it('does not throw when auth check fails', async () => {
65
+ vi.mocked(getStoredAuth).mockRejectedValue(new Error('fs error'));
66
+ await expect(trackCommand('standup', true)).resolves.not.toThrow();
67
+ });
68
+ it('truncates error messages to 500 chars', async () => {
69
+ vi.mocked(getStoredAuth).mockResolvedValue({
70
+ token: 'test-token',
71
+ email: 'user@meltstudio.co',
72
+ expiresAt: new Date(Date.now() + 86400000).toISOString(),
73
+ });
74
+ const longError = 'x'.repeat(1000);
75
+ await trackCommand('standup', false, longError);
76
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body);
77
+ expect(body.errorMessage.length).toBe(500);
78
+ });
79
+ it('includes success false and error message on failure', async () => {
80
+ vi.mocked(getStoredAuth).mockResolvedValue({
81
+ token: 'test-token',
82
+ email: 'user@meltstudio.co',
83
+ expiresAt: new Date(Date.now() + 86400000).toISOString(),
84
+ });
85
+ await trackCommand('audit submit', false, 'Not authenticated');
86
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body);
87
+ expect(body.command).toBe('audit submit');
88
+ expect(body.success).toBe(false);
89
+ expect(body.errorMessage).toBe('Not authenticated');
90
+ });
91
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meltstudio/meltctl",
3
- "version": "4.31.0",
3
+ "version": "4.33.0",
4
4
  "description": "AI-first development tools for teams - set up AGENTS.md, Claude Code, Cursor, and OpenCode standards",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",