@mrsekut/ccron 0.2.1 → 0.3.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/README.md +27 -1
- package/package.json +1 -1
- package/skills/ccron/SKILL.md +18 -0
- package/src/cli.ts +1 -0
- package/src/commands/add.ts +21 -0
- package/src/commands/edit.ts +21 -0
- package/src/commands/show.ts +1 -0
- package/src/commands/test.ts +26 -4
- package/src/config.ts +6 -0
- package/src/generator.ts +6 -1
package/README.md
CHANGED
|
@@ -30,7 +30,8 @@ ccron add \
|
|
|
30
30
|
--name daily-summary \
|
|
31
31
|
--schedule "15 17 * * 1-5" \
|
|
32
32
|
--prompt "日次サマリーを作成して #daily-summary チャンネルに投稿して" \
|
|
33
|
-
--mcp-config ~/mcp.json
|
|
33
|
+
--mcp-config ~/mcp.json \
|
|
34
|
+
--cwd ~/src/myproject
|
|
34
35
|
|
|
35
36
|
# Verify setup
|
|
36
37
|
ccron test daily-summary
|
|
@@ -66,6 +67,31 @@ Standard cron expression: `"minute hour * * day-of-week"`
|
|
|
66
67
|
|
|
67
68
|
Step values (`*/5`) and minute/hour ranges are not supported (launchd limitation).
|
|
68
69
|
|
|
70
|
+
## Working Directory
|
|
71
|
+
|
|
72
|
+
Tasks run in `/tmp` by default. Pass `--cwd` to run in a project directory instead:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
ccron add --name my-task --schedule "0 9 * * *" --prompt "hello" --cwd ~/src/myproject
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
This matters for more than file access. `claude` picks up its project context from the
|
|
79
|
+
directory it starts in, so with `--cwd` the task can also use that project's
|
|
80
|
+
`CLAUDE.md`, `.claude/skills/` and `.mcp.json`.
|
|
81
|
+
|
|
82
|
+
That makes it possible to keep the prompt itself in the project as a skill, and have the
|
|
83
|
+
task be a one-liner that invokes it:
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
ccron add --name member-watch --schedule "0 17 * * 1-5" \
|
|
87
|
+
--prompt "/member-watch を実行して" \
|
|
88
|
+
--cwd ~/src/myproject
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
The prompt then lives under version control, and manual runs and scheduled runs share the
|
|
92
|
+
same definition. Without `--cwd`, a task cannot read files outside `/tmp` — it will fail
|
|
93
|
+
with a permission error rather than a missing-file error.
|
|
94
|
+
|
|
69
95
|
## MCP
|
|
70
96
|
|
|
71
97
|
Pass your own MCP config JSON file via `--mcp-config`:
|
package/package.json
CHANGED
package/skills/ccron/SKILL.md
CHANGED
|
@@ -43,8 +43,26 @@ When a user asks to run something on a schedule:
|
|
|
43
43
|
--schedule "<cron>" Cron expression: "minute hour * * day-of-week"
|
|
44
44
|
--prompt "<text>" Prompt string
|
|
45
45
|
--mcp-config <path> Path to MCP config JSON file
|
|
46
|
+
--cwd <path> Directory to run claude in (default: /tmp)
|
|
46
47
|
```
|
|
47
48
|
|
|
49
|
+
## Working Directory
|
|
50
|
+
|
|
51
|
+
**If the task touches files outside `/tmp`, pass `--cwd`.** Without it the run fails with a
|
|
52
|
+
permission error, not a missing-file error — an absolute path in the prompt does not help.
|
|
53
|
+
|
|
54
|
+
`claude` also reads its project context from the directory it starts in, so `--cwd` makes
|
|
55
|
+
that project's `CLAUDE.md`, `.claude/skills/` and `.mcp.json` available to the task.
|
|
56
|
+
|
|
57
|
+
Prefer putting the prompt in the project as a skill and keeping the task a one-liner:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
ccron add --name member-watch --schedule "0 17 * * 1-5" \
|
|
61
|
+
--prompt "/member-watch を実行して" --cwd ~/src/myproject
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
The prompt stays under version control, and manual and scheduled runs share one definition.
|
|
65
|
+
|
|
48
66
|
## Schedule (cron)
|
|
49
67
|
|
|
50
68
|
Format: `"minute hour * * day-of-week"` (5 fields)
|
package/src/cli.ts
CHANGED
|
@@ -41,6 +41,7 @@ File locations:
|
|
|
41
41
|
Examples:
|
|
42
42
|
ccron add --name daily-summary --schedule "15 17 * * 1-5" --prompt "日次サマリーを作成して"
|
|
43
43
|
ccron add --name weekly-review --schedule "0 22 * * 5" --prompt "週次レビュー" --mcp-config ~/mcp.json
|
|
44
|
+
ccron add --name member-watch --schedule "0 17 * * 1-5" --prompt "/member-watch を実行して" --cwd ~/src/myproject
|
|
44
45
|
ccron list
|
|
45
46
|
ccron show daily-summary
|
|
46
47
|
ccron test daily-summary
|
package/src/commands/add.ts
CHANGED
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
type TaskConfig,
|
|
3
3
|
type GlobalConfig,
|
|
4
4
|
validateTaskName,
|
|
5
|
+
validateCwd,
|
|
5
6
|
readTaskConfig,
|
|
6
7
|
writeTaskConfig,
|
|
7
8
|
readGlobalConfig,
|
|
@@ -22,6 +23,7 @@ type AddOptions = {
|
|
|
22
23
|
schedule: string;
|
|
23
24
|
prompt: string | null;
|
|
24
25
|
mcpConfig: string | null;
|
|
26
|
+
cwd: string | null;
|
|
25
27
|
};
|
|
26
28
|
|
|
27
29
|
export async function addCommand(args: string[]): Promise<void> {
|
|
@@ -42,6 +44,7 @@ export async function addCommand(args: string[]): Promise<void> {
|
|
|
42
44
|
schedule: opts.schedule,
|
|
43
45
|
prompt: opts.prompt,
|
|
44
46
|
mcpConfig: opts.mcpConfig,
|
|
47
|
+
cwd: opts.cwd,
|
|
45
48
|
createdAt: now,
|
|
46
49
|
updatedAt: now,
|
|
47
50
|
};
|
|
@@ -84,6 +87,7 @@ function parseAddArgs(args: string[]): AddOptions {
|
|
|
84
87
|
schedule: { type: 'string' },
|
|
85
88
|
prompt: { type: 'string' },
|
|
86
89
|
'mcp-config': { type: 'string' },
|
|
90
|
+
cwd: { type: 'string' },
|
|
87
91
|
help: { type: 'boolean', short: 'h' },
|
|
88
92
|
},
|
|
89
93
|
strict: true,
|
|
@@ -99,6 +103,7 @@ function parseAddArgs(args: string[]): AddOptions {
|
|
|
99
103
|
schedule: values.schedule ?? '',
|
|
100
104
|
prompt: values.prompt ?? null,
|
|
101
105
|
mcpConfig: values['mcp-config'] ?? null,
|
|
106
|
+
cwd: values.cwd ?? null,
|
|
102
107
|
};
|
|
103
108
|
}
|
|
104
109
|
|
|
@@ -131,6 +136,18 @@ async function validateOptions(opts: AddOptions): Promise<void> {
|
|
|
131
136
|
errors.push('--prompt is required');
|
|
132
137
|
}
|
|
133
138
|
|
|
139
|
+
// Working directory
|
|
140
|
+
if (opts.cwd) {
|
|
141
|
+
const cwdError = validateCwd(opts.cwd);
|
|
142
|
+
if (cwdError) errors.push(cwdError);
|
|
143
|
+
else {
|
|
144
|
+
const { existsSync, statSync } = await import('fs');
|
|
145
|
+
if (!existsSync(opts.cwd) || !statSync(opts.cwd).isDirectory()) {
|
|
146
|
+
errors.push(`Working directory not found: ${opts.cwd}`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
134
151
|
// MCP config file
|
|
135
152
|
if (opts.mcpConfig) {
|
|
136
153
|
const file = Bun.file(opts.mcpConfig);
|
|
@@ -191,6 +208,10 @@ Required:
|
|
|
191
208
|
|
|
192
209
|
Optional:
|
|
193
210
|
--mcp-config <path> Path to MCP config JSON file
|
|
211
|
+
--cwd <path> Directory to run claude in (default: /tmp)
|
|
212
|
+
Set this to a project directory so the task can read
|
|
213
|
+
its files and pick up its CLAUDE.md, .claude/skills
|
|
214
|
+
and .mcp.json
|
|
194
215
|
|
|
195
216
|
Schedule format (cron):
|
|
196
217
|
"minute hour * * day-of-week"
|
package/src/commands/edit.ts
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
readGlobalConfig,
|
|
5
5
|
scriptPath,
|
|
6
6
|
plistPath,
|
|
7
|
+
validateCwd,
|
|
7
8
|
} from '../config';
|
|
8
9
|
import { parseSchedule } from '../schedule';
|
|
9
10
|
import {
|
|
@@ -20,6 +21,7 @@ export async function editCommand(args: string[]): Promise<void> {
|
|
|
20
21
|
schedule: { type: 'string' },
|
|
21
22
|
prompt: { type: 'string' },
|
|
22
23
|
'mcp-config': { type: 'string' },
|
|
24
|
+
cwd: { type: 'string' },
|
|
23
25
|
help: { type: 'boolean', short: 'h' },
|
|
24
26
|
},
|
|
25
27
|
allowPositionals: true,
|
|
@@ -66,6 +68,24 @@ export async function editCommand(args: string[]): Promise<void> {
|
|
|
66
68
|
changed = true;
|
|
67
69
|
}
|
|
68
70
|
|
|
71
|
+
if (values.cwd !== undefined) {
|
|
72
|
+
const cwd = values.cwd;
|
|
73
|
+
if (cwd) {
|
|
74
|
+
const cwdError = validateCwd(cwd);
|
|
75
|
+
if (cwdError) {
|
|
76
|
+
console.error(`Error: ${cwdError}`);
|
|
77
|
+
process.exit(1);
|
|
78
|
+
}
|
|
79
|
+
const { existsSync, statSync } = await import('fs');
|
|
80
|
+
if (!existsSync(cwd) || !statSync(cwd).isDirectory()) {
|
|
81
|
+
console.error(`Working directory not found: ${cwd}`);
|
|
82
|
+
process.exit(1);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
task.cwd = cwd || null;
|
|
86
|
+
changed = true;
|
|
87
|
+
}
|
|
88
|
+
|
|
69
89
|
if (!changed) {
|
|
70
90
|
console.log('No changes specified. Use --help to see available options.');
|
|
71
91
|
return;
|
|
@@ -110,6 +130,7 @@ Options:
|
|
|
110
130
|
--schedule <cron> Update cron expression
|
|
111
131
|
--prompt <text> Update prompt
|
|
112
132
|
--mcp-config <path> Update MCP config file path (pass "" to remove)
|
|
133
|
+
--cwd <path> Update the directory claude runs in (pass "" to reset to /tmp)
|
|
113
134
|
|
|
114
135
|
Examples:
|
|
115
136
|
ccron edit daily-summary --schedule "0 18 * * 1-5"
|
package/src/commands/show.ts
CHANGED
|
@@ -50,6 +50,7 @@ Displays task configuration, launchd status, and file locations.`);
|
|
|
50
50
|
`Schedule: ${task.schedule}`,
|
|
51
51
|
`Prompt: ${promptDisplay}`,
|
|
52
52
|
`MCP config: ${task.mcpConfig ?? '(none)'}`,
|
|
53
|
+
`Working dir: ${task.cwd ?? '/tmp (default)'}`,
|
|
53
54
|
`Status: ${statusStr}`,
|
|
54
55
|
`Last exit: ${exitStr}`,
|
|
55
56
|
`Created: ${formatDate(task.createdAt)}`,
|
package/src/commands/test.ts
CHANGED
|
@@ -25,7 +25,7 @@ Checks:
|
|
|
25
25
|
2. claude auth status (logged in)
|
|
26
26
|
3. ulimit -n (file descriptors)
|
|
27
27
|
4. Script file exists
|
|
28
|
-
5. Script path is outside TCC-protected directories
|
|
28
|
+
5. Script path (and cwd, if set) is outside TCC-protected directories
|
|
29
29
|
6. Plist registered in launchd
|
|
30
30
|
7. MCP config exists (if task uses MCP)
|
|
31
31
|
8. MCP auth (if task uses MCP) — runs claude CLI to verify OAuth tokens
|
|
@@ -58,6 +58,7 @@ Example:
|
|
|
58
58
|
checks.push(await checkUlimit());
|
|
59
59
|
checks.push(await checkScriptExists(name));
|
|
60
60
|
checks.push(checkScriptTccSafe(name));
|
|
61
|
+
if (task.cwd) checks.push(checkCwdTccSafe(task.cwd));
|
|
61
62
|
checks.push(await checkPlistRegistered(name));
|
|
62
63
|
|
|
63
64
|
if (task.mcpConfig) {
|
|
@@ -175,11 +176,32 @@ async function checkScriptExists(name: string): Promise<CheckResult> {
|
|
|
175
176
|
};
|
|
176
177
|
}
|
|
177
178
|
|
|
179
|
+
const TCC_DIRS = ['Desktop', 'Documents', 'Downloads'];
|
|
180
|
+
|
|
181
|
+
function tccProtects(path: string): boolean {
|
|
182
|
+
const home = homedir();
|
|
183
|
+
return TCC_DIRS.some(dir => path.startsWith(`${home}/${dir}`));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function checkCwdTccSafe(cwd: string): CheckResult {
|
|
187
|
+
const inTcc = tccProtects(cwd);
|
|
188
|
+
return {
|
|
189
|
+
label: 'TCC protection (cwd)',
|
|
190
|
+
ok: !inTcc,
|
|
191
|
+
detail: inTcc
|
|
192
|
+
? `cwd is in a TCC-protected directory (${cwd})`
|
|
193
|
+
: `cwd is outside TCC-protected directories`,
|
|
194
|
+
// Granting Full Disk Access to /bin/bash is not enough: the claude process
|
|
195
|
+
// gets its own TCC identity and still fails with EPERM on these paths.
|
|
196
|
+
fix: inTcc
|
|
197
|
+
? 'Move the project outside ~/Desktop, ~/Documents, ~/Downloads, or grant Full Disk Access to the claude binary'
|
|
198
|
+
: undefined,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
178
202
|
function checkScriptTccSafe(name: string): CheckResult {
|
|
179
203
|
const path = scriptPath(name);
|
|
180
|
-
const
|
|
181
|
-
const tccDirs = [`${home}/Desktop`, `${home}/Documents`, `${home}/Downloads`];
|
|
182
|
-
const inTcc = tccDirs.some(dir => path.startsWith(dir));
|
|
204
|
+
const inTcc = tccProtects(path);
|
|
183
205
|
return {
|
|
184
206
|
label: 'TCC protection',
|
|
185
207
|
ok: !inTcc,
|
package/src/config.ts
CHANGED
|
@@ -8,6 +8,7 @@ export type TaskConfig = {
|
|
|
8
8
|
schedule: string;
|
|
9
9
|
prompt: string | null;
|
|
10
10
|
mcpConfig: string | null;
|
|
11
|
+
cwd: string | null;
|
|
11
12
|
createdAt: string;
|
|
12
13
|
updatedAt: string;
|
|
13
14
|
};
|
|
@@ -123,6 +124,11 @@ function defaultGlobalConfig(): GlobalConfig {
|
|
|
123
124
|
|
|
124
125
|
const TASK_NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
125
126
|
|
|
127
|
+
export function validateCwd(cwd: string): string | null {
|
|
128
|
+
if (!cwd.startsWith('/')) return 'Working directory must be an absolute path';
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
|
|
126
132
|
export function validateTaskName(name: string): string | null {
|
|
127
133
|
if (!name) return 'Task name is required';
|
|
128
134
|
if (!TASK_NAME_RE.test(name))
|
package/src/generator.ts
CHANGED
|
@@ -31,7 +31,7 @@ export function generateScriptContent(
|
|
|
31
31
|
'',
|
|
32
32
|
`ulimit -n ${global.ulimit} 2>/dev/null || true`,
|
|
33
33
|
'',
|
|
34
|
-
|
|
34
|
+
`cd ${task.cwd ?? '/tmp'}`,
|
|
35
35
|
'',
|
|
36
36
|
];
|
|
37
37
|
|
|
@@ -59,6 +59,11 @@ export function generateScriptContent(
|
|
|
59
59
|
|
|
60
60
|
/**
|
|
61
61
|
* Generate the launchd plist XML content.
|
|
62
|
+
*
|
|
63
|
+
* WorkingDirectory stays /tmp even when the task sets `cwd`. launchd performs the
|
|
64
|
+
* chdir itself, and that chdir is denied for TCC-protected locations (~/Desktop,
|
|
65
|
+
* ~/Documents, ...), which fails the job before the script runs. The script's own
|
|
66
|
+
* `cd` handles `cwd` instead.
|
|
62
67
|
*/
|
|
63
68
|
export function generatePlistContent(
|
|
64
69
|
task: TaskConfig,
|