@cliphijack/santaclaude 1.0.4 → 1.0.6

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 +137 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cliphijack/santaclaude",
3
- "version": "1.0.4",
3
+ "version": "1.0.6",
4
4
  "publishConfig": { "access": "public" },
5
5
  "description": "SantaClaude 커넥터 — 클라우드 예약을 내 로컬 Claude(tmux)에 발사",
6
6
  "bin": { "santaclaude": "./santaclaude.js" },
package/santaclaude.js CHANGED
@@ -95,6 +95,82 @@ async function post(api, p, body) {
95
95
  return r.json();
96
96
  }
97
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
+
98
174
  async function run(conf) {
99
175
  const { token, pane, api = DEFAULT_API } = conf;
100
176
  const every = Math.max(5, Number(conf.every) || 20) * 1000;
@@ -117,6 +193,8 @@ async function run(conf) {
117
193
  } else {
118
194
  console.warn(` ⚠️ tmux "${session}" 세션 없음 (--no-spawn) — 직접 띄워줘.`);
119
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) {}
120
198
 
121
199
  // 루돌프 = 작업장(session) 안의 윈도우(탭). 별도 세션 아님.
122
200
  async function runControl(cmd) {
@@ -183,6 +261,7 @@ async function run(conf) {
183
261
  catch (e) { console.warn(` ⚠️ 폴더 생성 실패(${cwd}): ${e.message} — 홈에서 띄움`); cwd = ''; }
184
262
  }
185
263
  spawnWindow(session, cmd.name, claudeCmd, cwd || undefined);
264
+ try { installDefaultSkills(cwd || os.homedir()); } catch (e) {} // 새 루돌프에도 기본 스킬
186
265
  console.log(` 🦌 새 루돌프 "${session}:${cmd.name}"${cwd ? ' @' + cwd + ' (폴더 준비됨)' : ''} 탭 추가 (tmux attach -t ${session} → Ctrl+b 창번호로 이동, 첫 로그인 1회)`);
187
266
  } else if (cmd.action === 'kill') {
188
267
  execFileSync('tmux', ['kill-window', '-t', session + ':' + cmd.name]);
@@ -198,6 +277,11 @@ async function run(conf) {
198
277
  const w = (cmd.name && windowExists(session, cmd.name)) ? (session + ':' + cmd.name) : pane;
199
278
  execFileSync('tmux', ['send-keys', '-t', w, String(cmd.key || '')]);
200
279
  console.log(` ⌨️ 키 → ${w}: ${cmd.key}`);
280
+ } else if (cmd.action === 'resize') {
281
+ // 웹 뷰어 폭에 맞춰 tmux 윈도우 리사이즈 — claude TUI가 그 폭으로 reflow(모바일 가로스크롤 해소)
282
+ const w = (cmd.name && windowExists(session, cmd.name)) ? (session + ':' + cmd.name) : pane;
283
+ const cols = Math.max(20, Math.min(400, parseInt(cmd.cols) || 0)), rows = Math.max(10, Math.min(200, parseInt(cmd.rows) || 0));
284
+ if (cols && rows) { try { execFileSync('tmux', ['resize-window', '-t', w, '-x', String(cols), '-y', String(rows)]); console.log(` 📐 리사이즈 ${w} → ${cols}x${rows}`); } catch (e) {} }
201
285
  }
202
286
  } catch (e) { console.warn(` ⚠️ 제어 실패(${cmd.action} ${cmd.name}): ${e.message}`); }
203
287
  }
@@ -238,6 +322,58 @@ async function run(conf) {
238
322
  } catch (e) { console.error('[scan err]', e.message); }
239
323
  }
240
324
 
