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 +21 -0
- package/README.md +204 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +405 -0
- package/dist/cron.d.ts +10 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +1312 -0
- package/dist/install.d.ts +46 -0
- package/dist/internals.d.ts +19 -0
- package/dist/job.d.ts +43 -0
- package/dist/json.d.ts +4 -0
- package/dist/paths.d.ts +24 -0
- package/dist/project.d.ts +2 -0
- package/dist/registry.d.ts +14 -0
- package/dist/runs.d.ts +16 -0
- package/dist/systemd.d.ts +26 -0
- package/dist/tools.d.ts +2 -0
- package/package.json +76 -0
- package/skill/opencode-jobs/SKILL.md +51 -0
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
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;
|
package/dist/index.d.ts
ADDED