@cliphijack/santaclaude 1.0.3 → 1.0.5

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/santaclaude.js +142 -4
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cliphijack/santaclaude",
3
- "version": "1.0.3",
3
+ "version": "1.0.5",
4
4
  "publishConfig": { "access": "public" },
5
5
  "description": "SantaClaude 커넥터 — 클라우드 예약을 내 로컬 Claude(tmux)에 발사",
6
6
  "bin": { "santaclaude": "./santaclaude.js" },
package/santaclaude.js CHANGED
@@ -31,9 +31,16 @@ function saveConf(c) { fs.writeFileSync(CONF, JSON.stringify(c, null, 2)); }
31
31
  function paneExists(pane) { try { execFileSync('tmux', ['has-session', '-t', pane.split(':')[0]], { stdio: 'ignore' }); return true; } catch { return false; } }
32
32
 
33
33
  function inject(pane, message) {
34
- const oneLine = String(message).replace(/[\r\n]+/g, ' ');
35
- execFileSync('tmux', ['send-keys', '-t', pane, '-l', oneLine]);
36
- setTimeout(() => { try { execFileSync('tmux', ['send-keys', '-t', pane, 'Enter']); } catch (e) {} }, 350);
34
+ const msg = String(message);
35
+ if (/[\r\n]/.test(msg)) {
36
+ // 멀티라인 tmux paste-buffer로 줄바꿈 보존 주입 (claude code가 bracketed paste로 입력 인식)
37
+ execFileSync('tmux', ['set-buffer', '-b', 'sc-inj', msg]);
38
+ execFileSync('tmux', ['paste-buffer', '-b', 'sc-inj', '-p', '-d', '-t', pane]); // -p bracketed, -d 버퍼삭제
39
+ setTimeout(() => { try { execFileSync('tmux', ['send-keys', '-t', pane, 'Enter']); } catch (e) {} }, 400);
40
+ } else {
41
+ execFileSync('tmux', ['send-keys', '-t', pane, '-l', msg]);
42
+ setTimeout(() => { try { execFileSync('tmux', ['send-keys', '-t', pane, 'Enter']); } catch (e) {} }, 350);
43
+ }
37
44
  }
38
45
  // tmux 세션 새로 만들고 그 안에 claude 자동 실행 (cwd 지정 시 그 폴더에서 = 프로젝트별 분리)
