openads-ai 0.1.1 → 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
@@ -39,6 +39,16 @@ OpenAds is an **open-source CLI tool** that turns any AI model into a marketing
39
39
 
40
40
  ---
41
41
 
42
+ ## šŸ“ø Screenshots
43
+
44
+ Here is a look at OpenAds in action:
45
+
46
+ <p align="center">
47
+ <img src="docs/images/screenshot.png" alt="OpenAds Welcome Screen" width="600" />
48
+ </p>
49
+
50
+ ---
51
+
42
52
  ## ⚔ Quick Start
43
53
 
44
54
  ### 1. Install
@@ -108,6 +118,52 @@ Here are some real examples — just type what you need:
108
118
 
109
119
  ---
110
120
 
121
+ ## 🧠 Memory — Gets Smarter Every Session
122
+
123
+ OpenAds remembers what it learns about your business. After each conversation, the AI appends key insights to a plain markdown file at `~/.openads/context/my-business.md`:
124
+
125
+ - Your best-performing campaigns and creative angles
126
+ - Audience segments and buying triggers
127
+ - Budget constraints and seasonal patterns
128
+ - Competitor insights and positioning gaps
129
+
130
+ You can open and edit this file anytime — it's your data, not a black box. The longer you use OpenAds, the better its advice gets.
131
+
132
+ ---
133
+
134
+ ## ā° Scheduled Automations
135
+
136
+ Set up automated campaign checks that run in the background — no server required.
137
+
138
+ ```bash
139
+ openads schedule
140
+ ```
141
+
142
+ | Preset | Frequency |
143
+ |---|---|
144
+ | šŸ“Š Daily campaign health check | Every day at 8 AM |
145
+ | šŸ’ø Budget pacing alert | Every 6 hours |
146
+ | šŸ“‰ Performance drop alert | Twice daily (9 AM & 5 PM) |
147
+ | šŸ“‹ Weekly performance report | Every Monday at 9 AM |
148
+ | ā° Custom (describe in plain English) | You choose |
149
+
150
+ Reports are saved to `~/.openads/reports/` in both Markdown and premium HTML dashboard formats. You can view, list, and open your reports directly:
151
+
152
+ ```bash
153
+ openads report # List all generated reports
154
+ openads report [name] # Open a beautiful HTML dashboard in your browser
155
+ ```
156
+
157
+ Manage your schedules:
158
+
159
+ ```bash
160
+ openads schedule # Open the schedule manager
161
+ openads schedule list # See active schedules
162
+ openads schedule remove # Remove a schedule
163
+ ```
164
+
165
+ Uses your OS scheduler (macOS `launchd` / Linux `crontab`) — works even when your terminal is closed.
166
+
111
167
  ## šŸ”’ Security & Privacy
112
168
 
113
169
  - **Runs 100% locally.** OpenAds is not a cloud service. Nothing leaves your machine except the API calls you authorize.