325
+ // 세션 안에서 건 예약 수거 — santa-schedule 스킬이 ~/.santaclaude/schedule/*.json 쓰면 클라우드에 등록
326
+ const SCHEDDIR = path.join(os.homedir(), '.santaclaude', 'schedule');
327
+ async function scanSchedule() {
328
+ try {
329
+ if (!fs.existsSync(SCHEDDIR)) return;
330
+ for (const fn of fs.readdirSync(SCHEDDIR)) {
331
+ if (!fn.endsWith('.json')) continue;
332
+ const fp = path.join(SCHEDDIR, fn);
333
+ let req; try { req = JSON.parse(fs.readFileSync(fp, 'utf8')); } catch (e) { try { fs.renameSync(fp, fp + '.bad'); } catch (e) {} continue; }
334
+ if (!req || !req.message) { try { fs.renameSync(fp, fp + '.bad'); } catch (e) {} continue; }
335
+ try {
336
+ const j = await post(api, '/api/schedule', Object.assign({}, req, { token }));
337
+ if (j.ok) {
338
+ console.log(` 🎄 세션 예약 등록 — ${req.kind || 'once'} · ${String(req.message).slice(0, 40)}`);
339
+ try { fs.unlinkSync(fp); } catch (e) {}
340
+ execFileSync('tmux', ['send-keys', '-t', pane, '-l', '🎄 예약 걸렸어 — ' + String(req.message).slice(0, 40)]);
341
+ setTimeout(() => { try { execFileSync('tmux', ['send-keys', '-t', pane, 'Enter']); } catch (e) {} }, 300);
342
+ } else { console.warn(` 예약 실패: ${j.error}`); try { fs.renameSync(fp, fp + '.err'); } catch (e) {} }
343
+ } catch (e) { console.error('[sched fetch err]', e.message); }
344
+ }
345
+ } catch (e) { console.error('[sched err]', e.message); }
346
+ }
347
+
348
+ // 루돌프가 사용자에게 보내는 파일 — santa-show 스킬이 ~/.santaclaude/outbox/ 에 복사하면 릴레이
349
+ const OUTBOXDIR = path.join(os.homedir(), '.santaclaude', 'outbox');
350
+ const MAXOUT = 18 * 1024 * 1024; // 18MB (KV 릴레이 한계 — 초과는 R2 예정)
351
+ 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) {} }
352
+ async function scanOutbox() {
353
+ try {
354
+ if (!fs.existsSync(OUTBOXDIR)) return;
355
+ for (const fn of fs.readdirSync(OUTBOXDIR)) {
356
+ if (fn.startsWith('.') || /\.(sent|toobig|err)$/.test(fn)) continue;
357
+ const fp = path.join(OUTBOXDIR, fn);
358
+ let st; try { st = fs.statSync(fp); } catch (e) { continue; }
359
+ if (!st.isFile()) continue;
360
+ 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; }
361
+ const ext = (fn.split('.').pop() || '').toLowerCase();
362
+ const mime = EXT_MIME[ext] || 'application/octet-stream';
363
+ let data; try { data = fs.readFileSync(fp).toString('base64'); } catch (e) { continue; }
364
+ try {
365
+ const j = await post(api, '/api/upload', { token, data, mime, name: fn });
366
+ if (j && j.id) {
367
+ await post(api, '/api/outfile', { token, id: j.id, name: fn, mime, size: st.size }).catch(() => {});
368
+ console.log(` 📤 "${fn}" (${(st.size / 1024).toFixed(0)}KB) → 사용자 화면으로 보냄`);
369
+ try { fs.unlinkSync(fp); } catch (e) {}
370
+ tmuxNote('📤 보냈어 — ' + fn);
371
+ } else { console.warn(` 파일 보내기 실패(${fn}): ${j && j.error}`); try { fs.renameSync(fp, fp + '.err'); } catch (e) {} }
372
+ } catch (e) { console.error('[outbox fetch err]', e.message); }
373
+ }
374
+ } catch (e) { console.error('[outbox err]', e.message); }
375
+ }
376
+
241
377
  // 예약 발사(job) → 해당 탭에 주입
242
378
  function onJob(j) {
243
379
  if (!paneExists(session)) { if (!warned) { console.warn(` ⚠️ 작업장 "${session}" 없음 — 보류`); warned = true; } return; }
@@ -271,7 +407,7 @@ async function run(conf) {
271
407
  for (const j of (d.jobs || [])) onJob(j);
272
408
  } catch (e) {}
273
409
  }
274
- const scanIv = setInterval(scanPublish, Math.max(every, 10000)); // 로컬 클린등록 폴더 감지(주기)
410
+ const scanIv = setInterval(() => { scanPublish(); scanSchedule(); scanOutbox(); }, Math.max(every, 10000)); // 클린등록 + 세션예약 + 보낼파일 폴더 감지(주기)
275
411
  if (typeof WebSocket !== 'undefined') {
276
412
  connectWS();
277
413
  process.on('SIGINT', () => { clearInterval(hbIv); clearInterval(scrIv); clearInterval(scanIv); try { ws && ws.close(); } catch (e) {} console.log('\n🛷 커넥터 종료. 너의 루돌프들은 자러 간다.'); process.exit(0); });