@leejungkiin/awkit 1.6.4 → 1.6.5
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/bin/awk.js +70 -5
- package/bin/claude-generators.js +2 -2
- package/bin/cursor-generators.js +256 -0
- package/core/CURSOR.md +47 -0
- package/core/CURSOR_DETAILED.md +102 -0
- package/package.json +2 -2
- package/scripts/automation-gate.js +18 -0
- package/scripts/obsidian-sync.js +494 -0
- package/skills/app-store-screenshots/SKILL.md +86 -0
- package/skills/app-store-screenshots/resources/mockup.png +0 -0
- package/skills/aseprite-artist/SKILL.md +346 -0
- package/skills/aseprite-artist/resources/examples.md +188 -0
- package/skills/aseprite-artist/resources/palettes.md +133 -0
- package/skills/pixel-art-creator/SKILL.md +89 -0
- package/skills/semantic-qa-agent/SKILL.md +68 -0
- package/skills/symphony-enforcer/examples/three-phase.md +19 -11
- package/templates/project-identity/android.json +2 -0
- package/templates/project-identity/backend-nestjs.json +2 -0
- package/templates/project-identity/expo.json +2 -0
- package/templates/project-identity/ios.json +2 -0
- package/templates/project-identity/web-nextjs.json +2 -0
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Obsidian Sync — Exports Symphony task data to Obsidian Markdown notes.
|
|
5
|
+
*
|
|
6
|
+
* Reads task data from Symphony SQLite DB and writes a formatted Markdown
|
|
7
|
+
* section into an Obsidian note file. Uses boundary markers to preserve
|
|
8
|
+
* any user-written content outside the sync region.
|
|
9
|
+
*
|
|
10
|
+
* Config is read from `.project-identity` → `automation.obsidian`.
|
|
11
|
+
*
|
|
12
|
+
* Usage (via awkit CLI):
|
|
13
|
+
* awkit obsidian sync Full sync for current project
|
|
14
|
+
* awkit obsidian sync --all Sync all projects that have obsidian config
|
|
15
|
+
* awkit obsidian status Show sync status (dry-run)
|
|
16
|
+
* awkit obsidian help Show help
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const fs = require('fs');
|
|
20
|
+
const path = require('path');
|
|
21
|
+
const { execSync } = require('child_process');
|
|
22
|
+
|
|
23
|
+
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
const HOME = process.env.HOME || process.env.USERPROFILE;
|
|
26
|
+
const SYMPHONY_DB = path.join(HOME, '.gemini', 'antigravity', 'symphony', 'symphony.db');
|
|
27
|
+
|
|
28
|
+
// ─── Color helpers ────────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
const C = {
|
|
31
|
+
reset: '\x1b[0m',
|
|
32
|
+
red: '\x1b[31m',
|
|
33
|
+
green: '\x1b[32m',
|
|
34
|
+
yellow: '\x1b[33m',
|
|
35
|
+
cyan: '\x1b[36m',
|
|
36
|
+
gray: '\x1b[90m',
|
|
37
|
+
bold: '\x1b[1m',
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const log = (msg) => console.log(msg);
|
|
41
|
+
const ok = (msg) => log(`${C.green}✔${C.reset} ${msg}`);
|
|
42
|
+
const warn = (msg) => log(`${C.yellow}⚠${C.reset} ${msg}`);
|
|
43
|
+
const err = (msg) => log(`${C.red}✖${C.reset} ${msg}`);
|
|
44
|
+
const info = (msg) => log(`${C.cyan}ℹ${C.reset} ${msg}`);
|
|
45
|
+
const dim = (msg) => log(`${C.gray}${msg}${C.reset}`);
|
|
46
|
+
|
|
47
|
+
// ─── SQLite reader (uses system sqlite3 CLI) ──────────────────────────────────
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Execute a SQLite query via the system `sqlite3` command.
|
|
51
|
+
* Returns an array of objects with column names as keys.
|
|
52
|
+
*/
|
|
53
|
+
function querySqlite(dbPath, sql) {
|
|
54
|
+
if (!fs.existsSync(dbPath)) {
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
// Use JSON output mode for reliable parsing
|
|
60
|
+
const raw = execSync(
|
|
61
|
+
`sqlite3 -json "${dbPath}" "${sql.replace(/"/g, '\\"')}"`,
|
|
62
|
+
{ encoding: 'utf8', maxBuffer: 1024 * 1024, stdio: ['pipe', 'pipe', 'pipe'] }
|
|
63
|
+
);
|
|
64
|
+
const trimmed = raw.trim();
|
|
65
|
+
if (!trimmed || trimmed === '[]') return [];
|
|
66
|
+
return JSON.parse(trimmed);
|
|
67
|
+
} catch (e) {
|
|
68
|
+
// sqlite3 may not support -json on older versions, fallback to CSV
|
|
69
|
+
try {
|
|
70
|
+
const raw = execSync(
|
|
71
|
+
`sqlite3 -header -csv "${dbPath}" "${sql.replace(/"/g, '\\"')}"`,
|
|
72
|
+
{ encoding: 'utf8', maxBuffer: 1024 * 1024, stdio: ['pipe', 'pipe', 'pipe'] }
|
|
73
|
+
);
|
|
74
|
+
return parseCsvOutput(raw.trim());
|
|
75
|
+
} catch (e2) {
|
|
76
|
+
warn(`SQLite query failed: ${e2.message}`);
|
|
77
|
+
return [];
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Parse CSV output from sqlite3 -header -csv into array of objects.
|
|
84
|
+
*/
|
|
85
|
+
function parseCsvOutput(csv) {
|
|
86
|
+
if (!csv) return [];
|
|
87
|
+
const lines = csv.split('\n').filter(l => l.trim());
|
|
88
|
+
if (lines.length < 2) return [];
|
|
89
|
+
|
|
90
|
+
const headers = parseCSVLine(lines[0]);
|
|
91
|
+
const rows = [];
|
|
92
|
+
|
|
93
|
+
for (let i = 1; i < lines.length; i++) {
|
|
94
|
+
const values = parseCSVLine(lines[i]);
|
|
95
|
+
const obj = {};
|
|
96
|
+
headers.forEach((h, idx) => {
|
|
97
|
+
obj[h] = values[idx] || '';
|
|
98
|
+
});
|
|
99
|
+
rows.push(obj);
|
|
100
|
+
}
|
|
101
|
+
return rows;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Simple CSV line parser (handles quoted fields).
|
|
106
|
+
*/
|
|
107
|
+
function parseCSVLine(line) {
|
|
108
|
+
const fields = [];
|
|
109
|
+
let current = '';
|
|
110
|
+
let inQuotes = false;
|
|
111
|
+
|
|
112
|
+
for (let i = 0; i < line.length; i++) {
|
|
113
|
+
const ch = line[i];
|
|
114
|
+
if (ch === '"') {
|
|
115
|
+
if (inQuotes && line[i + 1] === '"') {
|
|
116
|
+
current += '"';
|
|
117
|
+
i++;
|
|
118
|
+
} else {
|
|
119
|
+
inQuotes = !inQuotes;
|
|
120
|
+
}
|
|
121
|
+
} else if (ch === ',' && !inQuotes) {
|
|
122
|
+
fields.push(current);
|
|
123
|
+
current = '';
|
|
124
|
+
} else {
|
|
125
|
+
current += ch;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
fields.push(current);
|
|
129
|
+
return fields;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ─── .project-identity reader ─────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
function readProjectIdentity(startDir = process.cwd()) {
|
|
135
|
+
let dir = path.resolve(startDir);
|
|
136
|
+
const root = path.parse(dir).root;
|
|
137
|
+
|
|
138
|
+
while (dir !== root) {
|
|
139
|
+
const candidate = path.join(dir, '.project-identity');
|
|
140
|
+
if (fs.existsSync(candidate)) {
|
|
141
|
+
try {
|
|
142
|
+
return JSON.parse(fs.readFileSync(candidate, 'utf8'));
|
|
143
|
+
} catch (e) {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
dir = path.dirname(dir);
|
|
148
|
+
}
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Get obsidian config from .project-identity.
|
|
154
|
+
* Returns { enabled, path, template, autoSync } or null.
|
|
155
|
+
*/
|
|
156
|
+
function getObsidianConfig(identity) {
|
|
157
|
+
if (!identity) return null;
|
|
158
|
+
const obsidian = identity.automation?.obsidian;
|
|
159
|
+
if (!obsidian || !obsidian.enabled || !obsidian.path) return null;
|
|
160
|
+
return obsidian;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ─── Markdown renderer (Obsidian Kanban format) ──────────────────────────────
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Map priority values to emoji tag for Kanban cards.
|
|
167
|
+
*/
|
|
168
|
+
function priorityTag(priority) {
|
|
169
|
+
const map = {
|
|
170
|
+
'0': '🔴', '1': '🟠', '2': '🟡', '3': '🟢',
|
|
171
|
+
'critical': '🔴', 'high': '🟠', 'medium': '🟡', 'low': '🟢',
|
|
172
|
+
};
|
|
173
|
+
return map[String(priority).toLowerCase()] || '⚪';
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Format a task line for Kanban card.
|
|
178
|
+
* Done tasks get ✅ YYYY-MM-DD suffix (Kanban plugin convention).
|
|
179
|
+
*/
|
|
180
|
+
function formatKanbanCard(task) {
|
|
181
|
+
const emoji = priorityTag(task.priority);
|
|
182
|
+
const title = task.title || 'Untitled';
|
|
183
|
+
|
|
184
|
+
if (task.status === 'done') {
|
|
185
|
+
const doneDate = task.completed_at
|
|
186
|
+
? task.completed_at.substring(0, 10) // Extract YYYY-MM-DD
|
|
187
|
+
: new Date().toISOString().substring(0, 10);
|
|
188
|
+
return `- [x] ${emoji} ${title} ✅ ${doneDate}`;
|
|
189
|
+
}
|
|
190
|
+
return `- [ ] ${emoji} ${title}`;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Extract existing frontmatter and kanban settings to preserve customizations.
|
|
195
|
+
*/
|
|
196
|
+
function extractExistingBlocks(filePath) {
|
|
197
|
+
let result = { frontmatterLines: null, settingsJson: null };
|
|
198
|
+
if (!fs.existsSync(filePath)) return result;
|
|
199
|
+
|
|
200
|
+
try {
|
|
201
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
202
|
+
|
|
203
|
+
// Extract Frontmatter
|
|
204
|
+
const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
|
|
205
|
+
if (fmMatch) {
|
|
206
|
+
result.frontmatterLines = fmMatch[1].trim().split('\n');
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Extract Kanban Settings
|
|
210
|
+
const setMatch = content.match(/%% kanban:settings\n```.*\n([\s\S]*?)\n```\n%%/);
|
|
211
|
+
if (setMatch) {
|
|
212
|
+
try {
|
|
213
|
+
result.settingsJson = JSON.parse(setMatch[1].trim());
|
|
214
|
+
} catch (e) {}
|
|
215
|
+
}
|
|
216
|
+
} catch (e) {}
|
|
217
|
+
|
|
218
|
+
return result;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Render full Obsidian Kanban board Markdown.
|
|
223
|
+
* Output is the ENTIRE file content (frontmatter + columns + settings).
|
|
224
|
+
*/
|
|
225
|
+
function renderKanbanContent(projectInfo, tasks, existingData = {}) {
|
|
226
|
+
// Group tasks by status
|
|
227
|
+
const inProgress = tasks.filter(t => t.status === 'in_progress' || t.status === 'internal_review');
|
|
228
|
+
const ready = tasks.filter(t => t.status === 'ready');
|
|
229
|
+
const done = tasks.filter(t => t.status === 'done');
|
|
230
|
+
const blocked = tasks.filter(t => t.status === 'blocked');
|
|
231
|
+
|
|
232
|
+
// Count columns that have content (for list-collapse array)
|
|
233
|
+
const columns = [];
|
|
234
|
+
|
|
235
|
+
const lines = [];
|
|
236
|
+
|
|
237
|
+
// ── Frontmatter ──
|
|
238
|
+
lines.push('---');
|
|
239
|
+
if (existingData.frontmatterLines && existingData.frontmatterLines.length > 0) {
|
|
240
|
+
existingData.frontmatterLines.forEach(l => lines.push(l));
|
|
241
|
+
} else {
|
|
242
|
+
lines.push('');
|
|
243
|
+
lines.push('kanban-plugin: board');
|
|
244
|
+
lines.push(`sticker: lucide//list-checks`);
|
|
245
|
+
lines.push('');
|
|
246
|
+
}
|
|
247
|
+
lines.push('---');
|
|
248
|
+
lines.push('');
|
|
249
|
+
|
|
250
|
+
// ── Doing column ──
|
|
251
|
+
lines.push('## Doing');
|
|
252
|
+
lines.push('');
|
|
253
|
+
if (inProgress.length > 0) {
|
|
254
|
+
inProgress
|
|
255
|
+
.sort((a, b) => String(a.priority).localeCompare(String(b.priority)))
|
|
256
|
+
.forEach(t => lines.push(formatKanbanCard(t)));
|
|
257
|
+
}
|
|
258
|
+
lines.push('');
|
|
259
|
+
lines.push('');
|
|
260
|
+
columns.push(false);
|
|
261
|
+
|
|
262
|
+
// ── Blocked column (only if there are blocked tasks) ──
|
|
263
|
+
if (blocked.length > 0) {
|
|
264
|
+
lines.push('## Blocked');
|
|
265
|
+
lines.push('');
|
|
266
|
+
blocked.forEach(t => lines.push(formatKanbanCard(t)));
|
|
267
|
+
lines.push('');
|
|
268
|
+
lines.push('');
|
|
269
|
+
columns.push(false);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ── Todo column ──
|
|
273
|
+
lines.push('## Todo');
|
|
274
|
+
lines.push('');
|
|
275
|
+
if (ready.length > 0) {
|
|
276
|
+
ready
|
|
277
|
+
.sort((a, b) => String(a.priority).localeCompare(String(b.priority)))
|
|
278
|
+
.forEach(t => lines.push(formatKanbanCard(t)));
|
|
279
|
+
}
|
|
280
|
+
lines.push('');
|
|
281
|
+
lines.push('');
|
|
282
|
+
columns.push(false);
|
|
283
|
+
|
|
284
|
+
// ── Done column ──
|
|
285
|
+
lines.push('## Done');
|
|
286
|
+
lines.push('');
|
|
287
|
+
if (done.length > 0) {
|
|
288
|
+
done
|
|
289
|
+
.sort((a, b) => (b.completed_at || '').localeCompare(a.completed_at || ''))
|
|
290
|
+
.forEach(t => lines.push(formatKanbanCard(t)));
|
|
291
|
+
}
|
|
292
|
+
lines.push('');
|
|
293
|
+
lines.push('');
|
|
294
|
+
columns.push(true); // Done column collapsed by default
|
|
295
|
+
|
|
296
|
+
// ── Kanban settings block ──
|
|
297
|
+
lines.push('');
|
|
298
|
+
lines.push('');
|
|
299
|
+
lines.push('%% kanban:settings');
|
|
300
|
+
lines.push('```');
|
|
301
|
+
const settings = existingData.settingsJson || {
|
|
302
|
+
'kanban-plugin': 'board',
|
|
303
|
+
'show-checkboxes': true,
|
|
304
|
+
'move-dates': true,
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
// Always sync list-collapse with the current number of dynamically generated columns
|
|
308
|
+
settings['list-collapse'] = columns;
|
|
309
|
+
|
|
310
|
+
lines.push(JSON.stringify(settings));
|
|
311
|
+
lines.push('```');
|
|
312
|
+
lines.push('%%');
|
|
313
|
+
|
|
314
|
+
return lines.join('\n');
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// ─── File writer (full Kanban board) ──────────────────────────────────────────
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Write Kanban board content to the Obsidian file.
|
|
321
|
+
* Since Kanban plugin owns the entire file format, we overwrite the full file.
|
|
322
|
+
*/
|
|
323
|
+
function writeSyncToFile(filePath, content) {
|
|
324
|
+
const dir = path.dirname(filePath);
|
|
325
|
+
if (!fs.existsSync(dir)) {
|
|
326
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const existed = fs.existsSync(filePath);
|
|
330
|
+
fs.writeFileSync(filePath, content + '\n', 'utf8');
|
|
331
|
+
return existed ? 'updated' : 'created';
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// ─── Sync executor ────────────────────────────────────────────────────────────
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Perform sync for a single project.
|
|
338
|
+
* @param {object} identity - Parsed .project-identity
|
|
339
|
+
* @param {object} obsidianConfig - The automation.obsidian config
|
|
340
|
+
* @param {boolean} dryRun - If true, only print what would happen
|
|
341
|
+
* @returns {boolean} success
|
|
342
|
+
*/
|
|
343
|
+
function syncProject(identity, obsidianConfig, dryRun = false) {
|
|
344
|
+
const projectId = identity.projectId;
|
|
345
|
+
const projectName = identity.projectName || projectId;
|
|
346
|
+
const projectIcon = identity.icon || '📁';
|
|
347
|
+
const filePath = obsidianConfig.path;
|
|
348
|
+
|
|
349
|
+
info(`Syncing "${projectName}" → ${filePath}`);
|
|
350
|
+
|
|
351
|
+
// Query project info from Symphony
|
|
352
|
+
const projects = querySqlite(SYMPHONY_DB,
|
|
353
|
+
`SELECT id, name, icon FROM projects WHERE id = '${projectId}'`
|
|
354
|
+
);
|
|
355
|
+
|
|
356
|
+
const projectInfo = projects[0] || {
|
|
357
|
+
id: projectId,
|
|
358
|
+
name: projectName,
|
|
359
|
+
icon: projectIcon,
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
// Query tasks for this project
|
|
363
|
+
const tasks = querySqlite(SYMPHONY_DB,
|
|
364
|
+
`SELECT id, title, status, priority, phase, completed_at FROM tasks WHERE project_id = '${projectId}' ORDER BY status, priority`
|
|
365
|
+
);
|
|
366
|
+
|
|
367
|
+
info(`Found ${tasks.length} tasks for project "${projectId}"`);
|
|
368
|
+
|
|
369
|
+
// Extract existing formatting if any
|
|
370
|
+
const existingData = extractExistingBlocks(filePath);
|
|
371
|
+
|
|
372
|
+
// Render Kanban board
|
|
373
|
+
const content = renderKanbanContent(projectInfo, tasks, existingData);
|
|
374
|
+
|
|
375
|
+
if (dryRun) {
|
|
376
|
+
dim('─── Preview ───');
|
|
377
|
+
log(content);
|
|
378
|
+
dim('─── End Preview ───');
|
|
379
|
+
return true;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Write to file
|
|
383
|
+
const result = writeSyncToFile(filePath, content);
|
|
384
|
+
ok(`Sync complete (${result}): ${filePath}`);
|
|
385
|
+
return true;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// ─── CLI handler ──────────────────────────────────────────────────────────────
|
|
389
|
+
|
|
390
|
+
function obsidianHelp() {
|
|
391
|
+
log('');
|
|
392
|
+
log(`${C.cyan}${C.bold}📓 Obsidian Sync${C.reset}`);
|
|
393
|
+
log(`${C.gray} Exports Symphony task data to Obsidian Markdown notes.${C.reset}`);
|
|
394
|
+
log('');
|
|
395
|
+
log(` ${C.green}awkit obsidian sync${C.reset} Sync current project`);
|
|
396
|
+
log(` ${C.green}awkit obsidian status${C.reset} Preview sync (dry-run)`);
|
|
397
|
+
log(` ${C.green}awkit obsidian help${C.reset} Show this help`);
|
|
398
|
+
log('');
|
|
399
|
+
log(`${C.gray} Config: .project-identity → automation.obsidian${C.reset}`);
|
|
400
|
+
log(`${C.gray} Example:${C.reset}`);
|
|
401
|
+
log(`${C.gray} "automation": {${C.reset}`);
|
|
402
|
+
log(`${C.gray} "obsidian": {${C.reset}`);
|
|
403
|
+
log(`${C.gray} "enabled": true,${C.reset}`);
|
|
404
|
+
log(`${C.gray} "path": "/path/to/vault/ProjectNote.md",${C.reset}`);
|
|
405
|
+
log(`${C.gray} "autoSync": true${C.reset}`);
|
|
406
|
+
log(`${C.gray} }${C.reset}`);
|
|
407
|
+
log(`${C.gray} }${C.reset}`);
|
|
408
|
+
log('');
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Main handler for `awkit obsidian` subcommand.
|
|
413
|
+
* @param {string[]} args - CLI args after 'obsidian'
|
|
414
|
+
*/
|
|
415
|
+
function cmdObsidian(args) {
|
|
416
|
+
const action = args[0];
|
|
417
|
+
|
|
418
|
+
if (!action || action === 'help' || action === '--help' || action === '-h') {
|
|
419
|
+
obsidianHelp();
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Read project identity
|
|
424
|
+
const identity = readProjectIdentity();
|
|
425
|
+
if (!identity) {
|
|
426
|
+
err('No .project-identity found. Run this from a project directory.');
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const obsidianConfig = getObsidianConfig(identity);
|
|
431
|
+
if (!obsidianConfig) {
|
|
432
|
+
err('Obsidian sync not configured. Add automation.obsidian to .project-identity.');
|
|
433
|
+
dim('Example:');
|
|
434
|
+
dim(' "automation": {');
|
|
435
|
+
dim(' "obsidian": {');
|
|
436
|
+
dim(' "enabled": true,');
|
|
437
|
+
dim(' "path": "/path/to/vault/Note.md",');
|
|
438
|
+
dim(' "autoSync": true');
|
|
439
|
+
dim(' }');
|
|
440
|
+
dim(' }');
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
switch (action) {
|
|
445
|
+
case 'sync':
|
|
446
|
+
log('');
|
|
447
|
+
log(`${C.cyan}${C.bold}📓 Obsidian Sync${C.reset}`);
|
|
448
|
+
log('');
|
|
449
|
+
syncProject(identity, obsidianConfig, false);
|
|
450
|
+
break;
|
|
451
|
+
|
|
452
|
+
case 'status':
|
|
453
|
+
log('');
|
|
454
|
+
log(`${C.cyan}${C.bold}📓 Obsidian Sync — Status (Dry Run)${C.reset}`);
|
|
455
|
+
log('');
|
|
456
|
+
syncProject(identity, obsidianConfig, true);
|
|
457
|
+
break;
|
|
458
|
+
|
|
459
|
+
default:
|
|
460
|
+
err(`Unknown obsidian action: ${action}`);
|
|
461
|
+
obsidianHelp();
|
|
462
|
+
break;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// ─── Auto-sync trigger (called from automation-gate) ──────────────────────────
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Silently attempt Obsidian sync if configured.
|
|
470
|
+
* Used as a hook after gate operations (git commit, trello, etc.).
|
|
471
|
+
* Does NOT throw errors — fails silently to avoid blocking gate operations.
|
|
472
|
+
*/
|
|
473
|
+
function autoSyncObsidian() {
|
|
474
|
+
try {
|
|
475
|
+
const identity = readProjectIdentity();
|
|
476
|
+
if (!identity) return;
|
|
477
|
+
|
|
478
|
+
const obsidianConfig = getObsidianConfig(identity);
|
|
479
|
+
if (!obsidianConfig || !obsidianConfig.autoSync) return;
|
|
480
|
+
|
|
481
|
+
syncProject(identity, obsidianConfig, false);
|
|
482
|
+
} catch (_) {
|
|
483
|
+
// Silent fail — don't block gate operations
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// ─── Exports ──────────────────────────────────────────────────────────────────
|
|
488
|
+
|
|
489
|
+
module.exports = {
|
|
490
|
+
cmdObsidian,
|
|
491
|
+
autoSyncObsidian,
|
|
492
|
+
syncProject,
|
|
493
|
+
getObsidianConfig,
|
|
494
|
+
};
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: app-store-screenshots
|
|
3
|
+
description: Specializes in generating high-conversion App Store and Google Play screenshots using Next.js and html-to-image. Handles design, copywriting, and bulk export at correct resolutions.
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
trigger: conditional
|
|
6
|
+
activation_keywords:
|
|
7
|
+
- "app store screenshots"
|
|
8
|
+
- "play store screenshots"
|
|
9
|
+
- "marketing screenshots"
|
|
10
|
+
- "marketing assets"
|
|
11
|
+
- "screenshot generator"
|
|
12
|
+
- "android screenshots"
|
|
13
|
+
- "ios screenshots"
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
# 📱 App Store & Google Play Screenshots Generator
|
|
17
|
+
|
|
18
|
+
This skill empowers you to build professional screenshot generators that render as **advertisements**, not just UI showcases. It leverages Next.js and `html-to-image` to ensure high-fidelity exports at all required Apple and Google resolutions.
|
|
19
|
+
|
|
20
|
+
## 🚀 Activation Triggers
|
|
21
|
+
Trigger this skill when asked to:
|
|
22
|
+
- "Build/Create App Store screenshots"
|
|
23
|
+
- "Generate marketing assets for the Play Store"
|
|
24
|
+
- "Design exportable screenshots for iOS/Android"
|
|
25
|
+
|
|
26
|
+
## 🛠️ Resources
|
|
27
|
+
- **Mockup**: iPhone mockup at `./resources/mockup.png`.
|
|
28
|
+
- **Note**: All other device frames (Android, iPad, Tablet) are rendered via CSS-only components defined in this skill.
|
|
29
|
+
|
|
30
|
+
## 📋 Interaction Flow
|
|
31
|
+
|
|
32
|
+
### Phase 1: Brand & Asset Discovery
|
|
33
|
+
Before any code is written, you MUST collect:
|
|
34
|
+
1. **Source Screenshots**: PNG captures of the actual app UI.
|
|
35
|
+
2. **App Icon**: PNG icon for branding slides.
|
|
36
|
+
3. **Brand Palette**: Accent, background, and text colors.
|
|
37
|
+
4. **Style Direction**: e.g., Dark/Moody, Clean/Minimal, Bold/Vibrant.
|
|
38
|
+
5. **Feature Priority**: Top 3-5 outcomes/benefits the app provides.
|
|
39
|
+
|
|
40
|
+
### Phase 2: Narrative & Copywriting
|
|
41
|
+
- **Rule**: One idea per slide.
|
|
42
|
+
- **Copy**: 3-5 words per headline. Readable at thumbnail size.
|
|
43
|
+
- **Tone**: Focus on outcomes, not feature lists.
|
|
44
|
+
|
|
45
|
+
### Phase 3: Scaffolding (Next.js)
|
|
46
|
+
The generator is always a single-file Next.js page (`page.tsx`) for simplicity.
|
|
47
|
+
- **Library**: `html-to-image` (Native SVG serialization).
|
|
48
|
+
- **Setup**: `bun create-next-app` (preferred) or `npx`.
|
|
49
|
+
|
|
50
|
+
### Phase 4: Resolution-Independent Design
|
|
51
|
+
Use the following coordinates and sizing logic provided in the reference instructions below.
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
## 📖 Reference Instructions (The Core Engine)
|
|
56
|
+
|
|
57
|
+
Build a Next.js page that renders App Store **and** Google Play screenshots as **advertisements** (not UI showcases) and exports them via `html-to-image` at Apple's and Google's required resolutions.
|
|
58
|
+
|
|
59
|
+
### Supported Devices
|
|
60
|
+
- **iPhone** (1320x2868)
|
|
61
|
+
- **iPad** (2064x2752)
|
|
62
|
+
- **Android Phone** (1080x1920)
|
|
63
|
+
- **Android Tablet 7" & 10"** (Portrait & Landscape)
|
|
64
|
+
- **Feature Graphic** (1024x500)
|
|
65
|
+
|
|
66
|
+
### ⚠️ Critical Implementation Rules
|
|
67
|
+
- **Double-Capture Trick**: `html-to-image` requires two sequential calls to warm up fonts/images.
|
|
68
|
+
- **Data URI Preloading**: ALL images (mockups, screenshots) MUST be converted to base64 data URIs at load time to prevent blank exports.
|
|
69
|
+
- **Canvas Scaling**: Use `ResizeObserver` and `transform: scale()` for the preview grid, but render at true resolution for export.
|
|
70
|
+
|
|
71
|
+
### [Core Device Geometries]
|
|
72
|
+
(Included in full in the implementation)
|
|
73
|
+
- iPhone Mockup Ratios (based on `./resources/mockup.png`)
|
|
74
|
+
- CSS-only frame components for iPad and Android.
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
> [!TIP]
|
|
79
|
+
> **Narrative Arc**: Start with a Hero (Main Benefit), follow with Differentiators, and end with a "Trust/More Features" slide.
|
|
80
|
+
|
|
81
|
+
> [!IMPORTANT]
|
|
82
|
+
> **Localization**: Supports RTL-native layouts for Arabic/Hebrew by mirroring asymmetric compositions intentionally.
|
|
83
|
+
|
|
84
|
+
---
|
|
85
|
+
|
|
86
|
+
*This skill was shipped by Antigravity from ParthJadhav/app-store-screenshots.*
|
|
Binary file
|