@upgraide/ui-notes-cli 0.1.3 → 0.2.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.
package/README.md CHANGED
@@ -45,10 +45,12 @@ uinotes config
45
45
 
46
46
  # Set a value
47
47
  uinotes config set apiUrl https://my-api.example.com
48
- uinotes config set defaultProject my-app
48
+
49
+ # Bind a project to the current directory
50
+ uinotes set project my-app
49
51
  ```
50
52
 
51
- **Config keys:** `apiUrl`, `apiKey`, `defaultProject`
53
+ **Config keys:** `apiUrl`, `apiKey`
52
54
 
53
55
  ### `uinotes projects`
54
56
 
@@ -122,11 +124,19 @@ The CLI looks for config in two places (local takes priority):
122
124
  ```json
123
125
  {
124
126
  "apiUrl": "http://localhost:3000",
125
- "apiKey": "your-api-key",
126
- "defaultProject": "my-app"
127
+ "apiKey": "your-api-key"
127
128
  }
128
129
  ```
129
130
 
131
+ ### Project Binding
132
+
133
+ Run `uinotes set project <slug>` to create a `.uinotes` file in the current directory. This binds the project to the directory tree so all commands automatically use it — no `--project` flag needed.
134
+
135
+ The CLI resolves the project in this order:
136
+ 1. `--project` flag (explicit override)
137
+ 2. `.uinotes` file in current directory or nearest parent
138
+ 3. No project (commands that require one will error)
139
+
130
140
  API keys are redacted when displayed with `uinotes config`.
131
141
 
132
142
  ## Global Options
package/api.ts CHANGED
@@ -11,7 +11,7 @@ export async function apiFetch(
11
11
  'x-api-key': config.apiKey,
12
12
  };
13
13
 
14
- const targetProject = project || config.defaultProject;
14
+ const targetProject = project || config.project;
15
15
  if (targetProject) {
16
16
  headers['x-project'] = targetProject;
17
17
  }
@@ -0,0 +1,116 @@
1
+ import type { Config } from '../config.js';
2
+ import { apiFetch } from '../api.js';
3
+ import { readFileSync } from 'fs';
4
+
5
+ interface BatchOp {
6
+ op: 'resolve' | 'wontfix' | 'comment' | 'reopen';
7
+ id: string;
8
+ message?: string;
9
+ text?: string;
10
+ }
11
+
12
+ interface BatchOptions {
13
+ project?: string;
14
+ concurrency?: number;
15
+ dryRun?: boolean;
16
+ }
17
+
18
+ export async function batch(config: Config, source: string, opts: BatchOptions): Promise<void> {
19
+ let input: string;
20
+ if (source === '-') {
21
+ input = await readStdin();
22
+ } else {
23
+ input = readFileSync(source, 'utf-8');
24
+ }
25
+
26
+ const lines = input.trim().split('\n').filter(l => l.trim());
27
+ const ops: BatchOp[] = [];
28
+
29
+ for (let i = 0; i < lines.length; i++) {
30
+ try {
31
+ ops.push(JSON.parse(lines[i]));
32
+ } catch {
33
+ console.error(`Line ${i + 1}: invalid JSON — skipped`);
34
+ }
35
+ }
36
+
37
+ if (ops.length === 0) {
38
+ console.log('No operations to execute.');
39
+ return;
40
+ }
41
+
42
+ if (opts.dryRun) {
43
+ console.log(`Dry run: ${ops.length} operations`);
44
+ for (let i = 0; i < ops.length; i++) {
45
+ console.log(`[${i + 1}/${ops.length}] ${ops[i].op} ${ops[i].id}`);
46
+ }
47
+ return;
48
+ }
49
+
50
+ const concurrency = opts.concurrency || 5;
51
+ let succeeded = 0;
52
+ let failed = 0;
53
+
54
+ for (let i = 0; i < ops.length; i += concurrency) {
55
+ const chunk = ops.slice(i, i + concurrency);
56
+ await Promise.allSettled(
57
+ chunk.map(async (op, idx) => {
58
+ const globalIdx = i + idx;
59
+ try {
60
+ await executeOp(config, op, opts.project);
61
+ succeeded++;
62
+ console.log(`[${globalIdx + 1}/${ops.length}] ${op.op} ${op.id} ✓`);
63
+ } catch (err: any) {
64
+ failed++;
65
+ console.log(`[${globalIdx + 1}/${ops.length}] ${op.op} ${op.id} ✗ ${err.message}`);
66
+ }
67
+ })
68
+ );
69
+ }
70
+
71
+ console.log(`Batch complete: ${succeeded}/${ops.length} succeeded${failed > 0 ? `, ${failed} failed` : ''}`);
72
+ }
73
+
74
+ async function executeOp(config: Config, op: BatchOp, project?: string): Promise<void> {
75
+ switch (op.op) {
76
+ case 'resolve':
77
+ await apiFetch(config, `/notes/${op.id}`, {
78
+ method: 'PATCH',
79
+ body: { status: 'resolved', resolution: op.message },
80
+ project,
81
+ });
82
+ break;
83
+ case 'wontfix':
84
+ await apiFetch(config, `/notes/${op.id}`, {
85
+ method: 'PATCH',
86
+ body: { status: 'wontfix', resolution: op.message },
87
+ project,
88
+ });
89
+ break;
90
+ case 'comment':
91
+ if (!op.text) throw new Error('Missing "text" field for comment operation');
92
+ await apiFetch(config, `/notes/${op.id}/comments`, {
93
+ method: 'POST',
94
+ body: { body: op.text },
95
+ project,
96
+ });
97
+ break;
98
+ case 'reopen':
99
+ await apiFetch(config, `/notes/${op.id}`, {
100
+ method: 'PATCH',
101
+ body: { status: 'open' },
102
+ project,
103
+ });
104
+ break;
105
+ default:
106
+ throw new Error(`Unknown operation: ${(op as any).op}`);
107
+ }
108
+ }
109
+
110
+ async function readStdin(): Promise<string> {
111
+ const chunks: Buffer[] = [];
112
+ for await (const chunk of process.stdin) {
113
+ chunks.push(Buffer.from(chunk));
114
+ }
115
+ return Buffer.concat(chunks).toString('utf-8');
116
+ }
@@ -31,9 +31,9 @@ async function bulkResolve(
31
31
  filterValue: string,
32
32
  opts: BulkResolveOptions,
33
33
  ): Promise<void> {
34
- const project = opts.project || config.defaultProject;
34
+ const project = opts.project || config.project;
35
35
  if (!project) {
36
- console.error('No project specified. Use --project or set defaultProject in config.');
36
+ console.error('No project specified. Use --project or run: uinotes set project <slug>');
37
37
  process.exit(1);
38
38
  }
39
39
 
@@ -0,0 +1,150 @@
1
+ import type { Config } from '../config.js';
2
+ import { apiFetch } from '../api.js';
3
+
4
+ interface ChangelogOptions {
5
+ project?: string;
6
+ since?: string;
7
+ format?: 'markdown' | 'json' | 'conventional';
8
+ }
9
+
10
+ interface Note {
11
+ id: string;
12
+ type: string;
13
+ body: string;
14
+ component: string | null;
15
+ resolved_at: string | null;
16
+ }
17
+
18
+ const TYPE_ORDER = ['bug', 'ux', 'feature', 'question'];
19
+
20
+ const TYPE_LABELS: Record<string, string> = {
21
+ bug: 'Bugs',
22
+ ux: 'UX',
23
+ feature: 'Features',
24
+ question: 'Questions',
25
+ };
26
+
27
+ const TYPE_CONVENTIONAL: Record<string, string> = {
28
+ bug: 'fix',
29
+ ux: 'style',
30
+ feature: 'feat',
31
+ question: 'docs',
32
+ };
33
+
34
+ function isISODate(value: string): boolean {
35
+ return /^\d{4}-\d{2}-\d{2}/.test(value);
36
+ }
37
+
38
+ function resolveTimestamp(since: string): string {
39
+ if (isISODate(since)) {
40
+ return since;
41
+ }
42
+
43
+ // Treat as git ref, resolve to ISO timestamp
44
+ const result = Bun.spawnSync(['git', 'log', '-1', '--format=%cI', since]);
45
+ const output = result.stdout.toString().trim();
46
+
47
+ if (result.exitCode !== 0 || !output) {
48
+ throw new Error(`Could not resolve git ref "${since}". Make sure it exists.`);
49
+ }
50
+
51
+ return output;
52
+ }
53
+
54
+ function formatMarkdown(notes: Note[]): string {
55
+ const grouped: Record<string, Note[]> = {};
56
+
57
+ for (const note of notes) {
58
+ if (!grouped[note.type]) grouped[note.type] = [];
59
+ grouped[note.type].push(note);
60
+ }
61
+
62
+ const lines: string[] = ['## UI Notes Resolved', ''];
63
+
64
+ for (const type of TYPE_ORDER) {
65
+ const group = grouped[type];
66
+ if (!group || group.length === 0) continue;
67
+
68
+ lines.push(`### ${TYPE_LABELS[type] || type}`);
69
+ for (const note of group) {
70
+ const shortId = note.id.slice(0, 8);
71
+ const body = note.body.split('\n')[0].trim();
72
+ lines.push(`- ${body} (#${shortId})`);
73
+ }
74
+ lines.push('');
75
+ }
76
+
77
+ return lines.join('\n');
78
+ }
79
+
80
+ function formatConventional(notes: Note[]): string {
81
+ const lines: string[] = [];
82
+
83
+ for (const type of TYPE_ORDER) {
84
+ const group = notes.filter((n) => n.type === type);
85
+ for (const note of group) {
86
+ const prefix = TYPE_CONVENTIONAL[note.type] || note.type;
87
+ const scope = note.component || 'ui';
88
+ const body = note.body.split('\n')[0].trim();
89
+ const shortId = note.id.slice(0, 8);
90
+ lines.push(`${prefix}(${scope}): ${body} (note:${shortId})`);
91
+ }
92
+ }
93
+
94
+ return lines.join('\n');
95
+ }
96
+
97
+ function formatJSON(notes: Note[]): string {
98
+ const items = notes.map((n) => ({
99
+ id: n.id,
100
+ type: n.type,
101
+ body: n.body,
102
+ component: n.component,
103
+ resolved_at: n.resolved_at,
104
+ }));
105
+ return JSON.stringify(items, null, 2);
106
+ }
107
+
108
+ export async function changelog(config: Config, opts: ChangelogOptions): Promise<void> {
109
+ const since = opts.since;
110
+ if (!since) {
111
+ console.error('--since is required. Provide a git ref (e.g., HEAD~5, v1.2.0) or ISO date (e.g., 2026-02-01).');
112
+ process.exit(1);
113
+ }
114
+
115
+ const timestamp = resolveTimestamp(since);
116
+ const format = opts.format || 'markdown';
117
+
118
+ const params = new URLSearchParams();
119
+ params.set('status', 'all');
120
+ params.set('resolved_after', timestamp);
121
+ params.set('limit', '500');
122
+
123
+ const path = `/notes?${params.toString()}`;
124
+ const response = (await apiFetch(config, path, { project: opts.project })) as {
125
+ ok: boolean;
126
+ data: { notes: Note[] };
127
+ };
128
+
129
+ const notes = response.data.notes
130
+ .filter((n) => n.resolved_at)
131
+ .sort((a, b) => (a.resolved_at! > b.resolved_at! ? 1 : -1));
132
+
133
+ if (notes.length === 0) {
134
+ console.log('No resolved notes found since ' + timestamp);
135
+ return;
136
+ }
137
+
138
+ switch (format) {
139
+ case 'json':
140
+ console.log(formatJSON(notes));
141
+ break;
142
+ case 'conventional':
143
+ console.log(formatConventional(notes));
144
+ break;
145
+ case 'markdown':
146
+ default:
147
+ console.log(formatMarkdown(notes));
148
+ break;
149
+ }
150
+ }
@@ -1,6 +1,6 @@
1
1
  import { getConfig, getConfigPath, setConfigValue } from '../config.js';
