opencode-jobs 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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Connor Fraser-Grant
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,204 @@
1
+ # opencode-jobs
2
+
3
+ > **Status:** v0.x — Linux-only early release. The job format, tool names,
4
+ > and CLI may still change.
5
+
6
+ Recurring scheduled jobs for [opencode](https://opencode.ai), backed by
7
+ systemd user timers. Job definitions are git-committable files in your repo,
8
+ each job runs through a frozen POSIX shell script, and every run appends
9
+ JSONL history your agent can read back. Jobs are just agent prompts — with
10
+ optional session continuity so a nightly job can build on what it learned
11
+ yesterday.
12
+
13
+ ## Install
14
+
15
+ Install the package globally, then wire the plugin and bundled skill into the
16
+ current project:
17
+
18
+ ```sh
19
+ npm install --global opencode-jobs
20
+ opencode-jobs install
21
+ ```
22
+
23
+ Pass a project path when running the command from elsewhere:
24
+
25
+ ```sh
26
+ opencode-jobs install /path/to/project
27
+ ```
28
+
29
+ The installer is idempotent. It adds `opencode-jobs` to the project's local
30
+ `plugin` array and copies the bundled skill to
31
+ `.opencode/skills/opencode-jobs/SKILL.md` — the skill teaches your agent the
32
+ job file format, session modes, and the day-to-day workflow. It does not
33
+ enable timers; create a job first, then use `enable_project`. If an existing
34
+ config contains comments or malformed JSON, the installer leaves it untouched
35
+ and reports the manual change required.
36
+
37
+ Job files are committed agent prompts that run on your machine on a
38
+ schedule — only install and enable projects whose
39
+ `.opencode/scheduler/jobs/` you trust, the same care you would take before
40
+ running an unfamiliar repository's build.
41
+
42
+ Alternatively, add the package manually to the project root `opencode.json`
43
+ or global `~/.config/opencode/opencode.json`:
44
+
45
+ ```json
46
+ {
47
+ "plugin": ["opencode-jobs"]
48
+ }
49
+ ```
50
+
51
+ Restart opencode and ask your agent to schedule something, e.g.
52
+ _"schedule a job that reviews new commits every weekday at 9am"_.
53
+
54
+ ## Uninstall
55
+
56
+ Reverse an install from anywhere:
57
+
58
+ ```sh
59
+ opencode-jobs uninstall # current directory
60
+ opencode-jobs uninstall /path/to/project
61
+ ```
62
+
63
+ Uninstall stops and removes the project's systemd units (if enabled),
64
+ removes the plugin entry from the project config, and deletes the copied
65
+ skill — a skill you have modified locally is kept and reported, and a config
66
+ the uninstaller cannot rewrite safely is left untouched with a non-zero
67
+ exit. Job definitions, run history, session state, and logs are kept.
68
+
69
+ Add `--purge` to also delete the project's job definitions
70
+ (`.opencode/scheduler/`) and its scheduler data (run scripts, run history,
71
+ session state, and logs):
72
+
73
+ ```sh
74
+ opencode-jobs uninstall --purge
75
+ ```
76
+
77
+ ## CLI
78
+
79
+ ```text
80
+ opencode-jobs install [projectDir]
81
+ opencode-jobs uninstall [projectDir] [--purge]
82
+ ```
83
+
84
+ The CLI is installed as the `opencode-jobs` npm executable. `projectDir`
85
+ defaults to the current directory. Both commands print a JSON result so they
86
+ can also be used from setup scripts and CI.
87
+
88
+ ## Tools
89
+
90
+ | Tool | What it does |
91
+ | ----------------- | ---------------------------------------------------------------------------------------------- |
92
+ | `schedule_job` | Create or update a job (cron schedule, prompt or custom command, session mode, guard, timeout) |
93
+ | `list_jobs` | List job definitions in the project with next/last run status |
94
+ | `get_job` | Show one job: definition, timer state, last runs, recent log tail |
95
+ | `run_job` | Fire a job now, through the exact script the timer would run |
96
+ | `job_logs` | Tail a job's log (scheduled and manual runs both append) |
97
+ | `delete_job` | Delete a job, its units, run script, and session state |
98
+ | `enable_project` | Install systemd user units for all jobs in the project and register it |
99
+ | `disable_project` | Stop and remove the project's units (jobs and history are kept) |
100
+ | `list_projects` | List all projects that have enabled jobs |
101
+
102
+ ## Job definitions
103
+
104
+ Jobs live in your repo at `.opencode/scheduler/jobs/<slug>.json` — review
105
+ them in PRs like any other code:
106
+
107
+ ```json
108
+ {
109
+ "slug": "nightly-review",
110
+ "name": "Nightly Review",
111
+ "schedule": "0 9 * * 1-5",
112
+ "run": {
113
+ "prompt": "Review commits since the last run and flag risky changes."
114
+ },
115
+ "session": "compact+last",
116
+ "guard": "! git diff --quiet",
117
+ "timeoutSeconds": 1800
118
+ }
119
+ ```
120
+
121
+ - `schedule` — 5-field cron expression (`m h dom mon dow`, names and ranges
122
+ supported), mapped to `OnCalendar` systemd timers.
123
+ - `run` — either a natural-language `prompt` (run with `opencode run`), or a
124
+ `command` + optional `arguments` for a project custom command. Optional
125
+ `agent` and `model` (`"provider/model"`) select who runs it.
126
+ - `session` — continuity between runs (below).
127
+ - `guard` — shell command run first; a non-zero exit skips the run (recorded
128
+ as `skipped`). Example: `"! git diff --quiet"` runs only when the repo has
129
+ changes.
130
+ - `timeoutSeconds` — hard limit; systemd stops the run with SIGTERM.
131
+
132
+ ### Session modes
133
+
134
+ | Mode | Behavior |
135
+ | -------------- | ------------------------------------------------------------------------------------------------------------------------ |
136
+ | `new` | Fresh session each run (default) |
137
+ | `persist` | Continue the same session every run (`opencode run --session`) |
138
+ | `compact` | Continue the same session; after each run the history is compacted into a summary the next run starts from |
139
+ | `compact+last` | Like `compact`, but the run's final result is re-injected after the summary so the next run starts from summary + result |
140
+
141
+ Tracked modes (`persist`/`compact`/`compact+last`) self-heal a deleted or
142
+ stale session by retrying once with a fresh session.
143
+
144
+ ## How it works
145
+
146
+ - `enable_project` generates, for each job: a frozen POSIX `run-<slug>.sh`
147
+ script, a `opencode-sched-<scope>-<slug>.service` + `.timer` unit pair,
148
+ and enables the timer. `disable_project` removes them.
149
+ - Run scripts work standalone: they append a JSONL record (status, exit
150
+ code, duration, session id) per run, capture `--format json` output for
151
+ tracked session modes, and never leave temp files behind.
152
+ - A global registry tracks enabled projects so `list_projects` and
153
+ `disable_project` work from any session.
154
+ - Schedules are cron expressions compiled to systemd `OnCalendar` and
155
+ verified with `systemd-analyze verify` before install.
156
+
157
+ ## Storage
158
+
159
+ | What | Where |
160
+ | ------------------- | ---------------------------------------------------------------------- |
161
+ | Job definitions | `<project>/.opencode/scheduler/jobs/<slug>.json` (git-committed) |
162
+ | Run scripts | `~/.config/opencode/scheduler/scopes/<scopeId>/run-<slug>.sh` |
163
+ | Run history (JSONL) | `~/.config/opencode/scheduler/runs/<scopeId>/<slug>.jsonl` |
164
+ | Session state | `~/.config/opencode/scheduler/sessions/<scopeId>/<slug>.txt` |
165
+ | Job logs | `~/.config/opencode/logs/scheduler/<scopeId>/<slug>.log` |
166
+ | Project registry | `~/.config/opencode/scheduler/registry.json` |
167
+ | systemd units | `~/.config/systemd/user/opencode-sched-<scope>-<slug>.{service,timer}` |
168
+
169
+ `scopeId` is a stable hash of the project path, so multiple projects can
170
+ define jobs without colliding. Because it is path-derived, moving or renaming
171
+ a project directory orphans its units, history, and registry entry — run
172
+ `opencode-jobs uninstall --purge` from the old path before moving, or clean
173
+ up `~/.config/opencode/scheduler/` (and the unit files) manually afterwards.
174
+
175
+ ## Configuration
176
+
177
+ - `OPENCODE_SCHEDULER_OPENCODE_PATH` — absolute path to the `opencode`
178
+ binary the run scripts invoke (default: resolved from `PATH`, then
179
+ `~/.opencode/bin/opencode`, then common install locations).
180
+
181
+ ## Requirements
182
+
183
+ - Linux with a systemd user session (jobs run via `systemctl --user`)
184
+ - `curl` (used by `compact`/`compact+last` modes only)
185
+ - POSIX `sh` (generated scripts are `sh`/`dash`-verified)
186
+ - The `opencode` CLI available to the timer environment
187
+
188
+ Not supported on macOS or Windows.
189
+
190
+ ## Development
191
+
192
+ ```
193
+ npm install
194
+ npm run check # prettier + eslint + tsc
195
+ npm run build # Bun plugin bundle + Node CLI bundle + declarations
196
+ npm run smoke # build, scheduler behavior, CLI install, and package contents
197
+ ```
198
+
199
+ Node >= 20 is required for the CLI and dev toolchain; the plugin runs on Bun
200
+ inside opencode.
201
+
202
+ ## License
203
+
204
+ MIT
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,405 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import path6 from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ // src/install.ts
8
+ import { existsSync as existsSync2, readFileSync as readFileSync2, rmSync as rmSync2, rmdirSync, statSync } from "node:fs";
9
+ import path5 from "node:path";
10
+
11
+ // src/json.ts
12
+ function isRecord(value) {
13
+ return typeof value === "object" && value !== null;
14
+ }
15
+ function stringProperty(record, key) {
16
+ const value = record[key];
17
+ return typeof value === "string" ? value : undefined;
18
+ }
19
+ function errorMessage(error) {
20
+ return error instanceof Error ? error.message : String(error);
21
+ }
22
+
23
+ // src/paths.ts
24
+ import { chmodSync, mkdirSync, renameSync, writeFileSync } from "node:fs";
25
+ import { createHash } from "node:crypto";
26
+ import path from "node:path";
27
+ import { homedir } from "node:os";
28
+ function configRoot() {
29
+ return process.env.XDG_CONFIG_HOME ?? path.join(homedir(), ".config");
30
+ }
31
+ function schedulerDirectory() {
32
+ return path.join(configRoot(), "opencode", "scheduler");
33
+ }
34
+ function registryPath() {
35
+ return path.join(schedulerDirectory(), "registry.json");
36
+ }
37
+ function scopeDirectory(scopeId) {
38
+ return path.join(schedulerDirectory(), "scopes", scopeId);
39
+ }
40
+ function runsDirectory(scopeId) {
41
+ return path.join(schedulerDirectory(), "runs", scopeId);
42
+ }
43
+ function sessionStateDirectory(scopeId) {
44
+ return path.join(schedulerDirectory(), "sessions", scopeId);
45
+ }
46
+ function logDirectory(scopeId) {
47
+ return path.join(configRoot(), "opencode", "logs", "scheduler", scopeId);
48
+ }
49
+ function systemdUserDirectory() {
50
+ return path.join(configRoot(), "systemd", "user");
51
+ }
52
+ function unitBase(scopeId, slug) {
53
+ return `opencode-sched-${scopeId}-${slug}`;
54
+ }
55
+ function timerUnit(base) {
56
+ return `${base}.timer`;
57
+ }
58
+ function serviceUnit(base) {
59
+ return `${base}.service`;
60
+ }
61
+ function runScriptPath(scopeId, slug) {
62
+ return path.join(scopeDirectory(scopeId), `run-${slug}.sh`);
63
+ }
64
+ function slugify(input) {
65
+ const slug = input.toLowerCase().replaceAll(/[^a-z0-9]+/g, "-").replaceAll(/^-+|-+$/g, "").slice(0, 64);
66
+ return slug.length > 0 ? slug : "job";
67
+ }
68
+ function atomicWrite(file, content) {
69
+ mkdirSync(path.dirname(file), { recursive: true });
70
+ const temporary = `${file}.tmp`;
71
+ writeFileSync(temporary, content);
72
+ renameSync(temporary, file);
73
+ }
74
+ function deriveScopeId(workdir) {
75
+ const abs = path.resolve(workdir);
76
+ const hash = createHash("sha256").update(abs).digest("hex").slice(0, 12);
77
+ return `${slugify(path.basename(abs))}-${hash}`;
78
+ }
79
+
80
+ // src/project.ts
81
+ import path4 from "node:path";
82
+
83
+ // src/registry.ts
84
+ import { readFileSync } from "node:fs";
85
+ import path2 from "node:path";
86
+ function isRegistryEntry(value) {
87
+ if (!isRecord(value))
88
+ return false;
89
+ return stringProperty(value, "scopeId") !== undefined && stringProperty(value, "workdir") !== undefined && stringProperty(value, "enabledAt") !== undefined && stringProperty(value, "updatedAt") !== undefined && Array.isArray(value.jobs) && value.jobs.every((job) => typeof job === "string");
90
+ }
91
+ function loadRegistry() {
92
+ try {
93
+ const parsed = JSON.parse(readFileSync(registryPath(), "utf8"));
94
+ if (!isRecord(parsed) || parsed.version !== 1 || !isRecord(parsed.projects)) {
95
+ return { version: 1, projects: {} };
96
+ }
97
+ const projects = {};
98
+ for (const [key, value] of Object.entries(parsed.projects)) {
99
+ if (isRegistryEntry(value))
100
+ projects[key] = value;
101
+ }
102
+ return { version: 1, projects };
103
+ } catch {
104
+ return { version: 1, projects: {} };
105
+ }
106
+ }
107
+ function saveRegistry(registry) {
108
+ atomicWrite(registryPath(), `${JSON.stringify(registry, undefined, 2)}
109
+ `);
110
+ }
111
+ function registryEntry(workdir) {
112
+ return loadRegistry().projects[path2.resolve(workdir)];
113
+ }
114
+
115
+ // src/systemd.ts
116
+ import { existsSync, mkdirSync as mkdirSync2, readdirSync, rmSync } from "node:fs";
117
+ import { spawnSync } from "node:child_process";
118
+ import path3 from "node:path";
119
+ var SESSION_ID_SED = String.raw`s/.*"sessionID":"\([^"]*\)".*/\1/p`;
120
+ var DEFAULT_PROVIDER_SED = String.raw`s/.*"default"[[:space:]]*:[[:space:]]*{[^}]*"\([^"]*\)"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p`;
121
+ var DEFAULT_MODEL_SED = String.raw`s/.*"default"[[:space:]]*:[[:space:]]*{[^}]*"\([^"]*\)"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\2/p`;
122
+ function systemctl(systemctlArguments) {
123
+ const result = spawnSync("systemctl", ["--user", ...systemctlArguments], {
124
+ encoding: "utf8"
125
+ });
126
+ return {
127
+ ok: result.status === 0,
128
+ stdout: result.stdout.trim(),
129
+ stderr: result.stderr.trim()
130
+ };
131
+ }
132
+ function removeJobUnits(scopeId, slug) {
133
+ const base = unitBase(scopeId, slug);
134
+ systemctl(["disable", "--now", timerUnit(base)]);
135
+ for (const file of [
136
+ path3.join(systemdUserDirectory(), timerUnit(base)),
137
+ path3.join(systemdUserDirectory(), serviceUnit(base))
138
+ ]) {
139
+ if (existsSync(file))
140
+ rmSync(file);
141
+ }
142
+ const script = runScriptPath(scopeId, slug);
143
+ if (existsSync(script))
144
+ rmSync(script);
145
+ }
146
+
147
+ // src/project.ts
148
+ function disableProject(workdir) {
149
+ const abs = path4.resolve(workdir);
150
+ const entry = registryEntry(abs);
151
+ if (entry === undefined)
152
+ return `Project is not enabled: ${abs}`;
153
+ for (const slug of entry.jobs)
154
+ removeJobUnits(entry.scopeId, slug);
155
+ systemctl(["daemon-reload"]);
156
+ const registry = loadRegistry();
157
+ const { [abs]: _omitted, ...remainingProjects } = registry.projects;
158
+ registry.projects = remainingProjects;
159
+ saveRegistry(registry);
160
+ return [
161
+ `Disabled ${String(entry.jobs.length)} job(s) for ${abs}`,
162
+ `Run history kept at ${runsDirectory(entry.scopeId)}`,
163
+ `Logs kept at ${logDirectory(entry.scopeId)}`
164
+ ].join(`
165
+ `);
166
+ }
167
+
168
+ // src/install.ts
169
+ var PACKAGE_NAME = "opencode-jobs";
170
+ var SKILL_NAME = "opencode-jobs";
171
+ var CONFIG_SCHEMA = "https://opencode.ai/config.json";
172
+ var CONFIG_LOCATIONS = [
173
+ "opencode.json",
174
+ "opencode.jsonc",
175
+ path5.join(".opencode", "opencode.json"),
176
+ path5.join(".opencode", "opencode.jsonc")
177
+ ];
178
+ function existingConfigPath(projectDirectory) {
179
+ for (const relativePath of CONFIG_LOCATIONS) {
180
+ const configPath = path5.join(projectDirectory, relativePath);
181
+ if (existsSync2(configPath))
182
+ return configPath;
183
+ }
184
+ return;
185
+ }
186
+ function readConfig(configPath) {
187
+ try {
188
+ const value = JSON.parse(readFileSync2(configPath, "utf8"));
189
+ return isRecord(value) && !Array.isArray(value) ? value : undefined;
190
+ } catch {
191
+ return;
192
+ }
193
+ }
194
+ function isPackageSpecifier(value) {
195
+ return value === PACKAGE_NAME || typeof value === "string" && value.startsWith(`${PACKAGE_NAME}@`);
196
+ }
197
+ function isPackageReference(entry) {
198
+ if (typeof entry === "string")
199
+ return isPackageSpecifier(entry);
200
+ return Array.isArray(entry) && isPackageSpecifier(entry[0]);
201
+ }
202
+ function installPluginConfig(projectDirectory) {
203
+ const existingPath = existingConfigPath(projectDirectory);
204
+ const configPath = existingPath ?? path5.join(projectDirectory, "opencode.json");
205
+ if (existingPath === undefined) {
206
+ atomicWrite(configPath, `${JSON.stringify({
207
+ $schema: CONFIG_SCHEMA,
208
+ plugin: [PACKAGE_NAME]
209
+ }, undefined, 2)}
210
+ `);
211
+ return { status: "added", configPath };
212
+ }
213
+ const config = readConfig(configPath);
214
+ if (config === undefined)
215
+ return { status: "manual", configPath };
216
+ const plugins = config.plugin;
217
+ if (plugins !== undefined && !Array.isArray(plugins)) {
218
+ return { status: "manual", configPath };
219
+ }
220
+ const entries = Array.isArray(plugins) ? plugins : [];
221
+ if (entries.some((entry) => isPackageReference(entry))) {
222
+ return { status: "present", configPath };
223
+ }
224
+ config.plugin = [...entries, PACKAGE_NAME];
225
+ atomicWrite(configPath, `${JSON.stringify(config, undefined, 2)}
226
+ `);
227
+ return { status: "added", configPath };
228
+ }
229
+ function installSkill(projectDirectory, packageDirectory) {
230
+ const sourcePath = path5.join(packageDirectory, "skill", SKILL_NAME, "SKILL.md");
231
+ const skillPath = path5.join(projectDirectory, ".opencode", "skills", SKILL_NAME, "SKILL.md");
232
+ const content = readFileSync2(sourcePath, "utf8");
233
+ if (existsSync2(skillPath) && readFileSync2(skillPath, "utf8") === content) {
234
+ return { status: "unchanged", skillPath };
235
+ }
236
+ atomicWrite(skillPath, content);
237
+ return { status: "written", skillPath };
238
+ }
239
+ function installProject(projectDirectory, packageDirectory) {
240
+ const resolvedProject = path5.resolve(projectDirectory);
241
+ if (!existsSync2(resolvedProject) || !statSync(resolvedProject).isDirectory()) {
242
+ throw new Error(`Project directory does not exist: ${resolvedProject}`);
243
+ }
244
+ return {
245
+ projectDirectory: resolvedProject,
246
+ plugin: installPluginConfig(resolvedProject),
247
+ skill: installSkill(resolvedProject, packageDirectory)
248
+ };
249
+ }
250
+ function removeDirectoryIfEmpty(directory) {
251
+ try {
252
+ rmdirSync(directory);
253
+ } catch {}
254
+ }
255
+ function uninstallPluginConfig(projectDirectory) {
256
+ const existingPath = existingConfigPath(projectDirectory);
257
+ const configPath = existingPath ?? path5.join(projectDirectory, "opencode.json");
258
+ if (existingPath === undefined)
259
+ return { status: "absent", configPath };
260
+ const config = readConfig(configPath);
261
+ if (config === undefined)
262
+ return { status: "manual", configPath };
263
+ const plugins = config.plugin;
264
+ if (plugins !== undefined && !Array.isArray(plugins)) {
265
+ return { status: "manual", configPath };
266
+ }
267
+ const entries = Array.isArray(plugins) ? plugins : [];
268
+ const remaining = entries.filter((entry) => !isPackageReference(entry));
269
+ if (remaining.length === entries.length) {
270
+ return { status: "absent", configPath };
271
+ }
272
+ delete config.plugin;
273
+ const wasInstallerCreated = entries.length === 1 && Object.keys(config).length === 1 && config.$schema === CONFIG_SCHEMA;
274
+ if (wasInstallerCreated) {
275
+ rmSync2(configPath);
276
+ } else {
277
+ if (remaining.length > 0)
278
+ config.plugin = remaining;
279
+ atomicWrite(configPath, `${JSON.stringify(config, undefined, 2)}
280
+ `);
281
+ }
282
+ return { status: "removed", configPath };
283
+ }
284
+ function uninstallSkill(projectDirectory, packageDirectory) {
285
+ const sourcePath = path5.join(packageDirectory, "skill", SKILL_NAME, "SKILL.md");
286
+ const skillDirectory = path5.join(projectDirectory, ".opencode", "skills", SKILL_NAME);
287
+ const skillPath = path5.join(skillDirectory, "SKILL.md");
288
+ if (!existsSync2(skillPath))
289
+ return { status: "absent", skillPath };
290
+ if (readFileSync2(skillPath, "utf8") !== readFileSync2(sourcePath, "utf8")) {
291
+ return { status: "kept-modified", skillPath };
292
+ }
293
+ rmSync2(skillDirectory, { recursive: true, force: true });
294
+ removeDirectoryIfEmpty(path5.dirname(skillDirectory));
295
+ removeDirectoryIfEmpty(path5.join(projectDirectory, ".opencode"));
296
+ return { status: "removed", skillPath };
297
+ }
298
+ function purgeProjectData(projectDirectory) {
299
+ const abs = path5.resolve(projectDirectory);
300
+ const scopeId = deriveScopeId(abs);
301
+ const targets = [
302
+ scopeDirectory(scopeId),
303
+ runsDirectory(scopeId),
304
+ sessionStateDirectory(scopeId),
305
+ logDirectory(scopeId),
306
+ path5.join(abs, ".opencode", "scheduler")
307
+ ];
308
+ const paths = [];
309
+ for (const target of targets) {
310
+ if (!existsSync2(target))
311
+ continue;
312
+ rmSync2(target, { recursive: true, force: true });
313
+ paths.push(target);
314
+ }
315
+ removeDirectoryIfEmpty(path5.join(abs, ".opencode"));
316
+ return { paths };
317
+ }
318
+ function uninstallProject(projectDirectory, packageDirectory, shouldPurge) {
319
+ const resolvedProject = path5.resolve(projectDirectory);
320
+ if (!existsSync2(resolvedProject) || !statSync(resolvedProject).isDirectory()) {
321
+ throw new Error(`Project directory does not exist: ${resolvedProject}`);
322
+ }
323
+ const wasEnabled = registryEntry(resolvedProject) !== undefined;
324
+ if (wasEnabled)
325
+ disableProject(resolvedProject);
326
+ const uninstall = {
327
+ projectDirectory: resolvedProject,
328
+ disabled: wasEnabled,
329
+ plugin: uninstallPluginConfig(resolvedProject),
330
+ skill: uninstallSkill(resolvedProject, packageDirectory)
331
+ };
332
+ if (shouldPurge)
333
+ uninstall.purge = purgeProjectData(resolvedProject);
334
+ return uninstall;
335
+ }
336
+
337
+ // src/cli.ts
338
+ var USAGE = `Usage: opencode-jobs <command> [projectDir]
339
+
340
+ Commands:
341
+ install [projectDir] Add the plugin and bundled skill to a project (default: current directory)
342
+ uninstall [projectDir] [--purge] Remove the plugin entry, skill, and systemd units from a project;
343
+ --purge also deletes job definitions and scheduler data
344
+ help Show this help`;
345
+ function packageDirectory() {
346
+ return path6.resolve(path6.dirname(fileURLToPath(import.meta.url)), "..");
347
+ }
348
+ function printError(message) {
349
+ console.error(`Error: ${message}
350
+
351
+ ${USAGE}`);
352
+ process.exitCode = 1;
353
+ }
354
+ function parseUninstallArguments(arguments_) {
355
+ let project;
356
+ let shouldPurge = false;
357
+ for (const argument of arguments_) {
358
+ if (argument === "--purge") {
359
+ shouldPurge = true;
360
+ } else if (argument.startsWith("--")) {
361
+ throw new Error(`unknown option: ${argument}`);
362
+ } else if (project === undefined) {
363
+ project = argument;
364
+ } else {
365
+ throw new Error("uninstall accepts at most one project directory");
366
+ }
367
+ }
368
+ return { project, purge: shouldPurge };
369
+ }
370
+ var [command, ...rest] = process.argv.slice(2);
371
+ if ([undefined, "help", "--help"].includes(command)) {
372
+ console.log(USAGE);
373
+ } else if (command === "install") {
374
+ if (rest.length > 1) {
375
+ printError("install accepts at most one project directory");
376
+ } else {
377
+ try {
378
+ const result = installProject(rest[0] ?? process.cwd(), packageDirectory());
379
+ console.log(JSON.stringify(result, undefined, 2));
380
+ if (result.plugin.status === "manual") {
381
+ console.error(`Add "opencode-jobs" to the "plugin" array in ${result.plugin.configPath}; the installer did not rewrite that file.`);
382
+ process.exitCode = 1;
383
+ }
384
+ } catch (error) {
385
+ printError(errorMessage(error));
386
+ }
387
+ }
388
+ } else if (command === "uninstall") {
389
+ try {
390
+ const { project, purge: shouldPurge } = parseUninstallArguments(rest);
391
+ const result = uninstallProject(project ?? process.cwd(), packageDirectory(), shouldPurge);
392
+ console.log(JSON.stringify(result, undefined, 2));
393
+ if (result.plugin.status === "manual") {
394
+ console.error(`Remove "opencode-jobs" from the "plugin" array in ${result.plugin.configPath}; the uninstaller did not rewrite that file.`);
395
+ process.exitCode = 1;
396
+ }
397
+ if (result.skill.status === "kept-modified") {
398
+ console.error(`Skill kept at ${result.skill.skillPath} (modified locally); delete it manually if unwanted.`);
399
+ }
400
+ } catch (error) {
401
+ printError(errorMessage(error));
402
+ }
403
+ } else {
404
+ printError(`unknown command: ${command ?? "(missing)"}`);
405
+ }
package/dist/cron.d.ts ADDED
@@ -0,0 +1,10 @@
1
+ export interface CronSets {
2
+ minute: number[];
3
+ hour: number[];
4
+ dom: number[];
5
+ month: number[];
6
+ dow: number[];
7
+ }
8
+ export declare function parseCron(expression: string): CronSets;
9
+ export declare function cronToOnCalendar(sets: CronSets): string[];
10
+ export declare function describeCron(sets: CronSets): string;
@@ -0,0 +1,3 @@
1
+ import type { Hooks } from "@opencode-ai/plugin";
2
+ declare const _default: () => Promise<Hooks>;
3
+ export default _default;