@@ -136,9 +192,13 @@ This verifies your config file, API keys, platform connections (live token check
136
192
  - [x] Interactive setup wizard with live token verification
137
193
  - [x] 12 pre-built skills: Ads, CRO, Copywriting, Analytics, Email, Video, Research, Strategy
138
194
  - [x] Autonomous research loops
195
+ - [x] Published to npm (`npm install -g openads-ai`)
196
+ - [x] Memory system — AI learns about your business over time
197
+ - [x] Scheduled automations — daily health checks, budget alerts, weekly reports
198
+ - [ ] Telegram bot gateway — talk to your ads from your phone
139
199
  - [ ] LinkedIn Ads integration
140
- - [ ] Pinterest Ads integration
141
- - [ ] Publish to npm registry
200
+ - [ ] TikTok Ads integration (leveraging their new [TikTok Ads MCP Server](https://digiday.com/media/tiktok-world-ads-mcp-server/))
201
+ - [ ] Pinterest Ads integration (leveraging lessons/patterns from their [MCP Ecosystem](https://medium.com/pinterest-engineering/building-an-mcp-ecosystem-at-pinterest-c3b6b1b9e0f6))
142
202
 
143
203
  ---
144
204
 
@@ -162,4 +222,4 @@ Read [CONTRIBUTING.md](CONTRIBUTING.md) to get started.
162
222
 
163
223
  MIT.
164
224
 
165
- *Built on [Pi](https://github.com/earendil-works/pi) (MIT). Includes tools derived from [adloop](https://github.com/kLOsk/adloop) (MIT) by kLOsk. Marketing skills inspired by [marketingskills](https://github.com/coreyhaines31/marketingskills) (MIT) by Corey Haines.*
225
+ *Built on [Pi](https://github.com/earendil-works/pi) (MIT). Includes tools derived from [adloop](https://github.com/kLOsk/adloop) (MIT) by kLOsk. Marketing skills inspired by [marketingskills](https://github.com/coreyhaines31/marketingskills) (MIT) by Corey Haines. Memory and background automation concepts inspired by [Hermes Agent](https://github.com/NousResearch/hermes-agent) by Nous Research.*
package/dist/cli.js CHANGED
@@ -10,6 +10,7 @@ import gradient from 'gradient-string';
10
10
  import ora from 'ora';
11
11
  import { runSetup } from './setup.js';
12
12
  import { runDoctor } from './doctor.js';
13
+ import { runScheduleManager, runScheduledTask, openReportInBrowser, listReports } from './schedule.js';
13
14
  import enquirer from 'enquirer';
14
15
  const __filename = fileURLToPath(import.meta.url);
15
16
  const __dirname = path.dirname(__filename);
@@ -44,9 +45,11 @@ function loadConfig() {
44
45
  function resolveModel(provider) {
45
46
  return DEPRECATED_MODELS[provider] || provider;
46
47
  }
47
- // ─── Product Context Injection ──────────────────────────────────────
48
- // Writes the user's product context as a skill file so the agent always
49
- // knows what the user sells, who their customer is, etc.
48
+ // ─── Product Context & Memory ───────────────────────────────────────
49
+ // The business context file grows over time. The agent appends learnings
50
+ // (audience insights, campaign results, winning creative angles, etc.)
51
+ // after each session. We only create the initial file if it doesn't exist
52
+ // so accumulated knowledge is never overwritten.
50
53
  function injectProductContext(config) {
51
54
  if (!config?.productContext)
52
55
  return null;
@@ -55,7 +58,9 @@ function injectProductContext(config) {
55
58
  fs.mkdirSync(contextDir, { recursive: true });
56
59
  }
57
60
  const contextPath = path.join(contextDir, 'my-business.md');
58
- const content = `---
61
+ // Only create the initial file — never overwrite accumulated learnings
62
+ if (!fs.existsSync(contextPath)) {
63
+ const content = `---
59
64
  name: my-business
60
65
  description: The user's business context — always read this first.
61
66
  ---
@@ -63,15 +68,19 @@ description: The user's business context — always read this first.
63
68
 
64
69
  ${config.productContext}
65
70
 
66
- Use this context to personalize all recommendations, ad copy, and strategy outputs.
67
- Always reference this when applying any marketing skill.
71
+ ## Learnings
72
+
73
+ _The AI will automatically add insights here as it learns about your business._
74
+ _You can also edit this file manually at: ${contextPath}_
68
75
  `;
69
- fs.writeFileSync(contextPath, content);
76
+ fs.writeFileSync(contextPath, content);
77
+ }
70
78
  return contextDir;
71
79
  }
72
80
  // ─── System Prompt ──────────────────────────────────────────────────
73
81
  // Makes the agent behave as "OpenAds" instead of generic Pi.
74
82
  function buildSystemPrompt(config) {
83
+ const contextPath = path.join(CONFIG_DIR, 'context', 'my-business.md');
75
84
  const parts = [
76
85
  'You are OpenAds, an AI marketing assistant built for digital marketers.',
77
86
  'You specialize in Google Ads, Meta Ads, copywriting, analytics, CRO, and go-to-market strategy.',
@@ -79,6 +88,17 @@ function buildSystemPrompt(config) {
79
88
  'Address the user as a marketing professional.',
80
89
  'When writing ad copy or recommendations, always reference the user\'s product context first.',
81
90
  'For any write operation (creating campaigns, changing budgets), always preview the change and ask for explicit confirmation before executing.',
91
+ '',
92
+ '## Memory',
93
+ '',
94
+ `Your business context file is at: ${contextPath}`,
95
+ 'This file contains everything you have learned about the user\'s business across sessions.',
96
+ 'At the START of every conversation, read this file to recall past context.',
97
+ 'At the END of a conversation (or when you learn something significant), APPEND new insights to the "## Learnings" section of that file.',
98
+ 'Things worth remembering: product details, audience segments, campaign performance benchmarks, winning ad angles, competitor insights, budget constraints, seasonal patterns, and any preferences the user expresses.',
99
+ 'Format each learning as a bullet point with a date, e.g.: "- (2026-05-24) Best-performing Meta creative uses customer testimonial videos."',
100
+ 'Never overwrite existing learnings — only append new ones.',
101
+ 'If the learnings section grows beyond 50 items, summarize the oldest 25 into a "## Summary" section at the top and remove the individual bullets.',
82
102
  ];
83
103
  if (config?.productContext) {
84
104
  parts.push(`\nThe user's business: ${config.productContext}`);
@@ -193,6 +213,26 @@ async function main() {
193
213
  await runDoctor();
194
214
  return;
195
215
  }
216
+ if (args[0] === 'schedule') {
217
+ await runScheduleManager(args[1]);
218
+ return;
219
+ }
220
+ if (args[0] === 'run-schedule') {
221
+ await runScheduledTask(args[1]);
222
+ return;
223
+ }
224
+ if (args[0] === 'report') {
225
+ if (args[1] === 'list') {
226
+ listReports();
227
+ }
228
+ else if (args[1]) {
229
+ await openReportInBrowser(args[1]);
230
+ }
231
+ else {
232
+ listReports();
233
+ }
234
+ return;
235
+ }
196
236
  // ─── First-Run Detection ────────────────────────────────────────
197
237
  const config = loadConfig();
198
238
  if (!config || !config.provider) {
@@ -241,6 +281,7 @@ async function main() {
241
281
  { name: 'autoresearch', message: `${chalk.cyan('šŸ”„')} Test and improve ideas automatically ${chalk.gray('(autoresearch)')}` },
242
282
  { name: 'gtm', message: `${chalk.cyan('šŸ“ˆ')} Build a go-to-market plan ${chalk.gray('(strategy)')}` },
243
283
  { name: 'skills', message: `${chalk.cyan('šŸ“š')} Browse available skills` },
284
+ { name: 'schedule', message: `${chalk.cyan('ā°')} Schedule automations` },
244
285
  { name: 'setup', message: `${chalk.gray('āš™ļø')} Settings` },
245
286
  { name: 'doctor', message: `${chalk.gray('🩺')} Diagnostics` },
246
287
  { name: 'exit', message: `${chalk.gray('āŒ')} Exit` }
@@ -262,6 +303,10 @@ async function main() {
262
303
  showSkills();
263
304
  return;
264
305
  }
306
+ if (action === 'schedule') {
307
+ await runScheduleManager();
308
+ return;
309
+ }
265
310
  const actionMap = {
266
311
  chat: [],
267
312
  audit: ['audit-google-ads'],
@@ -0,0 +1,628 @@
1
+ /**
2
+ * Custom inline style parser that converts standard markdown styling
3
+ * (bold, italic, code, checkbox) and typical keywords into premium HTML components/badges.
4
+ */
5
+ function parseInlineStyles(text) {
6
+ let res = text;
7
+ // Escape basic HTML characters to prevent breaking layout
8
+ res = res
9
+ .replace(/&/g, '&amp;')
10
+ .replace(/</g, '&lt;')
11
+ .replace(/>/g, '&gt;');
12
+ // Bold (**text** or __text__)
13
+ res = res.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
14
+ res = res.replace(/__(.*?)__/g, '<strong>$1</strong>');
15
+ // Italic (*text* or _text_)
16
+ res = res.replace(/\*(.*?)\*/g, '<em>$1</em>');
17
+ res = res.replace(/_(.*?)_/g, '<em>$1</em>');
18
+ // Inline code (`code`)
19
+ res = res.replace(/`(.*?)`/g, '<code class="inline-code">$1</code>');
20
+ // Keywords -> High-fidelity badges with strict bracket matching to avoid double replacements
21
+ res = res.replace(/\[(Alert|Warning|Caution|Anomaly|Drop|Spike)\]/gi, '<span class="badge badge-warning">$1</span>');
22
+ res = res.replace(/\[(Critical|Error|Failed|High Risk)\]/gi, '<span class="badge badge-danger">$1</span>');
23
+ res = res.replace(/\[(Success|On Track|Active|Passed|Resolved)\]/gi, '<span class="badge badge-success">$1</span>');
24
+ res = res.replace(/\[(Info|Daily|Weekly|Monthly|Report|Recommended|Notice)\]/gi, '<span class="badge badge-info">$1</span>');
25
+ return res;
26
+ }
27
+ /**
28
+ * Builds a beautifully styled HTML table from markdown table rows.
29
+ */
30
+ function buildTableHtml(rows) {
31
+ if (rows.length < 2)
32
+ return '';
33
+ const headerRow = rows[0];
34
+ const dataRows = rows.slice(1).filter(r => !r.match(/^\|\s*[-|:\s]+\s*\|$/));
35
+ const parseRow = (row) => {
36
+ return row
37
+ .split('|')
38
+ .slice(1, -1)
39
+ .map(cell => cell.trim());
40
+ };
41
+ const headers = parseRow(headerRow);
42
+ const thead = `<thead><tr>${headers.map(h => `<th>${parseInlineStyles(h)}</th>`).join('')}</tr></thead>`;
43
+ const tbody = `<tbody>${dataRows.map(row => {
44
+ const cells = parseRow(row);
45
+ return `<tr>${cells.map(c => `<td>${parseInlineStyles(c)}</td>`).join('')}</tr>`;
46
+ }).join('')}</tbody>`;
47
+ return `<div class="table-container"><table>${thead}${tbody}</table></div>`;
48
+ }
49
+ /**
50
+ * Core Markdown-to-HTML parser optimized for OpenAds reports.
51
+ * Uses a robust line-by-line state machine for exact output representation.
52
+ */
53
+ export function parseMarkdown(md) {
54
+ let html = md.replace(/\r\n/g, '\n');
55
+ // 1. Fenced code blocks
56
+ const codeBlocks = [];
57
+ html = html.replace(/```([\s\S]*?)```/g, (_, code) => {
58
+ const id = `___CODE_BLOCK_${codeBlocks.length}___`;
59
+ const escapedCode = code
60
+ .replace(/&/g, '&amp;')
61
+ .replace(/</g, '&lt;')
62
+ .replace(/>/g, '&gt;');
63
+ codeBlocks.push(`<pre class="code-block"><code>${escapedCode.trim()}</code></pre>`);
64
+ return id;
65
+ });
66
+ // 2. Identify tables
67
+ const tables = [];
68
+ const lines = html.split('\n');
69
+ const linesWithTables = [];
70
+ let inTable = false;
71
+ let tableRows = [];
72
+ for (let i = 0; i < lines.length; i++) {
73
+ const line = lines[i].trim();
74
+ if (line.startsWith('|') && line.endsWith('|')) {
75
+ inTable = true;
76
+ tableRows.push(line);
77
+ }
78
+ else {
79
+ if (inTable) {
80
+ const tableHtml = buildTableHtml(tableRows);
81
+ const id = `___TABLE_${tables.length}___`;
82
+ tables.push(tableHtml);
83
+ linesWithTables.push(id);
84
+ tableRows = [];
85
+ inTable = false;
86
+ }
87
+ linesWithTables.push(lines[i]);
88
+ }
89
+ }
90
+ if (inTable && tableRows.length > 0) {
91
+ const tableHtml = buildTableHtml(tableRows);
92
+ const id = `___TABLE_${tables.length}___`;
93
+ tables.push(tableHtml);
94
+ linesWithTables.push(id);
95
+ }
96
+ // 3. Process line-by-line
97
+ const processedHtml = [];
98
+ let inList = false;
99
+ let isOrdered = false;
100
+ const closeListIfNeeded = () => {
101
+ if (inList) {
102
+ processedHtml.push(isOrdered ? '</ol>' : '</ul>');
103
+ inList = false;
104
+ }
105
+ };
106
+ for (let i = 0; i < linesWithTables.length; i++) {
107
+ const line = linesWithTables[i].trim();
108
+ if (!line) {
109
+ closeListIfNeeded();
110
+ continue;
111
+ }
112
+ // Fenced code blocks / table placeholders
113
+ if (line.startsWith('___CODE_BLOCK_') && line.endsWith('___')) {
114
+ closeListIfNeeded();
115
+ processedHtml.push(line);
116
+ continue;
117
+ }
118
+ if (line.startsWith('___TABLE_') && line.endsWith('___')) {
119
+ closeListIfNeeded();
120
+ processedHtml.push(line);
121
+ continue;
122
+ }
123
+ // Headings
124
+ if (line.startsWith('#')) {
125
+ closeListIfNeeded();
126
+ const match = line.match(/^(#{1,6})\s+(.*)$/);
127
+ if (match) {
128
+ const level = match[1].length;
129
+ const text = parseInlineStyles(match[2]);
130
+ if (level === 1) {
131
+ processedHtml.push(`<h1>${text}</h1>`);
132
+ }
133
+ else if (level === 2) {
134
+ processedHtml.push(`</section><section class="report-card"><h2>${text}</h2>`);
135
+ }
136
+ else {
137
+ processedHtml.push(`<h${level}>${text}</h${level}>`);
138
+ }
139
+ }
140
+ continue;
141
+ }
142
+ // Horizontal rule / dividers
143
+ if (line === '---' || line === '***') {
144
+ closeListIfNeeded();
145
+ processedHtml.push('<hr class="report-divider">');
146
+ continue;
147
+ }
148
+ // Lists (unordered)
149
+ const ulMatch = line.match(/^[-*]\s+(.*)$/);
150
+ if (ulMatch) {
151
+ if (!inList || isOrdered) {
152
+ closeListIfNeeded();
153
+ processedHtml.push('<ul>');
154
+ inList = true;
155
+ isOrdered = false;
156
+ }
157
+ let content = ulMatch[1];
158
+ const hasChecked = content.startsWith('[x] ');
159
+ const hasUnchecked = content.startsWith('[ ] ');
160
+ if (hasChecked || hasUnchecked) {
161
+ content = content.slice(4);
162
+ }
163
+ // Parse inline styles on text only before wrapping in custom elements
164
+ content = parseInlineStyles(content);
165
+ if (hasChecked) {
166
+ content = `<span class="checkbox checked">āœ“</span> <span class="checkbox-text">${content}</span>`;
167
+ }
168
+ else if (hasUnchecked) {
169
+ content = `<span class="checkbox unchecked"></span> <span class="checkbox-text">${content}</span>`;
170
+ }
171
+ processedHtml.push(`<li>${content}</li>`);
172
+ continue;
173
+ }
174
+ // Lists (ordered)
175
+ const olMatch = line.match(/^(\d+)\.\s+(.*)$/);
176
+ if (olMatch) {
177
+ if (!inList || !isOrdered) {
178
+ closeListIfNeeded();
179
+ processedHtml.push('<ol>');
180
+ inList = true;
181
+ isOrdered = true;
182
+ }
183
+ let content = olMatch[2];
184
+ content = parseInlineStyles(content);
185
+ processedHtml.push(`<li>${content}</li>`);
186
+ continue;
187
+ }
188
+ // If it's a plain line, it is a paragraph
189
+ closeListIfNeeded();
190
+ processedHtml.push(`<p>${parseInlineStyles(line)}</p>`);
191
+ }
192
+ closeListIfNeeded();
193
+ let finalHtml = processedHtml.join('\n');
194
+ // Clean sections wrap
195
+ finalHtml = '<section class="report-card intro-card">\n' + finalHtml;
196
+ finalHtml += '\n</section>';
197
+ finalHtml = finalHtml.replace(/<section class="report-card intro-card">\s*<\/section>/g, '');
198
+ finalHtml = finalHtml.replace(/<section class="report-card">\s*<\/section>/g, '');
199
+ // 4. Restore placeholders
200
+ codeBlocks.forEach((code, idx) => {
201
+ finalHtml = finalHtml.replace(`___CODE_BLOCK_${idx}___`, code);
202
+ });
203
+ tables.forEach((table, idx) => {
204
+ finalHtml = finalHtml.replace(`___TABLE_${idx}___`, table);
205
+ });
206
+ return finalHtml;
207
+ }
208
+ /**
209
+ * Wraps parsed HTML body in a complete, premium styled HTML page template.
210
+ */
211
+ export function compileHtmlReport(title, markdownContent) {
212
+ const parsedBody = parseMarkdown(markdownContent);
213
+ const formattedTitle = title.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
214
+ return `<!DOCTYPE html>
215
+ <html lang="en">
216
+ <head>
217
+ <meta charset="UTF-8">
218
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
219
+ <title>OpenAds Report — ${formattedTitle}</title>
220
+ <!-- Modern Premium Typography -->
221
+ <link rel="preconnect" href="https://fonts.googleapis.com">
222
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
223
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Outfit:wght@500;600;700;800&display=swap" rel="stylesheet">
224
+
225
+ <style>
226
+ /* ─── Premium Modern CSS variables ─────────────────────────────────── */
227
+ :root {
228
+ --bg-color: #0b0f19;
229
+ --card-bg: rgba(23, 28, 41, 0.6);
230
+ --card-border: rgba(255, 255, 255, 0.08);
231
+ --text-main: #a4b0be;
232
+ --text-bright: #ffffff;
233
+ --text-muted: #747d8c;
234
+
235
+ /* Glowing Gradients */
236
+ --primary-gradient: linear-gradient(135deg, #00d2ff 0%, #3a7bd5 100%);
237
+ --accent-color: #00d2ff;
238
+
239
+ /* Status Colors */
240
+ --color-success: #2ed573;
241
+ --bg-success: rgba(46, 213, 115, 0.15);
242
+ --color-warning: #ffa502;
243
+ --bg-warning: rgba(255, 165, 2, 0.15);
244
+ --color-danger: #ff4757;
245
+ --bg-danger: rgba(255, 71, 87, 0.15);
246
+ --color-info: #00d2ff;
247
+ --bg-info: rgba(0, 210, 255, 0.15);
248
+
249
+ --shadow-premium: 0 12px 40px rgba(0, 0, 0, 0.5);
250
+ --transition-smooth: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
251
+ }
252
+
253
+ /* ─── Global Reset & Styling ────────────────────────────────────────── */
254
+ * {
255
+ box-sizing: border-box;
256
+ margin: 0;
257
+ padding: 0;
258
+ }
259
+
260
+ body {
261
+ font-family: 'Inter', sans-serif;
262
+ background-color: var(--bg-color);
263
+ color: var(--text-main);
264
+ line-height: 1.6;
265
+ padding: 3rem 1.5rem;
266
+ min-height: 100vh;
267
+ background-image:
268
+ radial-gradient(circle at 10% 20%, rgba(0, 210, 255, 0.04) 0%, transparent 40%),
269
+ radial-gradient(circle at 90% 80%, rgba(58, 123, 213, 0.04) 0%, transparent 40%);
270
+ background-attachment: fixed;
271
+ }
272
+
273
+ .container {
274
+ max-width: 900px;
275
+ margin: 0 auto;
276
+ }
277
+
278
+ /* ─── Premium Header ────────────────────────────────────────────────── */
279
+ header {
280
+ margin-bottom: 3.5rem;
281
+ text-align: center;
282
+ position: relative;
283
+ }
284
+
285
+ .logo-badge {
286
+ display: inline-flex;
287
+ align-items: center;
288
+ gap: 0.5rem;
289
+ background: rgba(255, 255, 255, 0.03);
290
+ border: 1px solid rgba(255, 255, 255, 0.05);
291
+ padding: 0.4rem 1rem;
292
+ border-radius: 100px;
293
+ font-family: 'Outfit', sans-serif;
294
+ font-size: 0.85rem;
295
+ letter-spacing: 1px;
296
+ text-transform: uppercase;
297
+ color: var(--accent-color);
298
+ margin-bottom: 1.5rem;
299
+ backdrop-filter: blur(10px);
300
+ }
301
+
302
+ .logo-badge svg {
303
+ width: 14px;
304
+ height: 14px;
305
+ fill: currentColor;
306
+ }
307
+
308
+ header h1 {
309
+ font-family: 'Outfit', sans-serif;
310
+ font-weight: 800;
311
+ font-size: 2.8rem;
312
+ color: var(--text-bright);
313
+ line-height: 1.2;
314
+ background: var(--primary-gradient);
315
+ -webkit-background-clip: text;
316
+ -webkit-text-fill-color: transparent;
317
+ margin-bottom: 0.5rem;
318
+ letter-spacing: -0.5px;
319
+ }
320
+
321
+ header .report-subtitle {
322
+ font-size: 1.1rem;
323
+ color: var(--text-muted);
324
+ margin-bottom: 1.5rem;
325
+ }
326
+
327
+ /* ─── Premium Card Layout ───────────────────────────────────────────── */
328
+ .report-card {
329
+ background: var(--card-bg);
330
+ border: 1px solid var(--card-border);
331
+ border-radius: 20px;
332
+ padding: 2.5rem;
333
+ margin-bottom: 2.5rem;
334
+ box-shadow: var(--shadow-premium);
335
+ backdrop-filter: blur(12px);
336
+ transition: var(--transition-smooth);
337
+ position: relative;
338
+ overflow: hidden;
339
+ }
340
+
341
+ .report-card:hover {
342
+ transform: translateY(-4px);
343
+ border-color: rgba(0, 210, 255, 0.2);
344
+ box-shadow: 0 16px 48px rgba(0, 210, 255, 0.08);
345
+ }
346
+
347
+ .report-card::before {
348
+ content: '';
349
+ position: absolute;
350
+ top: 0;
351
+ left: 0;
352
+ width: 4px;
353
+ height: 100%;
354
+ background: var(--primary-gradient);
355
+ }
356
+
357
+ /* Card Typography */
358
+ .report-card h2 {
359
+ font-family: 'Outfit', sans-serif;
360
+ font-weight: 700;
361
+ font-size: 1.6rem;
362
+ color: var(--text-bright);
363
+ margin-bottom: 1.5rem;
364
+ display: flex;
365
+ align-items: center;
366
+ gap: 0.75rem;
367
+ }
368
+
369
+ .report-card h3 {
370
+ font-family: 'Outfit', sans-serif;
371
+ font-weight: 600;
372
+ font-size: 1.25rem;
373
+ color: var(--text-bright);
374
+ margin: 1.5rem 0 1rem 0;
375
+ }
376
+
377
+ .report-card p {
378
+ margin-bottom: 1.2rem;
379
+ font-size: 1.05rem;
380
+ color: var(--text-main);
381
+ }
382
+
383
+ .report-card p strong {
384
+ color: var(--text-bright);
385
+ font-weight: 600;
386
+ }
387
+
388
+ /* Divider */
389
+ .report-divider {
390
+ border: none;
391
+ height: 1px;
392
+ background: rgba(255, 255, 255, 0.06);
393
+ margin: 2rem 0;
394
+ }
395
+
396
+ /* ─── Lists & Checkboxes ────────────────────────────────────────────── */
397
+ .report-card ul, .report-card ol {
398
+ margin-bottom: 1.5rem;
399
+ padding-left: 0.5rem;
400
+ }
401
+
402
+ .report-card li {
403
+ margin-bottom: 0.8rem;
404
+ font-size: 1.05rem;
405
+ list-style: none;
406
+ display: flex;
407
+ align-items: flex-start;
408
+ gap: 0.75rem;
409
+ }
410
+
411
+ .report-card ul li::before {
412
+ content: "•";
413
+ color: var(--accent-color);
414
+ font-weight: bold;
415
+ font-size: 1.2rem;
416
+ line-height: 1.4rem;
417
+ display: inline-block;
418
+ flex-shrink: 0;
419
+ }
420
+
421
+ .report-card ol {
422
+ list-style-type: decimal;
423
+ padding-left: 1.5rem;
424
+ }
425
+
426
+ .report-card ol li {
427
+ display: list-item;
428
+ list-style-position: outside;
429
+ }
430
+
431
+ /* Styled Checkboxes */
432
+ .checkbox {
433
+ display: inline-flex;
434
+ align-items: center;
435
+ justify-content: center;
436
+ width: 18px;
437
+ height: 18px;
438
+ border-radius: 4px;
439
+ margin-top: 0.2rem;
440
+ flex-shrink: 0;
441
+ }
442
+
443
+ .checkbox.checked {
444
+ background: var(--color-success);
445
+ color: #0b0f19;
446
+ font-size: 0.75rem;
447
+ font-weight: bold;
448
+ }
449
+
450
+ .checkbox.unchecked {
451
+ border: 2px solid var(--text-muted);
452
+ }
453
+
454
+ .checkbox-text {
455
+ flex: 1;
456
+ }
457
+
458
+ /* ─── Premium Tables ────────────────────────────────────────────────── */
459
+ .table-container {
460
+ width: 100%;
461
+ overflow-x: auto;
462
+ margin: 2rem 0;
463
+ border-radius: 12px;
464
+ border: 1px solid rgba(255, 255, 255, 0.05);
465
+ background: rgba(0, 0, 0, 0.15);
466
+ }
467
+
468
+ table {
469
+ width: 100%;
470
+ border-collapse: collapse;
471
+ text-align: left;
472
+ font-size: 0.95rem;
473
+ }
474
+
475
+ th {
476
+ font-family: 'Outfit', sans-serif;
477
+ font-weight: 600;
478
+ color: var(--text-bright);
479
+ background: rgba(255, 255, 255, 0.02);
480
+ padding: 1rem 1.25rem;
481
+ border-bottom: 1px solid rgba(255, 255, 255, 0.08);
482
+ font-size: 0.85rem;
483
+ text-transform: uppercase;
484
+ letter-spacing: 0.5px;
485
+ }
486
+
487
+ td {
488
+ padding: 1rem 1.25rem;
489
+ border-bottom: 1px solid rgba(255, 255, 255, 0.04);
490
+ color: var(--text-main);
491
+ vertical-align: middle;
492
+ }
493
+
494
+ tr:last-child td {
495
+ border-bottom: none;
496
+ }
497
+
498
+ tr:hover td {
499
+ background: rgba(255, 255, 255, 0.015);
500
+ color: var(--text-bright);
501
+ }
502
+
503
+ /* ─── High-Fidelity Badges ──────────────────────────────────────────── */
504
+ .badge {
505
+ display: inline-flex;
506
+ align-items: center;
507
+ padding: 0.25rem 0.65rem;
508
+ border-radius: 100px;
509
+ font-size: 0.75rem;
510
+ font-weight: 600;
511
+ letter-spacing: 0.3px;
512
+ text-transform: uppercase;
513
+ font-family: 'Outfit', sans-serif;
514
+ }
515
+
516
+ .badge-success {
517
+ background: var(--bg-success);
518
+ color: var(--color-success);
519
+ border: 1px solid rgba(46, 213, 115, 0.3);
520
+ }
521
+
522
+ .badge-warning {
523
+ background: var(--bg-warning);
524
+ color: var(--color-warning);
525
+ border: 1px solid rgba(255, 165, 2, 0.3);
526
+ }
527
+
528
+ .badge-danger {
529
+ background: var(--bg-danger);
530
+ color: var(--color-danger);
531
+ border: 1px solid rgba(255, 71, 87, 0.3);
532
+ }
533
+
534
+ .badge-info {
535
+ background: var(--bg-info);
536
+ color: var(--color-info);
537
+ border: 1px solid rgba(0, 210, 255, 0.3);
538
+ }
539
+
540
+ /* ─── Code Blocks ───────────────────────────────────────────────────── */
541
+ .code-block {
542
+ background: #060911;
543
+ border: 1px solid rgba(255, 255, 255, 0.05);
544
+ border-radius: 12px;
545
+ padding: 1.5rem;
546
+ margin: 1.5rem 0;
547
+ overflow-x: auto;
548
+ }
549
+
550
+ .code-block code {
551
+ font-family: 'Courier New', Courier, monospace;
552
+ color: #00d2ff;
553
+ font-size: 0.9rem;
554
+ }
555
+
556
+ .inline-code {
557
+ font-family: 'Courier New', Courier, monospace;
558
+ background: rgba(255, 255, 255, 0.05);
559
+ color: var(--accent-color);
560
+ padding: 0.15rem 0.35rem;
561
+ border-radius: 4px;
562
+ font-size: 0.9em;
563
+ }
564
+
565
+ /* ─── Footer ────────────────────────────────────────────────────────── */
566
+ footer {
567
+ text-align: center;
568
+ margin-top: 5rem;
569
+ padding-top: 2rem;
570
+ border-top: 1px solid rgba(255, 255, 255, 0.05);
571
+ color: var(--text-muted);
572
+ font-size: 0.85rem;
573
+ }
574
+
575
+ footer a {
576
+ color: var(--accent-color);
577
+ text-decoration: none;
578
+ transition: var(--transition-smooth);
579
+ }
580
+
581
+ footer a:hover {
582
+ text-decoration: underline;
583
+ }
584
+
585
+ /* ─── Responsive Adjustments ────────────────────────────────────────── */
586
+ @media (max-width: 768px) {
587
+ body {
588
+ padding: 2rem 1rem;
589
+ }
590
+
591
+ header h1 {
592
+ font-size: 2.2rem;
593
+ }
594
+
595
+ .report-card {
596
+ padding: 1.75rem;
597
+ }
598
+
599
+ th, td {
600
+ padding: 0.75rem 1rem;
601
+ }
602
+ }
603
+ </style>
604
+ </head>
605
+ <body>
606
+ <div class="container">
607
+ <header>
608
+ <div class="logo-badge">
609
+ <svg viewBox="0 0 24 24">
610
+ <path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"/>
611
+ </svg>
612
+ OpenAds AI
613
+ </div>
614
+ <h1>${formattedTitle}</h1>
615
+ <div class="report-subtitle">Autonomous Campaign Performance Report</div>
616
+ </header>
617
+
618
+ <main>
619
+ ${parsedBody}
620
+ </main>
621
+
622
+ <footer>
623
+ <p>Report compiled autonomously by <a href="https://github.com/lamorim-net/openads-ai" target="_blank">OpenAds AI Command Center</a>.</p>
624
+ </footer>
625
+ </div>
626
+ </body>
627
+ </html>`;
628
+ }
@@ -0,0 +1,511 @@
1
+ import enquirer from 'enquirer';
2
+ import chalk from 'chalk';
3
+ import fs from 'fs';
4
+ import path from 'path';
5
+ import os from 'os';
6
+ import { spawnSync } from 'child_process';
7
+ import gradient from 'gradient-string';
8
+ import open from 'open';
9
+ import { compileHtmlReport } from './report-template.js';
10
+ const openadsGradient = gradient(['#00d2ff', '#3a7bd5', '#00d2ff']);
11
+ const CONFIG_DIR = path.join(os.homedir(), '.openads');
12
+ const SCHEDULES_DIR = path.join(CONFIG_DIR, 'schedules');
13
+ const REPORTS_DIR = path.join(CONFIG_DIR, 'reports');
14
+ const PRESETS = [
15
+ {
16
+ name: 'daily-health',
17
+ label: 'šŸ“Š Daily campaign health check',
18
+ prompt: 'Run a health check on all my connected ad campaigns. Flag any anomalies: budget pacing issues, sudden CPA spikes, quality score drops, disapproved ads, or campaigns that spent more than 20% above/below their daily budget. Give me a concise summary with action items.',
19
+ cron: '0 8 * * *',
20
+ description: 'Every day at 8:00 AM',
21
+ },
22
+ {
23
+ name: 'budget-pacing',
24
+ label: 'šŸ’ø Budget pacing alert (every 6 hours)',
25
+ prompt: 'Check my ad campaign spend pacing against daily/monthly budgets. Flag any campaign that is on track to overspend by more than 15% or underspend by more than 25%. Include the current spend, projected spend, and budget for each flagged campaign.',
26
+ cron: '0 */6 * * *',
27
+ description: 'Every 6 hours',
28
+ },
29
+ {
30
+ name: 'performance-drop',
31
+ label: 'šŸ“‰ Performance drop alert (twice daily)',
32
+ prompt: 'Compare my ad campaign performance (ROAS, CPA, CTR, conversion rate) for the last 24 hours against the 7-day average. Flag any metric that shifted more than 15% in either direction. For each flag, suggest a possible cause and a recommended action.',
33
+ cron: '0 9,17 * * *',
34
+ description: 'At 9:00 AM and 5:00 PM',
35
+ },
36
+ {
37
+ name: 'weekly-report',
38
+ label: 'šŸ“‹ Weekly performance report (Monday 9am)',
39
+ prompt: 'Generate a comprehensive weekly performance report for all my connected ad campaigns. Include: total spend, ROAS, CPA, impressions, clicks, conversions, top 3 performing campaigns, bottom 3 performing campaigns, and 3 actionable recommendations for next week.',
40
+ cron: '0 9 * * 1',
41
+ description: 'Every Monday at 9:00 AM',
42
+ },
43
+ ];
44
+ // ─── Platform Detection ──────────────────────────────────────────────
45
+ function isMacOS() {
46
+ return os.platform() === 'darwin';
47
+ }
48
+ function ensureDirs() {
49
+ if (!fs.existsSync(SCHEDULES_DIR))
50
+ fs.mkdirSync(SCHEDULES_DIR, { recursive: true });
51
+ if (!fs.existsSync(REPORTS_DIR))
52
+ fs.mkdirSync(REPORTS_DIR, { recursive: true });
53
+ }
54
+ function loadSchedules() {
55
+ ensureDirs();
56
+ const indexPath = path.join(SCHEDULES_DIR, 'schedules.json');
57
+ if (!fs.existsSync(indexPath))
58
+ return [];
59
+ try {
60
+ return JSON.parse(fs.readFileSync(indexPath, 'utf8'));
61
+ }
62
+ catch {
63
+ return [];
64
+ }
65
+ }
66
+ function saveSchedules(schedules) {
67
+ ensureDirs();
68
+ fs.writeFileSync(path.join(SCHEDULES_DIR, 'schedules.json'), JSON.stringify(schedules, null, 2));
69
+ }
70
+ // ─── macOS launchd ───────────────────────────────────────────────────
71
+ function cronToLaunchdCalendar(cron) {
72
+ const parts = cron.split(' ');
73
+ const [minute, hour, day, _month, weekday] = parts;
74
+ const cal = {};
75
+ if (minute !== '*') {
76
+ if (minute.startsWith('*/')) {
77
+ cal.Minute = parseInt(minute.slice(2));
78
+ }
79
+ else {
80
+ cal.Minute = parseInt(minute);
81
+ }
82
+ }
83
+ if (hour !== '*') {
84
+ if (hour.startsWith('*/')) {
85
+ // launchd doesn't support */N for hours directly, use Interval instead
86
+ }
87
+ else if (hour.includes(',')) {
88
+ // Multiple hours — return array of calendars
89
+ return hour.split(',').map((h) => ({
90
+ ...cal,
91
+ Hour: parseInt(h),
92
+ Minute: cal.Minute ?? 0,
93
+ }));
94
+ }
95
+ else {
96
+ cal.Hour = parseInt(hour);
97
+ }
98
+ }
99
+ if (day !== '*')
100
+ cal.Day = parseInt(day);
101
+ if (weekday !== '*')
102
+ cal.Weekday = parseInt(weekday);
103
+ return cal;
104
+ }
105
+ function installLaunchd(schedule, openadsPath) {
106
+ const label = `com.openads.schedule.${schedule.name}`;
107
+ const plistPath = path.join(os.homedir(), 'Library', 'LaunchAgents', `${label}.plist`);
108
+ const reportFile = path.join(REPORTS_DIR, `${schedule.name}-latest.md`);
109
+ const calendar = cronToLaunchdCalendar(schedule.cron);
110
+ const calendarEntries = Array.isArray(calendar) ? calendar : [calendar];
111
+ // Check if */N hour pattern — use StartInterval instead
112
+ const parts = schedule.cron.split(' ');
113
+ const hourPart = parts[1];
114
+ let useInterval = false;
115
+ let intervalSeconds = 0;
116
+ if (hourPart.startsWith('*/')) {
117
+ useInterval = true;
118
+ intervalSeconds = parseInt(hourPart.slice(2)) * 3600;
119
+ }
120
+ let schedulingXml;
121
+ if (useInterval) {
122
+ schedulingXml = ` <key>StartInterval</key>\n <integer>${intervalSeconds}</integer>`;
123
+ }
124
+ else {
125
+ const calXml = calendarEntries.map((cal) => {
126
+ let entries = '';
127
+ if (cal.Minute !== undefined)
128
+ entries += ` <key>Minute</key>\n <integer>${cal.Minute}</integer>\n`;
129
+ if (cal.Hour !== undefined)
130
+ entries += ` <key>Hour</key>\n <integer>${cal.Hour}</integer>\n`;
131
+ if (cal.Day !== undefined)
132
+ entries += ` <key>Day</key>\n <integer>${cal.Day}</integer>\n`;
133
+ if (cal.Weekday !== undefined)
134
+ entries += ` <key>Weekday</key>\n <integer>${cal.Weekday}</integer>\n`;
135
+ return ` <dict>\n${entries} </dict>`;
136
+ }).join('\n');
137
+ schedulingXml = ` <key>StartCalendarInterval</key>\n <array>\n${calXml}\n </array>`;
138
+ }
139
+ const plist = `<?xml version="1.0" encoding="UTF-8"?>
140
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
141
+ <plist version="1.0">
142
+ <dict>
143
+ <key>Label</key>
144
+ <string>${label}</string>
145
+ <key>ProgramArguments</key>
146
+ <array>
147
+ <string>${openadsPath}</string>
148
+ <string>run-schedule</string>
149
+ <string>${schedule.name}</string>
150
+ </array>
151
+ ${schedulingXml}
152
+ <key>StandardOutPath</key>
153
+ <string>${reportFile}</string>
154
+ <key>StandardErrorPath</key>
155
+ <string>${path.join(REPORTS_DIR, `${schedule.name}-error.log`)}</string>
156
+ <key>RunAtLoad</key>
157
+ <false/>
158
+ <key>EnvironmentVariables</key>
159
+ <dict>
160
+ <key>PATH</key>
161
+ <string>/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin</string>
162
+ </dict>
163
+ </dict>
164
+ </plist>`;
165
+ const agentsDir = path.join(os.homedir(), 'Library', 'LaunchAgents');
166
+ if (!fs.existsSync(agentsDir))
167
+ fs.mkdirSync(agentsDir, { recursive: true });
168
+ fs.writeFileSync(plistPath, plist);
169
+ // Unload if already loaded, then load
170
+ spawnSync('launchctl', ['bootout', `gui/${process.getuid()}`, plistPath], { stdio: 'ignore' });
171
+ const result = spawnSync('launchctl', ['bootstrap', `gui/${process.getuid()}`, plistPath]);
172
+ return result.status === 0;
173
+ }
174
+ function uninstallLaunchd(name) {
175
+ const label = `com.openads.schedule.${name}`;
176
+ const plistPath = path.join(os.homedir(), 'Library', 'LaunchAgents', `${label}.plist`);
177
+ if (fs.existsSync(plistPath)) {
178
+ spawnSync('launchctl', ['bootout', `gui/${process.getuid()}`, plistPath], { stdio: 'ignore' });
179
+ fs.unlinkSync(plistPath);
180
+ }
181
+ }
182
+ // ─── Linux/Generic crontab ───────────────────────────────────────────
183
+ function installCrontab(schedule, openadsPath) {
184
+ const marker = `# openads:${schedule.name}`;
185
+ const reportFile = path.join(REPORTS_DIR, `${schedule.name}-latest.md`);
186
+ const cronLine = `${schedule.cron} ${openadsPath} run-schedule ${schedule.name} > ${reportFile} 2>&1 ${marker}`;
187
+ // Read current crontab
188
+ const current = spawnSync('crontab', ['-l'], { encoding: 'utf8' });
189
+ let lines = (current.stdout || '').split('\n').filter((l) => !l.includes(marker));
190
+ lines.push(cronLine);
191
+ // Write back
192
+ const result = spawnSync('crontab', ['-'], {
193
+ input: lines.join('\n') + '\n',
194
+ encoding: 'utf8',
195
+ });
196
+ return result.status === 0;
197
+ }
198
+ function uninstallCrontab(name) {
199
+ const marker = `# openads:${name}`;
200
+ const current = spawnSync('crontab', ['-l'], { encoding: 'utf8' });
201
+ const lines = (current.stdout || '').split('\n').filter((l) => !l.includes(marker));
202
+ spawnSync('crontab', ['-'], {
203
+ input: lines.join('\n') + '\n',
204
+ encoding: 'utf8',
205
+ });
206
+ }
207
+ // ─── Run a Scheduled Task ────────────────────────────────────────────
208
+ export async function runScheduledTask(name) {
209
+ const schedules = loadSchedules();
210
+ const schedule = schedules.find(s => s.name === name);
211
+ if (!schedule) {
212
+ console.error(`Schedule "${name}" not found.`);
213
+ process.exit(1);
214
+ }
215
+ // Load config for API key and model
216
+ const configPath = path.join(CONFIG_DIR, 'openads.config.json');
217
+ if (!fs.existsSync(configPath)) {
218
+ console.error('OpenAds is not configured. Run `openads setup` first.');
219
+ process.exit(1);
220
+ }
221
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
222
+ // Print header and accumulate markdown
223
+ const now = new Date().toISOString().slice(0, 19).replace('T', ' ');
224
+ let fullMarkdown = '';
225
+ fullMarkdown += `# OpenAds Scheduled Report: ${schedule.name}\n`;
226
+ fullMarkdown += `_Generated: ${now}_\n\n`;
227
+ fullMarkdown += `**Prompt:** ${schedule.prompt}\n\n`;
228
+ fullMarkdown += '---\n\n';
229
+ console.log(fullMarkdown.trim() + '\n');
230
+ // Find the pi CLI
231
+ const pkgDir = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..');
232
+ const piCliPath = path.resolve(pkgDir, 'node_modules', '@earendil-works', 'pi-coding-agent', 'dist', 'cli.js');
233
+ // Build environment
234
+ const env = { ...process.env, NODE_NO_WARNINGS: '1' };
235
+ if (config.apiKey && config.apiKey !== 'dummy-key') {
236
+ if (config.provider.startsWith('google/'))
237
+ env.GOOGLE_API_KEY = config.apiKey;
238
+ else if (config.provider.startsWith('openai/'))
239
+ env.OPENAI_API_KEY = config.apiKey;
240
+ else if (config.provider.startsWith('anthropic/'))
241
+ env.ANTHROPIC_API_KEY = config.apiKey;
242
+ else
243
+ env.OPENAI_API_KEY = config.apiKey;
244
+ }
245
+ if (config.localBaseUrl)
246
+ env.OPENAI_BASE_URL = config.localBaseUrl;
247
+ const skillsDir = path.resolve(pkgDir, 'skills');
248
+ const contextDir = path.join(CONFIG_DIR, 'context');
249
+ const args = [
250
+ piCliPath,
251
+ '--model', config.provider,
252
+ '--skill', skillsDir,
253
+ ...(fs.existsSync(contextDir) ? ['--skill', contextDir] : []),
254
+ '--print',
255
+ schedule.prompt,
256
+ ];
257
+ const result = spawnSync('node', args, {
258
+ env,
259
+ encoding: 'utf8',
260
+ timeout: 300000, // 5 minute timeout
261
+ stdio: ['ignore', 'pipe', 'pipe'],
262
+ });
263
+ if (result.stdout) {
264
+ console.log(result.stdout);
265
+ fullMarkdown += result.stdout;
266
+ }
267
+ if (result.stderr) {
268
+ console.error(result.stderr);
269
+ }
270
+ // Compile and save the HTML report
271
+ try {
272
+ const reportHtmlFile = path.join(REPORTS_DIR, `${schedule.name}-latest.html`);
273
+ const htmlContent = compileHtmlReport(schedule.name, fullMarkdown);
274
+ fs.writeFileSync(reportHtmlFile, htmlContent, 'utf8');
275
+ }
276
+ catch (err) {
277
+ console.error(`Failed to generate HTML report: ${err.message}`);
278
+ }
279
+ }
280
+ // ─── Interactive Schedule Setup ──────────────────────────────────────
281
+ export async function runScheduleManager(subcommand) {
282
+ // Handle sub-commands
283
+ if (subcommand === 'list') {
284
+ return listSchedules();
285
+ }
286
+ if (subcommand === 'remove') {
287
+ return removeSchedule();
288
+ }
289
+ console.log(openadsGradient('\n OpenAds Scheduler ā°\n'));
290
+ console.log(chalk.gray(' Automate campaign checks, reports, and alerts.\n'));
291
+ const presetChoices = PRESETS.map(p => ({
292
+ name: p.name,
293
+ message: `${p.label} ${chalk.gray(`(${p.description})`)}`,
294
+ }));
295
+ presetChoices.push({
296
+ name: 'custom',
297
+ message: `${chalk.cyan('ā°')} Custom schedule (describe in plain English)`,
298
+ });
299
+ presetChoices.push({
300
+ name: 'list',
301
+ message: `${chalk.gray('šŸ“‹')} View active schedules`,
302
+ });
303
+ presetChoices.push({
304
+ name: 'remove',
305
+ message: `${chalk.gray('šŸ—‘ļø')} Remove a schedule`,
306
+ });
307
+ presetChoices.push({
308
+ name: 'view-reports',
309
+ message: `${chalk.green('šŸ“Š')} View latest HTML reports in browser`,
310
+ });
311
+ const { action } = await enquirer.prompt({
312
+ type: 'select',
313
+ name: 'action',
314
+ message: chalk.bold('What would you like to automate?'),
315
+ choices: presetChoices,
316
+ });
317
+ if (action === 'list')
318
+ return listSchedules();
319
+ if (action === 'remove')
320
+ return removeSchedule();
321
+ if (action === 'view-reports')
322
+ return chooseAndOpenReport();
323
+ let schedule;
324
+ if (action === 'custom') {
325
+ const answers = await enquirer.prompt([
326
+ {
327
+ type: 'input',
328
+ name: 'prompt',
329
+ message: 'What should OpenAds check or report on?',
330
+ validate: (v) => v.trim() ? true : 'Please describe what to automate.',
331
+ },
332
+ {
333
+ type: 'select',
334
+ name: 'cron',
335
+ message: 'How often?',
336
+ choices: [
337
+ { name: '0 8 * * *', message: 'Every day at 8:00 AM' },
338
+ { name: '0 */6 * * *', message: 'Every 6 hours' },
339
+ { name: '0 9,17 * * *', message: 'Twice daily (9 AM & 5 PM)' },
340
+ { name: '0 9 * * 1', message: 'Weekly (Monday 9 AM)' },
341
+ { name: '0 9 1 * *', message: 'Monthly (1st at 9 AM)' },
342
+ ],
343
+ },
344
+ ]);
345
+ const safeName = 'custom-' + Date.now();
346
+ const cronDesc = {
347
+ '0 8 * * *': 'Every day at 8:00 AM',
348
+ '0 */6 * * *': 'Every 6 hours',
349
+ '0 9,17 * * *': 'Twice daily (9 AM & 5 PM)',
350
+ '0 9 * * 1': 'Weekly (Monday 9 AM)',
351
+ '0 9 1 * *': 'Monthly (1st at 9 AM)',
352
+ }[answers.cron] || answers.cron;
353
+ schedule = {
354
+ name: safeName,
355
+ prompt: answers.prompt,
356
+ cron: answers.cron,
357
+ description: cronDesc,
358
+ createdAt: new Date().toISOString(),
359
+ };
360
+ }
361
+ else {
362
+ const preset = PRESETS.find(p => p.name === action);
363
+ schedule = {
364
+ name: preset.name,
365
+ prompt: preset.prompt,
366
+ cron: preset.cron,
367
+ description: preset.description,
368
+ createdAt: new Date().toISOString(),
369
+ };
370
+ }
371
+ // Find openads executable
372
+ const openadsPath = process.argv[1];
373
+ // Install the schedule
374
+ console.log('');
375
+ let installed = false;
376
+ if (isMacOS()) {
377
+ console.log(chalk.cyan('Installing schedule via macOS launchd...'));
378
+ installed = installLaunchd(schedule, openadsPath);
379
+ }
380
+ else {
381
+ console.log(chalk.cyan('Installing schedule via crontab...'));
382
+ installed = installCrontab(schedule, openadsPath);
383
+ }
384
+ if (installed) {
385
+ // Save to our index
386
+ const schedules = loadSchedules().filter(s => s.name !== schedule.name);
387
+ schedules.push(schedule);
388
+ saveSchedules(schedules);
389
+ console.log(chalk.green(`\nāœ“ Schedule "${schedule.name}" installed!`));
390
+ console.log(chalk.gray(` Frequency: ${schedule.description}`));
391
+ console.log(chalk.gray(` Reports saved to: ${REPORTS_DIR}`));
392
+ console.log(chalk.gray(`\n Manage with: openads schedule list | openads schedule remove\n`));
393
+ }
394
+ else {
395
+ console.log(chalk.red('\nāœ— Failed to install schedule. Check permissions and try again.\n'));
396
+ }
397
+ }
398
+ // ─── List / Remove ───────────────────────────────────────────────────
399
+ function listSchedules() {
400
+ const schedules = loadSchedules();
401
+ if (schedules.length === 0) {
402
+ console.log(chalk.yellow('\n No active schedules. Run `openads schedule` to create one.\n'));
403
+ return;
404
+ }
405
+ console.log(chalk.bold.cyan('\n Active Schedules'));
406
+ console.log(chalk.gray(' ─────────────────────────────────────────────────────\n'));
407
+ for (const s of schedules) {
408
+ console.log(` ${chalk.cyan(s.name.padEnd(25))} ${chalk.white(s.description)}`);
409
+ console.log(` ${' '.repeat(25)} ${chalk.gray(s.prompt.slice(0, 80))}${s.prompt.length > 80 ? '...' : ''}`);
410
+ console.log('');
411
+ }
412
+ console.log(chalk.gray(` Reports saved to: ${REPORTS_DIR}\n`));
413
+ }
414
+ async function removeSchedule() {
415
+ const schedules = loadSchedules();
416
+ if (schedules.length === 0) {
417
+ console.log(chalk.yellow('\n No active schedules to remove.\n'));
418
+ return;
419
+ }
420
+ const choices = schedules.map(s => ({
421
+ name: s.name,
422
+ message: `${s.name} — ${s.description}`,
423
+ }));
424
+ const { name } = await enquirer.prompt({
425
+ type: 'select',
426
+ name: 'name',
427
+ message: 'Which schedule do you want to remove?',
428
+ choices,
429
+ });
430
+ // Uninstall from OS
431
+ if (isMacOS()) {
432
+ uninstallLaunchd(name);
433
+ }
434
+ else {
435
+ uninstallCrontab(name);
436
+ }
437
+ // Remove from index
438
+ const updated = schedules.filter(s => s.name !== name);
439
+ saveSchedules(updated);
440
+ console.log(chalk.green(`\nāœ“ Schedule "${name}" removed.\n`));
441
+ }
442
+ export async function openReportInBrowser(name) {
443
+ const reportHtmlFile = path.join(REPORTS_DIR, `${name}-latest.html`);
444
+ if (!fs.existsSync(reportHtmlFile)) {
445
+ // If HTML doesn't exist but MD does, let's compile it on the fly!
446
+ const reportMdFile = path.join(REPORTS_DIR, `${name}-latest.md`);
447
+ if (fs.existsSync(reportMdFile)) {
448
+ const mdContent = fs.readFileSync(reportMdFile, 'utf8');
449
+ const htmlContent = compileHtmlReport(name, mdContent);
450
+ fs.writeFileSync(reportHtmlFile, htmlContent, 'utf8');
451
+ }
452
+ else {
453
+ console.error(chalk.red(`\nāœ— Report for "${name}" not found. Run the schedule first or select another report.\n`));
454
+ return;
455
+ }
456
+ }
457
+ console.log(chalk.cyan(`\nOpening HTML report for "${name}" in your default browser...`));
458
+ await open(reportHtmlFile);
459
+ }
460
+ async function chooseAndOpenReport() {
461
+ const schedules = loadSchedules();
462
+ if (schedules.length === 0) {
463
+ console.log(chalk.yellow('\n No active schedules. Run `openads schedule` to create one.\n'));
464
+ return;
465
+ }
466
+ // Find all schedules that have reports
467
+ const choices = schedules.map(s => {
468
+ const reportMdFile = path.join(REPORTS_DIR, `${s.name}-latest.md`);
469
+ const hasReport = fs.existsSync(reportMdFile);
470
+ return {
471
+ name: s.name,
472
+ message: `${s.name} ${chalk.gray(`(${s.description})`)} ${hasReport ? chalk.green('[Report Available]') : chalk.red('[No Report Yet]')}`,
473
+ disabled: !hasReport,
474
+ };
475
+ });
476
+ if (choices.every(c => c.disabled)) {
477
+ console.log(chalk.yellow('\n No reports have been generated yet. Please wait for schedules to run.\n'));
478
+ return;
479
+ }
480
+ const { name } = await enquirer.prompt({
481
+ type: 'select',
482
+ name: 'name',
483
+ message: 'Select a report to open:',
484
+ choices: choices.filter(c => !c.disabled),
485
+ });
486
+ await openReportInBrowser(name);
487
+ }
488
+ export function listReports() {
489
+ ensureDirs();
490
+ const files = fs.readdirSync(REPORTS_DIR);
491
+ const reports = files.filter(f => f.endsWith('-latest.md'));
492
+ if (reports.length === 0) {
493
+ console.log(chalk.yellow('\n No reports found in your reports directory. Please wait for schedules to run.\n'));
494
+ return;
495
+ }
496
+ console.log(chalk.bold.cyan('\n Generated Reports'));
497
+ console.log(chalk.gray(' ──────────────────────────────────────────────────────────────────────────'));
498
+ for (const file of reports) {
499
+ const filePath = path.join(REPORTS_DIR, file);
500
+ const stats = fs.statSync(filePath);
501
+ const name = file.replace('-latest.md', '');
502
+ const date = stats.mtime.toISOString().slice(0, 19).replace('T', ' ');
503
+ const sizeKb = (stats.size / 1024).toFixed(1);
504
+ // Check if HTML version exists
505
+ const htmlExists = fs.existsSync(path.join(REPORTS_DIR, `${name}-latest.html`));
506
+ const formatSupport = htmlExists ? chalk.green('MD + HTML') : chalk.yellow('MD Only');
507
+ console.log(` ${chalk.cyan(name.padEnd(25))} ${chalk.white(`${sizeKb} KB`)} ${chalk.gray(date)} ${formatSupport}`);
508
+ }
509
+ console.log(chalk.gray(`\n Reports saved to: ${REPORTS_DIR}`));
510
+ console.log(chalk.gray(` To view a report in your browser, run: ${chalk.white('openads report [name]')}\n`));
511
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openads-ai",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
4
4
  "description": "Open-source AI command center for digital marketers. Audit campaigns, write ad copy, and build strategies — from your terminal.",
5
5
  "main": "dist/cli.js",
6
6
  "bin": {