2
2
 
3
- const VALID_KEYS = ['apiUrl', 'apiKey', 'defaultProject'];
3
+ const VALID_KEYS = ['apiUrl', 'apiKey'];
4
4
 
5
5
  function redactKey(value: string): string {
6
6
  if (!value || value.length <= 4) return '****';
@@ -55,7 +55,7 @@ export async function context(config: Config, opts: ContextOptions): Promise<voi
55
55
  const output = formatNotesForAI(notes, {
56
56
  format: opts.format || 'markdown',
57
57
  componentFilter: opts.component,
58
- projectName: opts.project || config.defaultProject || 'unknown',
58
+ projectName: opts.project || config.project || 'unknown',
59
59
  resolvedNotes: resolvedMap,
60
60
  });
61
61
 
@@ -0,0 +1,67 @@
1
+ import type { Config } from '../config.js';
2
+ import { apiFetch } from '../api.js';
3
+
4
+ interface ExplainOptions {
5
+ format?: 'markdown' | 'json';
6
+ project?: string;
7
+ }
8
+
9
+ export async function explain(config: Config, noteId: string, opts: ExplainOptions): Promise<void> {
10
+ const response = await apiFetch(config, `/notes/${noteId}/explain`, {
11
+ project: opts.project,
12
+ }) as any;
13
+
14
+ const { note, comments, history, visual_diffs } = response.data;
15
+
16
+ if (opts.format === 'json') {
17
+ console.log(JSON.stringify(response.data, null, 2));
18
+ return;
19
+ }
20
+
21
+ // Default: markdown format
22
+ const lines: string[] = [];
23
+ lines.push(`# Note ${note.id} — ${note.type} (${note.status})`);
24
+ lines.push('');
25
+ if (note.component) lines.push(`**Component**: ${note.component}`);
26
+ if (note.selector) lines.push(`**Selector**: \`${note.selector}\``);
27
+ lines.push(`**URL**: ${note.url}`);
28
+ if (note.author) lines.push(`**Author**: ${note.author}`);
29
+ lines.push(`**Created**: ${note.created_at}`);
30
+ if (note.resolved_at) lines.push(`**Resolved**: ${note.resolved_at}`);
31
+ if (note.screenshot_url) lines.push(`**Screenshot**: ${note.screenshot_url}`);
32
+ lines.push('');
33
+ lines.push(`> ${note.body}`);
34
+
35
+ if (comments.length > 0) {
36
+ lines.push('');
37
+ lines.push(`## Comments (${comments.length})`);
38
+ lines.push('');
39
+ for (const c of comments) {
40
+ lines.push(`**${c.author}** (${c.created_at}):`);
41
+ lines.push(`> ${c.body}`);
42
+ lines.push('');
43
+ }
44
+ }
45
+
46
+ if (history.length > 0) {
47
+ lines.push('## History');
48
+ lines.push('');
49
+ for (const h of history) {
50
+ lines.push(`- ${h.changed_at}: ${h.field} ${h.old_value ?? '(none)'} → ${h.new_value ?? '(none)'}${h.changed_by ? ` (${h.changed_by})` : ''}`);
51
+ }
52
+ }
53
+
54
+ if (visual_diffs.length > 0) {
55
+ lines.push('');
56
+ lines.push(`## Visual Diffs (${visual_diffs.length})`);
57
+ lines.push('');
58
+ for (const vd of visual_diffs) {
59
+ lines.push(`- ${vd.created_at}: diff ${vd.diff_percentage != null ? `${vd.diff_percentage}%` : 'N/A'}`);
60
+ if (vd.before_url) lines.push(` Before: ${vd.before_url}`);
61
+ if (vd.after_url) lines.push(` After: ${vd.after_url}`);
62
+ if (vd.diff_url) lines.push(` Diff: ${vd.diff_url}`);
63
+ }
64
+ }
65
+
66
+ console.log(lines.join('\n'));
67
+ }
@@ -0,0 +1,58 @@
1
+ import type { Config } from '../config.js';
2
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync } from 'fs';
3
+ import { join } from 'path';
4
+
5
+ interface InstallSkillsOptions {
6
+ dir?: string;
7
+ check?: boolean;
8
+ }
9
+
10
+ const DEFAULT_DIR = '.claude/skills';
11
+
12
+ export async function installSkills(_config: Config, opts: InstallSkillsOptions): Promise<void> {
13
+ const targetDir = opts.dir || join(process.cwd(), DEFAULT_DIR);
14
+ const skillsSourceDir = join(import.meta.dir, '..', 'skills');
15
+
16
+ // List available skills
17
+ let skillFiles: string[];
18
+ try {
19
+ skillFiles = readdirSync(skillsSourceDir).filter(f => f.endsWith('.md'));
20
+ } catch {
21
+ console.error('Could not read bundled skill files.');
22
+ process.exit(1);
23
+ }
24
+
25
+ if (skillFiles.length === 0) {
26
+ console.error('No skill files found in package.');
27
+ process.exit(1);
28
+ }
29
+
30
+ if (opts.check) {
31
+ // Check installed vs bundled
32
+ console.log('Skill status:');
33
+ for (const file of skillFiles) {
34
+ const targetPath = join(targetDir, file);
35
+ if (existsSync(targetPath)) {
36
+ console.log(` ${file}: installed`);
37
+ } else {
38
+ console.log(` ${file}: not installed`);
39
+ }
40
+ }
41
+ return;
42
+ }
43
+
44
+ // Install skills
45
+ mkdirSync(targetDir, { recursive: true });
46
+
47
+ for (const file of skillFiles) {
48
+ const sourcePath = join(skillsSourceDir, file);
49
+ const targetPath = join(targetDir, file);
50
+ const content = readFileSync(sourcePath, 'utf-8');
51
+ writeFileSync(targetPath, content);
52
+ }
53
+
54
+ console.log(`Installed ${skillFiles.length} skills to ${targetDir}/`);
55
+ for (const file of skillFiles) {
56
+ console.log(` ${file}`);
57
+ }
58
+ }
@@ -0,0 +1,33 @@
1
+ import type { Config } from '../config.js';
2
+ import { readFileSync } from 'fs';
3
+ import { join } from 'path';
4
+
5
+ const RESOURCES = ['notes', 'comments', 'projects', 'webhooks'];
6
+
7
+ export async function schema(_config: Config, resource?: string): Promise<void> {
8
+ if (!resource) {
9
+ console.log('Available schemas:');
10
+ for (const r of RESOURCES) {
11
+ console.log(` ${r}`);
12
+ }
13
+ console.log('\nUsage: uinotes schema --resource <name>');
14
+ return;
15
+ }
16
+
17
+ if (!RESOURCES.includes(resource)) {
18
+ console.error(`Unknown resource: ${resource}`);
19
+ console.error(`Available: ${RESOURCES.join(', ')}`);
20
+ process.exit(1);
21
+ }
22
+
23
+ const schemaDir = join(import.meta.dir, '..', 'schemas');
24
+ const schemaPath = join(schemaDir, `${resource}.json`);
25
+
26
+ try {
27
+ const content = readFileSync(schemaPath, 'utf-8');
28
+ console.log(content);
29
+ } catch {
30
+ console.error(`Schema file not found: ${schemaPath}`);
31
+ process.exit(1);
32
+ }
33
+ }
@@ -0,0 +1,9 @@
1
+ import { writeFileSync } from 'fs';
2
+ import { join } from 'path';
3
+
4
+ export function setProject(slug: string): void {
5
+ const filePath = join(process.cwd(), '.uinotes');
6
+ writeFileSync(filePath, JSON.stringify({ project: slug }, null, 2) + '\n');
7
+ console.log(`Bound project "${slug}" to ${process.cwd()}`);
8
+ console.log(`Created .uinotes — commit this file so your team and AI agents inherit it.`);
9
+ }
@@ -37,9 +37,9 @@ async function loadPlaywright() {
37
37
  }
