@cliphijack/santaclaude 1.0.4 → 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.
- package/package.json +1 -1
- package/santaclaude.js +132 -1
package/package.json
CHANGED
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]);
|
|
@@ -238,6 +317,58 @@ async function run(conf) {
|
|
|
238
317
|
} catch (e) { console.error('[scan err]', e.message); }
|
|
239
318
|
}
|
|
240
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
|
+
|
|
241
372
|
// 예약 발사(job) → 해당 탭에 주입
|
|
242
373
|
function onJob(j) {
|
|
243
374
|
if (!paneExists(session)) { if (!warned) { console.warn(` ⚠️ 작업장 "${session}" 없음 — 보류`); warned = true; } return; }
|
|
@@ -271,7 +402,7 @@ async function run(conf) {
|
|
|
271
402
|
for (const j of (d.jobs || [])) onJob(j);
|
|
272
403
|
} catch (e) {}
|
|
273
404
|
}
|
|
274
|
-
const scanIv = setInterval(scanPublish, Math.max(every, 10000)); //
|
|
405
|
+
const scanIv = setInterval(() => { scanPublish(); scanSchedule(); scanOutbox(); }, Math.max(every, 10000)); // 클린등록 + 세션예약 + 보낼파일 폴더 감지(주기)
|
|
275
406
|
if (typeof WebSocket !== 'undefined') {
|
|
276
407
|
connectWS();
|
|
277
408
|
process.on('SIGINT', () => { clearInterval(hbIv); clearInterval(scrIv); clearInterval(scanIv); try { ws && ws.close(); } catch (e) {} console.log('\n🛷 커넥터 종료. 너의 루돌프들은 자러 간다.'); process.exit(0); });
|