@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,158 @@
1
+ import {
2
+ readTaskConfig,
3
+ writeTaskConfig,
4
+ readGlobalConfig,
5
+ mcpConfigPath,
6
+ scriptPath,
7
+ plistPath,
8
+ } from '../config';
9
+ import { parseSchedule } from '../schedule';
10
+ import { validateMcpPresets } from '../mcp';
11
+ import {
12
+ generateScriptContent,
13
+ generatePlistContent,
14
+ generateMcpConfigContent,
15
+ } from '../generator';
16
+ import { bootout, bootstrap } from '../launchd';
17
+
18
+ export async function editCommand(args: string[]): Promise<void> {
19
+ const { parseArgs } = require('util');
20
+ const { values, positionals } = parseArgs({
21
+ args,
22
+ options: {
23
+ schedule: { type: 'string' },
24
+ prompt: { type: 'string' },
25
+ 'prompt-file': { type: 'string' },
26
+ mcp: { type: 'string' },
27
+ 'allowed-tools': { type: 'string' },
28
+ 'max-turns': { type: 'string' },
29
+ help: { type: 'boolean', short: 'h' },
30
+ },
31
+ allowPositionals: true,
32
+ strict: true,
33
+ });
34
+
35
+ if (values.help) {
36
+ printEditHelp();
37
+ process.exit(0);
38
+ }
39
+
40
+ const name = positionals[0] as string | undefined;
41
+ if (!name) {
42
+ console.error('Usage: ccron edit <name> [options]');
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
+ let changed = false;
53
+
54
+ if (values.schedule !== undefined) {
55
+ task.schedule = values.schedule;
56
+ parseSchedule(task.schedule);
57
+ changed = true;
58
+ }
59
+
60
+ if (values.prompt !== undefined) {
61
+ task.prompt = values.prompt;
62
+ task.promptFile = null;
63
+ changed = true;
64
+ }
65
+
66
+ if (values['prompt-file'] !== undefined) {
67
+ task.promptFile = values['prompt-file'];
68
+ task.prompt = null;
69
+ if (!(await Bun.file(task.promptFile!).exists())) {
70
+ console.error(`Prompt file not found: ${task.promptFile}`);
71
+ process.exit(1);
72
+ }
73
+ changed = true;
74
+ }
75
+
76
+ if (values.mcp !== undefined) {
77
+ const mcpNames = values.mcp.split(',').filter(Boolean);
78
+ const mcpError = validateMcpPresets(mcpNames);
79
+ if (mcpError) {
80
+ console.error(`Error: ${mcpError}`);
81
+ process.exit(1);
82
+ }
83
+ task.mcp = mcpNames;
84
+ changed = true;
85
+ }
86
+
87
+ if (values['allowed-tools'] !== undefined) {
88
+ task.allowedTools = values['allowed-tools'].split(',').filter(Boolean);
89
+ changed = true;
90
+ }
91
+
92
+ if (values['max-turns'] !== undefined) {
93
+ task.maxTurns = Number(values['max-turns']);
94
+ changed = true;
95
+ }
96
+
97
+ if (!changed) {
98
+ console.log('No changes specified. Use --help to see available options.');
99
+ return;
100
+ }
101
+
102
+ task.updatedAt = new Date().toISOString();
103
+
104
+ const globalConfig = await readGlobalConfig();
105
+ const intervals = parseSchedule(task.schedule);
106
+
107
+ // Update task config
108
+ await writeTaskConfig(task);
109
+ console.log(`✓ Task config updated`);
110
+
111
+ // Regenerate MCP config
112
+ if (task.mcp.length > 0) {
113
+ await Bun.write(
114
+ mcpConfigPath(task.name),
115
+ generateMcpConfigContent(task.mcp),
116
+ );
117
+ console.log(`✓ MCP config regenerated`);
118
+ }
119
+
120
+ // Regenerate script
121
+ const scriptContent = generateScriptContent(task, globalConfig);
122
+ const scriptFile = scriptPath(task.name);
123
+ await Bun.write(scriptFile, scriptContent);
124
+ const { chmod } = await import('fs/promises');
125
+ await chmod(scriptFile, 0o755);
126
+ console.log(`✓ Script regenerated`);
127
+
128
+ // Regenerate plist
129
+ const plistContent = generatePlistContent(task, globalConfig, intervals);
130
+ await Bun.write(plistPath(task.name), plistContent);
131
+ console.log(`✓ Plist regenerated`);
132
+
133
+ // Reload launchd
134
+ await bootout(task.name);
135
+ await bootstrap(plistPath(task.name));
136
+ console.log(`✓ launchd reloaded`);
137
+
138
+ console.log(`\n"${task.name}" updated.`);
139
+ }
140
+
141
+ function printEditHelp() {
142
+ console.log(`ccron edit - Edit a task's configuration
143
+
144
+ Usage: ccron edit <name> [options]
145
+
146
+ Options:
147
+ --schedule <cron> Update cron expression
148
+ --prompt <text> Update prompt (clears --prompt-file)
149
+ --prompt-file <path> Update prompt file (clears --prompt)
150
+ --mcp <names> Update MCP presets (comma-separated)
151
+ --allowed-tools <tools> Update allowed tools (comma-separated)
152
+ --max-turns <n> Update max turns
153
+
154
+ Examples:
155
+ ccron edit daily-summary --schedule "0 18 * * 1-5"
156
+ ccron edit daily-summary --prompt-file ./prompts/v2.txt
157
+ ccron edit daily-summary --mcp slack,linear`);
158
+ }
@@ -0,0 +1,58 @@
1
+ import { listTaskConfigs } from '../config';
2
+ import { listOne } from '../launchd';
3
+
4
+ export async function listCommand(args: string[]): Promise<void> {
5
+ if (args.includes('--help') || args.includes('-h')) {
6
+ console.log(`ccron list - List all registered tasks
7
+
8
+ Usage: ccron list
9
+
10
+ Shows all tasks with their schedule, launchd status, and last exit code.
11
+
12
+ Columns:
13
+ NAME Task name
14
+ SCHEDULE Cron expression
15
+ STATUS "active" (loaded in launchd) or "not loaded"
16
+ LAST EXIT Exit code of last execution (0 = success)`);
17
+ return;
18
+ }
19
+
20
+ const tasks = await listTaskConfigs();
21
+
22
+ if (tasks.length === 0) {
23
+ console.log('No tasks registered. Use `ccron add` to create one.');
24
+ return;
25
+ }
26
+
27
+ // Header
28
+ const nameW = Math.max(4, ...tasks.map(t => t.name.length));
29
+ const schedW = Math.max(8, ...tasks.map(t => t.schedule.length));
30
+
31
+ const header = [
32
+ 'NAME'.padEnd(nameW),
33
+ 'SCHEDULE'.padEnd(schedW),
34
+ 'STATUS'.padEnd(10),
35
+ 'LAST EXIT',
36
+ ].join(' ');
37
+
38
+ console.log(header);
39
+ console.log('-'.repeat(header.length));
40
+
41
+ for (const task of tasks) {
42
+ const status = await listOne(task.name);
43
+ const statusStr = status ? 'active' : 'not loaded';
44
+ const exitStr =
45
+ status?.lastExitStatus !== null && status?.lastExitStatus !== undefined
46
+ ? String(status.lastExitStatus)
47
+ : '-';
48
+
49
+ console.log(
50
+ [
51
+ task.name.padEnd(nameW),
52
+ task.schedule.padEnd(schedW),
53
+ statusStr.padEnd(10),
54
+ exitStr,
55
+ ].join(' '),
56
+ );
57
+ }
58
+ }
@@ -0,0 +1,61 @@
1
+ import { readTaskConfig, logPath } from '../config';
2
+ import { spawn } from 'child_process';
3
+
4
+ export async function logCommand(args: string[]): Promise<void> {
5
+ if (args.includes('--help') || args.includes('-h')) {
6
+ console.log(`ccron log - Show task execution logs
7
+
8
+ Usage: ccron log <name> [--follow]
9
+
10
+ Options:
11
+ --follow, -f Tail the log in real-time (Ctrl+C to stop)
12
+
13
+ Without --follow, shows the last 50 lines of the log.
14
+
15
+ Log location: ~/.local/share/ccron/logs/<name>.log
16
+
17
+ Examples:
18
+ ccron log daily-summary
19
+ ccron log daily-summary --follow`);
20
+ return;
21
+ }
22
+
23
+ const name = args.find(a => !a.startsWith('-'));
24
+ const follow = args.includes('--follow') || args.includes('-f');
25
+
26
+ if (!name) {
27
+ console.error('Usage: ccron log <name> [--follow]');
28
+ process.exit(1);
29
+ }
30
+
31
+ const task = await readTaskConfig(name);
32
+ if (!task) {
33
+ console.error(`Task "${name}" not found.`);
34
+ process.exit(1);
35
+ }
36
+
37
+ const log = logPath(name);
38
+ const file = Bun.file(log);
39
+
40
+ if (!(await file.exists())) {
41
+ console.log(`No logs yet for "${name}".`);
42
+ return;
43
+ }
44
+
45
+ if (follow) {
46
+ const tail = spawn('tail', ['-f', log], {
47
+ stdio: ['ignore', 'inherit', 'inherit'],
48
+ });
49
+ process.on('SIGINT', () => {
50
+ tail.kill();
51
+ process.exit(0);
52
+ });
53
+ await new Promise<void>(resolve => {
54
+ tail.on('close', () => resolve());
55
+ });
56
+ } else {
57
+ // Show last 50 lines
58
+ const result = await Bun.$`tail -50 ${log}`.quiet();
59
+ console.log(result.stdout.toString());
60
+ }
61
+ }
@@ -0,0 +1,94 @@
1
+ import {
2
+ readTaskConfig,
3
+ deleteTaskConfig,
4
+ scriptPath,
5
+ plistPath,
6
+ mcpConfigPath,
7
+ logPath,
8
+ listTaskConfigs,
9
+ } from '../config';
10
+ import { bootout } from '../launchd';
11
+
12
+ export async function removeCommand(args: string[]): Promise<void> {
13
+ if (args.includes('--help') || args.includes('-h')) {
14
+ console.log(`ccron remove - Remove a registered task
15
+
16
+ Usage: ccron remove <name> [--purge]
17
+
18
+ Removes a task by:
19
+ 1. Unregistering from launchd (launchctl bootout)
20
+ 2. Deleting plist, script, task config
21
+ 3. Deleting MCP config (if not used by other tasks)
22
+
23
+ Options:
24
+ --purge Also delete log files (logs are kept by default)
25
+
26
+ Examples:
27
+ ccron remove daily-summary
28
+ ccron remove daily-summary --purge`);
29
+ return;
30
+ }
31
+
32
+ const purge = args.includes('--purge');
33
+ const name = args.find(a => !a.startsWith('-'));
34
+
35
+ if (!name) {
36
+ console.error('Usage: ccron remove <name> [--purge]');
37
+ process.exit(1);
38
+ }
39
+
40
+ const task = await readTaskConfig(name);
41
+ if (!task) {
42
+ console.error(`Task "${name}" not found.`);
43
+ process.exit(1);
44
+ }
45
+
46
+ // 1. Unregister from launchd
47
+ await bootout(name);
48
+ console.log(`✓ Unregistered from launchd`);
49
+
50
+ // 2. Delete plist
51
+ await tryUnlink(plistPath(name));
52
+ console.log(`✓ Plist removed`);
53
+
54
+ // 3. Delete script
55
+ await tryUnlink(scriptPath(name));
56
+ console.log(`✓ Script removed`);
57
+
58
+ // 4. Delete MCP config if not shared by other tasks
59
+ if (task.mcp.length > 0) {
60
+ const otherTasks = await listTaskConfigs();
61
+ const otherUsing = otherTasks.some(
62
+ t => t.name !== name && t.mcp.some(m => task.mcp.includes(m)),
63
+ );
64
+ if (!otherUsing) {
65
+ await tryUnlink(mcpConfigPath(name));
66
+ console.log(`✓ MCP config removed`);
67
+ } else {
68
+ console.log(` MCP config kept (used by other tasks)`);
69
+ }
70
+ }
71
+
72
+ // 5. Delete task config
73
+ await deleteTaskConfig(name);
74
+ console.log(`✓ Task config removed`);
75
+
76
+ // 6. Logs
77
+ if (purge) {
78
+ await tryUnlink(logPath(name));
79
+ console.log(`✓ Logs purged`);
80
+ } else {
81
+ console.log(` Logs kept at ${logPath(name)} (use --purge to remove)`);
82
+ }
83
+
84
+ console.log(`\n"${name}" removed.`);
85
+ }
86
+
87
+ async function tryUnlink(path: string): Promise<void> {
88
+ const { unlink } = await import('fs/promises');
89
+ try {
90
+ await unlink(path);
91
+ } catch {
92
+ // ignore
93
+ }
94
+ }
@@ -0,0 +1,61 @@
1
+ import { readTaskConfig, logPath } from '../config';
2
+ import { kickstart } from '../launchd';
3
+ import { spawn } from 'child_process';
4
+
5
+ export async function runCommand(args: string[]): Promise<void> {
6
+ if (args.includes('--help') || args.includes('-h')) {
7
+ console.log(`ccron run - Manually trigger a task
8
+
9
+ Usage: ccron run <name>
10
+
11
+ Kicks start the task via launchctl and tails the log file in real-time.
12
+ Press Ctrl+C to stop following the log (the task continues running).
13
+
14
+ Example:
15
+ ccron run daily-summary`);
16
+ return;
17
+ }
18
+
19
+ const name = args[0];
20
+ if (!name) {
21
+ console.error('Usage: ccron run <name>');
22
+ process.exit(1);
23
+ }
24
+
25
+ const task = await readTaskConfig(name);
26
+ if (!task) {
27
+ console.error(`Task "${name}" not found.`);
28
+ process.exit(1);
29
+ }
30
+
31
+ const log = logPath(name);
32
+
33
+ // Ensure log file exists for tail
34
+ await Bun.write(
35
+ log,
36
+ await Bun.file(log)
37
+ .text()
38
+ .catch(() => ''),
39
+ {
40
+ createPath: true,
41
+ },
42
+ );
43
+
44
+ console.log(`Kicking start ${name}...`);
45
+ await kickstart(name);
46
+ console.log(`Started. Tailing log (Ctrl+C to stop):\n`);
47
+
48
+ // tail -f the log file
49
+ const tail = spawn('tail', ['-f', log], {
50
+ stdio: ['ignore', 'inherit', 'inherit'],
51
+ });
52
+
53
+ process.on('SIGINT', () => {
54
+ tail.kill();
55
+ process.exit(0);
56
+ });
57
+
58
+ await new Promise<void>(resolve => {
59
+ tail.on('close', () => resolve());
60
+ });
61
+ }
@@ -0,0 +1,91 @@
1
+ import {
2
+ readTaskConfig,
3
+ scriptPath,
4
+ plistPath,
5
+ logPath,
6
+ taskConfigPath,
7
+ mcpConfigPath,
8
+ } from '../config';
9
+ import { listOne } from '../launchd';
10
+
11
+ export async function showCommand(args: string[]): Promise<void> {
12
+ if (args.includes('--help') || args.includes('-h')) {
13
+ console.log(`ccron show - Show detailed information about a task
14
+
15
+ Usage: ccron show <name>
16
+
17
+ Displays task configuration, launchd status, and file locations.`);
18
+ return;
19
+ }
20
+
21
+ const name = args[0];
22
+ if (!name) {
23
+ console.error('Task name is required. Usage: ccron show <name>');
24
+ process.exit(1);
25
+ }
26
+
27
+ const task = await readTaskConfig(name);
28
+ if (!task) {
29
+ console.error(`Task "${name}" not found.`);
30
+ process.exit(1);
31
+ }
32
+
33
+ const status = await listOne(name);
34
+ const statusStr = status
35
+ ? status.pid
36
+ ? `active (pid: ${status.pid})`
37
+ : 'active (pid: idle)'
38
+ : 'not loaded';
39
+ const exitStr =
40
+ status?.lastExitStatus !== null && status?.lastExitStatus !== undefined
41
+ ? String(status.lastExitStatus)
42
+ : '-';
43
+
44
+ const promptDisplay = task.promptFile
45
+ ? `(file) ${task.promptFile}`
46
+ : task.prompt && task.prompt.length > 80
47
+ ? task.prompt.slice(0, 80) + '...'
48
+ : (task.prompt ?? '(none)');
49
+
50
+ const lines = [
51
+ `Name: ${task.name}`,
52
+ `Schedule: ${task.schedule}`,
53
+ `Prompt: ${promptDisplay}`,
54
+ `MCP: ${task.mcp.length > 0 ? task.mcp.join(', ') : '(none)'}`,
55
+ `Tools: ${task.allowedTools.length > 0 ? task.allowedTools.join(', ') : '(none)'}`,
56
+ `Max turns: ${task.maxTurns ?? '(default)'}`,
57
+ `Status: ${statusStr}`,
58
+ `Last exit: ${exitStr}`,
59
+ `Created: ${formatDate(task.createdAt)}`,
60
+ `Updated: ${formatDate(task.updatedAt)}`,
61
+ ``,
62
+ `Files:`,
63
+ ` Config: ${shorten(taskConfigPath(name))}`,
64
+ ` Script: ${shorten(scriptPath(name))}`,
65
+ ` Plist: ${shorten(plistPath(name))}`,
66
+ ` Log: ${shorten(logPath(name))}`,
67
+ ];
68
+
69
+ if (task.mcp.length > 0) {
70
+ lines.push(` MCP: ${shorten(mcpConfigPath(name))}`);
71
+ }
72
+
73
+ console.log(lines.join('\n'));
74
+ }
75
+
76
+ function formatDate(iso: string): string {
77
+ const d = new Date(iso);
78
+ return d.toLocaleString('ja-JP', {
79
+ year: 'numeric',
80
+ month: '2-digit',
81
+ day: '2-digit',
82
+ hour: '2-digit',
83
+ minute: '2-digit',
84
+ timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
85
+ });
86
+ }
87
+
88
+ function shorten(path: string): string {
89
+ const home = process.env['HOME'] ?? '';
90
+ return home && path.startsWith(home) ? '~' + path.slice(home.length) : path;
91
+ }