@meltstudio/meltctl 4.23.1 → 4.24.1

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.
@@ -65,6 +65,16 @@ description: >-
65
65
  report with scores and actionable fixes.
66
66
  ---
67
67
 
68
+ `,
69
+ 'ux-audit': `---
70
+ user-invocable: true
71
+ description: >-
72
+ Review the project's UI against usability heuristics using Chrome DevTools
73
+ MCP. Use when the developer wants to check UX quality, says "review the UI",
74
+ or "UX audit". In full audit mode, crawls the entire app. During an active
75
+ plan, scopes to the current feature and appends results to the plan file.
76
+ ---
77
+
68
78
  `,
69
79
  validate: `---
70
80
  user-invocable: true
@@ -128,6 +138,11 @@ description: Systematically investigate and fix bugs.
128
138
  description: Run a comprehensive project compliance audit against team standards.
129
139
  ---
130
140
 
141
+ `,
142
+ 'ux-audit': `---
143
+ description: Review the project's UI against usability heuristics using Chrome DevTools MCP.
144
+ ---
145
+
131
146
  `,
132
147
  validate: `---
133
148
  description: Run the validation plan from the plan document after implementation.
@@ -286,6 +301,7 @@ export async function initCommand(options) {
286
301
  'pr',
287
302
  'debug',
288
303
  'audit',
304
+ 'ux-audit',
289
305
  'update',
290
306
  'help',
291
307
  ];
@@ -320,7 +336,7 @@ export async function initCommand(options) {
320
336
  await fs.writeFile(path.join(skillDir, 'SKILL.md'), skillContent, 'utf-8');
321
337
  }
322
338
  }
323
- createdFiles.push('.claude/skills/melt-{setup,plan,validate,review,pr,debug,audit,update,help}/SKILL.md');
339
+ createdFiles.push('.claude/skills/melt-{setup,plan,validate,review,pr,debug,audit,ux-audit,update,help}/SKILL.md');
324
340
  }
325
341
  // Cursor files
326
342
  if (tools.cursor) {
@@ -331,7 +347,7 @@ export async function initCommand(options) {
331
347
  await fs.writeFile(path.join(cwd, `.cursor/commands/melt-${name}.md`), workflowContent, 'utf-8');
332
348
  }
333
349
  }
334
- createdFiles.push('.cursor/commands/melt-{setup,plan,validate,review,pr,debug,audit,update,help}.md');
350
+ createdFiles.push('.cursor/commands/melt-{setup,plan,validate,review,pr,debug,audit,ux-audit,update,help}.md');
335
351
  }
336
352
  // OpenCode files
337
353
  if (tools.opencode) {
@@ -343,7 +359,7 @@ export async function initCommand(options) {
343
359
  await fs.writeFile(path.join(cwd, `.opencode/commands/melt-${name}.md`), commandContent, 'utf-8');
344
360
  }
345
361
  }
346
- createdFiles.push('.opencode/commands/melt-{setup,plan,validate,review,pr,debug,audit,update,help}.md');
362
+ createdFiles.push('.opencode/commands/melt-{setup,plan,validate,review,pr,debug,audit,ux-audit,update,help}.md');
347
363
  }
348
364
  // Print summary
349
365
  console.log(chalk.green('Created files:'));
@@ -355,7 +371,7 @@ export async function initCommand(options) {
355
371
  console.log(chalk.cyan('Want support for your tool? Let us know in #dev on Slack'));
356
372
  console.log();
357
373
  }
358
- const commandNames = 'melt-setup, melt-plan, melt-validate, melt-review, melt-pr, melt-debug, melt-audit, melt-update, melt-help';
374
+ const commandNames = 'melt-setup, melt-plan, melt-validate, melt-review, melt-pr, melt-debug, melt-audit, melt-ux-audit, melt-update, melt-help';
359
375
  if (tools.claude) {
360
376
  console.log(chalk.dim(`Available skills: /${commandNames.replace(/, /g, ', /')}`));
361
377
  }
