openads-ai 0.1.0 ā 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +52 -1
- package/dist/cli.js +41 -14
- package/dist/schedule.js +419 -0
- package/dist/setup.js +21 -12
- package/package.json +1 -1
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,44 @@ 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/`. Manage your schedules:
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
openads schedule list # See active schedules
|
|
154
|
+
openads schedule remove # Remove a schedule
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Uses your OS scheduler (macOS `launchd` / Linux `crontab`) ā works even when your terminal is closed.
|
|
158
|
+
|
|
111
159
|
## š Security & Privacy
|
|
112
160
|
|
|
113
161
|
- **Runs 100% locally.** OpenAds is not a cloud service. Nothing leaves your machine except the API calls you authorize.
|
|
@@ -136,9 +184,12 @@ This verifies your config file, API keys, platform connections (live token check
|
|
|
136
184
|
- [x] Interactive setup wizard with live token verification
|
|
137
185
|
- [x] 12 pre-built skills: Ads, CRO, Copywriting, Analytics, Email, Video, Research, Strategy
|
|
138
186
|
- [x] Autonomous research loops
|
|
187
|
+
- [x] Published to npm (`npm install -g openads-ai`)
|
|
188
|
+
- [x] Memory system ā AI learns about your business over time
|
|
189
|
+
- [x] Scheduled automations ā daily health checks, budget alerts, weekly reports
|
|
190
|
+
- [ ] Telegram bot gateway ā talk to your ads from your phone
|
|
139
191
|
- [ ] LinkedIn Ads integration
|
|
140
192
|
- [ ] Pinterest Ads integration
|
|
141
|
-
- [ ] Publish to npm registry
|
|
142
193
|
|
|
143
194
|
---
|
|
144
195
|
|
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 } from './schedule.js';
|
|
13
14
|
import enquirer from 'enquirer';
|
|
14
15
|
const __filename = fileURLToPath(import.meta.url);
|
|
15
16
|
const __dirname = path.dirname(__filename);
|
|
@@ -29,13 +30,7 @@ const openadsGradient = gradient(['#00d2ff', '#3a7bd5', '#00d2ff']);
|
|
|
29
30
|
const CONFIG_DIR = path.join(os.homedir(), '.openads');
|
|
30
31
|
const CONFIG_PATH = path.join(CONFIG_DIR, 'openads.config.json');
|
|
31
32
|
const DEPRECATED_MODELS = {
|
|
32
|
-
'google/gemini-1.
|
|
33
|
-
'google/gemini-1.5-pro-latest': 'google/gemini-2.5-flash',
|
|
34
|
-
'google/gemini-3.5-flash': 'google/gemini-2.5-flash',
|
|
35
|
-
'openai/gpt-4o': 'openai/gpt-4.1',
|
|
36
|
-
'openai/gpt-4o-mini': 'openai/gpt-4.1-mini',
|
|
37
|
-
'anthropic/claude-3-5-sonnet-20241022': 'anthropic/claude-sonnet-4',
|
|
38
|
-
'anthropic/claude-3-5-haiku-20241022': 'anthropic/claude-haiku-4',
|
|
33
|
+
'google/gemini-1.0-pro': 'google/gemini-2.5-flash',
|
|
39
34
|
};
|
|
40
35
|
function loadConfig() {
|
|
41
36
|
if (!fs.existsSync(CONFIG_PATH))
|
|
@@ -50,9 +45,11 @@ function loadConfig() {
|
|
|
50
45
|
function resolveModel(provider) {
|
|
51
46
|
return DEPRECATED_MODELS[provider] || provider;
|
|
52
47
|
}
|
|
53
|
-
// āāā Product Context
|
|
54
|
-
//
|
|
55
|
-
//
|
|
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.
|
|
56
53
|
function injectProductContext(config) {
|
|
57
54
|
if (!config?.productContext)
|
|
58
55
|
return null;
|
|
@@ -61,7 +58,9 @@ function injectProductContext(config) {
|
|
|
61
58
|
fs.mkdirSync(contextDir, { recursive: true });
|
|
62
59
|
}
|
|
63
60
|
const contextPath = path.join(contextDir, 'my-business.md');
|
|
64
|
-
|
|
61
|
+
// Only create the initial file ā never overwrite accumulated learnings
|
|
62
|
+
if (!fs.existsSync(contextPath)) {
|
|
63
|
+
const content = `---
|
|
65
64
|
name: my-business
|
|
66
65
|
description: The user's business context ā always read this first.
|
|
67
66
|
---
|
|
@@ -69,15 +68,19 @@ description: The user's business context ā always read this first.
|
|
|
69
68
|
|
|
70
69
|
${config.productContext}
|
|
71
70
|
|
|
72
|
-
|
|
73
|
-
|
|
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}_
|
|
74
75
|
`;
|
|
75
|
-
|
|
76
|
+
fs.writeFileSync(contextPath, content);
|
|
77
|
+
}
|
|
76
78
|
return contextDir;
|
|
77
79
|
}
|
|
78
80
|
// āāā System Prompt āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
79
81
|
// Makes the agent behave as "OpenAds" instead of generic Pi.
|
|
80
82
|
function buildSystemPrompt(config) {
|
|
83
|
+
const contextPath = path.join(CONFIG_DIR, 'context', 'my-business.md');
|
|
81
84
|
const parts = [
|
|
82
85
|
'You are OpenAds, an AI marketing assistant built for digital marketers.',
|
|
83
86
|
'You specialize in Google Ads, Meta Ads, copywriting, analytics, CRO, and go-to-market strategy.',
|
|
@@ -85,6 +88,17 @@ function buildSystemPrompt(config) {
|
|
|
85
88
|
'Address the user as a marketing professional.',
|
|
86
89
|
'When writing ad copy or recommendations, always reference the user\'s product context first.',
|
|
87
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.',
|
|
88
102
|
];
|
|
89
103
|
if (config?.productContext) {
|
|
90
104
|
parts.push(`\nThe user's business: ${config.productContext}`);
|
|
@@ -199,6 +213,14 @@ async function main() {
|
|
|
199
213
|
await runDoctor();
|
|
200
214
|
return;
|
|
201
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
|
+
}
|
|
202
224
|
// āāā First-Run Detection āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
203
225
|
const config = loadConfig();
|
|
204
226
|
if (!config || !config.provider) {
|
|
@@ -247,6 +269,7 @@ async function main() {
|
|
|
247
269
|
{ name: 'autoresearch', message: `${chalk.cyan('š')} Test and improve ideas automatically ${chalk.gray('(autoresearch)')}` },
|
|
248
270
|
{ name: 'gtm', message: `${chalk.cyan('š')} Build a go-to-market plan ${chalk.gray('(strategy)')}` },
|
|
249
271
|
{ name: 'skills', message: `${chalk.cyan('š')} Browse available skills` },
|
|
272
|
+
{ name: 'schedule', message: `${chalk.cyan('ā°')} Schedule automations` },
|
|
250
273
|
{ name: 'setup', message: `${chalk.gray('āļø')} Settings` },
|
|
251
274
|
{ name: 'doctor', message: `${chalk.gray('š©ŗ')} Diagnostics` },
|
|
252
275
|
{ name: 'exit', message: `${chalk.gray('ā')} Exit` }
|
|
@@ -268,6 +291,10 @@ async function main() {
|
|
|
268
291
|
showSkills();
|
|
269
292
|
return;
|
|
270
293
|
}
|
|
294
|
+
if (action === 'schedule') {
|
|
295
|
+
await runScheduleManager();
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
271
298
|
const actionMap = {
|
|
272
299
|
chat: [],
|
|
273
300
|
audit: ['audit-google-ads'],
|
package/dist/schedule.js
ADDED
|
@@ -0,0 +1,419 @@
|
|
|
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
|
+
const openadsGradient = gradient(['#00d2ff', '#3a7bd5', '#00d2ff']);
|
|
9
|
+
const CONFIG_DIR = path.join(os.homedir(), '.openads');
|
|
10
|
+
const SCHEDULES_DIR = path.join(CONFIG_DIR, 'schedules');
|
|
11
|
+
const REPORTS_DIR = path.join(CONFIG_DIR, 'reports');
|
|
12
|
+
const PRESETS = [
|
|
13
|
+
{
|
|
14
|
+
name: 'daily-health',
|
|
15
|
+
label: 'š Daily campaign health check',
|
|
16
|
+
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.',
|
|
17
|
+
cron: '0 8 * * *',
|
|
18
|
+
description: 'Every day at 8:00 AM',
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
name: 'budget-pacing',
|
|
22
|
+
label: 'šø Budget pacing alert (every 6 hours)',
|
|
23
|
+
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.',
|
|
24
|
+
cron: '0 */6 * * *',
|
|
25
|
+
description: 'Every 6 hours',
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
name: 'performance-drop',
|
|
29
|
+
label: 'š Performance drop alert (twice daily)',
|
|
30
|
+
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.',
|
|
31
|
+
cron: '0 9,17 * * *',
|
|
32
|
+
description: 'At 9:00 AM and 5:00 PM',
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
name: 'weekly-report',
|
|
36
|
+
label: 'š Weekly performance report (Monday 9am)',
|
|
37
|
+
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.',
|
|
38
|
+
cron: '0 9 * * 1',
|
|
39
|
+
description: 'Every Monday at 9:00 AM',
|
|
40
|
+
},
|
|
41
|
+
];
|
|
42
|
+
// āāā Platform Detection āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
43
|
+
function isMacOS() {
|
|
44
|
+
return os.platform() === 'darwin';
|
|
45
|
+
}
|
|
46
|
+
function ensureDirs() {
|
|
47
|
+
if (!fs.existsSync(SCHEDULES_DIR))
|
|
48
|
+
fs.mkdirSync(SCHEDULES_DIR, { recursive: true });
|
|
49
|
+
if (!fs.existsSync(REPORTS_DIR))
|
|
50
|
+
fs.mkdirSync(REPORTS_DIR, { recursive: true });
|
|
51
|
+
}
|
|
52
|
+
function loadSchedules() {
|
|
53
|
+
ensureDirs();
|
|
54
|
+
const indexPath = path.join(SCHEDULES_DIR, 'schedules.json');
|
|
55
|
+
if (!fs.existsSync(indexPath))
|
|
56
|
+
return [];
|
|
57
|
+
try {
|
|
58
|
+
return JSON.parse(fs.readFileSync(indexPath, 'utf8'));
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return [];
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function saveSchedules(schedules) {
|
|
65
|
+
ensureDirs();
|
|
66
|
+
fs.writeFileSync(path.join(SCHEDULES_DIR, 'schedules.json'), JSON.stringify(schedules, null, 2));
|
|
67
|
+
}
|
|
68
|
+
// āāā macOS launchd āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
69
|
+
function cronToLaunchdCalendar(cron) {
|
|
70
|
+
const parts = cron.split(' ');
|
|
71
|
+
const [minute, hour, day, _month, weekday] = parts;
|
|
72
|
+
const cal = {};
|
|
73
|
+
if (minute !== '*') {
|
|
74
|
+
if (minute.startsWith('*/')) {
|
|
75
|
+
cal.Minute = parseInt(minute.slice(2));
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
cal.Minute = parseInt(minute);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (hour !== '*') {
|
|
82
|
+
if (hour.startsWith('*/')) {
|
|
83
|
+
// launchd doesn't support */N for hours directly, use Interval instead
|
|
84
|
+
}
|
|
85
|
+
else if (hour.includes(',')) {
|
|
86
|
+
// Multiple hours ā return array of calendars
|
|
87
|
+
return hour.split(',').map((h) => ({
|
|
88
|
+
...cal,
|
|
89
|
+
Hour: parseInt(h),
|
|
90
|
+
Minute: cal.Minute ?? 0,
|
|
91
|
+
}));
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
cal.Hour = parseInt(hour);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (day !== '*')
|
|
98
|
+
cal.Day = parseInt(day);
|
|
99
|
+
if (weekday !== '*')
|
|
100
|
+
cal.Weekday = parseInt(weekday);
|
|
101
|
+
return cal;
|
|
102
|
+
}
|
|
103
|
+
function installLaunchd(schedule, openadsPath) {
|
|
104
|
+
const label = `com.openads.schedule.${schedule.name}`;
|
|
105
|
+
const plistPath = path.join(os.homedir(), 'Library', 'LaunchAgents', `${label}.plist`);
|
|
106
|
+
const reportFile = path.join(REPORTS_DIR, `${schedule.name}-latest.md`);
|
|
107
|
+
const calendar = cronToLaunchdCalendar(schedule.cron);
|
|
108
|
+
const calendarEntries = Array.isArray(calendar) ? calendar : [calendar];
|
|
109
|
+
// Check if */N hour pattern ā use StartInterval instead
|
|
110
|
+
const parts = schedule.cron.split(' ');
|
|
111
|
+
const hourPart = parts[1];
|
|
112
|
+
let useInterval = false;
|
|
113
|
+
let intervalSeconds = 0;
|
|
114
|
+
if (hourPart.startsWith('*/')) {
|
|
115
|
+
useInterval = true;
|
|
116
|
+
intervalSeconds = parseInt(hourPart.slice(2)) * 3600;
|
|
117
|
+
}
|
|
118
|
+
let schedulingXml;
|
|
119
|
+
if (useInterval) {
|
|
120
|
+
schedulingXml = ` <key>StartInterval</key>\n <integer>${intervalSeconds}</integer>`;
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
const calXml = calendarEntries.map((cal) => {
|
|
124
|
+
let entries = '';
|
|
125
|
+
if (cal.Minute !== undefined)
|
|
126
|
+
entries += ` <key>Minute</key>\n <integer>${cal.Minute}</integer>\n`;
|
|
127
|
+
if (cal.Hour !== undefined)
|
|
128
|
+
entries += ` <key>Hour</key>\n <integer>${cal.Hour}</integer>\n`;
|
|
129
|
+
if (cal.Day !== undefined)
|
|
130
|
+
entries += ` <key>Day</key>\n <integer>${cal.Day}</integer>\n`;
|
|
131
|
+
if (cal.Weekday !== undefined)
|
|
132
|
+
entries += ` <key>Weekday</key>\n <integer>${cal.Weekday}</integer>\n`;
|
|
133
|
+
return ` <dict>\n${entries} </dict>`;
|
|
134
|
+
}).join('\n');
|
|
135
|
+
schedulingXml = ` <key>StartCalendarInterval</key>\n <array>\n${calXml}\n </array>`;
|
|
136
|
+
}
|
|
137
|
+
const plist = `<?xml version="1.0" encoding="UTF-8"?>
|
|
138
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
139
|
+
<plist version="1.0">
|
|
140
|
+
<dict>
|
|
141
|
+
<key>Label</key>
|
|
142
|
+
<string>${label}</string>
|
|
143
|
+
<key>ProgramArguments</key>
|
|
144
|
+
<array>
|
|
145
|
+
<string>${openadsPath}</string>
|
|
146
|
+
<string>run-schedule</string>
|
|
147
|
+
<string>${schedule.name}</string>
|
|
148
|
+
</array>
|
|
149
|
+
${schedulingXml}
|
|
150
|
+
<key>StandardOutPath</key>
|
|
151
|
+
<string>${reportFile}</string>
|
|
152
|
+
<key>StandardErrorPath</key>
|
|
153
|
+
<string>${path.join(REPORTS_DIR, `${schedule.name}-error.log`)}</string>
|
|
154
|
+
<key>RunAtLoad</key>
|
|
155
|
+
<false/>
|
|
156
|
+
<key>EnvironmentVariables</key>
|
|
157
|
+
<dict>
|
|
158
|
+
<key>PATH</key>
|
|
159
|
+
<string>/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin</string>
|
|
160
|
+
</dict>
|
|
161
|
+
</dict>
|
|
162
|
+
</plist>`;
|
|
163
|
+
const agentsDir = path.join(os.homedir(), 'Library', 'LaunchAgents');
|
|
164
|
+
if (!fs.existsSync(agentsDir))
|
|
165
|
+
fs.mkdirSync(agentsDir, { recursive: true });
|
|
166
|
+
fs.writeFileSync(plistPath, plist);
|
|
167
|
+
// Unload if already loaded, then load
|
|
168
|
+
spawnSync('launchctl', ['bootout', `gui/${process.getuid()}`, plistPath], { stdio: 'ignore' });
|
|
169
|
+
const result = spawnSync('launchctl', ['bootstrap', `gui/${process.getuid()}`, plistPath]);
|
|
170
|
+
return result.status === 0;
|
|
171
|
+
}
|
|
172
|
+
function uninstallLaunchd(name) {
|
|
173
|
+
const label = `com.openads.schedule.${name}`;
|
|
174
|
+
const plistPath = path.join(os.homedir(), 'Library', 'LaunchAgents', `${label}.plist`);
|
|
175
|
+
if (fs.existsSync(plistPath)) {
|
|
176
|
+
spawnSync('launchctl', ['bootout', `gui/${process.getuid()}`, plistPath], { stdio: 'ignore' });
|
|
177
|
+
fs.unlinkSync(plistPath);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
// āāā Linux/Generic crontab āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
181
|
+
function installCrontab(schedule, openadsPath) {
|
|
182
|
+
const marker = `# openads:${schedule.name}`;
|
|
183
|
+
const reportFile = path.join(REPORTS_DIR, `${schedule.name}-latest.md`);
|
|
184
|
+
const cronLine = `${schedule.cron} ${openadsPath} run-schedule ${schedule.name} > ${reportFile} 2>&1 ${marker}`;
|
|
185
|
+
// Read current crontab
|
|
186
|
+
const current = spawnSync('crontab', ['-l'], { encoding: 'utf8' });
|
|
187
|
+
let lines = (current.stdout || '').split('\n').filter((l) => !l.includes(marker));
|
|
188
|
+
lines.push(cronLine);
|
|
189
|
+
// Write back
|
|
190
|
+
const result = spawnSync('crontab', ['-'], {
|
|
191
|
+
input: lines.join('\n') + '\n',
|
|
192
|
+
encoding: 'utf8',
|
|
193
|
+
});
|
|
194
|
+
return result.status === 0;
|
|
195
|
+
}
|
|
196
|
+
function uninstallCrontab(name) {
|
|
197
|
+
const marker = `# openads:${name}`;
|
|
198
|
+
const current = spawnSync('crontab', ['-l'], { encoding: 'utf8' });
|
|
199
|
+
const lines = (current.stdout || '').split('\n').filter((l) => !l.includes(marker));
|
|
200
|
+
spawnSync('crontab', ['-'], {
|
|
201
|
+
input: lines.join('\n') + '\n',
|
|
202
|
+
encoding: 'utf8',
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
// āāā Run a Scheduled Task āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
206
|
+
export async function runScheduledTask(name) {
|
|
207
|
+
const schedules = loadSchedules();
|
|
208
|
+
const schedule = schedules.find(s => s.name === name);
|
|
209
|
+
if (!schedule) {
|
|
210
|
+
console.error(`Schedule "${name}" not found.`);
|
|
211
|
+
process.exit(1);
|
|
212
|
+
}
|
|
213
|
+
// Load config for API key and model
|
|
214
|
+
const configPath = path.join(CONFIG_DIR, 'openads.config.json');
|
|
215
|
+
if (!fs.existsSync(configPath)) {
|
|
216
|
+
console.error('OpenAds is not configured. Run `openads setup` first.');
|
|
217
|
+
process.exit(1);
|
|
218
|
+
}
|
|
219
|
+
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
220
|
+
// Print header
|
|
221
|
+
const now = new Date().toISOString().slice(0, 19).replace('T', ' ');
|
|
222
|
+
console.log(`# OpenAds Scheduled Report: ${schedule.name}`);
|
|
223
|
+
console.log(`_Generated: ${now}_\n`);
|
|
224
|
+
console.log(`**Prompt:** ${schedule.prompt}\n`);
|
|
225
|
+
console.log('---\n');
|
|
226
|
+
// Find the pi CLI
|
|
227
|
+
const pkgDir = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..');
|
|
228
|
+
const piCliPath = path.resolve(pkgDir, 'node_modules', '@earendil-works', 'pi-coding-agent', 'dist', 'cli.js');
|
|
229
|
+
// Build environment
|
|
230
|
+
const env = { ...process.env, NODE_NO_WARNINGS: '1' };
|
|
231
|
+
if (config.apiKey && config.apiKey !== 'dummy-key') {
|
|
232
|
+
if (config.provider.startsWith('google/'))
|
|
233
|
+
env.GOOGLE_API_KEY = config.apiKey;
|
|
234
|
+
else if (config.provider.startsWith('openai/'))
|
|
235
|
+
env.OPENAI_API_KEY = config.apiKey;
|
|
236
|
+
else if (config.provider.startsWith('anthropic/'))
|
|
237
|
+
env.ANTHROPIC_API_KEY = config.apiKey;
|
|
238
|
+
else
|
|
239
|
+
env.OPENAI_API_KEY = config.apiKey;
|
|
240
|
+
}
|
|
241
|
+
if (config.localBaseUrl)
|
|
242
|
+
env.OPENAI_BASE_URL = config.localBaseUrl;
|
|
243
|
+
const skillsDir = path.resolve(pkgDir, 'skills');
|
|
244
|
+
const contextDir = path.join(CONFIG_DIR, 'context');
|
|
245
|
+
const args = [
|
|
246
|
+
piCliPath,
|
|
247
|
+
'--model', config.provider,
|
|
248
|
+
'--skill', skillsDir,
|
|
249
|
+
...(fs.existsSync(contextDir) ? ['--skill', contextDir] : []),
|
|
250
|
+
'--print',
|
|
251
|
+
schedule.prompt,
|
|
252
|
+
];
|
|
253
|
+
const result = spawnSync('node', args, {
|
|
254
|
+
env,
|
|
255
|
+
encoding: 'utf8',
|
|
256
|
+
timeout: 300000, // 5 minute timeout
|
|
257
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
258
|
+
});
|
|
259
|
+
if (result.stdout)
|
|
260
|
+
console.log(result.stdout);
|
|
261
|
+
if (result.stderr)
|
|
262
|
+
console.error(result.stderr);
|
|
263
|
+
}
|
|
264
|
+
// āāā Interactive Schedule Setup āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
265
|
+
export async function runScheduleManager(subcommand) {
|
|
266
|
+
// Handle sub-commands
|
|
267
|
+
if (subcommand === 'list') {
|
|
268
|
+
return listSchedules();
|
|
269
|
+
}
|
|
270
|
+
if (subcommand === 'remove') {
|
|
271
|
+
return removeSchedule();
|
|
272
|
+
}
|
|
273
|
+
console.log(openadsGradient('\n OpenAds Scheduler ā°\n'));
|
|
274
|
+
console.log(chalk.gray(' Automate campaign checks, reports, and alerts.\n'));
|
|
275
|
+
const presetChoices = PRESETS.map(p => ({
|
|
276
|
+
name: p.name,
|
|
277
|
+
message: `${p.label} ${chalk.gray(`(${p.description})`)}`,
|
|
278
|
+
}));
|
|
279
|
+
presetChoices.push({
|
|
280
|
+
name: 'custom',
|
|
281
|
+
message: `${chalk.cyan('ā°')} Custom schedule (describe in plain English)`,
|
|
282
|
+
});
|
|
283
|
+
presetChoices.push({
|
|
284
|
+
name: 'list',
|
|
285
|
+
message: `${chalk.gray('š')} View active schedules`,
|
|
286
|
+
});
|
|
287
|
+
presetChoices.push({
|
|
288
|
+
name: 'remove',
|
|
289
|
+
message: `${chalk.gray('šļø')} Remove a schedule`,
|
|
290
|
+
});
|
|
291
|
+
const { action } = await enquirer.prompt({
|
|
292
|
+
type: 'select',
|
|
293
|
+
name: 'action',
|
|
294
|
+
message: chalk.bold('What would you like to automate?'),
|
|
295
|
+
choices: presetChoices,
|
|
296
|
+
});
|
|
297
|
+
if (action === 'list')
|
|
298
|
+
return listSchedules();
|
|
299
|
+
if (action === 'remove')
|
|
300
|
+
return removeSchedule();
|
|
301
|
+
let schedule;
|
|
302
|
+
if (action === 'custom') {
|
|
303
|
+
const answers = await enquirer.prompt([
|
|
304
|
+
{
|
|
305
|
+
type: 'input',
|
|
306
|
+
name: 'prompt',
|
|
307
|
+
message: 'What should OpenAds check or report on?',
|
|
308
|
+
validate: (v) => v.trim() ? true : 'Please describe what to automate.',
|
|
309
|
+
},
|
|
310
|
+
{
|
|
311
|
+
type: 'select',
|
|
312
|
+
name: 'cron',
|
|
313
|
+
message: 'How often?',
|
|
314
|
+
choices: [
|
|
315
|
+
{ name: '0 8 * * *', message: 'Every day at 8:00 AM' },
|
|
316
|
+
{ name: '0 */6 * * *', message: 'Every 6 hours' },
|
|
317
|
+
{ name: '0 9,17 * * *', message: 'Twice daily (9 AM & 5 PM)' },
|
|
318
|
+
{ name: '0 9 * * 1', message: 'Weekly (Monday 9 AM)' },
|
|
319
|
+
{ name: '0 9 1 * *', message: 'Monthly (1st at 9 AM)' },
|
|
320
|
+
],
|
|
321
|
+
},
|
|
322
|
+
]);
|
|
323
|
+
const safeName = 'custom-' + Date.now();
|
|
324
|
+
const cronDesc = {
|
|
325
|
+
'0 8 * * *': 'Every day at 8:00 AM',
|
|
326
|
+
'0 */6 * * *': 'Every 6 hours',
|
|
327
|
+
'0 9,17 * * *': 'Twice daily (9 AM & 5 PM)',
|
|
328
|
+
'0 9 * * 1': 'Weekly (Monday 9 AM)',
|
|
329
|
+
'0 9 1 * *': 'Monthly (1st at 9 AM)',
|
|
330
|
+
}[answers.cron] || answers.cron;
|
|
331
|
+
schedule = {
|
|
332
|
+
name: safeName,
|
|
333
|
+
prompt: answers.prompt,
|
|
334
|
+
cron: answers.cron,
|
|
335
|
+
description: cronDesc,
|
|
336
|
+
createdAt: new Date().toISOString(),
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
else {
|
|
340
|
+
const preset = PRESETS.find(p => p.name === action);
|
|
341
|
+
schedule = {
|
|
342
|
+
name: preset.name,
|
|
343
|
+
prompt: preset.prompt,
|
|
344
|
+
cron: preset.cron,
|
|
345
|
+
description: preset.description,
|
|
346
|
+
createdAt: new Date().toISOString(),
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
// Find openads executable
|
|
350
|
+
const openadsPath = process.argv[1];
|
|
351
|
+
// Install the schedule
|
|
352
|
+
console.log('');
|
|
353
|
+
let installed = false;
|
|
354
|
+
if (isMacOS()) {
|
|
355
|
+
console.log(chalk.cyan('Installing schedule via macOS launchd...'));
|
|
356
|
+
installed = installLaunchd(schedule, openadsPath);
|
|
357
|
+
}
|
|
358
|
+
else {
|
|
359
|
+
console.log(chalk.cyan('Installing schedule via crontab...'));
|
|
360
|
+
installed = installCrontab(schedule, openadsPath);
|
|
361
|
+
}
|
|
362
|
+
if (installed) {
|
|
363
|
+
// Save to our index
|
|
364
|
+
const schedules = loadSchedules().filter(s => s.name !== schedule.name);
|
|
365
|
+
schedules.push(schedule);
|
|
366
|
+
saveSchedules(schedules);
|
|
367
|
+
console.log(chalk.green(`\nā Schedule "${schedule.name}" installed!`));
|
|
368
|
+
console.log(chalk.gray(` Frequency: ${schedule.description}`));
|
|
369
|
+
console.log(chalk.gray(` Reports saved to: ${REPORTS_DIR}`));
|
|
370
|
+
console.log(chalk.gray(`\n Manage with: openads schedule list | openads schedule remove\n`));
|
|
371
|
+
}
|
|
372
|
+
else {
|
|
373
|
+
console.log(chalk.red('\nā Failed to install schedule. Check permissions and try again.\n'));
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
// āāā List / Remove āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
377
|
+
function listSchedules() {
|
|
378
|
+
const schedules = loadSchedules();
|
|
379
|
+
if (schedules.length === 0) {
|
|
380
|
+
console.log(chalk.yellow('\n No active schedules. Run `openads schedule` to create one.\n'));
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
console.log(chalk.bold.cyan('\n Active Schedules'));
|
|
384
|
+
console.log(chalk.gray(' āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā\n'));
|
|
385
|
+
for (const s of schedules) {
|
|
386
|
+
console.log(` ${chalk.cyan(s.name.padEnd(25))} ${chalk.white(s.description)}`);
|
|
387
|
+
console.log(` ${' '.repeat(25)} ${chalk.gray(s.prompt.slice(0, 80))}${s.prompt.length > 80 ? '...' : ''}`);
|
|
388
|
+
console.log('');
|
|
389
|
+
}
|
|
390
|
+
console.log(chalk.gray(` Reports saved to: ${REPORTS_DIR}\n`));
|
|
391
|
+
}
|
|
392
|
+
async function removeSchedule() {
|
|
393
|
+
const schedules = loadSchedules();
|
|
394
|
+
if (schedules.length === 0) {
|
|
395
|
+
console.log(chalk.yellow('\n No active schedules to remove.\n'));
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
const choices = schedules.map(s => ({
|
|
399
|
+
name: s.name,
|
|
400
|
+
message: `${s.name} ā ${s.description}`,
|
|
401
|
+
}));
|
|
402
|
+
const { name } = await enquirer.prompt({
|
|
403
|
+
type: 'select',
|
|
404
|
+
name: 'name',
|
|
405
|
+
message: 'Which schedule do you want to remove?',
|
|
406
|
+
choices,
|
|
407
|
+
});
|
|
408
|
+
// Uninstall from OS
|
|
409
|
+
if (isMacOS()) {
|
|
410
|
+
uninstallLaunchd(name);
|
|
411
|
+
}
|
|
412
|
+
else {
|
|
413
|
+
uninstallCrontab(name);
|
|
414
|
+
}
|
|
415
|
+
// Remove from index
|
|
416
|
+
const updated = schedules.filter(s => s.name !== name);
|
|
417
|
+
saveSchedules(updated);
|
|
418
|
+
console.log(chalk.green(`\nā Schedule "${name}" removed.\n`));
|
|
419
|
+
}
|
package/dist/setup.js
CHANGED
|
@@ -64,12 +64,16 @@ export async function runSetup() {
|
|
|
64
64
|
let selectedModel = '';
|
|
65
65
|
if (provider === 'google') {
|
|
66
66
|
const googleChoices = [
|
|
67
|
-
{ name: 'google/gemini-
|
|
68
|
-
{ name: 'google/gemini-
|
|
67
|
+
{ name: 'google/gemini-3.5-flash', message: 'Gemini 3.5 Flash (Cutting Edge ā High-speed flagship)' },
|
|
68
|
+
{ name: 'google/gemini-3.5-pro', message: 'Gemini 3.5 Pro (Reasoning Frontier ā Ultimate capabilities)' },
|
|
69
|
+
{ name: 'google/gemini-2.5-flash', message: 'Gemini 2.5 Flash (Fast, smart & cost-effective)' },
|
|
70
|
+
{ name: 'google/gemini-2.5-pro', message: 'Gemini 2.5 Pro (Best reasoning, huge context)' },
|
|
71
|
+
{ name: 'google/gemini-1.5-flash', message: 'Gemini 1.5 Flash (Reliable legacy lightweight)' },
|
|
72
|
+
{ name: 'google/gemini-1.5-pro', message: 'Gemini 1.5 Pro (Reliable legacy standard)' }
|
|
69
73
|
];
|
|
70
|
-
let initialIndex =
|
|
71
|
-
if (
|
|
72
|
-
initialIndex =
|
|
74
|
+
let initialIndex = googleChoices.findIndex(c => c.name === existingConfig.provider);
|
|
75
|
+
if (initialIndex === -1)
|
|
76
|
+
initialIndex = 0;
|
|
73
77
|
let { model } = await enquirer.prompt({
|
|
74
78
|
type: 'select',
|
|
75
79
|
name: 'model',
|
|
@@ -81,12 +85,14 @@ export async function runSetup() {
|
|
|
81
85
|
}
|
|
82
86
|
else if (provider === 'openai') {
|
|
83
87
|
const openaiChoices = [
|
|
88
|
+
{ name: 'openai/gpt-4o', message: 'GPT-4o (Omni ā Dynamic reasoning & vision)' },
|
|
89
|
+
{ name: 'openai/gpt-4o-mini', message: 'GPT-4o Mini (Omni Mini ā Fast & affordable)' },
|
|
84
90
|
{ name: 'openai/gpt-4.1', message: 'GPT-4.1 (Recommended ā Excellent instruction following)' },
|
|
85
91
|
{ name: 'openai/gpt-4.1-mini', message: 'GPT-4.1 Mini (Lightweight ā Fast and budget-friendly)' }
|
|
86
92
|
];
|
|
87
|
-
let initialIndex =
|
|
88
|
-
if (
|
|
89
|
-
initialIndex =
|
|
93
|
+
let initialIndex = openaiChoices.findIndex(c => c.name === existingConfig.provider);
|
|
94
|
+
if (initialIndex === -1)
|
|
95
|
+
initialIndex = 2;
|
|
90
96
|
let { model } = await enquirer.prompt({
|
|
91
97
|
type: 'select',
|
|
92
98
|
name: 'model',
|
|
@@ -99,11 +105,14 @@ export async function runSetup() {
|
|
|
99
105
|
else if (provider === 'anthropic') {
|
|
100
106
|
const anthropicChoices = [
|
|
101
107
|
{ name: 'anthropic/claude-sonnet-4', message: 'Claude Sonnet 4 (Recommended ā Outstanding reasoning)' },
|
|
102
|
-
{ name: 'anthropic/claude-haiku-4', message: 'Claude Haiku 4 (Lightweight ā Fast & responsive)' }
|
|
108
|
+
{ name: 'anthropic/claude-haiku-4', message: 'Claude Haiku 4 (Lightweight ā Fast & responsive)' },
|
|
109
|
+
{ name: 'anthropic/claude-3-5-sonnet', message: 'Claude 3.5 Sonnet (Highly popular and smart)' },
|
|
110
|
+
{ name: 'anthropic/claude-3-5-haiku', message: 'Claude 3.5 Haiku (Fast & cost-efficient)' },
|
|
111
|
+
{ name: 'anthropic/claude-3-opus', message: 'Claude 3 Opus (Deep creative reasoning)' }
|
|
103
112
|
];
|
|
104
|
-
let initialIndex =
|
|
105
|
-
if (
|
|
106
|
-
initialIndex =
|
|
113
|
+
let initialIndex = anthropicChoices.findIndex(c => c.name === existingConfig.provider);
|
|
114
|
+
if (initialIndex === -1)
|
|
115
|
+
initialIndex = 0;
|
|
107
116
|
let { model } = await enquirer.prompt({
|
|
108
117
|
type: 'select',
|
|
109
118
|
name: 'model',
|
package/package.json
CHANGED