38
38
 
39
39
  export async function visualDiff(config: Config, opts: VisualDiffOptions): Promise<void> {
40
- const project = opts.project || config.defaultProject;
40
+ const project = opts.project || config.project;
41
41
  if (!project) {
42
- console.error('Error: --project is required (or set defaultProject in config)');
42
+ console.error('Error: --project is required (or run: uinotes set project <slug>)');
43
43
  process.exit(1);
44
44
  }
45
45
 
package/config.ts CHANGED
@@ -11,7 +11,7 @@ export interface ResolveConfig {
11
11
  export interface Config {
12
12
  apiUrl: string;
13
13
  apiKey: string;
14
- defaultProject?: string;
14
+ project?: string;
15
15
  resolve?: ResolveConfig;
16
16
  }
17
17
 
@@ -23,11 +23,34 @@ const DEFAULT_CONFIG: Config = {
23
23
  apiKey: '',
24
24
  };
25
25
 
26
- function readJsonFile(path: string): Config | null {
26
+ function readJsonFile(path: string): Record<string, any> | null {
27
27
  if (!existsSync(path)) return null;
28
28
  return JSON.parse(readFileSync(path, 'utf-8'));
29
29
  }
30
30
 
31
+ /**
32
+ * Walk up the directory tree from cwd looking for a .uinotes file
33
+ * that binds a project slug to this directory tree.
34
+ */
35
+ function findDirectoryProject(startDir: string): string | undefined {
36
+ let dir = startDir;
37
+ while (true) {
38
+ const filePath = join(dir, '.uinotes');
39
+ if (existsSync(filePath)) {
40
+ try {
41
+ const content = JSON.parse(readFileSync(filePath, 'utf-8'));
42
+ if (content.project) return content.project;
43
+ } catch {
44
+ // Invalid JSON — skip
45
+ }
46
+ }
47
+ const parent = dirname(dir);
48
+ if (parent === dir) break; // reached filesystem root
49
+ dir = parent;
50
+ }
51
+ return undefined;
52
+ }
53
+
31
54
  export function getConfigPath(): string {
32
55
  if (existsSync(LOCAL_PATH)) return LOCAL_PATH;
33
56
  return GLOBAL_PATH;
@@ -36,14 +59,22 @@ export function getConfigPath(): string {
36
59
  export function getConfig(): Config {
37
60
  // Per-repo config takes priority
38
61
  const local = readJsonFile(LOCAL_PATH);
39
- if (local) return { ...DEFAULT_CONFIG, ...local };
62
+ const base = local
63
+ ? { ...DEFAULT_CONFIG, ...local }
64
+ : (() => {
65
+ const global = readJsonFile(GLOBAL_PATH);
66
+ if (global) return { ...DEFAULT_CONFIG, ...global };
67
+ // Create default global config
68
+ writeFileSync(GLOBAL_PATH, JSON.stringify(DEFAULT_CONFIG, null, 2) + '\n');
69
+ return { ...DEFAULT_CONFIG };
70
+ })();
40
71
 
41
- const global = readJsonFile(GLOBAL_PATH);
42
- if (global) return { ...DEFAULT_CONFIG, ...global };
72
+ // Resolve project from .uinotes directory binding (if not already set)
73
+ if (!base.project) {
74
+ base.project = findDirectoryProject(process.cwd());
75
+ }
43
76
 
44
- // Create default global config
45
- writeFileSync(GLOBAL_PATH, JSON.stringify(DEFAULT_CONFIG, null, 2) + '\n');
46
- return DEFAULT_CONFIG;
77
+ return base as Config;
47
78
  }
48
79
 
49
80
  export function setConfigValue(key: string, value: string): Config {
package/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env bun
1
+ #!/usr/bin/env bun
2
2
 
3
3
  import { getConfig } from './config.js';
4
4
  import { configShow, configSet } from './commands/config.js';
@@ -22,16 +22,22 @@ Commands:
22
22
  projects add-url <slug> <pattern> Add URL pattern to project
23
23
  projects remove-url <slug> <pattern> Remove URL pattern
24
24
  projects delete <slug> Archive a project
25
+ set project <slug> Bind project to current directory
25
26
  login Login and create API key
26
27
  pull Fetch open notes
27
28
  context AI-optimized digest of open notes
29
+ explain <id> Full context for a single note
28
30
  resolve <id> Mark note as resolved
29
31
  wontfix <id> Mark note as won't fix
30
32
  comment <id> "text" Add a comment to a note
31
33
  comments <id> List comments for a note
32
34
  resolve-by-component <name> [msg] Bulk resolve notes by component
33
35
  resolve-by-selector <sel> [msg] Bulk resolve notes by selector
36
+ changelog Generate changelog from resolved notes
34
37
  visual-diff Capture & compare page screenshots
38
+ batch <file|-> Execute NDJSON batch operations
39
+ schema List or print API resource schemas
40
+ install-skills Install Claude Code skill files
35
41
  config View/edit configuration
36
42
 
37
43
  Options:
@@ -47,7 +53,13 @@ Options:
47
53
  --root-dir <dir> Source root for file resolution (default: cwd)
48
54
  --compare Compare current screenshots against previous (visual-diff)
49
55
  --width <px> Viewport width for screenshots (default: 1280)
50
- --full-page Capture full page height (default: true)`;
56
+ --full-page Capture full page height (default: true)
57
+ --since <ref> Git ref or ISO date for changelog start
58
+ --dir <path> Target directory for skill installation
59
+ --check Check installed skill versions
60
+ --resource <name> Resource name for schema command
61
+ --concurrency <n> Concurrent batch operations (default: 5)
62
+ --dry-run Preview batch operations without executing`;
51
63
 
52
64
  function parseFlags(args: string[]): { flags: Record<string, string>; arrays: Record<string, string[]>; positional: string[] } {
53
65
  const flags: Record<string, string> = {};
@@ -116,13 +128,47 @@ async function main() {
116
128
  return;
117
129
  }
118
130
 
131
+ // set command is local-only, no API key needed
132
+ if (command === 'set') {
133
+ const sub = positional[1];
134
+ if (sub === 'project') {
135
+ const slug = positional[2];
136
+ if (!slug) {
137
+ console.error('Usage: uinotes set project <slug>');
138
+ process.exit(1);
139
+ }
140
+ const { setProject } = await import('./commands/set.js');
141
+ setProject(slug);
142
+ return;
143
+ }
144
+ console.error(`Unknown set subcommand: ${sub}`);
145
+ process.exit(1);
146
+ }
147
+
148
+ // Schema command is local-only, no API key needed
149
+ if (command === 'schema') {
150
+ const { schema } = await import('./commands/schema.js');
151
+ await schema(getConfig(), flags.resource);
152
+ return;
153
+ }
154
+
155
+ // install-skills is local-only, no API key needed
156
+ if (command === 'install-skills') {
157
+ const { installSkills } = await import('./commands/install-skills.js');
158
+ await installSkills(getConfig(), {
159
+ dir: flags.dir,
160
+ check: flags.check === 'true',
161
+ });
162
+ return;
163
+ }
164
+
119
165
  const config = getConfig();
120
166
  if (!config.apiKey) {
121
167
  console.error('No API key configured. Run: uinotes config set apiKey <your-key>');
122
168
  process.exit(1);
123
169
  }
124
170
 
125
- const project = flags.project;
171
+ const project = flags.project || config.project;
126
172
 
127
173
  switch (command) {
128
174
  case 'projects': {
@@ -230,6 +276,20 @@ async function main() {
230
276
  });
231
277
  break;
232
278
 
279
+ case 'explain': {
280
+ const id = positional[1];
281
+ if (!id) {
282
+ console.error('Usage: uinotes explain <note-id> [--format markdown|json]');
283
+ process.exit(1);
284
+ }
285
+ const { explain } = await import('./commands/explain.js');
286
+ await explain(config, id, {
287
+ project,
288
+ format: (flags.format as 'markdown' | 'json') || undefined,
289
+ });
290
+ break;
291
+ }
292
+
233
293
  case 'resolve': {
234
294
  const id = positional[1];
235
295
  if (!id) {
@@ -288,6 +348,16 @@ async function main() {
288
348
  break;
289
349
  }
290
350
 
351
+ case 'changelog': {
352
+ const { changelog } = await import('./commands/changelog.js');
353
+ await changelog(config, {
354
+ project,
355
+ since: flags.since,
356
+ format: (flags.format as 'markdown' | 'json' | 'conventional') || undefined,
357
+ });
358
+ break;
359
+ }
360
+
291
361
  case 'visual-diff': {
292
362
  const { visualDiff } = await import('./commands/visual-diff.js');
293
363
  await visualDiff(config, {
@@ -300,6 +370,21 @@ async function main() {
300
370
  break;
301
371
  }
302
372
 
373
+ case 'batch': {
374
+ const source = positional[1];
375
+ if (!source) {
376
+ console.error('Usage: uinotes batch <file|-> [--concurrency 5] [--dry-run]');
377
+ process.exit(1);
378
+ }
379
+ const { batch } = await import('./commands/batch.js');
380
+ await batch(config, source, {
381
+ project,
382
+ concurrency: flags.concurrency ? parseInt(flags.concurrency) : undefined,
383
+ dryRun: flags['dry-run'] === 'true',
384
+ });
385
+ break;
386
+ }
387
+
303
388
  case 'resolve-by-selector': {
304
389
  const sel = positional[1];
305
390
  if (!sel) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@upgraide/ui-notes-cli",
3
- "version": "0.1.3",
3
+ "version": "0.2.1",
4
4
  "description": "CLI for UI Notes - pull and manage UI feedback notes",
5
5
  "license": "MIT",
6
6
  "keywords": ["cli", "ui", "notes", "feedback", "ui-notes"],
@@ -11,7 +11,9 @@
11
11
  "*.ts",
12
12
  "commands/*.ts",
13
13
  "formatters/*.ts",
14
- "resolvers/*.ts"
14
+ "resolvers/*.ts",
15
+ "schemas/*.json",
16
+ "skills/*.md"
15
17
  ],
16
18
  "engines": {
17
19
  "bun": ">=1.0.0"
@@ -0,0 +1,14 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "title": "Comment",
4
+ "description": "A comment on a note",
5
+ "type": "object",
6
+ "properties": {
7
+ "id": { "type": "string", "description": "Unique comment identifier" },
8
+ "note_id": { "type": "string", "description": "ID of the parent note" },
9
+ "author": { "type": "string", "description": "Comment author" },
10
+ "body": { "type": "string", "description": "Comment text" },
11
+ "created_at": { "type": "string", "format": "date-time", "description": "Creation timestamp" }
12
+ },
13
+ "required": ["id", "note_id", "author", "body", "created_at"]
14
+ }
@@ -0,0 +1,24 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "title": "Note",
4
+ "description": "A UI feedback note attached to a page element",
5
+ "type": "object",
6
+ "properties": {
7
+ "id": { "type": "string", "description": "Unique note identifier" },
8
+ "project": { "type": "string", "description": "Project slug this note belongs to" },
9
+ "type": { "type": "string", "enum": ["bug", "ux", "feature", "question"], "description": "Type of feedback" },
10
+ "status": { "type": "string", "enum": ["open", "resolved", "wontfix"], "description": "Current status" },
11
+ "body": { "type": "string", "description": "Note content/description" },
12
+ "url": { "type": "string", "description": "Page URL where the note was created" },
13
+ "component": { "type": ["string", "null"], "description": "Component name from data attributes" },
14
+ "text_content": { "type": ["string", "null"], "description": "Text content of the annotated element" },
15
+ "selector": { "type": ["string", "null"], "description": "CSS selector for the annotated element" },
16
+ "tag": { "type": ["string", "null"], "description": "HTML tag of the annotated element" },
17
+ "author": { "type": ["string", "null"], "description": "Author email or name" },
18
+ "screenshot_url": { "type": ["string", "null"], "description": "URL to the screenshot capture" },
19
+ "resolution": { "type": ["string", "null"], "description": "Resolution message when resolved" },
20
+ "resolved_at": { "type": ["string", "null"], "format": "date-time", "description": "When the note was resolved" },
21
+ "created_at": { "type": "string", "format": "date-time", "description": "Creation timestamp" }
22
+ },
23
+ "required": ["id", "project", "type", "status", "body", "url", "created_at"]
24
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "title": "Project",
4
+ "description": "A UI Notes project that groups notes",
5
+ "type": "object",
6
+ "properties": {
7
+ "id": { "type": "string", "description": "Unique project identifier" },
8
+ "slug": { "type": "string", "description": "URL-friendly project identifier" },
9
+ "name": { "type": "string", "description": "Display name" },
10
+ "url_patterns": { "type": "array", "items": { "type": "string" }, "description": "URL glob patterns for auto-matching" },
11
+ "archived": { "type": "boolean", "description": "Whether the project is archived" },
12
+ "created_at": { "type": "string", "format": "date-time", "description": "Creation timestamp" }
13
+ },
14
+ "required": ["id", "slug", "name", "created_at"]
15
+ }
@@ -0,0 +1,16 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "title": "Webhook",
4
+ "description": "A webhook endpoint for receiving note events",
5
+ "type": "object",
6
+ "properties": {
7
+ "id": { "type": "string", "description": "Unique webhook identifier" },
8
+ "project_id": { "type": "string", "description": "Project this webhook belongs to" },
9
+ "url": { "type": "string", "description": "Webhook delivery URL" },
10
+ "secret": { "type": ["string", "null"], "description": "HMAC signing secret" },
11
+ "events": { "type": "array", "items": { "type": "string" }, "description": "Events to subscribe to" },
12
+ "active": { "type": "boolean", "description": "Whether the webhook is active" },
13
+ "created_at": { "type": "string", "format": "date-time", "description": "Creation timestamp" }
14
+ },
15
+ "required": ["id", "project_id", "url", "events", "active", "created_at"]
16
+ }
@@ -0,0 +1,23 @@
1
+ ---
2
+ name: uinotes-batch
3
+ description: Execute multiple note operations in a single call using NDJSON. Use for bulk triage of multiple notes.
4
+ ---
5
+
6
+ # uinotes-batch
7
+
8
+ Execute multiple operations (resolve, wontfix, comment, reopen) in one invocation.
9
+
10
+ ## When to use
11
+ - After triaging multiple notes and deciding actions for each
12
+ - When resolving several notes as part of a single fix
13
+ - For bulk operations that span multiple notes
14
+
15
+ ## Command
16
+ ```bash
17
+ echo '{"op":"resolve","id":"abc123","message":"Fixed overflow"}
18
+ {"op":"comment","id":"def456","text":"Needs design input"}
19
+ {"op":"wontfix","id":"ghi789","message":"Deprecated component"}' | uinotes batch -
20
+ ```
21
+
22
+ Operations: `resolve`, `wontfix`, `comment` (requires `text`), `reopen`.
23
+ Add `--dry-run` to preview without executing.
@@ -0,0 +1,22 @@
1
+ ---
2
+ name: uinotes-changelog
3
+ description: Generate a changelog from resolved UI notes. Use after resolving notes to create release notes or PR descriptions.
4
+ ---
5
+
6
+ # uinotes-changelog
7
+
8
+ Generate a changelog from notes resolved since a git ref or date.
9
+
10
+ ## When to use
11
+ - After resolving notes, to generate a summary for PR descriptions
12
+ - For release notes listing UI fixes
13
+ - To audit what was fixed in a time period
14
+
15
+ ## Command
16
+ ```bash
17
+ uinotes changelog --since HEAD~5
18
+ uinotes changelog --since v1.2.0 --format conventional
19
+ uinotes changelog --since 2026-02-01 --format json
20
+ ```
21
+
22
+ Formats: `markdown` (default), `conventional` (conventional commits), `json`.
@@ -0,0 +1,19 @@
1
+ ---
2
+ name: uinotes-comment
3
+ description: Add a comment to a UI note. Use to document progress, ask questions, or provide status updates on notes.
4
+ ---
5
+
6
+ # uinotes-comment
7
+
8
+ Add comments to notes to document progress or communicate.
9
+
10
+ ## When to use
11
+ - To document what was tried or what's blocking a fix
12
+ - To ask clarifying questions about a note
13
+ - To provide a status update before resolving
14
+
15
+ ## Command
16
+ ```bash
17
+ uinotes comment <note-id> "Comment text here"
18
+ uinotes comments <note-id> # List existing comments
19
+ ```
@@ -0,0 +1,22 @@
1
+ ---
2
+ name: uinotes-context
3
+ description: Fetch AI-optimized context of open UI notes for the current project. Use when asked to check for UI bugs, feedback, or notes.
4
+ ---
5
+
6
+ # uinotes-context
7
+
8
+ Run `uinotes context` to get a structured digest of all open UI notes.
9
+
10
+ ## When to use
11
+ - At the start of a task to understand what UI issues exist
12
+ - Before making UI changes to check for related notes
13
+ - When the user asks about UI bugs, feedback, or design issues
14
+
15
+ ## Command
16
+ ```bash
17
+ uinotes context --format markdown
18
+ ```
19
+
20
+ Add `--resolve-files --root-dir .` to map notes to source files.
21
+ Add `--type bug` to filter by type (bug, ux, feature, question).
22
+ Add `--project <slug>` to target a specific project.
@@ -0,0 +1,21 @@
1
+ ---
2
+ name: uinotes-explain
3
+ description: Get full context for a single note including comments, history, and visual diffs. Use when investigating a specific note.
4
+ ---
5
+
6
+ # uinotes-explain
7
+
8
+ Get fully-hydrated context for a single note in one call.
9
+
10
+ ## When to use
11
+ - When investigating a specific note before fixing it
12
+ - When you need comments, history, or visual diff context
13
+ - When triaging a single note
14
+
15
+ ## Command
16
+ ```bash
17
+ uinotes explain <note-id>
18
+ uinotes explain <note-id> --format json
19
+ ```
20
+
21
+ Returns: note body, type, status, component, selector, URL, screenshot URL, all comments, status history, and visual diffs.
@@ -0,0 +1,26 @@
1
+ ---
2
+ name: uinotes-resolve
3
+ description: Mark a UI note as resolved after fixing it. Use after completing a fix for a UI issue.
4
+ ---
5
+
6
+ # uinotes-resolve
7
+
8
+ Mark notes as resolved after fixing the issue they describe.
9
+
10
+ ## When to use
11
+ - After fixing a bug, UX issue, or implementing a feature request described in a note
12
+ - After verifying a fix addresses the feedback
13
+
14
+ ## Commands
15
+ ```bash
16
+ # Resolve with a message explaining the fix
17
+ uinotes resolve <note-id> "Fixed by updating the component"
18
+
19
+ # Mark as won't fix with explanation
20
+ uinotes wontfix <note-id> "Working as intended"
21
+
22
+ # Bulk resolve all notes for a component
23
+ uinotes resolve-by-component <ComponentName> --message "Refactored component"
24
+ ```
25
+
26
+ Always include a resolution message describing what was done.