@@ -0,0 +1,7 @@
1
+ export declare function reportPlanCommand(file?: string): Promise<void>;
2
+ export declare function reportAuditCommand(file?: string): Promise<void>;
3
+ export declare function reportListCommand(options: {
4
+ type?: string;
5
+ repository?: string;
6
+ limit?: string;
7
+ }): Promise<void>;
@@ -0,0 +1,295 @@
1
+ import chalk from 'chalk';
2
+ import fs from 'fs-extra';
3
+ import path from 'path';
4
+ import { execSync } from 'child_process';
5
+ import { getStoredAuth, API_BASE } from '../utils/auth.js';
6
+ async function getToken() {
7
+ const envToken = process.env['MELTCTL_TOKEN'];
8
+ if (envToken) {
9
+ return envToken;
10
+ }
11
+ const auth = await getStoredAuth();
12
+ if (!auth) {
13
+ console.error(chalk.red('Not authenticated. Run `npx @meltstudio/meltctl@latest login` or set MELTCTL_TOKEN for CI.'));
14
+ process.exit(1);
15
+ }
16
+ if (new Date(auth.expiresAt) <= new Date()) {
17
+ console.error(chalk.red('Session expired. Run `npx @meltstudio/meltctl@latest login` to re-authenticate, or set MELTCTL_TOKEN for CI.'));
18
+ process.exit(1);
19
+ }
20
+ return auth.token;
21
+ }
22
+ async function tokenFetch(token, urlPath, options = {}) {
23
+ const response = await fetch(`${API_BASE}${urlPath}`, {
24
+ ...options,
25
+ headers: {
26
+ Authorization: `Bearer ${token}`,
27
+ ...options.headers,
28
+ },
29
+ });
30
+ if (response.status === 401) {
31
+ throw new Error('Authentication failed. Run `npx @meltstudio/meltctl@latest login` or check your MELTCTL_TOKEN.');
32
+ }
33
+ return response;
34
+ }
35
+ function getGitBranch() {
36
+ try {
37
+ return execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf-8' }).trim();
38
+ }
39
+ catch {
40
+ return 'unknown';
41
+ }
42
+ }
43
+ function getGitCommit() {
44
+ try {
45
+ return execSync('git rev-parse --short HEAD', { encoding: 'utf-8' }).trim();
46
+ }
47
+ catch {
48
+ return 'unknown';
49
+ }
50
+ }
51
+ function getGitRepository() {
52
+ try {
53
+ const url = execSync('git remote get-url origin', { encoding: 'utf-8' }).trim();
54
+ // Extract owner/repo from various URL formats:
55
+ // git@github.com:Owner/Repo.git -> Owner/Repo
56
+ // https://github.com/Owner/Repo.git -> Owner/Repo
57
+ // git@gitlab.com:Owner/Repo.git -> Owner/Repo
58
+ const match = url.match(/[/:]([\w.-]+\/[\w.-]+?)(?:\.git)?$/);
59
+ const slug = match ? match[1] : url;
60
+ return { slug, url };
61
+ }
62
+ catch {
63
+ return null;
64
+ }
65
+ }
66
+ function getProjectName() {
67
+ const cwd = process.cwd();
68
+ try {
69
+ const pkgPath = path.join(cwd, 'package.json');
70
+ if (fs.pathExistsSync(pkgPath)) {
71
+ const pkg = fs.readJsonSync(pkgPath);
72
+ if (pkg.name) {
73
+ return pkg.name;
74
+ }
75
+ }
76
+ }
77
+ catch {
78
+ // fall through
79
+ }
80
+ return path.basename(cwd);
81
+ }
82
+ function extractTicketId(branch) {
83
+ const match = branch.match(/([A-Z]+-\d+)/i);
84
+ return match ? match[1] : null;
85
+ }
86
+ async function findMdFiles(dir) {
87
+ if (!(await fs.pathExists(dir))) {
88
+ return [];
89
+ }
90
+ const results = [];
91
+ async function walk(current) {
92
+ const entries = await fs.readdir(current, { withFileTypes: true });
93
+ for (const entry of entries) {
94
+ const fullPath = path.join(current, entry.name);
95
+ if (entry.isDirectory()) {
96
+ await walk(fullPath);
97
+ }
98
+ else if (entry.isFile() && entry.name.endsWith('.md')) {
99
+ const stat = await fs.stat(fullPath);
100
+ results.push({ path: fullPath, mtime: stat.mtimeMs });
101
+ }
102
+ }
103
+ }
104
+ await walk(dir);
105
+ results.sort((a, b) => b.mtime - a.mtime);
106
+ return results.map(r => r.path);
107
+ }
108
+ async function autoDetectPlanFile() {
109
+ const cwd = process.cwd();
110
+ const plansDir = path.join(cwd, '.plans');
111
+ const mdFiles = await findMdFiles(plansDir);
112
+ if (mdFiles.length === 0) {
113
+ return null;
114
+ }
115
+ const branch = getGitBranch();
116
+ const ticketId = extractTicketId(branch);
117
+ if (ticketId) {
118
+ const ticketLower = ticketId.toLowerCase();
119
+ const match = mdFiles.find(f => path.basename(f).toLowerCase().includes(ticketLower));
120
+ if (match) {
121
+ return match;
122
+ }
123
+ }
124
+ return mdFiles[0] ?? null;
125
+ }
126
+ async function autoDetectAuditFile() {
127
+ const cwd = process.cwd();
128
+ // Check .audits/ directory first (most recent file)
129
+ const auditsDir = path.join(cwd, '.audits');
130
+ const auditFiles = await findMdFiles(auditsDir);
131
+ if (auditFiles.length > 0) {
132
+ return auditFiles[0] ?? null;
133
+ }
134
+ // Fall back to legacy locations
135
+ const candidates = ['AUDIT.md', 'UX-AUDIT.md'];
136
+ for (const name of candidates) {
137
+ const filePath = path.join(cwd, name);
138
+ if (await fs.pathExists(filePath)) {
139
+ return filePath;
140
+ }
141
+ }
142
+ return null;
143
+ }
144
+ function buildReportPayload(type, content, filename) {
145
+ const project = getProjectName();
146
+ const branch = getGitBranch();
147
+ const commit = getGitCommit();
148
+ const repo = getGitRepository();
149
+ return {
150
+ type,
151
+ project,
152
+ repository: repo?.slug ?? null,
153
+ repositoryUrl: repo?.url ?? null,
154
+ branch,
155
+ commit,
156
+ content,
157
+ metadata: { filename },
158
+ };
159
+ }
160
+ export async function reportPlanCommand(file) {
161
+ const token = await getToken();
162
+ let filePath;
163
+ if (file) {
164
+ filePath = path.resolve(file);
165
+ }
166
+ else {
167
+ const detected = await autoDetectPlanFile();
168
+ if (!detected) {
169
+ console.error(chalk.red('No plan file found. Provide a file path or create a plan in the .plans/ directory.'));
170
+ process.exit(1);
171
+ }
172
+ filePath = detected;
173
+ console.log(chalk.dim(`Auto-detected plan file: ${path.relative(process.cwd(), filePath)}`));
174
+ }
175
+ if (!(await fs.pathExists(filePath))) {
176
+ console.error(chalk.red(`File not found: ${filePath}`));
177
+ process.exit(1);
178
+ }
179
+ const content = await fs.readFile(filePath, 'utf-8');
180
+ const filename = path.basename(filePath);
181
+ const payload = buildReportPayload('plan', content, filename);
182
+ try {
183
+ const res = await tokenFetch(token, '/reports', {
184
+ method: 'POST',
185
+ headers: { 'Content-Type': 'application/json' },
186
+ body: JSON.stringify(payload),
187
+ });
188
+ if (res.ok) {
189
+ const body = (await res.json());
190
+ console.log(chalk.green(`\n ✓ Plan report submitted! Report ID: ${body.id}\n`));
191
+ }
192
+ else {
193
+ const body = (await res.json());
194
+ console.error(chalk.red(`\nFailed to submit plan report: ${body.error ?? res.statusText}`));
195
+ process.exit(1);
196
+ }
197
+ }
198
+ catch (error) {
199
+ console.error(chalk.red(`Failed to submit plan report: ${error instanceof Error ? error.message : 'Unknown error'}`));
200
+ process.exit(1);
201
+ }
202
+ }
203
+ export async function reportAuditCommand(file) {
204
+ const token = await getToken();
205
+ let filePath;
206
+ if (file) {
207
+ filePath = path.resolve(file);
208
+ }
209
+ else {
210
+ const detected = await autoDetectAuditFile();
211
+ if (!detected) {
212
+ console.error(chalk.red('No audit file found. Provide a file path or create AUDIT.md or UX-AUDIT.md in the project root.'));
213
+ process.exit(1);
214
+ }
215
+ filePath = detected;
216
+ console.log(chalk.dim(`Auto-detected audit file: ${path.relative(process.cwd(), filePath)}`));
217
+ }
218
+ if (!(await fs.pathExists(filePath))) {
219
+ console.error(chalk.red(`File not found: ${filePath}`));
220
+ process.exit(1);
221
+ }
222
+ const content = await fs.readFile(filePath, 'utf-8');
223
+ const filename = path.basename(filePath);
224
+ const payload = buildReportPayload('audit', content, filename);
225
+ try {
226
+ const res = await tokenFetch(token, '/reports', {
227
+ method: 'POST',
228
+ headers: { 'Content-Type': 'application/json' },
229
+ body: JSON.stringify(payload),
230
+ });
231
+ if (res.ok) {
232
+ const body = (await res.json());
233
+ console.log(chalk.green(`\n ✓ Audit report submitted! Report ID: ${body.id}\n`));
234
+ }
235
+ else {
236
+ const body = (await res.json());
237
+ console.error(chalk.red(`\nFailed to submit audit report: ${body.error ?? res.statusText}`));
238
+ process.exit(1);
239
+ }
240
+ }
241
+ catch (error) {
242
+ console.error(chalk.red(`Failed to submit audit report: ${error instanceof Error ? error.message : 'Unknown error'}`));
243
+ process.exit(1);
244
+ }
245
+ }
246
+ export async function reportListCommand(options) {
247
+ const token = await getToken();
248
+ const params = new URLSearchParams();
249
+ if (options.type)
250
+ params.set('type', options.type);
251
+ if (options.repository)
252
+ params.set('repository', options.repository);
253
+ if (options.limit)
254
+ params.set('limit', options.limit);
255
+ const query = params.toString();
256
+ const urlPath = `/reports${query ? `?${query}` : ''}`;
257
+ try {
258
+ const res = await tokenFetch(token, urlPath);
259
+ if (res.status === 403) {
260
+ console.error(chalk.red('Access denied. Only Team Managers can list reports.'));
261
+ process.exit(1);
262
+ }
263
+ if (!res.ok) {
264
+ const body = (await res.json());
265
+ console.error(chalk.red(`Failed to list reports: ${body.error ?? res.statusText}`));
266
+ process.exit(1);
267
+ }
268
+ const body = (await res.json());
269
+ if (body.reports.length === 0) {
270
+ console.log(chalk.dim('\n No reports found.\n'));
271
+ return;
272
+ }
273
+ console.log(chalk.bold(`\n Reports (${body.count}):\n`));
274
+ for (const r of body.reports) {
275
+ const date = new Date(r.createdAt).toLocaleDateString('en-US', {
276
+ month: 'short',
277
+ day: 'numeric',
278
+ year: 'numeric',
279
+ hour: '2-digit',
280
+ minute: '2-digit',
281
+ });
282
+ const repo = r.repository ?? r.project;
283
+ const typeColor = r.type === 'plan' ? chalk.cyan : chalk.magenta;
284
+ console.log(` ${typeColor(r.type.padEnd(6))} ${chalk.white(repo.padEnd(40))} ${chalk.dim(r.author.padEnd(30))} ${chalk.dim(date)}`);
285
+ if (r.branch && r.branch !== 'main') {
286
+ console.log(` ${chalk.dim(`branch: ${r.branch} commit: ${r.commit ?? 'N/A'}`)}`);
287
+ }
288
+ }
289
+ console.log();
290
+ }
291
+ catch (error) {
292
+ console.error(chalk.red(`Failed to list reports: ${error instanceof Error ? error.message : 'Unknown error'}`));
293
+ process.exit(1);
294
+ }
295
+ }
package/dist/index.js CHANGED
@@ -14,6 +14,7 @@ import { versionCheckCommand } from './commands/version.js';
14
14
  import { standupCommand } from './commands/standup.js';
