@wonsukchoi/crondex 0.9.1 → 0.11.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 CHANGED
@@ -1,97 +1,104 @@
1
- # crondex
1
+ <div align="center">
2
2
 
3
- A public, growing directory of pre-made cron jobs — built so any AI agent
4
- (Claude, Codex, Hermes, OpenClaw, or a plain LLM with shell access) can pull
5
- one, tweak it, and schedule it. Clone it, grab a job, adjust the knobs, run it.
3
+ ```
4
+ ______________ ____ ____/ /__ _ __
5
+ / ___/ ___/ __ \/ __ \/ __ / _ \| |/_/
6
+ / /__/ / / /_/ / / / / /_/ / __/> <
7
+ \___/_/ \____/_/ /_/\__,_/\___/_/|_|
8
+ ```
6
9
 
7
- ## Quick start
10
+ **Pre-made cron jobs any AI agent can pull, tweak, and schedule.**
11
+ A directory, not a framework.
8
12
 
9
- ```bash
10
- git clone https://github.com/wonsukchoi/crondex.git
11
- cat crondex/catalog.json # browse what's available
12
- cat crondex/jobs/devops/dependency-audit.yaml # read one job
13
- ```
13
+ [![npm version](https://img.shields.io/npm/v/@wonsukchoi/crondex.svg)](https://www.npmjs.com/package/@wonsukchoi/crondex)
14
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
14
15
 
15
- Give an agent the repo and a goal ("set up something that checks my repo
16
- health every morning") — it reads `catalog.json`, finds the closest match,
17
- adjusts `schedule`/`variables`, and wires it into whatever scheduler it has.
16
+ </div>
18
17
 
19
- ## CLI
18
+ ---
20
19
 
21
- Published on npm as `@wonsukchoi/crondex` (the plain name `crondex` was
22
- blocked by npm's anti-typosquat check). The command itself is still
23
- `crondex` once installed — only the package name is scoped:
20
+ Works with Claude, Codex, Hermes, OpenClaw, or any plain LLM with shell
21
+ access.
22
+
23
+ ## Get a job
24
24
 
25
25
  ```bash
26
- npx @wonsukchoi/crondex list # browse everything
27
- npx @wonsukchoi/crondex list --category devops # filter by category or --tag
28
- npx @wonsukchoi/crondex categories # list categories with job counts
29
- npx @wonsukchoi/crondex show backup-reminder # print one job's YAML
30
- npx @wonsukchoi/crondex add backup-reminder --dest ./cron/backup-reminder.yaml
31
26
  npx @wonsukchoi/crondex recommend "warn me before my SSL cert expires"
32
- npx @wonsukchoi/crondex init ssl-cert-expiry-check --category security
27
+ npx @wonsukchoi/crondex show ssl-cert-expiry-check
28
+ npx @wonsukchoi/crondex add ssl-cert-expiry-check --dest ./cron/ssl-cert-expiry-check.yaml
33
29
  ```
34
30
 
35
- `recommend` takes a free-text description of what you want and ranks the
36
- catalog against it (weighted keyword match over tags/name/id/category/
37
- description, zero tokens, no network call) — handy for an agent that just
38
- got asked "can you set up something for X" and needs to check whether a
39
- ready-made job already covers it before writing one from scratch. Add
40
- `--limit <n>` to control how many results come back (default 5).
31
+ - `recommend "<what you want>"` find the closest matching job (zero
32
+ tokens, no network call, so an agent can check before writing one from
33
+ scratch)
34
+ - `list [--category x] [--tag y]` / `categories` browse everything
35
+ - `show <id>` print a job's full YAML
36
+ - `add <id> [--dest path]` copy it into your project to edit
37
+ - `init <id> [--category x]` — scaffold a brand-new job from the template
41
38
 
42
- `init` scaffolds a new job file from `templates/job.template.yaml` into your
43
- own project (default `./<id>.yaml`) for writing a job that isn't in the
44
- catalog yet, whether or not you plan to contribute it back.
39
+ Add `--json` to `list`/`categories`/`show`/`recommend` for machine-readable
40
+ output useful when an agent is parsing the result programmatically
41
+ instead of a human reading it.
45
42
 
46
- Or install once and drop the scope prefix on every call:
43
+ No install needed `npx` always runs against the latest catalog. Prefer
44
+ installing once? `npm install -g @wonsukchoi/crondex`, then drop the `npx`
45
+ prefix (run `npm update -g` later to pick up new jobs). Prefer no npm at
46
+ all? `git clone` this repo and run `node bin/crondex.js list`.
47
47
 
48
- ```bash
49
- npm install -g @wonsukchoi/crondex
50
- crondex list
51
- ```
48
+ ---
52
49
 
53
- `add` copies the job's YAML as-is into your project — it's yours to edit
54
- from there. `npx` always pulls the latest published catalog; a global
55
- install needs `npm update -g @wonsukchoi/crondex` to see new jobs (`crondex
56
- list` prints a reminder either way).
50
+ ## What's in a job
57
51
 
58
- A clone works too, no npm required:
52
+ Every job is one YAML file:
59
53
 
60
- ```bash
61
- git clone https://github.com/wonsukchoi/crondex.git && cd crondex
62
- node bin/crondex.js list
54
+ ```yaml
55
+ id: dependency-audit
56
+ version: 1
57
+ name: Dependency Vulnerability Audit
58
+ category: devops
59
+ schedule: "0 8 * * 1" # standard 5-field cron
60
+ runner: hybrid # shell | agent-prompt | hybrid
61
+ command: | # for runner: shell/hybrid
62
+ ...raw shell audit, zero tokens...
63
+ prompt: | # for runner: agent-prompt/hybrid
64
+ ...instructions with {{repo_path}}, LLM synthesizes+prioritizes...
65
+ script_note: what you lose by using `command` instead of `prompt`
66
+ variables:
67
+ repo_path:
68
+ default: "."
69
+ compatible_agents: [claude, codex, hermes, openclaw, generic]
63
70
  ```
64
71
 
65
- ## Layout
72
+ - **`shell`** — runs `command` only. Zero LLM tokens, deterministic.
73
+ - **`agent-prompt`** — hands `prompt` to an LLM each run. Costs tokens, but
74
+ can synthesize, prioritize, and draft prose.
75
+ - **`hybrid`** — ships both, pick per run: script to save tokens, prompt for
76
+ more judgment. `script_note` explains the tradeoff.
66
77
 
67
- ```
68
- crondex/
69
- ├── bin/crondex.js CLI: list / categories / show / add / recommend / init
70
- ├── catalog.json generated index of every job read this first
71
- ├── schema/job.schema.json spec every job file follows
72
- ├── jobs/ one YAML per job, grouped by category subdirectory
73
- └── scripts/
74
- ├── build-catalog.js regenerates catalog.json + the summary below from jobs/**/*.yaml
75
- └── validate-jobs.js validates every job against the schema
76
- ```
78
+ `{{placeholders}}` resolve from `variables` — override them for your case,
79
+ then hand `command`/`prompt` plus `schedule` to whatever scheduler you have
80
+ (system crontab, a hosted cron, your agent's own scheduling mechanism). This
81
+ repo defines *what* to run and *when*, not the executor. Full field spec:
82
+ [`schema/job.schema.json`](schema/job.schema.json).
83
+
84
+ ---
77
85
 
78
- ## Available jobs
86
+ ## Browse the catalog
79
87
 
80
- Full details (description, tags, variables) live in `catalog.json` and each
81
- job's YAML file — run `crondex list`, `crondex recommend "<what you want>"`,
82
- or browse `jobs/<category>/` for the plain-language rundown of each. The
83
- counts below are regenerated by `npm run build-catalog`, so they never drift
84
- from what's actually in `jobs/`.
88
+ The table below is regenerated by `npm run build-catalog`, so it never
89
+ drifts from what's actually in `jobs/`. For full details (description,
90
+ tags, variables) use `crondex list`, `crondex recommend`, or browse
91
+ `jobs/<category>/` directly.
85
92
 
86
93
  <!-- BEGIN JOB SUMMARY -->
87
- 182 jobs across 37 categories:
94
+ 189 jobs across 37 categories:
88
95
 
89
96
  | category | jobs |
90
97
  |---|---|
91
98
  | `agriculture` | 3 |
92
99
  | `construction` | 3 |
93
- | `content` | 12 |
94
- | `creator` | 3 |
100
+ | `content` | 13 |
101
+ | `creator` | 4 |
95
102
  | `crypto` | 3 |
96
103
  | `devops` | 26 |
97
104
  | `ecommerce` | 3 |
@@ -100,7 +107,7 @@ from what's actually in `jobs/`.
100
107
  | `finance` | 10 |
101
108
  | `fleet` | 3 |
102
109
  | `gaming` | 3 |
103
- | `growth` | 4 |
110
+ | `growth` | 5 |
104
111
  | `healthcare` | 3 |
105
112
  | `hiring` | 3 |
106
113
  | `home` | 5 |
@@ -110,80 +117,47 @@ from what's actually in `jobs/`.
110
117
  | `inventory` | 3 |
111
118
  | `investing` | 3 |
112
119
  | `learning` | 5 |
113
- | `legal` | 3 |
120
+ | `legal` | 4 |
114
121
  | `logistics` | 3 |
115
122
  | `manufacturing` | 3 |
116
123
  | `marketing` | 4 |
117
124
  | `nonprofit` | 3 |
118
- | `personal` | 8 |
125
+ | `personal` | 9 |
119
126
  | `podcast` | 3 |
120
127
  | `productivity` | 9 |
121
- | `realestate` | 3 |
128
+ | `realestate` | 4 |
122
129
  | `restaurant` | 3 |
123
- | `sales` | 4 |
130
+ | `sales` | 5 |
124
131
  | `security` | 14 |
125
132
  | `support` | 3 |
126
133
  | `team` | 4 |
127
134
  | `travel` | 5 |
128
135
  <!-- END JOB SUMMARY -->
129
136
 
130
- ## How a job works
131
-
132
- Every job is one YAML file with a `runner`:
133
-
134
- - **`shell`** — run `command` directly. Zero LLM tokens, deterministic, no
135
- judgment calls.
136
- - **`agent-prompt`** — hand the `prompt` field to an LLM agent each run.
137
- Costs tokens, but can synthesize, prioritize, and draft prose.
138
- - **`hybrid`** — ships both. You (or your agent) pick per run: `command` to
139
- save tokens and get raw data, or `prompt` when you want the LLM to
140
- interpret it. `script_note` on the job explains exactly what you trade
141
- away by choosing the script.
137
+ ---
142
138
 
143
- `{{placeholders}}` in `command`/`prompt` resolve from `variables`. Each job
144
- also carries a `version` — bumped whenever `prompt`/`command` behavior
145
- changes, so if you've already scheduled a job elsewhere you can tell when
146
- the upstream copy has moved on without you.
139
+ ## Layout
147
140
 
148
- ```yaml
149
- id: dependency-audit
150
- version: 1
151
- name: Dependency Vulnerability Audit
152
- description: ...
153
- category: devops
154
- tags: [security, dependencies]
155
- schedule: "0 8 * * 1" # standard 5-field cron
156
- timezone: "UTC"
157
- runner: hybrid
158
- command: |
159
- ...raw shell audit, zero tokens...
160
- prompt: |
161
- ...instructions with {{repo_path}}, LLM synthesizes+prioritizes...
162
- script_note: what you lose by using `command` instead of `prompt`
163
- variables:
164
- repo_path:
165
- default: "."
166
- description: ...
167
- compatible_agents: [claude, codex, hermes, openclaw, generic]
141
+ ```
142
+ crondex/
143
+ ├── llms.txt agent-discovery manifest (llms.txt convention)
144
+ ├── bin/crondex.js CLI: list / categories / show / add / recommend / init
145
+ ├── lib/recommend.js recommend's scoring logic (unit tested in test/)
146
+ ├── catalog.json generated index of every job — read this first
147
+ ├── schema/job.schema.json spec every job file follows
148
+ ├── jobs/ one YAML per job, grouped by category subdirectory
149
+ └── scripts/ build-catalog.js, validate-jobs.js, lint-shell.js
168
150
  ```
169
151
 
170
- Full spec: [`schema/job.schema.json`](schema/job.schema.json).
171
-
172
- ## Using a job
173
-
174
- 1. Pick a job from `catalog.json` — check `modes` to see if it's script-only,
175
- agent-prompt-only, or both.
176
- 2. Decide script vs. agent-prompt if the job is `hybrid`: script saves
177
- tokens, agent-prompt gives you more detail/judgment (see `script_note`).
178
- 3. Override any `variables` and `schedule` for your case.
179
- 4. Hand `command` or `prompt` plus `schedule` to your scheduler — system
180
- crontab, a hosted cron, or your agent's own scheduling mechanism. This
181
- repo defines *what* to run and *when*, not the executor.
152
+ ---
182
153
 
183
154
  ## Contributing a job
184
155
 
185
- See [`CONTRIBUTING.md`](CONTRIBUTING.md) — copy `templates/job.template.yaml`,
186
- fill it in, `npm run validate && npm run build-catalog`, open a PR.
156
+ See [`CONTRIBUTING.md`](CONTRIBUTING.md) — copy `templates/job.template.yaml`
157
+ (or run `crondex init`), fill it in, `npm run validate && npm run
158
+ build-catalog`, open a PR.
159
+
160
+ ---
187
161
 
188
162
  ## License
189
163
 
package/bin/crondex.js CHANGED
@@ -2,6 +2,8 @@
2
2
  // CLI over catalog.json — browse jobs, read one, or pull one into your project.
3
3
  import { readFileSync, writeFileSync, existsSync } from "node:fs";
4
4
  import { join } from "node:path";
5
+ import yaml from "js-yaml";
6
+ import { tokenize, rankJobs } from "../lib/recommend.js";
5
7
 
6
8
  const ROOT = new URL("..", import.meta.url).pathname;
7
9
  const CATALOG = JSON.parse(readFileSync(join(ROOT, "catalog.json"), "utf8"));
@@ -14,6 +16,14 @@ function flag(name) {
14
16
  return i === -1 ? undefined : args[i + 1];
15
17
  }
16
18
 
19
+ function hasFlag(name) {
20
+ return args.includes(`--${name}`);
21
+ }
22
+
23
+ function printJson(value) {
24
+ console.log(JSON.stringify(value, null, 2));
25
+ }
26
+
17
27
  function findJob(id) {
18
28
  const meta = CATALOG.jobs.find((j) => j.id === id);
19
29
  if (!meta) {
@@ -27,11 +37,11 @@ function printHelp() {
27
37
  console.log(`crondex — browse and pull pre-made cron jobs
28
38
 
29
39
  Usage:
30
- crondex list [--category <name>] [--tag <name>]
31
- crondex categories
32
- crondex show <id>
40
+ crondex list [--category <name>] [--tag <name>] [--json]
41
+ crondex categories [--json]
42
+ crondex show <id> [--json]
33
43
  crondex add <id> [--dest <path>]
34
- crondex recommend "<what you want done>" [--limit <n>]
44
+ crondex recommend "<what you want done>" [--limit <n>] [--json]
35
45
  crondex init <id> [--category <name>] [--dest <path>]
36
46
 
37
47
  Examples:
@@ -41,75 +51,39 @@ Examples:
41
51
  crondex add backup-reminder --dest ./cron/backup-reminder.yaml
42
52
  crondex recommend "warn me before my SSL cert expires"
43
53
  crondex init ssl-cert-expiry-check --category security
44
- `);
45
- }
46
-
47
- const STOPWORDS = new Set([
48
- "a", "an", "the", "to", "for", "of", "in", "on", "at", "by", "and", "or", "my", "me", "i",
49
- "can", "you", "do", "does", "this", "that", "these", "those", "please", "want", "wants",
50
- "wanted", "need", "needs", "help", "with", "is", "are", "be", "it", "so", "when", "should",
51
- "could", "would", "how", "what", "which", "up", "down", "get", "make", "set", "just", "really",
52
- "some", "something", "any", "want", "us", "our", "your", "yours", "if", "then", "than",
53
- ]);
54
-
55
- function stem(word) {
56
- if (word.length > 4 && word.endsWith("ies")) return word.slice(0, -3) + "y";
57
- if (word.length > 4 && word.endsWith("es") && !word.endsWith("ses")) return word.slice(0, -2);
58
- if (word.length > 3 && word.endsWith("s") && !word.endsWith("ss")) return word.slice(0, -1);
59
- return word;
60
- }
61
-
62
- function tokenize(text) {
63
- return text
64
- .toLowerCase()
65
- .replace(/[^a-z0-9\s-]/g, " ")
66
- .split(/[\s-]+/)
67
- .filter((w) => w.length > 1 && !STOPWORDS.has(w))
68
- .map(stem);
69
- }
70
54
 
71
- // id and name are near-duplicates of each other (id is just the slugified
72
- // name), so they're merged into one "title" field — otherwise a match on a
73
- // shared word like "water" gets weighted 3+3 instead of 3, which can outrank
74
- // a more specific job that only matches once via tags.
75
- const RECOMMEND_WEIGHTS = { tags: 4, title: 3, category: 2, description: 1 };
76
-
77
- function scoreJob(queryTokens, job) {
78
- const fields = {
79
- tags: job.tags.map((t) => stem(t.toLowerCase())),
80
- title: [...new Set([...tokenize(job.name), ...tokenize(job.id.replace(/-/g, " "))])],
81
- category: tokenize(job.category ?? ""),
82
- description: tokenize(job.description ?? ""),
83
- };
84
- let score = 0;
85
- const matched = new Set();
86
- for (const qt of queryTokens) {
87
- for (const [field, weight] of Object.entries(RECOMMEND_WEIGHTS)) {
88
- if (fields[field].includes(qt)) {
89
- score += weight;
90
- matched.add(qt);
91
- }
92
- }
93
- }
94
- return { score, matchedTerms: [...matched] };
55
+ Add --json to list/categories/show/recommend for machine-readable output
56
+ useful when an agent is parsing crondex's output programmatically instead
57
+ of a human reading it.
58
+ `);
95
59
  }
96
60
 
97
61
  function recommend(queryText) {
98
62
  const limit = Number(flag("limit")) || 5;
99
- const queryTokens = tokenize(queryText);
100
- if (!queryTokens.length) {
63
+ const json = hasFlag("json");
64
+ if (!tokenize(queryText).length) {
65
+ if (json) return printJson([]);
101
66
  console.log("query too vague to match on — describe what you want the job to check or remind you about.");
102
67
  return;
103
68
  }
104
- const ranked = CATALOG.jobs
105
- .map((job) => ({ job, ...scoreJob(queryTokens, job) }))
106
- .filter((r) => r.score > 0)
107
- .sort((a, b) => b.score - a.score)
108
- .slice(0, limit);
69
+ const ranked = rankJobs(CATALOG.jobs, queryText, limit);
109
70
  if (!ranked.length) {
71
+ if (json) return printJson([]);
110
72
  console.log(`no confident match for "${queryText}". Run "crondex list" to browse everything.`);
111
73
  return;
112
74
  }
75
+ if (json) {
76
+ return printJson(
77
+ ranked.map((r) => ({
78
+ id: r.job.id,
79
+ category: r.job.category,
80
+ score: r.score,
81
+ matched_terms: r.matchedTerms,
82
+ modes: r.job.modes,
83
+ description: r.job.description,
84
+ }))
85
+ );
86
+ }
113
87
  console.log(`top match${ranked.length > 1 ? "es" : ""} for "${queryText}":`);
114
88
  console.log();
115
89
  for (const r of ranked) {
@@ -131,9 +105,11 @@ function catalogInfoLine() {
131
105
  function list() {
132
106
  const category = flag("category");
133
107
  const tag = flag("tag");
108
+ const json = hasFlag("json");
134
109
  const jobs = CATALOG.jobs.filter(
135
110
  (j) => (!category || j.category === category) && (!tag || j.tags.includes(tag))
136
111
  );
112
+ if (json) return printJson(jobs);
137
113
  console.log(catalogInfoLine());
138
114
  console.log();
139
115
  if (!jobs.length) {
@@ -148,15 +124,20 @@ function list() {
148
124
  }
149
125
 
150
126
  function show(id) {
151
- console.log(readFileSync(join(ROOT, findJob(id).path), "utf8"));
127
+ const meta = findJob(id);
128
+ const raw = readFileSync(join(ROOT, meta.path), "utf8");
129
+ if (hasFlag("json")) return printJson(yaml.load(raw));
130
+ console.log(raw);
152
131
  }
153
132
 
154
133
  function categories() {
155
134
  const counts = {};
156
135
  for (const j of CATALOG.jobs) counts[j.category] = (counts[j.category] ?? 0) + 1;
136
+ const sorted = Object.entries(counts).sort(([a], [b]) => a.localeCompare(b));
137
+ if (hasFlag("json")) return printJson(sorted.map(([category, count]) => ({ category, count })));
157
138
  console.log(catalogInfoLine());
158
139
  console.log();
159
- for (const [cat, n] of Object.entries(counts).sort(([a], [b]) => a.localeCompare(b))) {
140
+ for (const [cat, n] of sorted) {
160
141
  console.log(`${cat} (${n})`);
161
142
  }
162
143
  }
package/catalog.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "generated_by": "scripts/build-catalog.js",
3
3
  "schema": "schema/job.schema.json",
4
- "count": 182,
4
+ "count": 189,
5
5
  "jobs": [
6
6
  {
7
7
  "id": "1on1-prep-reminder",
@@ -809,6 +809,31 @@
809
809
  ],
810
810
  "path": "jobs/learning/course-progress-checkin.yaml"
811
811
  },
812
+ {
813
+ "id": "court-deadline-reminder",
814
+ "version": 1,
815
+ "name": "Court Deadline Reminder",
816
+ "description": "Checks a tracked list of court/filing deadlines and warns before any come due. Use this if a filing deadline has ever been missed because it lived only in one person's head or a sticky note instead of a shared, checked calendar.",
817
+ "category": "legal",
818
+ "tags": [
819
+ "legal",
820
+ "court",
821
+ "deadline",
822
+ "compliance"
823
+ ],
824
+ "schedule": "0 7 * * *",
825
+ "timezone": "UTC",
826
+ "runner": "shell",
827
+ "modes": [
828
+ "script"
829
+ ],
830
+ "compatible_agents": [
831
+ "generic",
832
+ "claude",
833
+ "codex"
834
+ ],
835
+ "path": "jobs/legal/court-deadline-reminder.yaml"
836
+ },
812
837
  {
813
838
  "id": "coverage-gap-audit",
814
839
  "version": 1,
@@ -1186,6 +1211,31 @@
1186
1211
  ],
1187
1212
  "path": "jobs/devops/docker-image-prune.yaml"
1188
1213
  },
1214
+ {
1215
+ "id": "downloads-folder-auto-organize",
1216
+ "version": 1,
1217
+ "name": "Downloads Folder Auto-Organize",
1218
+ "description": "Sorts files sitting in your downloads folder into subfolders by type (images, documents, archives, installers), so it doesn't turn into an unsearchable pile. Use this if your downloads folder has ever hit hundreds of files with zero structure.",
1219
+ "category": "personal",
1220
+ "tags": [
1221
+ "files",
1222
+ "organization",
1223
+ "downloads",
1224
+ "cleanup"
1225
+ ],
1226
+ "schedule": "0 3 * * *",
1227
+ "timezone": "UTC",
1228
+ "runner": "shell",
1229
+ "modes": [
1230
+ "script"
1231
+ ],
1232
+ "compatible_agents": [
1233
+ "generic",
1234
+ "claude",
1235
+ "codex"
1236
+ ],
1237
+ "path": "jobs/personal/downloads-folder-auto-organize.yaml"
1238
+ },
1189
1239
  {
1190
1240
  "id": "email-deliverability-check",
1191
1241
  "version": 1,
@@ -1581,6 +1631,33 @@
1581
1631
  ],
1582
1632
  "path": "jobs/hospitality/food-safety-temp-log-check.yaml"
1583
1633
  },
1634
+ {
1635
+ "id": "form-lead-crm-sync-check",
1636
+ "version": 1,
1637
+ "name": "Form Lead CRM Sync Check",
1638
+ "description": "Checks that leads submitted through your web forms actually landed in your CRM, catching a broken integration instead of a slow rep. Use this if a form-to-CRM zap/integration has ever quietly broken and leads piled up on the form provider's side while sales thought the pipeline was just empty.",
1639
+ "category": "sales",
1640
+ "tags": [
1641
+ "sales",
1642
+ "leads",
1643
+ "crm",
1644
+ "integration"
1645
+ ],
1646
+ "schedule": "*/30 * * * *",
1647
+ "timezone": "UTC",
1648
+ "runner": "agent-prompt",
1649
+ "modes": [
1650
+ "agent-prompt"
1651
+ ],
1652
+ "compatible_agents": [
1653
+ "claude",
1654
+ "codex",
1655
+ "hermes",
1656
+ "openclaw",
1657
+ "generic"
1658
+ ],
1659
+ "path": "jobs/sales/form-lead-crm-sync-check.yaml"
1660
+ },
1584
1661
  {
1585
1662
  "id": "freight-rate-spike-watch",
1586
1663
  "version": 1,
@@ -2333,12 +2410,12 @@
2333
2410
  "id": "low-stock-alert",
2334
2411
  "version": 1,
2335
2412
  "name": "Low Stock Alert",
2336
- "description": "Checks inventory levels against a reorder point and flags SKUs running low. Use this if you've ever gone out of stock on a bestseller because nobody was watching the count.",
2413
+ "description": "Checks a general-purpose inventory database (warehouse, retail backroom, restaurant supplies — anything with a quantity and a set reorder point) against that fixed threshold and flags what's running low. Use this for physical stock tracked in a database, not tied to online sales; if you specifically need SKU reorder timing driven by e-commerce sales velocity and supplier lead time, see `low-stock-reorder-alert` instead.",
2337
2414
  "category": "inventory",
2338
2415
  "tags": [
2339
2416
  "inventory",
2340
2417
  "stock",
2341
- "ecommerce"
2418
+ "warehouse"
2342
2419
  ],
2343
2420
  "schedule": "0 7 * * *",
2344
2421
  "timezone": "UTC",
@@ -2980,6 +3057,31 @@
2980
3057
  ],
2981
3058
  "path": "jobs/manufacturing/production-line-downtime-watch.yaml"
2982
3059
  },
3060
+ {
3061
+ "id": "property-maintenance-inspection-reminder",
3062
+ "version": 1,
3063
+ "name": "Property Maintenance Inspection Reminder",
3064
+ "description": "Tracks when each property was last inspected and flags any that are due for their next routine walkthrough. Use this if a property has ever gone a year or more without a proper inspection because nothing forced anyone to schedule one.",
3065
+ "category": "realestate",
3066
+ "tags": [
3067
+ "realestate",
3068
+ "maintenance",
3069
+ "inspection",
3070
+ "property"
3071
+ ],
3072
+ "schedule": "0 8 * * 1",
3073
+ "timezone": "UTC",
3074
+ "runner": "shell",
3075
+ "modes": [
3076
+ "script"
3077
+ ],
3078
+ "compatible_agents": [
3079
+ "generic",
3080
+ "claude",
3081
+ "codex"
3082
+ ],
3083
+ "path": "jobs/realestate/property-maintenance-inspection-reminder.yaml"
3084
+ },
2983
3085
  {
2984
3086
  "id": "pto-balance-check",
2985
3087
  "version": 1,
@@ -3397,6 +3499,33 @@
3397
3499
  ],
3398
3500
  "path": "jobs/content/rss-feed-validate.yaml"
3399
3501
  },
3502
+ {
3503
+ "id": "rss-to-social-repurpose",
3504
+ "version": 1,
3505
+ "name": "RSS-to-Social Repurpose Draft",
3506
+ "description": "Checks your blog/RSS feed for new posts and drafts a set of social posts repurposing each one, instead of publishing and moving on without ever promoting it. Use this if you've ever shipped a blog post and then simply forgot to tell anyone it existed.",
3507
+ "category": "content",
3508
+ "tags": [
3509
+ "content",
3510
+ "rss",
3511
+ "social",
3512
+ "repurposing"
3513
+ ],
3514
+ "schedule": "0 10 * * *",
3515
+ "timezone": "UTC",
3516
+ "runner": "agent-prompt",
3517
+ "modes": [
3518
+ "agent-prompt"
3519
+ ],
3520
+ "compatible_agents": [
3521
+ "claude",
3522
+ "codex",
3523
+ "hermes",
3524
+ "openclaw",
3525
+ "generic"
3526
+ ],
3527
+ "path": "jobs/content/rss-to-social-repurpose.yaml"
3528
+ },
3400
3529
  {
3401
3530
  "id": "rsvp-followup-reminder",
3402
3531
  "version": 1,
@@ -4535,6 +4664,60 @@
4535
4664
  "generic"
4536
4665
  ],
4537
4666
  "path": "jobs/productivity/weekly-report.yaml"
4667
+ },
4668
+ {
4669
+ "id": "welcome-email-drip-check",
4670
+ "version": 1,
4671
+ "name": "Welcome Email Drip Check",
4672
+ "description": "Checks that everyone who signed up recently actually received the welcome email, and flags anyone who fell through the cracks. Use this if a welcome-email automation has ever silently failed for a batch of new users and nobody noticed until support tickets started asking \"where's my onboarding email?\"",
4673
+ "category": "growth",
4674
+ "tags": [
4675
+ "growth",
4676
+ "email",
4677
+ "onboarding",
4678
+ "welcome-email"
4679
+ ],
4680
+ "schedule": "0 10 * * *",
4681
+ "timezone": "UTC",
4682
+ "runner": "agent-prompt",
4683
+ "modes": [
4684
+ "agent-prompt"
4685
+ ],
4686
+ "compatible_agents": [
4687
+ "claude",
4688
+ "codex",
4689
+ "hermes",
4690
+ "openclaw",
4691
+ "generic"
4692
+ ],
4693
+ "path": "jobs/growth/welcome-email-drip-check.yaml"
4694
+ },
4695
+ {
4696
+ "id": "youtube-clip-repurposing",
4697
+ "version": 1,
4698
+ "name": "YouTube Clip Repurposing Draft",
4699
+ "description": "Checks for a newly published long-form video and drafts a shortlist of moments worth cutting into shorts/clips for other platforms. Use this if a video has ever gone up and just sat there as one long upload instead of becoming five pieces of content.",
4700
+ "category": "creator",
4701
+ "tags": [
4702
+ "youtube",
4703
+ "video",
4704
+ "clips",
4705
+ "repurposing"
4706
+ ],
4707
+ "schedule": "0 12 * * *",
4708
+ "timezone": "UTC",
4709
+ "runner": "agent-prompt",
4710
+ "modes": [
4711
+ "agent-prompt"
4712
+ ],
4713
+ "compatible_agents": [
4714
+ "claude",
4715
+ "codex",
4716
+ "hermes",
4717
+ "openclaw",
4718
+ "generic"
4719
+ ],
4720
+ "path": "jobs/creator/youtube-clip-repurposing.yaml"
4538
4721
  }
4539
4722
  ]
4540
4723
  }
@@ -0,0 +1,36 @@
1
+ id: rss-to-social-repurpose
2
+ version: 1
3
+ name: RSS-to-Social Repurpose Draft
4
+ description: >
5
+ Checks your blog/RSS feed for new posts and drafts a set of social posts
6
+ repurposing each one, instead of publishing and moving on without ever
7
+ promoting it. Use this if you've ever shipped a blog post and then simply
8
+ forgot to tell anyone it existed.
9
+ category: content
10
+ tags: [content, rss, social, repurposing]
11
+ schedule: "0 10 * * *"
12
+ timezone: "UTC"
13
+ runner: agent-prompt
14
+ prompt: |
15
+ Check {{feed_url}} for posts published in the last {{lookback_hours}}
16
+ hours.
17
+ 1. For each new post, read the title/summary (fetch the full post if the
18
+ feed only has a snippet).
19
+ 2. Draft {{platforms}} versions: a short one for X/Bluesky-style
20
+ character limits, and a longer one for LinkedIn-style posts — each
21
+ written to stand alone, not just "new post: [link]".
22
+ 3. Suggest one pull-quote or stat from the post that would work as a
23
+ standalone hook.
24
+ 4. If no new posts, say so in one line.
25
+ variables:
26
+ feed_url:
27
+ default: "https://example.com/rss.xml"
28
+ description: URL of the RSS/Atom feed to check.
29
+ lookback_hours:
30
+ default: 24
31
+ description: How far back to check for new posts.
32
+ platforms:
33
+ default: "X, LinkedIn"
34
+ description: Comma-separated list of platforms to draft posts for.
35
+ compatible_agents: [claude, codex, hermes, openclaw, generic]
36
+ notes: Draft-only — review before posting, and adjust tone/length per platform's actual current norms rather than trusting fixed character-count assumptions.
@@ -0,0 +1,37 @@
1
+ id: youtube-clip-repurposing
2
+ version: 1
3
+ name: YouTube Clip Repurposing Draft
4
+ description: >
5
+ Checks for a newly published long-form video and drafts a shortlist of
6
+ moments worth cutting into shorts/clips for other platforms. Use this if
7
+ a video has ever gone up and just sat there as one long upload instead of
8
+ becoming five pieces of content.
9
+ category: creator
10
+ tags: [youtube, video, clips, repurposing]
11
+ schedule: "0 12 * * *"
12
+ timezone: "UTC"
13
+ runner: agent-prompt
14
+ prompt: |
15
+ Check {{channel_url}} for videos published in the last
16
+ {{lookback_hours}} hours.
17
+ 1. For any new video, pull the transcript/chapters if available.
18
+ 2. Identify {{clip_count}} moments (with approximate timestamps) that
19
+ would work as standalone short-form clips — look for a strong hook in
20
+ the first few seconds, a self-contained point, or a punchy reaction.
21
+ 3. Draft a one-line caption per clip suited for {{short_platforms}}.
22
+ 4. If no new video, say so in one line.
23
+ variables:
24
+ channel_url:
25
+ default: "https://www.youtube.com/@yourchannel"
26
+ description: Channel or specific video URL to check.
27
+ lookback_hours:
28
+ default: 24
29
+ description: How far back to check for a new upload.
30
+ clip_count:
31
+ default: 3
32
+ description: How many clip candidates to suggest per video.
33
+ short_platforms:
34
+ default: "YouTube Shorts, TikTok, Instagram Reels"
35
+ description: Comma-separated short-form platforms to caption for.
36
+ compatible_agents: [claude, codex, hermes, openclaw, generic]
37
+ notes: Suggests timestamps and captions only — actual cutting/editing still needs a video tool; this just removes the "which moments are worth clipping" decision.
@@ -0,0 +1,36 @@
1
+ id: welcome-email-drip-check
2
+ version: 1
3
+ name: Welcome Email Drip Check
4
+ description: >
5
+ Checks that everyone who signed up recently actually received the
6
+ welcome email, and flags anyone who fell through the cracks. Use this if
7
+ a welcome-email automation has ever silently failed for a batch of new
8
+ users and nobody noticed until support tickets started asking "where's
9
+ my onboarding email?"
10
+ category: growth
11
+ tags: [growth, email, onboarding, welcome-email]
12
+ schedule: "0 10 * * *"
13
+ timezone: "UTC"
14
+ runner: agent-prompt
15
+ prompt: |
16
+ Check {{signup_source}} for accounts created in the last
17
+ {{lookback_hours}} hours, and cross-reference against
18
+ {{email_platform}} send logs for the welcome email campaign.
19
+ 1. List any new signup with no corresponding welcome email send logged.
20
+ 2. Note how long ago they signed up, sorted oldest-first.
21
+ 3. If any gap is found, flag whether it looks like an isolated miss or a
22
+ systemic failure (e.g. every signup in a time window is missing it,
23
+ which points at the automation itself breaking).
24
+ 4. If everyone got their email, say so in one line.
25
+ variables:
26
+ signup_source:
27
+ default: "your user database or signup form"
28
+ description: Where new signups are recorded.
29
+ email_platform:
30
+ default: "your transactional email platform (e.g. Postmark, SendGrid, Customer.io)"
31
+ description: Where welcome-email sends are logged.
32
+ lookback_hours:
33
+ default: 24
34
+ description: How far back to check for new signups.
35
+ compatible_agents: [claude, codex, hermes, openclaw, generic]
36
+ notes: Read-only check — wire up a manual resend for anyone flagged rather than having this job send email directly.
@@ -2,11 +2,14 @@ id: low-stock-alert
2
2
  version: 1
3
3
  name: Low Stock Alert
4
4
  description: >
5
- Checks inventory levels against a reorder point and flags SKUs running
6
- low. Use this if you've ever gone out of stock on a bestseller because
7
- nobody was watching the count.
5
+ Checks a general-purpose inventory database (warehouse, retail backroom,
6
+ restaurant supplies anything with a quantity and a set reorder point)
7
+ against that fixed threshold and flags what's running low. Use this for
8
+ physical stock tracked in a database, not tied to online sales; if you
9
+ specifically need SKU reorder timing driven by e-commerce sales velocity
10
+ and supplier lead time, see `low-stock-reorder-alert` instead.
8
11
  category: inventory
9
- tags: [inventory, stock, ecommerce]
12
+ tags: [inventory, stock, warehouse]
10
13
  schedule: "0 7 * * *"
11
14
  timezone: "UTC"
12
15
  runner: shell
@@ -0,0 +1,40 @@
1
+ id: court-deadline-reminder
2
+ version: 1
3
+ name: Court Deadline Reminder
4
+ description: >
5
+ Checks a tracked list of court/filing deadlines and warns before any come
6
+ due. Use this if a filing deadline has ever been missed because it lived
7
+ only in one person's head or a sticky note instead of a shared, checked
8
+ calendar.
9
+ category: legal
10
+ tags: [legal, court, deadline, compliance]
11
+ schedule: "0 7 * * *"
12
+ timezone: "UTC"
13
+ runner: shell
14
+ command: >
15
+ awk -F',' -v days="{{warn_days_before}}" '
16
+ NR==1 {next}
17
+ {
18
+ cmd = "date -d \"" $3 "\" +%s 2>/dev/null || date -j -f %Y-%m-%d \"" $3 "\" +%s 2>/dev/null";
19
+ cmd | getline due_epoch; close(cmd);
20
+ "date +%s" | getline now_epoch; close("date +%s");
21
+ diff_days = (due_epoch - now_epoch) / 86400;
22
+ if (diff_days <= days && diff_days >= 0) {
23
+ printf "DUE SOON: %s — %s due %s (%d days)\n", $1, $2, $3, diff_days;
24
+ }
25
+ }
26
+ ' "{{deadlines_csv_path}}"
27
+ variables:
28
+ deadlines_csv_path:
29
+ default: "./court-deadlines.csv"
30
+ description: "Path to a CSV with columns: matter_name,deadline_type,due_date (YYYY-MM-DD) — e.g. one row per filing/response/statute-of-limitations deadline."
31
+ warn_days_before:
32
+ default: 14
33
+ description: How many days before a deadline to start warning.
34
+ compatible_agents: [generic, claude, codex]
35
+ notes: >
36
+ This is a plain date-threshold check, not a rules-based calendaring
37
+ engine — it won't calculate deadlines from court rules the way dedicated
38
+ legal calendaring software does. Export computed due dates from that
39
+ system into the CSV, or maintain them manually, and use this as the
40
+ "did anyone actually notice" backstop.
@@ -0,0 +1,34 @@
1
+ id: downloads-folder-auto-organize
2
+ version: 1
3
+ name: Downloads Folder Auto-Organize
4
+ description: >
5
+ Sorts files sitting in your downloads folder into subfolders by type
6
+ (images, documents, archives, installers), so it doesn't turn into an
7
+ unsearchable pile. Use this if your downloads folder has ever hit
8
+ hundreds of files with zero structure.
9
+ category: personal
10
+ tags: [files, organization, downloads, cleanup]
11
+ schedule: "0 3 * * *"
12
+ timezone: "UTC"
13
+ runner: shell
14
+ command: >
15
+ dir="{{downloads_dir}}";
16
+ cd "$dir" || exit 1;
17
+ mkdir -p images documents archives installers other;
18
+ for f in *; do
19
+ [ -f "$f" ] || continue;
20
+ case "$f" in
21
+ *.jpg|*.jpeg|*.png|*.gif|*.webp|*.heic) mv -n "$f" images/ ;;
22
+ *.pdf|*.doc|*.docx|*.txt|*.md|*.xlsx|*.csv) mv -n "$f" documents/ ;;
23
+ *.zip|*.tar|*.gz|*.7z|*.rar) mv -n "$f" archives/ ;;
24
+ *.dmg|*.pkg|*.exe|*.msi|*.deb) mv -n "$f" installers/ ;;
25
+ *) mv -n "$f" other/ ;;
26
+ esac;
27
+ done;
28
+ echo "sorted $(find images documents archives installers other -type f 2>/dev/null | wc -l) file(s) in $dir"
29
+ variables:
30
+ downloads_dir:
31
+ default: "$HOME/Downloads"
32
+ description: Path to the downloads folder to organize.
33
+ compatible_agents: [generic, claude, codex]
34
+ notes: Only sorts files sitting directly in the folder (not subfolders you've already made), and never overwrites a same-named file already in the destination (`mv -n`).
@@ -0,0 +1,35 @@
1
+ id: property-maintenance-inspection-reminder
2
+ version: 1
3
+ name: Property Maintenance Inspection Reminder
4
+ description: >
5
+ Tracks when each property was last inspected and flags any that are due
6
+ for their next routine walkthrough. Use this if a property has ever gone
7
+ a year or more without a proper inspection because nothing forced anyone
8
+ to schedule one.
9
+ category: realestate
10
+ tags: [realestate, maintenance, inspection, property]
11
+ schedule: "0 8 * * 1"
12
+ timezone: "UTC"
13
+ runner: shell
14
+ command: >
15
+ awk -F',' -v days="{{inspection_interval_days}}" '
16
+ NR==1 {next}
17
+ {
18
+ cmd = "date -d \"" $2 "\" +%s 2>/dev/null || date -j -f %Y-%m-%d \"" $2 "\" +%s 2>/dev/null";
19
+ cmd | getline last_epoch; close(cmd);
20
+ "date +%s" | getline now_epoch; close("date +%s");
21
+ days_since = (now_epoch - last_epoch) / 86400;
22
+ if (days_since >= days) {
23
+ printf "DUE: %s — last inspected %s (%d days ago)\n", $1, $2, days_since;
24
+ }
25
+ }
26
+ ' "{{properties_csv_path}}"
27
+ variables:
28
+ properties_csv_path:
29
+ default: "./property-inspections.csv"
30
+ description: "Path to a CSV with columns: property_name,last_inspection_date (YYYY-MM-DD)."
31
+ inspection_interval_days:
32
+ default: 180
33
+ description: How often each property should be inspected, in days.
34
+ compatible_agents: [generic, claude, codex]
35
+ notes: Assumes a maintained CSV of last-inspection dates per property — export it from whatever property management system you use, or update it manually after each walkthrough.
@@ -0,0 +1,36 @@
1
+ id: form-lead-crm-sync-check
2
+ version: 1
3
+ name: Form Lead CRM Sync Check
4
+ description: >
5
+ Checks that leads submitted through your web forms actually landed in
6
+ your CRM, catching a broken integration instead of a slow rep. Use this
7
+ if a form-to-CRM zap/integration has ever quietly broken and leads piled
8
+ up on the form provider's side while sales thought the pipeline was just
9
+ empty.
10
+ category: sales
11
+ tags: [sales, leads, crm, integration]
12
+ schedule: "*/30 * * * *"
13
+ timezone: "UTC"
14
+ runner: agent-prompt
15
+ prompt: |
16
+ Check {{form_platform}} for form submissions in the last
17
+ {{lookback_hours}} hours, and check {{crm_platform}} for matching lead
18
+ records (by email).
19
+ 1. List any form submission with no matching CRM record.
20
+ 2. If there's a gap, note how many are missing and since when — a
21
+ sudden cluster of misses starting at a specific time usually means
22
+ the integration itself broke, not that leads are individually
23
+ falling through.
24
+ 3. If everything matches, say so in one line.
25
+ variables:
26
+ form_platform:
27
+ default: "your web form tool (e.g. Typeform, HubSpot forms, a custom form)"
28
+ description: Where form submissions are recorded.
29
+ crm_platform:
30
+ default: "your CRM (e.g. HubSpot, Salesforce, Pipedrive)"
31
+ description: Where leads should be showing up.
32
+ lookback_hours:
33
+ default: 2
34
+ description: How far back to check for submissions, tight enough to catch a break quickly.
35
+ compatible_agents: [claude, codex, hermes, openclaw, generic]
36
+ notes: Read-only — this catches the gap, but reprocessing missed leads into the CRM is a separate, deliberate action.
@@ -0,0 +1,62 @@
1
+ // Scoring logic behind `crondex recommend` — pulled out of bin/crondex.js so it can
2
+ // be unit tested directly (see test/recommend.test.js).
3
+
4
+ export const STOPWORDS = new Set([
5
+ "a", "an", "the", "to", "for", "of", "in", "on", "at", "by", "and", "or", "my", "me", "i",
6
+ "can", "you", "do", "does", "this", "that", "these", "those", "please", "want", "wants",
7
+ "wanted", "need", "needs", "help", "with", "is", "are", "be", "it", "so", "when", "should",
8
+ "could", "would", "how", "what", "which", "up", "down", "get", "make", "set", "just", "really",
9
+ "some", "something", "any", "want", "us", "our", "your", "yours", "if", "then", "than",
10
+ ]);
11
+
12
+ export function stem(word) {
13
+ if (word.length > 4 && word.endsWith("ies")) return word.slice(0, -3) + "y";
14
+ if (word.length > 4 && word.endsWith("es") && !word.endsWith("ses")) return word.slice(0, -2);
15
+ if (word.length > 3 && word.endsWith("s") && !word.endsWith("ss")) return word.slice(0, -1);
16
+ return word;
17
+ }
18
+
19
+ export function tokenize(text) {
20
+ return text
21
+ .toLowerCase()
22
+ .replace(/[^a-z0-9\s-]/g, " ")
23
+ .split(/[\s-]+/)
24
+ .filter((w) => w.length > 1 && !STOPWORDS.has(w))
25
+ .map(stem);
26
+ }
27
+
28
+ // id and name are near-duplicates of each other (id is just the slugified
29
+ // name), so they're merged into one "title" field — otherwise a match on a
30
+ // shared word like "water" gets weighted 3+3 instead of 3, which can outrank
31
+ // a more specific job that only matches once via tags.
32
+ export const RECOMMEND_WEIGHTS = { tags: 4, title: 3, category: 2, description: 1 };
33
+
34
+ export function scoreJob(queryTokens, job) {
35
+ const fields = {
36
+ tags: job.tags.map((t) => stem(t.toLowerCase())),
37
+ title: [...new Set([...tokenize(job.name), ...tokenize(job.id.replace(/-/g, " "))])],
38
+ category: tokenize(job.category ?? ""),
39
+ description: tokenize(job.description ?? ""),
40
+ };
41
+ let score = 0;
42
+ const matched = new Set();
43
+ for (const qt of queryTokens) {
44
+ for (const [field, weight] of Object.entries(RECOMMEND_WEIGHTS)) {
45
+ if (fields[field].includes(qt)) {
46
+ score += weight;
47
+ matched.add(qt);
48
+ }
49
+ }
50
+ }
51
+ return { score, matchedTerms: [...matched] };
52
+ }
53
+
54
+ export function rankJobs(jobs, queryText, limit = 5) {
55
+ const queryTokens = tokenize(queryText);
56
+ if (!queryTokens.length) return [];
57
+ return jobs
58
+ .map((job) => ({ job, ...scoreJob(queryTokens, job) }))
59
+ .filter((r) => r.score > 0)
60
+ .sort((a, b) => b.score - a.score)
61
+ .slice(0, limit);
62
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wonsukchoi/crondex",
3
- "version": "0.9.1",
3
+ "version": "0.11.0",
4
4
  "description": "A public directory of pre-made, agent-editable cron jobs.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -23,6 +23,7 @@
23
23
  },
24
24
  "files": [
25
25
  "bin",
26
+ "lib",
26
27
  "jobs",
27
28
  "schema",
28
29
  "catalog.json"
@@ -33,10 +34,14 @@
33
34
  "scripts": {
34
35
  "build-catalog": "node scripts/build-catalog.js",
35
36
  "validate": "node scripts/validate-jobs.js",
36
- "lint-shell": "node scripts/lint-shell.js"
37
+ "lint-shell": "node scripts/lint-shell.js",
38
+ "check-duplicates": "node scripts/check-duplicates.js",
39
+ "test": "node --test"
37
40
  },
38
- "devDependencies": {
39
- "ajv": "^8.17.1",
41
+ "dependencies": {
40
42
  "js-yaml": "^4.1.0"
43
+ },
44
+ "devDependencies": {
45
+ "ajv": "^8.17.1"
41
46
  }
42
47
  }