39
46
  function spawnClaude(session, cmd, cwd) {
@@ -88,6 +95,82 @@ async function post(api, p, body) {
88
95
  return r.json();
89
96
  }
90
97
 
98
+ // ── 기본 스킬(번들) — npx 가동 시 각 루돌프 프로젝트에 자동 설치 ──
99
+ const DEFAULT_SKILLS = {
100
+ 'santa-schedule': {
101
+ 'SKILL.md': `---
102
+ name: santa-schedule
103
+ description: 산타클로드 예약 — 이 세션에서 말로 반복·예약 작업을 걸 때. "매일 9시에 ~해줘", "30분마다 ~", "내일 3시에 ~" 같은 미래 실행 요청에 사용.
104
+ ---
105
+
106
+ # 산타클로드 예약 (채찍 걸기)
107
+
108
+ 사용자가 "매일/매주/N분마다/특정 시각에 ~해줘" 처럼 **미래에 반복·예약 실행**을 요청하면, 아래 절차로 예약을 등록한다. 직접 그 시각까지 기다리거나 sleep 하지 말 것 — 산타클로드 커넥터가 시간이 되면 이 세션에 명령을 자동 주입한다.
109
+
110
+ ## 등록 방법
111
+ \`~/.santaclaude/schedule/\` 폴더에 JSON 파일 하나를 쓴다(파일명 자유, 확장자 \`.json\`). 커넥터가 감지해 클라우드에 등록하고 파일을 지운다.
112
+
113
+ ### JSON 형식
114
+ \`\`\`json
115
+ {
116
+ "message": "시킬 명령 (예: 깃헙 새 이슈 정리해서 요약)",
117
+ "kind": "once | interval | daily | weekly",
118
+ "fire_at": "2026-06-20T15:00", // kind=once 일 때만 (한국 시각)
119
+ "interval_min": 30, // kind=interval 일 때만 (1 이상 정수)
120
+ "at_time": "09:00", // kind=daily/weekly 일 때만 (HH:MM 24시간)
121
+ "weekday": 1, // kind=weekly 일 때만 (0=일 1=월 ... 6=토)
122
+ "max_runs": 10 // 선택 — 이 횟수만 실행하고 종료
123
+ }
124
+ \`\`\`
125
+ kind에 맞는 필드만 넣는다. \`message\`는 필수.
126
+
127
+ ## 규칙
128
+ - \`interval_min\`은 **1 이상 정수만** (음수·소수 금지).
129
+ - 모든 시각은 **한국 시각(KST)** 기준.
130
+ - 등록 후 사용자에게 "언제 / 무엇" 한 줄로 확인해준다.
131
+ - 예약 **취소·조회**는 산타클로드 워크샵(웹)에서 한다고 안내. 이 스킬은 등록 전용.
132
+ - 한 번 등록하면 끝. 그 시각까지 세션에서 대기·sleep 금지.
133
+ `
134
+ },
135
+ 'santa-show': {
136
+ 'SKILL.md': `---
137
+ name: santa-show
138
+ description: 산타클로드 파일 보내기 — 만든·가진 파일(이미지·PDF·txt·csv·음원·영상 등)을 사용자(웹·모바일)에게 보여주거나 전달할 때.
139
+ ---
140
+
141
+ # 산타클로드 파일 보내기
142
+
143
+ 사용자가 "보여줘 / 받고 싶어 / 다운로드 / 들려줘 / 파일 줘" 하거나, 네가 만든 결과물(차트·문서·스샷·음원·영상 등)을 사용자에게 전달해야 하면:
144
+
145
+ → 그 파일을 \`~/.santaclaude/outbox/\` 폴더에 **복사**한다. (\`mkdir -p ~/.santaclaude/outbox\` 후 \`cp\`)
146
+
147
+ 커넥터가 감지해 사용자 화면(/term)에 띄운다 — **이미지·오디오·영상은 인라인 재생**, 그 외(PDF·txt·zip 등)는 **다운로드 칩**으로. 전송 뒤 로컬·서버 양쪽에서 자동 삭제(안 쌓임).
148
+
149
+ ## 규칙
150
+ - **파일명 그대로 유지**(확장자 포함) — 사용자가 그 이름으로 받는다.
151
+ - **18MB 이하**만 지금 가능. 초과 파일은 못 보내고 알림이 뜬다 → 큰 영상은 잘라 보내거나 외부 링크로.
152
+ - **원본 그대로 복사** — 재인코딩·리사이즈·압축 금지.
153
+ - 옮기지 말고 **복사**(cp). 원본 작업물은 그대로 둔다.
154
+ - 보낸 뒤 사용자에게 "보냈어 — <파일명>" 한 줄 확인.
155
+ `
156
+ }
157
+ };
158
+ // 확장자 → MIME (보낼 파일 타입 판별)
159
+ const EXT_MIME = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml', bmp: 'image/bmp', pdf: 'application/pdf', txt: 'text/plain', md: 'text/markdown', csv: 'text/csv', json: 'application/json', log: 'text/plain', html: 'text/html', xml: 'text/xml', mp3: 'audio/mpeg', wav: 'audio/wav', ogg: 'audio/ogg', m4a: 'audio/mp4', flac: 'audio/flac', mp4: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime', mkv: 'video/x-matroska', zip: 'application/zip', gz: 'application/gzip', tar: 'application/x-tar' };
160
+ function installDefaultSkills(cwd) {
161
+ const base = cwd && fs.existsSync(cwd) ? cwd : os.homedir();
162
+ for (const [name, files] of Object.entries(DEFAULT_SKILLS)) {
163
+ for (const [fn, content] of Object.entries(files)) {
164
+ try {
165
+ const fp = path.join(base, '.claude', 'skills', name, fn);
166
+ if (fs.existsSync(fp)) continue; // 이미 있으면 덮지 않음(고객 수정 보존)
167
+ fs.mkdirSync(path.dirname(fp), { recursive: true });
168
+ fs.writeFileSync(fp, content);
169
+ } catch (e) {}
170
+ }
171
+ }
172
+ }
173
+
91
174
  async function run(conf) {
92
175
  const { token, pane, api = DEFAULT_API } = conf;
93
176
  const every = Math.max(5, Number(conf.every) || 20) * 1000;
@@ -110,6 +193,8 @@ async function run(conf) {
110
193
  } else {
111
194
  console.warn(` ⚠️ tmux "${session}" 세션 없음 (--no-spawn) — 직접 띄워줘.`);
112
195
  }
196
+ // 기본 스킬 자동 설치 (메인 루돌프 작업 폴더에)
197
+ try { let c = os.homedir(); try { const pc = execFileSync('tmux', ['display-message', '-t', pane, '-p', '#{pane_current_path}'], { encoding: 'utf8' }).trim(); if (pc) c = pc; } catch (e) {} installDefaultSkills(c); } catch (e) {}
113
198
 
114
199
  // 루돌프 = 작업장(session) 안의 윈도우(탭). 별도 세션 아님.
115
200
  async function runControl(cmd) {
@@ -176,6 +261,7 @@ async function run(conf) {
176
261
  catch (e) { console.warn(` ⚠️ 폴더 생성 실패(${cwd}): ${e.message} — 홈에서 띄움`); cwd = ''; }
177
262
  }
178
263
  spawnWindow(session, cmd.name, claudeCmd, cwd || undefined);
264
+ try { installDefaultSkills(cwd || os.homedir()); } catch (e) {} // 새 루돌프에도 기본 스킬
179
265
  console.log(` 🦌 새 루돌프 "${session}:${cmd.name}"${cwd ? ' @' + cwd + ' (폴더 준비됨)' : ''} 탭 추가 (tmux attach -t ${session} → Ctrl+b 창번호로 이동, 첫 로그인 1회)`);
180
266
  } else if (cmd.action === 'kill') {
181
267
  execFileSync('tmux', ['kill-window', '-t', session + ':' + cmd.name]);
@@ -231,6 +317,58 @@ async function run(conf) {
231
317
  } catch (e) { console.error('[scan err]', e.message); }
232
318
  }
233
319
 
320
+ // 세션 안에서 건 예약 수거 — santa-schedule 스킬이 ~/.santaclaude/schedule/*.json 쓰면 클라우드에 등록
321
+ const SCHEDDIR = path.join(os.homedir(), '.santaclaude', 'schedule');
322
+ async function scanSchedule() {
323
+ try {
324
+ if (!fs.existsSync(SCHEDDIR)) return;
325
+ for (const fn of fs.readdirSync(SCHEDDIR)) {
326
+ if (!fn.endsWith('.json')) continue;
327
+ const fp = path.join(SCHEDDIR, fn);
328
+ let req; try { req = JSON.parse(fs.readFileSync(fp, 'utf8')); } catch (e) { try { fs.renameSync(fp, fp + '.bad'); } catch (e) {} continue; }
329
+ if (!req || !req.message) { try { fs.renameSync(fp, fp + '.bad'); } catch (e) {} continue; }
330
+ try {
331
+ const j = await post(api, '/api/schedule', Object.assign({}, req, { token }));
332
+ if (j.ok) {
333
+ console.log(` 🎄 세션 예약 등록 — ${req.kind || 'once'} · ${String(req.message).slice(0, 40)}`);
334
+ try { fs.unlinkSync(fp); } catch (e) {}
335
+ execFileSync('tmux', ['send-keys', '-t', pane, '-l', '🎄 예약 걸렸어 — ' + String(req.message).slice(0, 40)]);
336
+ setTimeout(() => { try { execFileSync('tmux', ['send-keys', '-t', pane, 'Enter']); } catch (e) {} }, 300);
337
+ } else { console.warn(` 예약 실패: ${j.error}`); try { fs.renameSync(fp, fp + '.err'); } catch (e) {} }
338
+ } catch (e) { console.error('[sched fetch err]', e.message); }
339
+ }
340
+ } catch (e) { console.error('[sched err]', e.message); }
341
+ }
342
+
343
+ // 루돌프가 사용자에게 보내는 파일 — santa-show 스킬이 ~/.santaclaude/outbox/ 에 복사하면 릴레이
344
+ const OUTBOXDIR = path.join(os.homedir(), '.santaclaude', 'outbox');
345
+ const MAXOUT = 18 * 1024 * 1024; // 18MB (KV 릴레이 한계 — 초과는 R2 예정)
346
+ function tmuxNote(msg) { try { execFileSync('tmux', ['send-keys', '-t', pane, '-l', msg]); setTimeout(() => { try { execFileSync('tmux', ['send-keys', '-t', pane, 'Enter']); } catch (e) {} }, 300); } catch (e) {} }
347
+ async function scanOutbox() {
348
+ try {
349
+ if (!fs.existsSync(OUTBOXDIR)) return;
350
+ for (const fn of fs.readdirSync(OUTBOXDIR)) {
351
+ if (fn.startsWith('.') || /\.(sent|toobig|err)$/.test(fn)) continue;
352
+ const fp = path.join(OUTBOXDIR, fn);
353
+ let st; try { st = fs.statSync(fp); } catch (e) { continue; }
354
+ if (!st.isFile()) continue;
355
+ if (st.size > MAXOUT) { console.warn(` ⚠️ "${fn}" ${(st.size / 1048576).toFixed(1)}MB > 18MB — 못 보냄`); try { fs.renameSync(fp, fp + '.toobig'); } catch (e) {} tmuxNote('⚠️ ' + fn + ' 는 18MB 초과라 아직 못 보내 (곧 큰 파일도 지원). 잘라 보내거나 링크로.'); continue; }
356
+ const ext = (fn.split('.').pop() || '').toLowerCase();
357
+ const mime = EXT_MIME[ext] || 'application/octet-stream';
358
+ let data; try { data = fs.readFileSync(fp).toString('base64'); } catch (e) { continue; }
359
+ try {
360
+ const j = await post(api, '/api/upload', { token, data, mime, name: fn });
361
+ if (j && j.id) {
362
+ await post(api, '/api/outfile', { token, id: j.id, name: fn, mime, size: st.size }).catch(() => {});
363
+ console.log(` 📤 "${fn}" (${(st.size / 1024).toFixed(0)}KB) → 사용자 화면으로 보냄`);
364
+ try { fs.unlinkSync(fp); } catch (e) {}
365
+ tmuxNote('📤 보냈어 — ' + fn);
366
+ } else { console.warn(` 파일 보내기 실패(${fn}): ${j && j.error}`); try { fs.renameSync(fp, fp + '.err'); } catch (e) {} }
367
+ } catch (e) { console.error('[outbox fetch err]', e.message); }
368
+ }
369
+ } catch (e) { console.error('[outbox err]', e.message); }
370
+ }
371
+
234
372
  // 예약 발사(job) → 해당 탭에 주입
235
373
  function onJob(j) {
236
374
  if (!paneExists(session)) { if (!warned) { console.warn(` ⚠️ 작업장 "${session}" 없음 — 보류`); warned = true; } return; }
@@ -264,7 +402,7 @@ async function run(conf) {
264
402
  for (const j of (d.jobs || [])) onJob(j);
265
403
  } catch (e) {}
266
404
  }
267
- const scanIv = setInterval(scanPublish, Math.max(every, 10000)); // 로컬 클린등록 폴더 감지(주기)
405
+ const scanIv = setInterval(() => { scanPublish(); scanSchedule(); scanOutbox(); }, Math.max(every, 10000)); // 클린등록 + 세션예약 + 보낼파일 폴더 감지(주기)
268
406
  if (typeof WebSocket !== 'undefined') {
269
407
  connectWS();
270
408
  process.on('SIGINT', () => { clearInterval(hbIv); clearInterval(scrIv); clearInterval(scanIv); try { ws && ws.close(); } catch (e) {} console.log('\n🛷 커넥터 종료. 너의 루돌프들은 자러 간다.'); process.exit(0); });