15
15
  import { feedbackCommand } from './commands/feedback.js';
16
16
  import { coinsCommand } from './commands/coins.js';
17
+ import { reportPlanCommand, reportAuditCommand, reportListCommand } from './commands/report.js';
17
18
  // Read version from package.json
18
19
  const __filename = fileURLToPath(import.meta.url);
19
20
  const __dirname = dirname(__filename);
@@ -89,6 +90,30 @@ program
89
90
  .action(async (options) => {
90
91
  await coinsCommand(options);
91
92
  });
93
+ const report = program.command('report').description('submit project reports (plans, audits)');
94
+ report
95
+ .command('plan')
96
+ .description('submit a plan report from a markdown file')
97
+ .argument('[file]', 'path to the plan file (auto-detects from .plans/ if omitted)')
98
+ .action(async (file) => {
99
+ await reportPlanCommand(file);
100
+ });
101
+ report
102
+ .command('audit')
103
+ .description('submit an audit report from a markdown file')
104
+ .argument('[file]', 'path to the audit file (auto-detects AUDIT.md or UX-AUDIT.md if omitted)')
105
+ .action(async (file) => {
106
+ await reportAuditCommand(file);
107
+ });
108
+ report
109
+ .command('list')
110
+ .description('list submitted reports (Team Managers only)')
111
+ .option('--type <type>', 'filter by type (plan, audit)')
112
+ .option('--repository <repo>', 'filter by repository (owner/repo)')
113
+ .option('--limit <n>', 'max results (default 50, max 200)')
114
+ .action(async (options) => {
115
+ await reportListCommand(options);
116
+ });
92
117
  program
93
118
  .command('version')
94
119
  .description('show current version')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meltstudio/meltctl",
3
- "version": "4.23.1",
3
+ "version": "4.24.1",
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",