Repository navigation
Expand file tree
/
Copy pathschedule-runner.js
More file actions
170 lines (157 loc) · 6 KB
/
Copy pathschedule-runner.js
File metadata and controls
170 lines (157 loc) · 6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
// schedule-runner.js — Scan the old schedule-*.md files.
// Only the scan survives: main imports what it finds as folder schedules
// (projects.importLegacySchedules) once per file, retrying missed files at startup.
// cronMatches stays for tests and for parity with public/schedule-time.js.
const fs = require('fs');
const path = require('path');
const os = require('os');
const { readHead } = require('./jsonl-scan');
const CLAUDE_DIR = path.join(os.homedir(), '.claude');
const PROJECTS_DIR = path.join(CLAUDE_DIR, 'projects');
/** Parse YAML-like frontmatter from a markdown file (simple key: value parser). */
function parseFrontmatter(content) {
const match = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
if (!match) return { meta: {}, body: content.trim() };
const meta = {};
let currentKey = null;
const nested = {};
for (const line of match[1].split('\n')) {
if (currentKey && line.match(/^\s+/) && line.includes(':')) {
const m = line.match(/^\s+([^:]+):\s*(.*)$/);
if (m && !m[1].trim().startsWith('#')) {
if (!nested[currentKey]) nested[currentKey] = {};
nested[currentKey][m[1].trim()] = m[2].trim();
}
continue;
}
const kv = line.match(/^([^:]+):\s*(.*)$/);
if (kv) {
const key = kv[1].trim();
const val = kv[2].trim();
if (val === '' || val === undefined) {
currentKey = key;
} else {
meta[key] = val;
currentKey = null;
}
}
}
for (const [k, v] of Object.entries(nested)) {
meta[k] = v;
}
return { meta, body: match[2].trim() };
}
// Check if a cron field matches a value. Supports *, ranges (1-5), lists (1,3,5), and steps.
function cronFieldMatches(field, value) {
if (field === '*') return true;
if (field.startsWith('*/')) {
const step = parseInt(field.slice(2), 10);
return value % step === 0;
}
if (field.includes(',')) {
return field.split(',').some(f => cronFieldMatches(f.trim(), value));
}
if (field.includes('-')) {
const [lo, hi] = field.split('-').map(Number);
return value >= lo && value <= hi;
}
return parseInt(field, 10) === value;
}
/** Check if a 5-field cron expression matches the current time. */
function cronMatches(cronExpr, now) {
const parts = cronExpr.trim().split(/\s+/);
if (parts.length !== 5) return false;
const [minute, hour, dom, month, dow] = parts;
return (
cronFieldMatches(minute, now.getMinutes()) &&
cronFieldMatches(hour, now.getHours()) &&
cronFieldMatches(dom, now.getDate()) &&
cronFieldMatches(month, now.getMonth() + 1) &&
cronFieldMatches(dow, now.getDay())
);
}
/**
* Resolve a project folder name to its project path from the SQLite cache.
* Returns a Map<folder, projectPath>, or an empty Map if the cache is
* unavailable (e.g. in tests that don't load the native DB binding).
*/
function loadFolderMetaMap() {
try {
// Lazy require so requiring schedule-runner.js never forces the native
// better-sqlite3 binding to load (keeps the module test-friendly).
const { getAllFolderMeta } = require('./db');
const meta = getAllFolderMeta();
const map = new Map();
for (const [folder, row] of meta) {
if (row && row.projectPath) map.set(folder, row.projectPath);
}
return map;
} catch {
return new Map();
}
}
/** Read a project folder's first JSONL just enough to extract its cwd. */
function readProjectPathFromJsonl(folderPath) {
try {
const jsonlFiles = fs.readdirSync(folderPath).filter(f => f.endsWith('.jsonl'));
for (const jf of jsonlFiles) {
// Head only: legacy schedule import scans every project folder, and a
// session .jsonl can be hundreds of MB.
const head = readHead(path.join(folderPath, jf), 4096);
for (const line of head.split('\n').filter(Boolean)) {
try {
const entry = JSON.parse(line);
if (entry.cwd) return entry.cwd;
} catch {}
}
}
} catch {}
return null;
}
/** Scan all projects for schedule-*.md files and return parsed schedule objects. */
function scanSchedules(log) {
const schedules = [];
try {
if (!fs.existsSync(PROJECTS_DIR)) return schedules;
const folders = fs.readdirSync(PROJECTS_DIR, { withFileTypes: true })
.filter(d => d.isDirectory());
// Prefer the cached folder→projectPath mapping; only read JSONLs for
// folders genuinely missing from the cache. This avoids re-reading 4KB of
// every JSONL of every project during import.
const folderMeta = loadFolderMetaMap();
for (const folder of folders) {
const folderPath = path.join(PROJECTS_DIR, folder.name);
let projectPath = folderMeta.get(folder.name) || null;
if (!projectPath) {
projectPath = readProjectPathFromJsonl(folderPath);
}
if (!projectPath) continue;
const commandsDir = path.join(projectPath, '.claude', 'commands');
try {
if (!fs.existsSync(commandsDir)) continue;
const files = fs.readdirSync(commandsDir).filter(f => f.startsWith('schedule-') && f.endsWith('.md'));
for (const file of files) {
try {
const content = fs.readFileSync(path.join(commandsDir, file), 'utf8');
const { meta, body } = parseFrontmatter(content);
if (!meta.cron || !body) continue;
// Disabled files are imported too, as schedules that are off.
schedules.push({
file, filePath: path.join(commandsDir, file),
projectPath, folder: folder.name,
name: meta.name || file, cron: meta.cron, enabled: meta.enabled !== 'false',
slug: meta.slug || file.replace(/^schedule-/, '').replace(/\.md$/, ''),
cli: meta.cli || {}, prompt: body,
});
} catch (err) {
if (log) log.warn(`[schedule] Failed to parse ${file}:`, err.message);
}
}
} catch {}
}
} catch (err) {
if (log) log.error('[schedule] Error scanning schedules:', err);
}
return schedules;
}
module.exports = { parseFrontmatter, cronMatches, scanSchedules };