@maci0/dsh-cron 0.1.1
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 +95 -0
- package/cordis.patch.yml +6 -0
- package/icon.svg +4 -0
- package/index.js +153 -0
- package/lib/parse.js +375 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +53 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Marcel W. Wysocki
|
|
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,95 @@
|
|
|
1
|
+
# dsh-cron
|
|
2
|
+
|
|
3
|
+
Run a prompt on a schedule in a DeepSeek Harness session: `/cron` takes a cron
|
|
4
|
+
expression or plain English like "every weekday at 9am", parses it in code,
|
|
5
|
+
and hands the timer to the harness schedule service, which keeps it across
|
|
6
|
+
restarts and wakes the session when it is due. Only text no grammar reads goes
|
|
7
|
+
to the model.
|
|
8
|
+
|
|
9
|
+
## What you get
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
/cron */15 9-17 * * 1-5 check the deploy # cron; MON-FRI, JAN and @daily work too
|
|
13
|
+
/cron every 5 minutes check the queue # fixed interval (at least one minute)
|
|
14
|
+
/cron 5m check the build # the same, as /loop takes it
|
|
15
|
+
/cron every day at 9am standup notes # daily wall-clock time
|
|
16
|
+
/cron every mon, wed and fri at 18:00 gym log # weekly
|
|
17
|
+
/cron monthly on the 1st at 9am pay rent # monthly, as cron
|
|
18
|
+
/cron in 10 minutes check the oven # once, after a delay
|
|
19
|
+
/cron tomorrow at 9:30 review the PR # once, at a time
|
|
20
|
+
/cron every day at 9 Europe/Berlin standup # an explicit zone (the host zone otherwise)
|
|
21
|
+
/cron list # this session's schedules
|
|
22
|
+
/cron delete 2 # by list number, by id, or all
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Each run arrives in the session as a message with the prompt, the way a
|
|
26
|
+
`/loop` round does.
|
|
27
|
+
|
|
28
|
+
## Install
|
|
29
|
+
|
|
30
|
+
> **Install it as a bundle.** `dsh plugin add …` mounts the row from the
|
|
31
|
+
> package's own patch layer, which is what the settings editor can write to. A
|
|
32
|
+
> row added with `--patch` is an overlay: it disappears at the next start.
|
|
33
|
+
|
|
34
|
+
```sh
|
|
35
|
+
dsh plugin --profile web add @maci0/dsh-cron@0.1.1 @deepseek-ai/dsh-experimental-schedule-bundle@0.2.0-rc.2
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
This installs the public npm package; no GitHub token or `~/.secrets` setup is needed.
|
|
39
|
+
The version is pinned. To upgrade, run the same command with a newer version,
|
|
40
|
+
then restart `dsh web` (bundle layers compose at boot).
|
|
41
|
+
|
|
42
|
+
The timers belong to the harness schedule service, which ships in the optional
|
|
43
|
+
`@deepseek-ai/dsh-experimental-schedule-bundle` (also on the Plugins page,
|
|
44
|
+
Official group). Without it `/cron` answers an error that names the bundle.
|
|
45
|
+
|
|
46
|
+
## How it works
|
|
47
|
+
|
|
48
|
+
`/cron <schedule> <prompt>` tries, in order:
|
|
49
|
+
|
|
50
|
+
1. **Cron.** When the first five tokens are cron-shaped (numbers, `*`, month or
|
|
51
|
+
day names, `,` `-` `/`), they are read as the service's five-field Vixie
|
|
52
|
+
dialect: names become numbers and `@hourly`, `@daily`, `@weekly`,
|
|
53
|
+
`@monthly`, `@yearly` become their expressions. A field the dialect cannot
|
|
54
|
+
take (out of range, `L`, `W`, `#`, `?`, a zero step, an inverted range) is
|
|
55
|
+
an error naming the field, never a guess.
|
|
56
|
+
2. **Phrases.** A small English grammar: `every N minutes|hours|days|weeks`
|
|
57
|
+
and the compact `5m`/`2h`/`1d` (fixed intervals); `hourly`, `every hour at
|
|
58
|
+
:15`; `every day|daily`, a weekday, a list (`mon, wed and fri`), a range
|
|
59
|
+
(`monday to friday`), `weekdays`, `weekends`, each optionally `at <time>`
|
|
60
|
+
(weekly or daily wall-clock); `monthly on the 15th at 18:30` (cron);
|
|
61
|
+
`in 10 minutes`, `at 3pm`, `today|tomorrow at <time>`, `on 2026-12-24 at
|
|
62
|
+
18:00` (once). Times are `9`, `9:30`, `9am`, `7:30pm`, `21:00`, `noon`,
|
|
63
|
+
`midnight`; a day phrase with no time means 09:00. A trailing IANA zone or
|
|
64
|
+
`UTC` overrides the host zone.
|
|
65
|
+
3. **The model.** Anything else, including a recognized start followed by a
|
|
66
|
+
qualifier the grammar does not read (`every 15 minutes during business
|
|
67
|
+
hours …`), is sent to the agent as a message asking it to set the schedule
|
|
68
|
+
up with its `schedule_create` tool and report what it created.
|
|
69
|
+
|
|
70
|
+
The prompt is what follows the schedule (a leading `:` or `to` is dropped); the
|
|
71
|
+
task title is its first line, cut to 80 characters. The reply names the
|
|
72
|
+
schedule and its next run in the host zone. The service validates again and
|
|
73
|
+
its refusal (for example a date that never occurs) is reported with its code.
|
|
74
|
+
|
|
75
|
+
## Limits
|
|
76
|
+
|
|
77
|
+
- Recurring intervals are at least one minute, the service's floor.
|
|
78
|
+
- The phrase grammar is English only; other languages reach the model.
|
|
79
|
+
- The model fallback runs as an agent turn, so it needs a working model route,
|
|
80
|
+
and its result is whatever the agent creates (it reports it in the session).
|
|
81
|
+
|
|
82
|
+
## Development
|
|
83
|
+
|
|
84
|
+
```sh
|
|
85
|
+
bun test # parser table, the /cron handler over a fake schedule service, and a real Cordis mount
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
For local development, install the checkout into a profile with
|
|
89
|
+
`dsh plugin --profile <name> add <path-to-checkout>`.
|
|
90
|
+
|
|
91
|
+
dsh loads plugins on Node `^22.19.0 || >=24.0.0`; development and tests run on bun.
|
|
92
|
+
|
|
93
|
+
## Licence
|
|
94
|
+
|
|
95
|
+
MIT. See [LICENSE](LICENSE).
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# Bundle layer: applied when a profile lists this bundle in dsh.profile.bundles
|
|
2
|
+
# (dsh plugin add does that from dsh.bundle). Do not also paste this row into
|
|
3
|
+
# the profile's cordis.patch.yml: insert does not dedupe ids.
|
|
4
|
+
- insert:
|
|
5
|
+
- id: cron
|
|
6
|
+
name: '@maci0/dsh-cron'
|
package/icon.svg
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
<svg width="36" height="36" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
2
|
+
<circle cx="18" cy="18" r="10.5" stroke="#145AF3" stroke-width="2.4"/>
|
|
3
|
+
<path d="M18 12v6.5l4 2.5" stroke="#145AF3" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/>
|
|
4
|
+
</svg>
|
package/index.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-cron: `/cron <schedule> <prompt>` runs a prompt on a schedule in this
|
|
3
|
+
* session, the way `/loop` repeats one across turns.
|
|
4
|
+
*
|
|
5
|
+
* The schedule is parsed in code first: a five-field cron expression, or a
|
|
6
|
+
* phrase like "every 5 minutes", "weekdays at 9am", "tomorrow at 15:00"
|
|
7
|
+
* (lib/parse.js). Only when neither grammar reads it is the text handed to the
|
|
8
|
+
* agent, which sets the timer up itself with the harness's `schedule_create`
|
|
9
|
+
* tool. Timers belong to the harness schedule service, which keeps them across
|
|
10
|
+
* restarts and wakes a cold session when one is due.
|
|
11
|
+
*
|
|
12
|
+
* /cron <schedule> <prompt> create a schedule in this session
|
|
13
|
+
* /cron list this session's schedules
|
|
14
|
+
* /cron delete <n|id|all> remove one by list number or id, or all
|
|
15
|
+
* /cron help the forms
|
|
16
|
+
*/
|
|
17
|
+
import { createUserMessage } from '@deepseek-ai/dsh-llm/message'
|
|
18
|
+
|
|
19
|
+
import { parseSchedule } from './lib/parse.js'
|
|
20
|
+
|
|
21
|
+
export const name = 'cron'
|
|
22
|
+
export const inject = ['commands']
|
|
23
|
+
|
|
24
|
+
/** The optional bundle that mounts the harness schedule service. */
|
|
25
|
+
const SCHEDULE_BUNDLE = '@deepseek-ai/dsh-experimental-schedule-bundle'
|
|
26
|
+
/** Longest task title shown in lists; the service allows 120. */
|
|
27
|
+
const MAX_TITLE = 80
|
|
28
|
+
|
|
29
|
+
const USAGE = [
|
|
30
|
+
'Usage: /cron <schedule> <prompt>',
|
|
31
|
+
' cron: /cron */15 9-17 * * 1-5 check the deploy (names like MON-FRI and @daily work too)',
|
|
32
|
+
' interval: /cron every 5 minutes check the queue (/cron 5m … as /loop takes it)',
|
|
33
|
+
' daily: /cron every day at 9am standup notes',
|
|
34
|
+
' weekly: /cron every mon, wed and fri at 18:00 gym log',
|
|
35
|
+
' once: /cron in 10 minutes check the oven · /cron tomorrow at 9:30 review',
|
|
36
|
+
' zone: add an IANA zone after the time: /cron every day at 9 Europe/Berlin …',
|
|
37
|
+
'Anything else is handed to the agent to set up with schedule_create.',
|
|
38
|
+
'/cron list · /cron delete <n|id|all>',
|
|
39
|
+
].join('\n')
|
|
40
|
+
|
|
41
|
+
/** The host's IANA zone, used unless the schedule names one. */
|
|
42
|
+
function hostTimeZone() {
|
|
43
|
+
return new Intl.DateTimeFormat().resolvedOptions().timeZone
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A task name from the prompt's first line, cut to fit lists. */
|
|
47
|
+
function titleOf(prompt) {
|
|
48
|
+
const line = prompt.split('\n')[0].trim()
|
|
49
|
+
return line.length <= MAX_TITLE ? line : `${line.slice(0, MAX_TITLE - 1).trimEnd()}…`
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A stored UTC target in the host's zone, as YYYY-MM-DD HH:MM. */
|
|
53
|
+
function localTime(iso, timeZone) {
|
|
54
|
+
const parts = Object.fromEntries(new Intl.DateTimeFormat('en-CA', {
|
|
55
|
+
timeZone, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23',
|
|
56
|
+
}).formatToParts(new Date(iso)).map(part => [part.type, part.value]))
|
|
57
|
+
return `${parts.year}-${parts.month}-${parts.day} ${parts.hour}:${parts.minute}`
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The message that asks the agent to read a schedule this plugin could not. */
|
|
61
|
+
function agentRequest(text, timeZone) {
|
|
62
|
+
return createUserMessage({
|
|
63
|
+
content: [{
|
|
64
|
+
type: 'text',
|
|
65
|
+
text: [
|
|
66
|
+
'[cron] Set this up with the schedule_create tool. It matched neither a cron expression nor the',
|
|
67
|
+
`/cron phrase grammar, so read the timing and the task from it yourself. Use time zone ${timeZone}`,
|
|
68
|
+
'unless it names another. If it cannot be expressed as one schedule, say why instead of guessing.',
|
|
69
|
+
'Then reply with what you created: title, timing, next run.',
|
|
70
|
+
'',
|
|
71
|
+
`Request: ${text}`,
|
|
72
|
+
].join('\n'),
|
|
73
|
+
}],
|
|
74
|
+
source: { kind: 'cron', form: 'relay' },
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function create(schedule, invocation, text) {
|
|
79
|
+
const timeZone = hostTimeZone()
|
|
80
|
+
const parsed = parseSchedule(text, { now: new Date(), timeZone })
|
|
81
|
+
if (parsed.kind === 'invalid') return { kind: 'error', text: parsed.text }
|
|
82
|
+
if (parsed.kind === 'unparsed') {
|
|
83
|
+
invocation.agent.followup(agentRequest(text, timeZone))
|
|
84
|
+
return { kind: 'success', text: 'No cron expression or built-in phrase matched; asked the agent to set it up with schedule_create.' }
|
|
85
|
+
}
|
|
86
|
+
const title = titleOf(parsed.prompt)
|
|
87
|
+
let record
|
|
88
|
+
try {
|
|
89
|
+
record = await schedule.create(invocation.agent.session.id, { prompt: parsed.prompt, title, ...parsed.selector }, invocation.signal)
|
|
90
|
+
} catch (error) {
|
|
91
|
+
// The service validates again (e.g. a cron date that never occurs) and
|
|
92
|
+
// names its rule; anything else propagates.
|
|
93
|
+
if (typeof error?.code !== 'string') throw error
|
|
94
|
+
return { kind: 'error', text: `The schedule service refused it (${error.code}): ${error.message}` }
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
kind: 'success',
|
|
98
|
+
text: `Scheduled "${title}": ${parsed.description}; next run ${localTime(record.scheduledAt, timeZone)} (${timeZone}). /cron list shows it.`,
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function list(schedule, sessionId) {
|
|
103
|
+
const records = await schedule.list({ sessionId })
|
|
104
|
+
if (records.length === 0) return { kind: 'success', text: 'No schedules in this session.' }
|
|
105
|
+
const timeZone = hostTimeZone()
|
|
106
|
+
return {
|
|
107
|
+
kind: 'success',
|
|
108
|
+
text: records.map((record, index) => `${String(index + 1)}. ${record.title} · next ${localTime(record.scheduledAt, timeZone)} (${timeZone}) · ${record.id}`).join('\n'),
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function remove(schedule, sessionId, target) {
|
|
113
|
+
const records = await schedule.list({ sessionId })
|
|
114
|
+
const chosen = target === 'all'
|
|
115
|
+
? records
|
|
116
|
+
: records.filter((record, index) => record.id === target || String(index + 1) === target)
|
|
117
|
+
if (chosen.length === 0) return { kind: 'error', text: `No schedule "${target}" in this session; /cron list shows them.` }
|
|
118
|
+
const deleted = []
|
|
119
|
+
for (const record of chosen) {
|
|
120
|
+
const result = await schedule.delete({ sessionId, id: record.id })
|
|
121
|
+
if (result.deleted) deleted.push(record)
|
|
122
|
+
}
|
|
123
|
+
if (target === 'all') return { kind: 'success', text: `Deleted ${String(deleted.length)} schedule(s).` }
|
|
124
|
+
if (deleted.length === 0) return { kind: 'error', text: `Schedule "${target}" was already gone.` }
|
|
125
|
+
return { kind: 'success', text: `Deleted "${deleted[0].title}".` }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function cronHandler(invocation, ctx) {
|
|
129
|
+
const input = invocation.rawInput.trim()
|
|
130
|
+
const verb = input.toLowerCase()
|
|
131
|
+
if (verb === '' || verb === 'help') return { kind: 'success', text: USAGE }
|
|
132
|
+
// Read per call: the service comes from an optional bundle, and /cron should
|
|
133
|
+
// say what is missing rather than vanish when it is not mounted.
|
|
134
|
+
const schedule = ctx.get('schedule')
|
|
135
|
+
if (schedule == null) {
|
|
136
|
+
return { kind: 'error', text: `/cron needs the harness schedule service: enable ${SCHEDULE_BUNDLE} (Plugins page, Official group) or list it in the profile's dsh.profile.bundles.` }
|
|
137
|
+
}
|
|
138
|
+
const sessionId = invocation.agent.session.id
|
|
139
|
+
if (verb === 'list') return list(schedule, sessionId)
|
|
140
|
+
const deleteMatch = /^delete\s+(\S+)$/iu.exec(input)
|
|
141
|
+
if (deleteMatch !== null) return remove(schedule, sessionId, deleteMatch[1].toLowerCase() === 'all' ? 'all' : deleteMatch[1])
|
|
142
|
+
return create(schedule, invocation, input)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function apply(ctx) {
|
|
146
|
+
ctx.effect(() => ctx.commands.register({
|
|
147
|
+
definitionId: 'dsh-cron:cron',
|
|
148
|
+
name: 'cron',
|
|
149
|
+
description: 'Run a prompt on a schedule: /cron <cron expression or phrase> <prompt>, /cron list, /cron delete <n|id|all>',
|
|
150
|
+
input: { hint: '<schedule> <prompt> | list | delete <n|id|all> | help' },
|
|
151
|
+
handler: (invocation) => cronHandler(invocation, ctx),
|
|
152
|
+
}))
|
|
153
|
+
}
|
package/lib/parse.js
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse the schedule at the start of a `/cron` argument into the harness
|
|
3
|
+
* schedule service's selectors, deterministically: a five-field cron
|
|
4
|
+
* expression (names and @macros translated to the service's numeric dialect)
|
|
5
|
+
* or a phrase from a small English grammar. What follows the schedule is the
|
|
6
|
+
* prompt. Text neither grammar covers is `unparsed`, and the caller hands it to
|
|
7
|
+
* the model; text that is clearly a schedule but cannot be one is `invalid`
|
|
8
|
+
* and is reported, never guessed at.
|
|
9
|
+
*
|
|
10
|
+
* @module dsh-cron/parse
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** The service's fixed-interval floor. */
|
|
14
|
+
const MIN_EVERY_SECONDS = 60
|
|
15
|
+
/** Time used when a recurring day phrase names no time ("every monday"). */
|
|
16
|
+
const DEFAULT_TIME = '09:00:00'
|
|
17
|
+
|
|
18
|
+
const UNIT_SECONDS = { s: 1, second: 1, sec: 1, m: 60, min: 60, minute: 60, h: 3600, hr: 3600, hour: 3600, d: 86400, day: 86400, w: 604800, week: 604800 }
|
|
19
|
+
|
|
20
|
+
const MONTH_NAMES = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec']
|
|
21
|
+
const DOW_NAMES = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat']
|
|
22
|
+
const ISO_DAY_LABELS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
|
|
23
|
+
|
|
24
|
+
const CRON_FIELDS = [
|
|
25
|
+
{ name: 'minute', min: 0, max: 59 },
|
|
26
|
+
{ name: 'hour', min: 0, max: 23 },
|
|
27
|
+
{ name: 'day-of-month', min: 1, max: 31 },
|
|
28
|
+
{ name: 'month', min: 1, max: 12, names: MONTH_NAMES, base: 1 },
|
|
29
|
+
{ name: 'day-of-week', min: 0, max: 7, names: DOW_NAMES, base: 0 },
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
const MACROS = {
|
|
33
|
+
'@yearly': '0 0 1 1 *',
|
|
34
|
+
'@annually': '0 0 1 1 *',
|
|
35
|
+
'@monthly': '0 0 1 * *',
|
|
36
|
+
'@weekly': '0 0 * * 0',
|
|
37
|
+
'@daily': '0 0 * * *',
|
|
38
|
+
'@midnight': '0 0 * * *',
|
|
39
|
+
'@hourly': '0 * * * *',
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Words that, right after a recognized schedule, mean the schedule goes on in a way this grammar does not read. */
|
|
43
|
+
const QUALIFIERS = /^(at|and|during|between|from|on|except|until|till|unless|starting|after|before|only|while|when|if|but)\b/u
|
|
44
|
+
|
|
45
|
+
/** One cron atom: `*`, a number, or an L/W/#/? form the dialect rejects but cron users write. */
|
|
46
|
+
const CRON_ATOM = /^(\*|\d+|L|\d+[LW]|\d+#\d+|\?)$/u
|
|
47
|
+
const CRON_NAMES = new Set([...MONTH_NAMES, ...DOW_NAMES])
|
|
48
|
+
|
|
49
|
+
/** Whether a token is shaped like a cron field (atoms or real month/day names), which decides whether input is meant as cron. */
|
|
50
|
+
function isCronToken(token) {
|
|
51
|
+
return token.split(/[,\-/]/u).every(atom => CRON_ATOM.test(atom) || CRON_NAMES.has(atom.toLowerCase()))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Weekday words: ISO number (Mon 1 .. Sun 7). */
|
|
55
|
+
const WEEKDAY_WORDS = [
|
|
56
|
+
[/^mon(day)?s?$/u, 1], [/^tue(s|sday)?s?$/u, 2], [/^wed(nesday)?s?$/u, 3], [/^thu(r|rs|rsday)?s?$/u, 4],
|
|
57
|
+
[/^fri(day)?s?$/u, 5], [/^sat(urday)?s?$/u, 6], [/^sun(day)?s?$/u, 7],
|
|
58
|
+
]
|
|
59
|
+
const WEEKDAY = '(?:mon(?:day)?s?|tue(?:s|sday)?s?|wed(?:nesday)?s?|thu(?:r|rs|rsday)?s?|fri(?:day)?s?|sat(?:urday)?s?|sun(?:day)?s?)'
|
|
60
|
+
const TIME = '(?:noon|midnight|\\d{1,2}(?::\\d{2})?\\s*(?:am|pm)?)'
|
|
61
|
+
const ZONE = '(?:\\s+(utc|[A-Za-z_]+(?:/[A-Za-z0-9_+-]+)+))?'
|
|
62
|
+
// A range before a list: "monday to friday" must not stop at "monday".
|
|
63
|
+
const DAY_SEPARATOR = '(?:,\\s*(?:and\\s+)?|and|&)'
|
|
64
|
+
const DAYSPEC = `(?:day|weekdays?|weekends?|${WEEKDAY}\\s*(?:-|to|through|thru)\\s*${WEEKDAY}|${WEEKDAY}(?:\\s*${DAY_SEPARATOR}\\s*${WEEKDAY})*)`
|
|
65
|
+
|
|
66
|
+
/** @returns {{ kind: 'invalid', text: string }} */
|
|
67
|
+
const invalid = (text) => ({ kind: 'invalid', text })
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Parse one `/cron` argument.
|
|
71
|
+
* @param {string} input - the text after `/cron`.
|
|
72
|
+
* @param {{ now: Date, timeZone: string }} context - the clock and the host's IANA zone.
|
|
73
|
+
* @returns {{ kind: 'schedule', selector: object, prompt: string, description: string }
|
|
74
|
+
* | { kind: 'invalid', text: string } | { kind: 'unparsed' }}
|
|
75
|
+
*/
|
|
76
|
+
export function parseSchedule(input, context) {
|
|
77
|
+
const text = input.trim()
|
|
78
|
+
const cron = parseCron(text, context)
|
|
79
|
+
if (cron !== undefined) return cron
|
|
80
|
+
return parseHuman(text, context)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// --- cron ---
|
|
84
|
+
|
|
85
|
+
function parseCron(text, context) {
|
|
86
|
+
const tokens = text.split(/\s+/u)
|
|
87
|
+
const macro = tokens[0]?.toLowerCase()
|
|
88
|
+
if (macro?.startsWith('@')) {
|
|
89
|
+
if (!(macro in MACROS)) return invalid(`${tokens[0]} is not a schedule this plugin can set (use a five-field cron expression).`)
|
|
90
|
+
return finishCron(MACROS[macro], text.slice(tokens[0].length), context)
|
|
91
|
+
}
|
|
92
|
+
if (tokens.length < 5 || tokens.slice(0, 5).filter(isCronToken).length < 4) return undefined
|
|
93
|
+
// Four cron-shaped fields still identify a cron with a malformed fifth field.
|
|
94
|
+
const fields = []
|
|
95
|
+
for (const [index, token] of tokens.slice(0, 5).entries()) {
|
|
96
|
+
const field = normalizeCronField(token, CRON_FIELDS[index])
|
|
97
|
+
if (typeof field !== 'string') return field
|
|
98
|
+
fields.push(field)
|
|
99
|
+
}
|
|
100
|
+
return finishCron(fields.join(' '), text.slice(/^\S+(?:\s+\S+){4}/u.exec(text)[0].length), context)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function finishCron(expression, rest, context) {
|
|
104
|
+
const { zone, prompt } = takeZone(rest, context)
|
|
105
|
+
if (zone.kind === 'invalid') return zone
|
|
106
|
+
return schedule({ cron: { expression, time_zone: zone.value } }, prompt, `cron ${expression} (${zone.value})`)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Translate names to numbers and check one field against the service's dialect. */
|
|
110
|
+
function normalizeCronField(token, field) {
|
|
111
|
+
const fail = () => invalid(`cron ${field.name} field "${token}" is not valid: use *, a value, a-b, */n or a-b/n, or a comma list of those, within ${field.min}-${field.max}.`)
|
|
112
|
+
const value = (raw) => {
|
|
113
|
+
const lower = raw.toLowerCase()
|
|
114
|
+
if (field.names !== undefined && field.names.includes(lower)) return field.names.indexOf(lower) + field.base
|
|
115
|
+
if (!/^\d+$/u.test(raw)) return undefined
|
|
116
|
+
const number = Number(raw)
|
|
117
|
+
return number >= field.min && number <= field.max ? number : undefined
|
|
118
|
+
}
|
|
119
|
+
const parts = []
|
|
120
|
+
for (const part of token.split(',')) {
|
|
121
|
+
const match = /^(\*|[a-z0-9]+(?:-[a-z0-9]+)?)(?:\/(\d+))?$/iu.exec(part)
|
|
122
|
+
if (match === null) return fail()
|
|
123
|
+
const [, range, step] = match
|
|
124
|
+
if (step !== undefined && (!Number.isSafeInteger(Number(step)) || Number(step) < 1 || (range !== '*' && !range.includes('-')))) return fail()
|
|
125
|
+
let out = range
|
|
126
|
+
if (range !== '*') {
|
|
127
|
+
const [from, to] = range.split('-').map(value)
|
|
128
|
+
if (from === undefined || (range.includes('-') && (to === undefined || to < from))) return fail()
|
|
129
|
+
out = range.includes('-') ? `${from}-${to}` : `${from}`
|
|
130
|
+
}
|
|
131
|
+
parts.push(step === undefined ? out : `${out}/${step}`)
|
|
132
|
+
}
|
|
133
|
+
return parts.join(',')
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// --- human grammar ---
|
|
137
|
+
|
|
138
|
+
function parseHuman(text, context) {
|
|
139
|
+
const lower = text.toLowerCase()
|
|
140
|
+
for (const rule of RULES) {
|
|
141
|
+
const match = rule.pattern.exec(lower)
|
|
142
|
+
if (match === null) continue
|
|
143
|
+
const rest = text.slice(match[0].length)
|
|
144
|
+
if (QUALIFIERS.test(rest.trim().toLowerCase())) return { kind: 'unparsed' }
|
|
145
|
+
// The grammar matched lowercased text; read the zone back in the user's casing.
|
|
146
|
+
const lowered = match.groups?.zone
|
|
147
|
+
const at = lowered === undefined ? -1 : lower.lastIndexOf(lowered, match[0].length - lowered.length)
|
|
148
|
+
const zone = resolveZone(at < 0 ? undefined : text.slice(at, at + lowered.length), context)
|
|
149
|
+
if (zone.kind === 'invalid') return zone
|
|
150
|
+
const built = rule.build(match.groups ?? {}, { ...context, timeZone: zone.value })
|
|
151
|
+
if (built.kind === 'invalid') return built
|
|
152
|
+
return schedule(built.selector, rest, built.description)
|
|
153
|
+
}
|
|
154
|
+
return { kind: 'unparsed' }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Anchored at the start; the match's end is where the prompt begins. Order matters: specific before general. */
|
|
158
|
+
const RULES = [
|
|
159
|
+
{
|
|
160
|
+
// in 10 minutes / in an hour
|
|
161
|
+
pattern: new RegExp(`^in\\s+(?<count>\\d+|an?)\\s+(?<unit>seconds?|secs?|minutes?|mins?|hours?|hrs?|days?|weeks?)\\b`, 'u'),
|
|
162
|
+
build: ({ count, unit }) => {
|
|
163
|
+
const seconds = amount(count) * unitSeconds(unit)
|
|
164
|
+
if (!Number.isSafeInteger(seconds) || seconds < 1) return invalid('a delay must be a positive safe integer number of seconds.')
|
|
165
|
+
return { selector: { after_seconds: seconds }, description: `once in ${plural(amount(count), unitName(unit))}` }
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
// hourly / hourly at :15 / every hour at :15 (a bare "every hour" is a fixed interval)
|
|
170
|
+
pattern: /^(?:hourly(?:\s+at\s+:?(?<minute>\d{1,2})(?:\s+past)?)?|(?:every|each)\s+hour\s+at\s+:?(?<atMinute>\d{1,2})(?:\s+past)?)(?=\s|$)/u,
|
|
171
|
+
build: ({ minute: hourlyMinute, atMinute }, context) => {
|
|
172
|
+
const minute = hourlyMinute ?? atMinute
|
|
173
|
+
const m = minute === undefined ? 0 : Number(minute)
|
|
174
|
+
if (m > 59) return invalid(`minute ${minute} is not a minute of the hour.`)
|
|
175
|
+
const expression = `${m} * * * *`
|
|
176
|
+
return { selector: { cron: { expression, time_zone: context.timeZone } }, description: `cron ${expression} (${context.timeZone})` }
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
// monthly on the 1st at 9am / every month on the 15th
|
|
181
|
+
pattern: new RegExp(`^(?:monthly|(?:every|each)\\s+month)(?:\\s+on\\s+the\\s+(?<dom>\\d{1,2})(?:st|nd|rd|th)?)?(?:\\s+at\\s+(?<time>${TIME}))?${ZONE.replace('(utc', '(?<zone>utc')}(?=\\s|$)`, 'u'),
|
|
182
|
+
build: ({ dom, time }, context) => {
|
|
183
|
+
const day = dom === undefined ? 1 : Number(dom)
|
|
184
|
+
if (day < 1 || day > 31) return invalid(`day ${dom} is not a day of the month.`)
|
|
185
|
+
const clock = parseTime(time ?? DEFAULT_TIME.slice(0, 5))
|
|
186
|
+
if (clock.kind === 'invalid') return clock
|
|
187
|
+
const expression = `${clock.minute} ${clock.hour} ${day} * *`
|
|
188
|
+
return { selector: { cron: { expression, time_zone: context.timeZone } }, description: `cron ${expression} (${context.timeZone})` }
|
|
189
|
+
},
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
// every day at 9 / every mon, wed and fri at 18:00 / weekdays at 8:45 / mondays at 9
|
|
193
|
+
pattern: new RegExp(`^(?:(?:every|each)\\s+(?<days>${DAYSPEC})|(?<bare>daily|${DAYSPEC.replace('(?:day|', '(?:')}))(?:\\s+at\\s+(?<time>${TIME}))?${ZONE.replace('(utc', '(?<zone>utc')}(?=\\s|$|[,:;])`, 'u'),
|
|
194
|
+
build: ({ days, bare, time }, context) => recurringDays(days ?? bare, time, context),
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
// at 7:30pm every day / at 9 on weekdays
|
|
198
|
+
pattern: new RegExp(`^at\\s+(?<time>${TIME})\\s+(?:every\\s+(?<days>${DAYSPEC})|on\\s+(?<on>${DAYSPEC})|(?<daily>daily))${ZONE.replace('(utc', '(?<zone>utc')}(?=\\s|$|[,:;])`, 'u'),
|
|
199
|
+
build: ({ time, days, on, daily }, context) => recurringDays(days ?? on ?? daily, time, context),
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
// every 5 minutes / every minute / each hour (fixed rate)
|
|
203
|
+
pattern: /^(?:every|each)\s+(?:(?<count>\d+)\s+)?(?<unit>seconds?|secs?|minutes?|mins?|hours?|hrs?|days?|weeks?)\b/u,
|
|
204
|
+
build: ({ count, unit }) => every(count === undefined ? 1 : Number(count), unit),
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
// 5m / 2h / 1d, as /loop takes them
|
|
208
|
+
pattern: /^(?<count>\d+)(?<unit>[smhdw])(?=\s|$)/u,
|
|
209
|
+
build: ({ count, unit }) => every(Number(count), unit),
|
|
210
|
+
},
|
|
211
|
+
{
|
|
212
|
+
// on 2026-12-24 at 18:00 / 2026-12-31 23:59 / tomorrow at 9 / today at 18:00 / at 3pm
|
|
213
|
+
pattern: new RegExp(`^(?:(?:on\\s+)?(?<date>\\d{4}-\\d{2}-\\d{2})(?:\\s+(?:at\\s+)?(?<dateTime>${TIME}))?|(?<day>today|tonight|tomorrow)(?:\\s+at\\s+(?<dayTime>${TIME}))|at\\s+(?<atTime>${TIME}))${ZONE.replace('(utc', '(?<zone>utc')}(?=\\s|$|[,:;])`, 'u'),
|
|
214
|
+
build: (groups, context) => oneShot(groups, context),
|
|
215
|
+
},
|
|
216
|
+
]
|
|
217
|
+
|
|
218
|
+
function every(count, unit) {
|
|
219
|
+
const seconds = count * unitSeconds(unit)
|
|
220
|
+
if (!Number.isSafeInteger(seconds)) return invalid('an interval must be a safe integer number of seconds.')
|
|
221
|
+
if (seconds < MIN_EVERY_SECONDS) return invalid(`a repeating schedule must be at least one minute apart (got ${plural(count, unitName(unit))}).`)
|
|
222
|
+
return { selector: { every_seconds: seconds }, description: `every ${count === 1 ? unitName(unit) : plural(count, unitName(unit))}` }
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function recurringDays(spec, time, context) {
|
|
226
|
+
const clock = time === undefined ? { hour: 9, minute: 0 } : parseTime(time)
|
|
227
|
+
if (clock.kind === 'invalid') return clock
|
|
228
|
+
const hhmmss = time === undefined ? DEFAULT_TIME : `${pad(clock.hour)}:${pad(clock.minute)}:00`
|
|
229
|
+
const days = weekdaysOf(spec)
|
|
230
|
+
if (days.length === 7) {
|
|
231
|
+
return { selector: { daily: { time: hhmmss, time_zone: context.timeZone } }, description: `daily at ${hhmmss.slice(0, 5)} (${context.timeZone})` }
|
|
232
|
+
}
|
|
233
|
+
return {
|
|
234
|
+
selector: { weekly: { time: hhmmss, time_zone: context.timeZone, weekdays: days } },
|
|
235
|
+
description: `weekly on ${days.map(day => ISO_DAY_LABELS[day - 1]).join(', ')} at ${hhmmss.slice(0, 5)} (${context.timeZone})`,
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** ISO weekday numbers a day phrase covers, ascending. */
|
|
240
|
+
function weekdaysOf(spec) {
|
|
241
|
+
const phrase = spec.trim()
|
|
242
|
+
if (phrase === 'day' || phrase === 'daily') return [1, 2, 3, 4, 5, 6, 7]
|
|
243
|
+
if (/^weekdays?$/u.test(phrase)) return [1, 2, 3, 4, 5]
|
|
244
|
+
if (/^weekends?$/u.test(phrase)) return [6, 7]
|
|
245
|
+
const range = new RegExp(`^(${WEEKDAY})\\s*(?:-|to|through|thru)\\s*(${WEEKDAY})$`, 'u').exec(phrase)
|
|
246
|
+
if (range !== null) {
|
|
247
|
+
const from = weekday(range[1])
|
|
248
|
+
const to = weekday(range[2])
|
|
249
|
+
const out = []
|
|
250
|
+
for (let day = from; ; day = day % 7 + 1) {
|
|
251
|
+
out.push(day)
|
|
252
|
+
if (day === to) break
|
|
253
|
+
}
|
|
254
|
+
return out.sort((a, b) => a - b)
|
|
255
|
+
}
|
|
256
|
+
return [...new Set(phrase.split(new RegExp(`\\s*${DAY_SEPARATOR}\\s*`, 'u')).map(weekday))].sort((a, b) => a - b)
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function weekday(word) {
|
|
260
|
+
return WEEKDAY_WORDS.find(([pattern]) => pattern.test(word))[1]
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function oneShot({ date, dateTime, day, dayTime, atTime }, context) {
|
|
264
|
+
const today = localParts(context.now, context.timeZone)
|
|
265
|
+
let target
|
|
266
|
+
let raw
|
|
267
|
+
if (date !== undefined) {
|
|
268
|
+
if (!validDate(date)) return invalid(`${date} is not a calendar date.`)
|
|
269
|
+
target = date
|
|
270
|
+
raw = dateTime ?? '00:00'
|
|
271
|
+
} else if (day !== undefined) {
|
|
272
|
+
target = day === 'tomorrow' ? addDays(today.date, 1) : today.date
|
|
273
|
+
raw = dayTime
|
|
274
|
+
} else {
|
|
275
|
+
raw = atTime
|
|
276
|
+
}
|
|
277
|
+
const clock = parseTime(raw)
|
|
278
|
+
if (clock.kind === 'invalid') return clock
|
|
279
|
+
const time = `${pad(clock.hour)}:${pad(clock.minute)}:00`
|
|
280
|
+
// A bare "at T" is the next T: today while it is still ahead, else tomorrow.
|
|
281
|
+
if (target === undefined) target = time > today.time ? today.date : addDays(today.date, 1)
|
|
282
|
+
if (target < today.date || (target === today.date && time <= today.time)) {
|
|
283
|
+
return invalid(`${target} ${time.slice(0, 5)} (${context.timeZone}) has already passed.`)
|
|
284
|
+
}
|
|
285
|
+
return {
|
|
286
|
+
selector: { at: { date: target, time, time_zone: context.timeZone } },
|
|
287
|
+
description: `once on ${target} at ${time.slice(0, 5)} (${context.timeZone})`,
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// --- shared pieces ---
|
|
292
|
+
|
|
293
|
+
function schedule(selector, rest, description) {
|
|
294
|
+
const prompt = rest.trim().replace(/^[,:;-]+\s*/u, '').replace(/^(to|then)\s+/iu, '').trim()
|
|
295
|
+
if (prompt === '') return invalid(`${description}: what to run? Put the prompt after the schedule, e.g. /cron every day at 9 check the deploy.`)
|
|
296
|
+
return { kind: 'schedule', selector, prompt, description }
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** A trailing zone token on cron input; the host zone otherwise. */
|
|
300
|
+
function takeZone(rest, context) {
|
|
301
|
+
const prompt = rest.trimStart()
|
|
302
|
+
const first = /^\S+/u.exec(prompt)?.[0]
|
|
303
|
+
if (first !== undefined && (/^utc$/iu.test(first) || /^[A-Za-z_]+(\/[A-Za-z0-9_+-]+)+$/u.test(first))) {
|
|
304
|
+
return { zone: resolveZone(first, context), prompt: prompt.slice(first.length) }
|
|
305
|
+
}
|
|
306
|
+
return { zone: { kind: 'zone', value: context.timeZone }, prompt }
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function resolveZone(raw, context) {
|
|
310
|
+
if (raw === undefined) return { kind: 'zone', value: context.timeZone }
|
|
311
|
+
if (/^utc$/iu.test(raw)) return { kind: 'zone', value: 'UTC' }
|
|
312
|
+
// The grammar lowercased the text; Intl canonicalizes the zone's case.
|
|
313
|
+
try {
|
|
314
|
+
return { kind: 'zone', value: new Intl.DateTimeFormat('en-US', { timeZone: raw }).resolvedOptions().timeZone }
|
|
315
|
+
} catch {
|
|
316
|
+
return invalid(`${raw} is not a time zone (use an IANA name like Europe/Berlin, or UTC).`)
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** 9, 9:30, 9am, 9:30pm, 21:00, noon, midnight. */
|
|
321
|
+
function parseTime(raw) {
|
|
322
|
+
const text = raw.trim().toLowerCase()
|
|
323
|
+
if (text === 'noon') return { hour: 12, minute: 0 }
|
|
324
|
+
if (text === 'midnight') return { hour: 0, minute: 0 }
|
|
325
|
+
const match = /^(\d{1,2})(?::(\d{2}))?\s*(am|pm)?$/u.exec(text)
|
|
326
|
+
const fail = invalid(`${raw} is not a time of day.`)
|
|
327
|
+
if (match === null) return fail
|
|
328
|
+
let hour = Number(match[1])
|
|
329
|
+
const minute = match[2] === undefined ? 0 : Number(match[2])
|
|
330
|
+
if (minute > 59) return fail
|
|
331
|
+
if (match[3] !== undefined) {
|
|
332
|
+
if (hour < 1 || hour > 12) return fail
|
|
333
|
+
hour = hour % 12 + (match[3] === 'pm' ? 12 : 0)
|
|
334
|
+
} else if (hour > 23) return fail
|
|
335
|
+
return { hour, minute }
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** The local calendar date and HH:MM:SS of `now` in `timeZone`. */
|
|
339
|
+
function localParts(now, timeZone) {
|
|
340
|
+
const parts = Object.fromEntries(new Intl.DateTimeFormat('en-CA', {
|
|
341
|
+
timeZone, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23',
|
|
342
|
+
}).formatToParts(now).map(part => [part.type, part.value]))
|
|
343
|
+
return { date: `${parts.year}-${parts.month}-${parts.day}`, time: `${parts.hour}:${parts.minute}:${parts.second}` }
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function addDays(date, days) {
|
|
347
|
+
const [y, m, d] = date.split('-').map(Number)
|
|
348
|
+
return new Date(Date.UTC(y, m - 1, d + days)).toISOString().slice(0, 10)
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function validDate(date) {
|
|
352
|
+
const [y, m, d] = date.split('-').map(Number)
|
|
353
|
+
return new Date(Date.UTC(y, m - 1, d)).toISOString().slice(0, 10) === date
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function amount(count) {
|
|
357
|
+
return /^an?$/u.test(count) ? 1 : Number(count)
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function unitSeconds(unit) {
|
|
361
|
+
return UNIT_SECONDS[unit.replace(/s$/u, '')] ?? UNIT_SECONDS[unit]
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function unitName(unit) {
|
|
365
|
+
const seconds = unitSeconds(unit)
|
|
366
|
+
return { 1: 'second', 60: 'minute', 3600: 'hour', 86400: 'day', 604800: 'week' }[seconds]
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function plural(count, word) {
|
|
370
|
+
return `${count} ${word}${count === 1 ? '' : 's'}`
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function pad(n) {
|
|
374
|
+
return String(n).padStart(2, '0')
|
|
375
|
+
}
|
package/locale/en.json
ADDED
package/locale/zh.json
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@maci0/dsh-cron",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"publishConfig": {
|
|
5
|
+
"access": "public",
|
|
6
|
+
"registry": "https://registry.npmjs.org/"
|
|
7
|
+
},
|
|
8
|
+
"type": "module",
|
|
9
|
+
"main": "index.js",
|
|
10
|
+
"description": "/cron for DeepSeek Harness: run a prompt on a cron or plain-English schedule, parsed in code, with the model as the last resort.",
|
|
11
|
+
"license": "MIT",
|
|
12
|
+
"exports": {
|
|
13
|
+
".": "./index.js",
|
|
14
|
+
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
15
|
+
"./locale/*.json": "./locale/*.json",
|
|
16
|
+
"./package.json": "./package.json"
|
|
17
|
+
},
|
|
18
|
+
"dsh": {
|
|
19
|
+
"bundle": {
|
|
20
|
+
"patch": "./cordis.patch.yml"
|
|
21
|
+
},
|
|
22
|
+
"compatibility": {
|
|
23
|
+
"dsh": ">=0.2.0-rc.2 <0.3.0"
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"icon.svg",
|
|
28
|
+
"locale/*.json",
|
|
29
|
+
"index.js",
|
|
30
|
+
"lib/parse.js",
|
|
31
|
+
"cordis.patch.yml",
|
|
32
|
+
"README.md",
|
|
33
|
+
"LICENSE"
|
|
34
|
+
],
|
|
35
|
+
"scripts": {
|
|
36
|
+
"test": "bun test",
|
|
37
|
+
"test:node": "node --test tests/*.test.*"
|
|
38
|
+
},
|
|
39
|
+
"engines": {
|
|
40
|
+
"node": "^22.19.0 || >=24.0.0"
|
|
41
|
+
},
|
|
42
|
+
"repository": {
|
|
43
|
+
"type": "git",
|
|
44
|
+
"url": "https://github.com/maci0/dsh-cron.git"
|
|
45
|
+
},
|
|
46
|
+
"icon": "./icon.svg",
|
|
47
|
+
"dependencies": {
|
|
48
|
+
"@deepseek-ai/dsh-llm": "0.2.1-alpha.1"
|
|
49
|
+
},
|
|
50
|
+
"devDependencies": {
|
|
51
|
+
"@deepseek-ai/cordis": "4.0.5-alpha.1"
|
|
52
|
+
}
|
|
53
|
+
}
|