@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrsekut/ccron",
3
- "version": "0.2.1",
3
+ "version": "0.3.1",
4
4
  "description": "Schedule claude -p execution on macOS with launchd",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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
@@ -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"
@@ -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"
@@ -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)}`,
@@ -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 home = homedir();
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
- 'cd /tmp',
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,