@mrsekut/ccron 0.1.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.
@@ -0,0 +1,223 @@
1
+ import {
2
+ readTaskConfig,
3
+ readGlobalConfig,
4
+ scriptPath,
5
+ plistPath,
6
+ mcpConfigPath,
7
+ } from '../config';
8
+ import { listOne } from '../launchd';
9
+ import { homedir } from 'os';
10
+
11
+ type CheckResult = {
12
+ label: string;
13
+ ok: boolean;
14
+ detail: string;
15
+ fix?: string | undefined;
16
+ };
17
+
18
+ export async function testCommand(args: string[]): Promise<void> {
19
+ if (args.includes('--help') || args.includes('-h')) {
20
+ console.log(`ccron test - Run environment checks for a task
21
+
22
+ Usage: ccron test <name>
23
+
24
+ Checks:
25
+ 1. claude CLI found in PATH
26
+ 2. claude auth status (logged in)
27
+ 3. ulimit -n (file descriptors)
28
+ 4. Script file exists
29
+ 5. Script path is outside TCC-protected directories
30
+ 6. Plist registered in launchd
31
+ 7. MCP config exists (if task uses MCP)
32
+
33
+ Each failed check shows a suggested fix command.
34
+
35
+ Example:
36
+ ccron test daily-summary`);
37
+ return;
38
+ }
39
+
40
+ const name = args[0];
41
+ if (!name) {
42
+ console.error('Usage: ccron test <name>');
43
+ process.exit(1);
44
+ }
45
+
46
+ const task = await readTaskConfig(name);
47
+ if (!task) {
48
+ console.error(`Task "${name}" not found.`);
49
+ process.exit(1);
50
+ }
51
+
52
+ console.log(`Checking ${name}...\n`);
53
+
54
+ const checks: CheckResult[] = [];
55
+
56
+ checks.push(await checkClaudeCli());
57
+ checks.push(await checkClaudeAuth());
58
+ checks.push(await checkUlimit());
59
+ checks.push(await checkScriptExists(name));
60
+ checks.push(checkScriptTccSafe(name));
61
+ checks.push(await checkPlistRegistered(name));
62
+
63
+ if (task.mcp.length > 0) {
64
+ checks.push(await checkMcpConfig(name));
65
+ }
66
+
67
+ // Print results
68
+ for (const check of checks) {
69
+ if (check.ok) {
70
+ console.log(` ✓ ${check.label}: ${check.detail}`);
71
+ } else {
72
+ console.log(` ✗ ${check.label}: ${check.detail}`);
73
+ if (check.fix) {
74
+ console.log(` → ${check.fix}`);
75
+ }
76
+ }
77
+ }
78
+
79
+ const passed = checks.filter(c => c.ok).length;
80
+ const failed = checks.filter(c => !c.ok).length;
81
+ console.log(`\n${passed}/${checks.length} checks passed.`);
82
+ if (failed > 0) {
83
+ console.log(`${failed} issue(s) found.`);
84
+ process.exit(1);
85
+ }
86
+ }
87
+
88
+ async function checkClaudeCli(): Promise<CheckResult> {
89
+ try {
90
+ const result = await Bun.$`which claude`.quiet();
91
+ if (result.exitCode === 0) {
92
+ const path = result.stdout.toString().trim();
93
+ // Try to get version
94
+ const ver = await Bun.$`claude --version`.quiet();
95
+ const version = ver.exitCode === 0 ? ver.stdout.toString().trim() : '';
96
+ return {
97
+ label: 'claude CLI',
98
+ ok: true,
99
+ detail: `found (${path}${version ? ` ${version}` : ''})`,
100
+ };
101
+ }
102
+ } catch {
103
+ // fall through
104
+ }
105
+ return {
106
+ label: 'claude CLI',
107
+ ok: false,
108
+ detail: 'not found in PATH',
109
+ fix: 'Install claude CLI or set path with ccron config',
110
+ };
111
+ }
112
+
113
+ async function checkClaudeAuth(): Promise<CheckResult> {
114
+ try {
115
+ const result = await Bun.$`claude auth status`.quiet();
116
+ const output = result.stdout.toString() + result.stderr.toString();
117
+ if (result.exitCode === 0) {
118
+ return {
119
+ label: 'claude auth',
120
+ ok: true,
121
+ detail: 'authenticated',
122
+ };
123
+ }
124
+ return {
125
+ label: 'claude auth',
126
+ ok: false,
127
+ detail: output.trim() || 'not authenticated',
128
+ fix: 'Run: claude auth login',
129
+ };
130
+ } catch {
131
+ return {
132
+ label: 'claude auth',
133
+ ok: false,
134
+ detail: 'failed to check',
135
+ fix: 'Run: claude auth login',
136
+ };
137
+ }
138
+ }
139
+
140
+ async function checkUlimit(): Promise<CheckResult> {
141
+ const global = await readGlobalConfig();
142
+ try {
143
+ const result = await Bun.$`ulimit -n`.quiet();
144
+ const current = Number(result.stdout.toString().trim());
145
+ if (current >= global.ulimit) {
146
+ return {
147
+ label: 'ulimit -n',
148
+ ok: true,
149
+ detail: `${current} (sufficient)`,
150
+ };
151
+ }
152
+ return {
153
+ label: 'ulimit -n',
154
+ ok: true, // Script sets it, so just a warning
155
+ detail: `${current} (script will set to ${global.ulimit})`,
156
+ };
157
+ } catch {
158
+ return {
159
+ label: 'ulimit -n',
160
+ ok: true,
161
+ detail: 'could not check (script will set it)',
162
+ };
163
+ }
164
+ }
165
+
166
+ async function checkScriptExists(name: string): Promise<CheckResult> {
167
+ const path = scriptPath(name);
168
+ const exists = await Bun.file(path).exists();
169
+ return {
170
+ label: 'script',
171
+ ok: exists,
172
+ detail: exists ? `exists (${path})` : `not found (${path})`,
173
+ fix: exists ? undefined : `Re-register: ccron add --name ${name} ...`,
174
+ };
175
+ }
176
+
177
+ function checkScriptTccSafe(name: string): CheckResult {
178
+ const path = scriptPath(name);
179
+ const home = homedir();
180
+ const tccDirs = [`${home}/Desktop`, `${home}/Documents`, `${home}/Downloads`];
181
+ const inTcc = tccDirs.some(dir => path.startsWith(dir));
182
+ return {
183
+ label: 'TCC protection',
184
+ ok: !inTcc,
185
+ detail: inTcc
186
+ ? `script is in TCC-protected directory`
187
+ : `script path is outside TCC-protected directories`,
188
+ fix: inTcc
189
+ ? 'Move script outside ~/Desktop, ~/Documents, ~/Downloads'
190
+ : undefined,
191
+ };
192
+ }
193
+
194
+ async function checkPlistRegistered(name: string): Promise<CheckResult> {
195
+ const status = await listOne(name);
196
+ const path = plistPath(name);
197
+ const fileExists = await Bun.file(path).exists();
198
+
199
+ if (status) {
200
+ return {
201
+ label: 'launchd',
202
+ ok: true,
203
+ detail: `registered (pid: ${status.pid ?? 'idle'})`,
204
+ };
205
+ }
206
+ return {
207
+ label: 'launchd',
208
+ ok: false,
209
+ detail: fileExists ? 'plist exists but not loaded' : 'plist not found',
210
+ fix: `Re-register: ccron add --name ${name} ...`,
211
+ };
212
+ }
213
+
214
+ async function checkMcpConfig(name: string): Promise<CheckResult> {
215
+ const path = mcpConfigPath(name);
216
+ const exists = await Bun.file(path).exists();
217
+ return {
218
+ label: 'MCP config',
219
+ ok: exists,
220
+ detail: exists ? `exists (${path})` : `not found (${path})`,
221
+ fix: exists ? undefined : `Re-register with --mcp flag`,
222
+ };
223
+ }
package/src/config.ts ADDED
@@ -0,0 +1,141 @@
1
+ import { homedir } from 'os';
2
+ import { join } from 'path';
3
+
4
+ // --- Types ---
5
+
6
+ export type TaskConfig = {
7
+ name: string;
8
+ schedule: string;
9
+ prompt: string | null;
10
+ promptFile: string | null;
11
+ mcp: string[];
12
+ allowedTools: string[];
13
+ maxTurns: number | null;
14
+ createdAt: string;
15
+ updatedAt: string;
16
+ };
17
+
18
+ export type GlobalConfig = {
19
+ claudePath: string;
20
+ extraPaths: string[];
21
+ ulimit: number;
22
+ };
23
+
24
+ // --- Paths ---
25
+
26
+ const home = homedir();
27
+
28
+ export const PATHS = {
29
+ configDir: join(home, '.config', 'ccron'),
30
+ tasksDir: join(home, '.config', 'ccron', 'tasks'),
31
+ mcpDir: join(home, '.config', 'ccron', 'mcp'),
32
+ globalConfig: join(home, '.config', 'ccron', 'config.json'),
33
+ binDir: join(home, '.local', 'bin'),
34
+ logsDir: join(home, '.local', 'share', 'ccron', 'logs'),
35
+ launchAgentsDir: join(home, 'Library', 'LaunchAgents'),
36
+ } as const;
37
+
38
+ export function taskConfigPath(name: string): string {
39
+ return join(PATHS.tasksDir, `${name}.json`);
40
+ }
41
+
42
+ export function mcpConfigPath(name: string): string {
43
+ return join(PATHS.mcpDir, `${name}.json`);
44
+ }
45
+
46
+ export function scriptPath(name: string): string {
47
+ return join(PATHS.binDir, `ccron-${name}.sh`);
48
+ }
49
+
50
+ export function plistPath(name: string): string {
51
+ return join(PATHS.launchAgentsDir, `com.ccron.${name}.plist`);
52
+ }
53
+
54
+ export function logPath(name: string): string {
55
+ return join(PATHS.logsDir, `${name}.log`);
56
+ }
57
+
58
+ export function plistLabel(name: string): string {
59
+ return `com.ccron.${name}`;
60
+ }
61
+
62
+ // --- I/O ---
63
+
64
+ export async function ensureDirectories(): Promise<void> {
65
+ const { mkdir } = await import('fs/promises');
66
+ await Promise.all([
67
+ mkdir(PATHS.tasksDir, { recursive: true }),
68
+ mkdir(PATHS.mcpDir, { recursive: true }),
69
+ mkdir(PATHS.binDir, { recursive: true }),
70
+ mkdir(PATHS.logsDir, { recursive: true }),
71
+ mkdir(PATHS.launchAgentsDir, { recursive: true }),
72
+ ]);
73
+ }
74
+
75
+ export async function readTaskConfig(name: string): Promise<TaskConfig | null> {
76
+ const file = Bun.file(taskConfigPath(name));
77
+ if (!(await file.exists())) return null;
78
+ return file.json() as Promise<TaskConfig>;
79
+ }
80
+
81
+ export async function writeTaskConfig(config: TaskConfig): Promise<void> {
82
+ await Bun.write(taskConfigPath(config.name), JSON.stringify(config, null, 2));
83
+ }
84
+
85
+ export async function listTaskConfigs(): Promise<TaskConfig[]> {
86
+ const { readdir } = await import('fs/promises');
87
+ let files: string[];
88
+ try {
89
+ files = await readdir(PATHS.tasksDir);
90
+ } catch {
91
+ return [];
92
+ }
93
+ const tasks: TaskConfig[] = [];
94
+ for (const file of files) {
95
+ if (!file.endsWith('.json')) continue;
96
+ const content = await Bun.file(join(PATHS.tasksDir, file)).json();
97
+ tasks.push(content as TaskConfig);
98
+ }
99
+ return tasks;
100
+ }
101
+
102
+ export async function deleteTaskConfig(name: string): Promise<void> {
103
+ const { unlink } = await import('fs/promises');
104
+ try {
105
+ await unlink(taskConfigPath(name));
106
+ } catch {
107
+ // ignore if not found
108
+ }
109
+ }
110
+
111
+ export async function readGlobalConfig(): Promise<GlobalConfig> {
112
+ const file = Bun.file(PATHS.globalConfig);
113
+ if (!(await file.exists())) {
114
+ return defaultGlobalConfig();
115
+ }
116
+ return file.json() as Promise<GlobalConfig>;
117
+ }
118
+
119
+ export async function writeGlobalConfig(config: GlobalConfig): Promise<void> {
120
+ await Bun.write(PATHS.globalConfig, JSON.stringify(config, null, 2));
121
+ }
122
+
123
+ function defaultGlobalConfig(): GlobalConfig {
124
+ return {
125
+ claudePath: '',
126
+ extraPaths: [],
127
+ ulimit: 2147483646,
128
+ };
129
+ }
130
+
131
+ // --- Validation (pure) ---
132
+
133
+ const TASK_NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
134
+
135
+ export function validateTaskName(name: string): string | null {
136
+ if (!name) return 'Task name is required';
137
+ if (!TASK_NAME_RE.test(name))
138
+ return 'Task name must contain only lowercase letters, numbers, and hyphens, and start with a letter or number';
139
+ if (name.length > 64) return 'Task name must be 64 characters or less';
140
+ return null;
141
+ }
@@ -0,0 +1,170 @@
1
+ import type { TaskConfig, GlobalConfig } from './config';
2
+ import type { CalendarInterval } from './schedule';
3
+ import { logPath, mcpConfigPath, plistLabel, scriptPath } from './config';
4
+ import { buildMcpConfig } from './mcp';
5
+ import { homedir } from 'os';
6
+
7
+ /**
8
+ * Generate the shell script content that launchd will execute.
9
+ */
10
+ export function generateScriptContent(
11
+ task: TaskConfig,
12
+ global: GlobalConfig,
13
+ ): string {
14
+ const home = homedir();
15
+ const pathEntries = [
16
+ global.claudePath,
17
+ ...global.extraPaths,
18
+ '/usr/local/bin',
19
+ '/usr/bin',
20
+ '/bin',
21
+ ]
22
+ .filter(Boolean)
23
+ .join(':');
24
+
25
+ const lines: string[] = [
26
+ '#!/usr/bin/env bash',
27
+ '# Generated by ccron. Do not edit manually.',
28
+ 'set -uo pipefail',
29
+ '',
30
+ `export HOME="${home}"`,
31
+ `export PATH="${pathEntries}:$PATH"`,
32
+ '',
33
+ `ulimit -n ${global.ulimit} 2>/dev/null || true`,
34
+ '',
35
+ 'cd /tmp',
36
+ '',
37
+ ];
38
+
39
+ // Prompt handling
40
+ if (task.promptFile) {
41
+ lines.push(`PROMPT=$(cat "${task.promptFile}")`);
42
+ } else if (task.prompt) {
43
+ // Escape single quotes for bash
44
+ const escaped = task.prompt.replace(/'/g, "'\\''");
45
+ lines.push(`PROMPT='${escaped}'`);
46
+ }
47
+
48
+ lines.push('');
49
+
50
+ // Build claude command
51
+ const claudeArgs: string[] = ['claude -p "$PROMPT"'];
52
+
53
+ if (task.mcp.length > 0) {
54
+ claudeArgs.push(` --mcp-config "${mcpConfigPath(task.name)}"`);
55
+ }
56
+
57
+ if (task.allowedTools.length > 0) {
58
+ claudeArgs.push(` --allowedTools "${task.allowedTools.join(',')}"`);
59
+ }
60
+
61
+ if (task.maxTurns !== null) {
62
+ claudeArgs.push(` --max-turns ${task.maxTurns}`);
63
+ }
64
+
65
+ lines.push(claudeArgs.join(' \\\n'));
66
+ lines.push('');
67
+
68
+ return lines.join('\n');
69
+ }
70
+
71
+ /**
72
+ * Generate the launchd plist XML content.
73
+ */
74
+ export function generatePlistContent(
75
+ task: TaskConfig,
76
+ global: GlobalConfig,
77
+ intervals: CalendarInterval[],
78
+ ): string {
79
+ const home = homedir();
80
+ const pathEntries = [
81
+ global.claudePath,
82
+ ...global.extraPaths,
83
+ '/usr/local/bin',
84
+ '/usr/bin',
85
+ '/bin',
86
+ ]
87
+ .filter(Boolean)
88
+ .join(':');
89
+
90
+ const label = plistLabel(task.name);
91
+ const script = scriptPath(task.name);
92
+ const log = logPath(task.name);
93
+
94
+ const calendarIntervalXml = generateCalendarIntervalXml(intervals);
95
+
96
+ return `<?xml version="1.0" encoding="UTF-8"?>
97
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
98
+ "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
99
+ <plist version="1.0">
100
+ <dict>
101
+ <key>Label</key>
102
+ <string>${escapeXml(label)}</string>
103
+ <key>ProgramArguments</key>
104
+ <array>
105
+ <string>/bin/bash</string>
106
+ <string>${escapeXml(script)}</string>
107
+ </array>
108
+ <key>EnvironmentVariables</key>
109
+ <dict>
110
+ <key>HOME</key>
111
+ <string>${escapeXml(home)}</string>
112
+ <key>PATH</key>
113
+ <string>${escapeXml(pathEntries)}</string>
114
+ </dict>
115
+ <key>WorkingDirectory</key>
116
+ <string>/tmp</string>
117
+ <key>StartCalendarInterval</key>
118
+ ${calendarIntervalXml}
119
+ <key>StandardOutPath</key>
120
+ <string>${escapeXml(log)}</string>
121
+ <key>StandardErrorPath</key>
122
+ <string>${escapeXml(log)}</string>
123
+ </dict>
124
+ </plist>
125
+ `;
126
+ }
127
+
128
+ function generateCalendarIntervalXml(intervals: CalendarInterval[]): string {
129
+ if (intervals.length === 1) {
130
+ return indent(calendarIntervalDict(intervals[0]!), 4);
131
+ }
132
+
133
+ const items = intervals.map(i => indent(calendarIntervalDict(i), 8));
134
+ return ` <array>\n${items.join('\n')}\n </array>`;
135
+ }
136
+
137
+ function calendarIntervalDict(interval: CalendarInterval): string {
138
+ const entries: string[] = [];
139
+ entries.push(`<key>Hour</key>\n<integer>${interval.Hour}</integer>`);
140
+ entries.push(`<key>Minute</key>\n<integer>${interval.Minute}</integer>`);
141
+ if (interval.Weekday !== undefined) {
142
+ entries.push(`<key>Weekday</key>\n<integer>${interval.Weekday}</integer>`);
143
+ }
144
+ return `<dict>\n${entries.map(e => indent(e, 4)).join('\n')}\n</dict>`;
145
+ }
146
+
147
+ function indent(text: string, spaces: number): string {
148
+ const pad = ' '.repeat(spaces);
149
+ return text
150
+ .split('\n')
151
+ .map(line => pad + line)
152
+ .join('\n');
153
+ }
154
+
155
+ function escapeXml(str: string): string {
156
+ return str
157
+ .replace(/&/g, '&amp;')
158
+ .replace(/</g, '&lt;')
159
+ .replace(/>/g, '&gt;')
160
+ .replace(/"/g, '&quot;')
161
+ .replace(/'/g, '&apos;');
162
+ }
163
+
164
+ /**
165
+ * Generate MCP config JSON string from task's MCP presets.
166
+ */
167
+ export function generateMcpConfigContent(presets: string[]): string {
168
+ const config = buildMcpConfig(presets);
169
+ return JSON.stringify(config, null, 2);
170
+ }
package/src/launchd.ts ADDED
@@ -0,0 +1,61 @@
1
+ import { plistLabel } from './config';
2
+
3
+ export async function bootstrap(plistPath: string): Promise<void> {
4
+ const uid = process.getuid?.() ?? 501;
5
+ const result =
6
+ await Bun.$`launchctl bootstrap gui/${uid} ${plistPath}`.quiet();
7
+ if (result.exitCode !== 0) {
8
+ const stderr = result.stderr.toString().trim();
9
+ // Already bootstrapped is not an error
10
+ if (stderr.includes('service already bootstrapped')) return;
11
+ throw new Error(`launchctl bootstrap failed: ${stderr}`);
12
+ }
13
+ }
14
+
15
+ export async function bootout(name: string): Promise<void> {
16
+ const uid = process.getuid?.() ?? 501;
17
+ const label = plistLabel(name);
18
+ const result = await Bun.$`launchctl bootout gui/${uid}/${label}`.quiet();
19
+ if (result.exitCode !== 0) {
20
+ const stderr = result.stderr.toString().trim();
21
+ // Not found is not an error during removal
22
+ if (stderr.includes('Could not find service')) return;
23
+ throw new Error(`launchctl bootout failed: ${stderr}`);
24
+ }
25
+ }
26
+
27
+ export async function kickstart(name: string): Promise<void> {
28
+ const uid = process.getuid?.() ?? 501;
29
+ const label = plistLabel(name);
30
+ const result = await Bun.$`launchctl kickstart gui/${uid}/${label}`.quiet();
31
+ if (result.exitCode !== 0) {
32
+ throw new Error(
33
+ `launchctl kickstart failed: ${result.stderr.toString().trim()}`,
34
+ );
35
+ }
36
+ }
37
+
38
+ export type LaunchdStatus = {
39
+ pid: number | null;
40
+ lastExitStatus: number | null;
41
+ };
42
+
43
+ export async function listOne(name: string): Promise<LaunchdStatus | null> {
44
+ const label = plistLabel(name);
45
+ const result = await Bun.$`launchctl list ${label}`.quiet();
46
+ if (result.exitCode !== 0) return null;
47
+
48
+ const output = result.stdout.toString();
49
+ let pid: number | null = null;
50
+ let lastExitStatus: number | null = null;
51
+
52
+ for (const line of output.split('\n')) {
53
+ const trimmed = line.trim();
54
+ const pidMatch = trimmed.match(/^"PID"\s*=\s*(\d+)/);
55
+ if (pidMatch) pid = Number(pidMatch[1]);
56
+ const statusMatch = trimmed.match(/^"LastExitStatus"\s*=\s*(\d+)/);
57
+ if (statusMatch) lastExitStatus = Number(statusMatch[1]);
58
+ }
59
+
60
+ return { pid, lastExitStatus };
61
+ }
package/src/mcp.ts ADDED
@@ -0,0 +1,49 @@
1
+ export type McpServerEntry = {
2
+ type: string;
3
+ url: string;
4
+ };
5
+
6
+ const MCP_PRESETS: Record<string, McpServerEntry> = {
7
+ slack: {
8
+ type: 'http',
9
+ url: 'https://mcp.slack.com/mcp',
10
+ },
11
+ linear: {
12
+ type: 'http',
13
+ url: 'https://mcp.linear.app/mcp',
14
+ },
15
+ };
16
+
17
+ export function getPresetNames(): string[] {
18
+ return Object.keys(MCP_PRESETS);
19
+ }
20
+
21
+ export function isKnownPreset(name: string): boolean {
22
+ return name in MCP_PRESETS;
23
+ }
24
+
25
+ export function validateMcpPresets(names: string[]): string | null {
26
+ const unknown = names.filter(n => !isKnownPreset(n));
27
+ if (unknown.length > 0) {
28
+ return `Unknown MCP preset(s): ${unknown.join(', ')}. Available: ${getPresetNames().join(', ')}`;
29
+ }
30
+ return null;
31
+ }
32
+
33
+ /**
34
+ * Build an MCP config JSON object from preset names.
35
+ * Output format matches claude CLI's --mcp-config expectation.
36
+ */
37
+ export function buildMcpConfig(presets: string[]): {
38
+ mcpServers: Record<string, McpServerEntry>;
39
+ } {
40
+ const servers: Record<string, McpServerEntry> = {};
41
+ for (const name of presets) {
42
+ const preset = MCP_PRESETS[name];
43
+ if (!preset) {
44
+ throw new Error(`Unknown MCP preset: ${name}`);
45
+ }
46
+ servers[name] = preset;
47
+ }
48
+ return { mcpServers: servers };
49
+ }