Skill: mupengi-bot/mufi-calendar (version 1.0.0)

--- name: mufi-calendar description: Google Calendar + 네이버 캘린더 통합 관리. 한국어 자연어 일정 파싱, 리마인더 지원. metadata: {"clawdbot":{"emoji":"📆","requires":{"bins":["node","python3"]},"install":[{"id":"npm","kind":"npm","packages":["@google-cloud/calendar"],"label":"Install Google Calendar Node.js library"}]}} --- # MUFI Calendar Google Calendar + 네이버 캘린더 통합 관리 스킬. 한국 SMB 고객용. ## 기능 - ✅ Google Calendar 연동 (조회/추가/수정/삭제) - ✅ 한국어 자연어 파싱 ("내일 3시 미팅" → 이벤트 생성) - ✅ 일정 리마인더 (cron 연동) - 🚧 네이버 캘린더 연동 (브라우저 자동화 or iCal 구독 방식) ## 설정 ### 1. Google Calendar API 설정 Google Cloud Console에서 OAuth 2.0 클라이언트 ID 생성: 1. https://console.cloud.google.com/apis/credentials 2. "OAuth 2.0 클라이언트 ID" 생성 → "데스크톱 앱" 3. JSON 다운로드 → `~/.secrets/google-calendar-credentials.json` 저장 ### 2. 초기 인증 ```bash node /Users/mupeng/.openclaw/workspace/skills/mufi-calendar/scripts/auth.js ``` 브라우저가 열리고 Google 계정 인증 → `~/.secrets/google-calendar-token.json` 생성 ### 3. 환경 변수 (선택) ```bash export GOOGLE_CALENDAR_ID="primary" # 기본 캘린더 ID (primary = 주 캘린더) ``` ## 사용법 ### 일정 조회 ```bash # 오늘 일정 node scripts/list.js # 내일 일정 node scripts/list.js --date tomorrow # 특정 날짜 node scripts/list.js --date 2026-02-20 # 다음 7일 node scripts/list.js --days 7 # JSON 출력 node scripts/list.js --json ``` ### 일정 추가 (자연어) ```bash # 한국어 자연어 파싱 node scripts/add.js "내일 오후 3시 미팅" node scripts/add.js "2026-02-20 10:00 개발팀 회의" node scripts/add.js "다음주 월요일 2시 치과" # 명시적 파라미터 node scripts/add.js \ --title "개발팀 회의" \ --start "2026-02-20T10:00:00+09:00" \ --end "2026-02-20T11:00:00+09:00" \ --location "본사 회의실 A" \ --description "Q1 스프린트 회고" ``` ### 일정 수정 ```bash # 이벤트 ID로 수정 node scripts/update.js EVENT_ID \ --title "새 제목" \ --start "2026-02-20T15:00:00+09:00" ``` ### 일정 삭제 ```bash node scripts/delete.js EVENT_ID ``` ### 리마인더 설정 ```bash # cron 등록 (매일 9시 오늘 일정 알림) crontab -e # 추가: 0 9 * * * node /Users/mupeng/.openclaw/workspace/skills/mufi-calendar/scripts/remind.js ``` ## 한국어 자연어 파싱 규칙 `scripts/parse-korean.js`가 다음 패턴을 인식: - **날짜**: 오늘, 내일, 모레, 다음주 월요일, 2월 20일, 2026-02-20 - **시간**: 오전 9시, 오후 3시, 15:00, 3시 30분 - **기간**: 1시간, 30분 (종료 시간 자동 계산) 예시: ``` "내일 오후 3시 미팅" → 2026-02-18 15:00 ~ 16:00 (기본 1시간) "2월 20일 10시 개발팀 회의 2시간" → 2026-02-20 10:00 ~ 12:00 "다음주 월요일 2시 치과" → 2026-02-24 14:00 ~ 15:00 ``` ## 네이버 캘린더 연동 (실험적) 네이버 캘린더는 공식 API가 없어 두 가지 방식 지원: ### 방식 1: iCal 구독 (읽기 전용) 1. 네이버 캘린더 → 설정 → iCal 주소 복사 2. `~/.secrets/naver-calendar-ical-url.txt`에 저장 3. `node scripts/sync-naver.js` 실행 → Google Calendar로 복사 ### 방식 2: 브라우저 자동화 (읽기/쓰기) OpenClaw 브라우저로 네이버 캘린더 조작: ```bash # 일정 추가 node scripts/naver-add.js "2026-02-20 10:00 회의" # 일정 조회 (snapshot + OCR) node scripts/naver-list.js ``` **제약**: 로그인 상태 유지 필요, UI 변경 시 스크립트 수정 필요 ## 출력 형식 ### 텍스트 (기본) ``` 📅 2026-02-20 (목) 10:00-11:00 개발팀 회의 15:00-16:00 미팅 📅 2026-02-21 (금) 종일 공휴일 ``` ### JSON ```json { "events": [ { "id": "abc123", "summary": "개발팀 회의", "start": "2026-02-20T10:00:00+09:00", "end": "2026-02-20T11:00:00+09:00", "location": "본사 회의실 A", "description": "Q1 스프린트 회고" } ] } ``` ## Discord 알림 리마인더를 Discord로 전송: ```bash # 오늘 일정을 형님 DM 채널로 node scripts/remind.js --channel 1468204132920725535 ``` `openclaw` CLI의 `message` 스킬과 연동. ## 트러블슈팅 | 문제 | 해결 | |------|------| | 401 Unauthorized | `node scripts/auth.js` 재인증 | | 토큰 만료 | `rm ~/.secrets/google-calendar-token.json` → 재인증 | | 한국어 파싱 실패 | `--start`, `--end` 명시적 지정 | | 네이버 로그인 필요 | OpenClaw 브라우저에서 naver.com 로그인 후 재시도 | ## 참고 - Google Calendar API: https://developers.google.com/calendar/api/v3/reference - 자연어 파싱: `chrono-node` 라이브러리 + 한국어 패턴 추가 - 네이버 캘린더 API 미제공 → 우회 방식만 가능

path: README.md
content: # mufi-calendar

Google Calendar + 네이버 캘린더 통합 관리 스킬. 한국 SMB 고객용.

## 기능

- ✅ Google Calendar API 연동 (조회/추가/수정/삭제)
- ✅ 한국어 자연어 파싱 ("내일 3시 미팅" → 이벤트 생성)
- ✅ 일정 리마인더 (cron 연동)
- 🚧 네이버 캘린더 연동 (브라우저 자동화 or iCal 구독 방식)

## 설치

```bash
cd /Users/mupeng/.openclaw/workspace/skills/mufi-calendar
npm install
```

## 초기 설정

### 1. Google Cloud Console에서 OAuth 2.0 클라이언트 ID 생성

1. https://console.cloud.google.com/apis/credentials 접속
2. "사용자 인증 정보 만들기" → "OAuth 2.0 클라이언트 ID"
3. 애플리케이션 유형: "데스크톱 앱"
4. JSON 다운로드 → `~/.secrets/google-calendar-credentials.json`에 저장

### 2. Google Calendar API 활성화

1. https://console.cloud.google.com/apis/library/calendar-json.googleapis.com
2. "사용" 버튼 클릭

### 3. 초기 인증

```bash
npm run auth
```

브라우저가 열리고 Google 계정 인증 → `~/.secrets/google-calendar-token.json` 생성

## 사용법

상세한 사용법은 `SKILL.md` 참고.

### 일정 조회

```bash
npm run list
npm run list -- --date tomorrow
npm run list -- --days 7 --json
```

### 일정 추가

```bash
# 한국어 자연어
npm run add -- "내일 오후 3시 미팅"
npm run add -- "2026-02-20 10:00 개발팀 회의"

# 명시적 파라미터
npm run add -- --title "회의" --start "2026-02-20T10:00:00+09:00" --end "2026-02-20T11:00:00+09:00"
```

### 일정 수정

```bash
npm run update -- EVENT_ID --title "새 제목"
```

### 일정 삭제

```bash
npm run delete -- EVENT_ID
```

### 리마인더 (cron용)

```bash
npm run remind
npm run remind -- --channel 1468204132920725535  # Discord 전송
```

## 한국어 자연어 파싱

`scripts/lib/parse-korean.js`가 다음 패턴 인식:

- **날짜**: 오늘, 내일, 모레, 다음주 월요일, 2월 20일, 2026-02-20
- **시간**: 오전 9시, 오후 3시, 15:00, 3시 30분
- **기간**: 1시간, 30분 (종료 시간 자동 계산)

예시:
```
"내일 오후 3시 미팅" → 2026-02-18 15:00 ~ 16:00
"2월 20일 10시 개발팀 회의 2시간" → 2026-02-20 10:00 ~ 12:00
```

## 아키텍처

```
skills/mufi-calendar/
├── SKILL.md              # OpenClaw 스킬 정의
├── README.md             # 개발자 문서
├── package.json          # Node.js 의존성
├── scripts/
│   ├── auth.js           # OAuth 인증
│   ├── list.js           # 일정 조회
│   ├── add.js            # 일정 추가
│   ├── update.js         # 일정 수정
│   ├── delete.js         # 일정 삭제
│   ├── remind.js         # 리마인더 (cron용)
│   └── lib/
│       ├── gcal.js       # Google Calendar 클라이언트
│       ├── parse-korean.js  # 한국어 자연어 파싱
│       └── date-utils.js    # 날짜 유틸리티
```

## 네이버 캘린더 연동 (TODO)

네이버 캘린더는 공식 API가 없어 두 가지 우회 방식 검토 중:

1. **iCal 구독** (읽기 전용): 네이버 캘린더 → 설정 → iCal 주소 복사 → Google Calendar로 동기화
2. **브라우저 자동화** (읽기/쓰기): OpenClaw browser tool로 네이버 캘린더 조작

## 트러블슈팅

| 문제 | 해결 |
|------|------|
| `Error: ENOENT: no such file or directory` | `~/.secrets/` 디렉토리 생성 + credentials.json 저장 |
| `401 Unauthorized` | `npm run auth` 재인증 |
| 한국어 파싱 실패 | `--start`, `--end` 명시적 지정 |

## 라이선스

MIT

## 작성자

MUFI (형님의 요청으로 제작)


path: package.json
content: {
  "name": "mufi-calendar",
  "version": "1.0.0",
  "description": "Google Calendar + 네이버 캘린더 통합 관리 스킬",
  "main": "scripts/list.js",
  "scripts": {
    "auth": "node scripts/auth.js",
    "list": "node scripts/list.js",
    "add": "node scripts/add.js",
    "update": "node scripts/update.js",
    "delete": "node scripts/delete.js",
    "remind": "node scripts/remind.js"
  },
  "dependencies": {
    "googleapis": "^144.0.0",
    "chrono-node": "^2.7.9",
    "yargs": "^17.7.2",
    "opener": "^1.5.2"
  },
  "keywords": [
    "calendar",
    "google-calendar",
    "korean",
    "nlp",
    "openclaw"
  ],
  "author": "MUFI",
  "license": "MIT"
}


path: scripts/add.js
content: #!/usr/bin/env node
/**
 * Google Calendar 일정 추가
 * 사용: node add.js "내일 3시 미팅"
 *       node add.js --title "회의" --start "2026-02-20T10:00:00+09:00" --end "2026-02-20T11:00:00+09:00"
 */

const { getCalendar } = require('./lib/gcal');
const { parseKoreanEvent } = require('./lib/parse-korean');
const yargs = require('yargs/yargs');
const { hideBin } = require('yargs/helpers');

const argv = yargs(hideBin(process.argv))
  .option('title', {
    type: 'string',
    description: '일정 제목',
  })
  .option('start', {
    type: 'string',
    description: '시작 시간 (ISO 8601)',
  })
  .option('end', {
    type: 'string',
    description: '종료 시간 (ISO 8601)',
  })
  .option('location', {
    type: 'string',
    description: '장소',
  })
  .option('description', {
    type: 'string',
    description: '설명',
  })
  .option('calendar', {
    type: 'string',
    description: '캘린더 ID',
    default: process.env.GOOGLE_CALENDAR_ID || 'primary',
  })
  .help()
  .argv;

async function addEvent() {
  let event;

  if (argv.title && argv.start) {
    // 명시적 파라미터
    event = {
      summary: argv.title,
      start: { dateTime: argv.start, timeZone: 'Asia/Seoul' },
      end: { dateTime: argv.end || argv.start, timeZone: 'Asia/Seoul' },
      location: argv.location,
      description: argv.description,
    };
  } else if (argv._[0]) {
    // 한국어 자연어 파싱
    const text = argv._.join(' ');
    event = parseKoreanEvent(text);
    if (!event) {
      console.error('❌ 파싱 실패. 형식: "내일 3시 미팅" 또는 --title, --start 사용');
      process.exit(1);
    }
  } else {
    console.error('❌ 일정 정보 필요. --help 참고');
    process.exit(1);
  }

  const calendar = await getCalendar();
  const res = await calendar.events.insert({
    calendarId: argv.calendar,
    requestBody: event,
  });

  console.log(`✅ 일정 추가 완료: ${res.data.summary}`);
  console.log(`   시작: ${res.data.start.dateTime || res.data.start.date}`);
  console.log(`   종료: ${res.data.end.dateTime || res.data.end.date}`);
  console.log(`   ID: ${res.data.id}`);
  console.log(`   링크: ${res.data.htmlLink}`);
}

addEvent().catch(console.error);


path: scripts/auth.js
content: #!/usr/bin/env node
/**
 * Google Calendar OAuth 인증
 * 실행: node auth.js
 * 결과: ~/.secrets/google-calendar-token.json 생성
 */

const fs = require('fs').promises;
const path = require('path');
const { google } = require('googleapis');
const http = require('http');
const { parse } = require('url');
const opener = require('opener');
const os = require('os');

const CREDENTIALS_PATH = path.join(os.homedir(), '.secrets', 'google-calendar-credentials.json');
const TOKEN_PATH = path.join(os.homedir(), '.secrets', 'google-calendar-token.json');
const SCOPES = ['https://www.googleapis.com/auth/calendar'];
const REDIRECT_URI = 'http://localhost:3000/oauth2callback';

async function authenticate() {
  // credentials.json 읽기
  const credentialsContent = await fs.readFile(CREDENTIALS_PATH, 'utf8');
  const credentials = JSON.parse(credentialsContent);
  const { client_id, client_secret } = credentials.installed || credentials.web;

  const oauth2Client = new google.auth.OAuth2(client_id, client_secret, REDIRECT_URI);

  // 로컬 서버로 OAuth 콜백 받기
  const authUrl = oauth2Client.generateAuthUrl({
    access_type: 'offline',
    scope: SCOPES,
  });

  console.log('브라우저에서 인증을 진행합니다...');
  console.log(authUrl);
  opener(authUrl);

  const code = await new Promise((resolve, reject) => {
    const server = http.createServer(async (req, res) => {
      try {
        if (req.url.indexOf('/oauth2callback') > -1) {
          const qs = parse(req.url, true).query;
          res.end('인증 완료! 이 창을 닫고 터미널로 돌아가세요.');
          server.close();
          resolve(qs.code);
        }
      } catch (e) {
        reject(e);
      }
    });
    server.listen(3000, () => {
      console.log('로컬 서버 시작: http://localhost:3000');
    });
  });

  // 토큰 교환
  const { tokens } = await oauth2Client.getToken(code);
  oauth2Client.setCredentials(tokens);

  // 토큰 저장
  await fs.mkdir(path.dirname(TOKEN_PATH), { recursive: true });
  await fs.writeFile(TOKEN_PATH, JSON.stringify(tokens, null, 2));
  console.log(`✅ 토큰 저장 완료: ${TOKEN_PATH}`);
}

authenticate().catch(console.error);


path: scripts/delete.js
content: #!/usr/bin/env node
/**
 * Google Calendar 일정 삭제
 * 사용: node delete.js EVENT_ID
 */

const { getCalendar } = require('./lib/gcal');
const yargs = require('yargs/yargs');
const { hideBin } = require('yargs/helpers');

const argv = yargs(hideBin(process.argv))
  .demandCommand(1, '이벤트 ID 필요')
  .option('calendar', {
    type: 'string',
    description: '캘린더 ID',
    default: process.env.GOOGLE_CALENDAR_ID || 'primary',
  })
  .help()
  .argv;

async function deleteEvent() {
  const eventId = argv._[0];
  const calendar = await getCalendar();

  // 삭제 전 확인
  const event = await calendar.events.get({
    calendarId: argv.calendar,
    eventId,
  });

  console.log(`삭제 대상: ${event.data.summary}`);
  console.log(`시작: ${event.data.start.dateTime || event.data.start.date}`);

  await calendar.events.delete({
    calendarId: argv.calendar,
    eventId,
  });

  console.log('✅ 일정 삭제 완료');
}

deleteEvent().catch(console.error);


path: scripts/lib/date-utils.js
content: /**
 * 날짜 유틸리티
 */

function parseDate(input) {
  const now = new Date();
  now.setHours(0, 0, 0, 0);

  if (input === 'today') {
    return now;
  }

  if (input === 'tomorrow') {
    const tomorrow = new Date(now);
    tomorrow.setDate(tomorrow.getDate() + 1);
    return tomorrow;
  }

  // ISO 8601 형식 (YYYY-MM-DD)
  if (/^\d{4}-\d{2}-\d{2}$/.test(input)) {
    return new Date(input + 'T00:00:00+09:00');
  }

  // 기타 형식은 Date 생성자에 위임
  const parsed = new Date(input);
  if (isNaN(parsed.getTime())) {
    throw new Error(`Invalid date: ${input}`);
  }
  return parsed;
}

function formatDate(date) {
  return date.toISOString().split('T')[0];
}

function formatDateTime(date) {
  return date.toLocaleString('ko-KR', {
    year: 'numeric',
    month: '2-digit',
    day: '2-digit',
    hour: '2-digit',
    minute: '2-digit',
    hour12: false,
  });
}

module.exports = { parseDate, formatDate, formatDateTime };


path: scripts/lib/gcal.js
content: /**
 * Google Calendar API 클라이언트
 */

const fs = require('fs').promises;
const path = require('path');
const { google } = require('googleapis');
const os = require('os');

const CREDENTIALS_PATH = path.join(os.homedir(), '.secrets', 'google-calendar-credentials.json');
const TOKEN_PATH = path.join(os.homedir(), '.secrets', 'google-calendar-token.json');

async function getCalendar() {
  // credentials 읽기
  const credentialsContent = await fs.readFile(CREDENTIALS_PATH, 'utf8');
  const credentials = JSON.parse(credentialsContent);
  const { client_id, client_secret, redirect_uris } = credentials.installed || credentials.web;

  const oauth2Client = new google.auth.OAuth2(
    client_id,
    client_secret,
    redirect_uris ? redirect_uris[0] : 'http://localhost:3000/oauth2callback'
  );

  // 토큰 읽기
  try {
    const tokenContent = await fs.readFile(TOKEN_PATH, 'utf8');
    const [REDACTED_SECRET]);
    oauth2Client.setCredentials(token);
  } catch (err) {
    console.error('❌ 토큰이 없습니다. 먼저 인증하세요: node scripts/auth.js');
    process.exit(1);
  }

  // 토큰 갱신 콜백
  oauth2Client.on('tokens', async (tokens) => {
    if (tokens.refresh_token) {
      const currentTokens = JSON.parse(await fs.readFile(TOKEN_PATH, 'utf8'));
      currentTokens.refresh_token = tokens.refresh_token;
      await fs.writeFile(TOKEN_PATH, JSON.stringify(currentTokens, null, 2));
    }
  });

  return google.calendar({ version: 'v3', auth: oauth2Client });
}

module.exports = { getCalendar };


path: scripts/lib/parse-korean.js
content: /**
 * 한국어 자연어 일정 파싱
 * 예: "내일 오후 3시 미팅" → { summary, start, end }
 */

const chrono = require('chrono-node');

// 한국어 시간 표현 정규식
const TIME_PATTERNS = [
  { regex: /오전\s*(\d{1,2})시(\s*(\d{1,2})분)?/, ampm: 'AM' },
  { regex: /오후\s*(\d{1,2})시(\s*(\d{1,2})분)?/, ampm: 'PM' },
  { regex: /(\d{1,2})시(\s*(\d{1,2})분)?/, ampm: null },
];

const DATE_PATTERNS = [
  { regex: /오늘/, days: 0 },
  { regex: /내일/, days: 1 },
  { regex: /모레/, days: 2 },
  { regex: /다음주\s*(월|화|수|목|금|토|일)요일/, type: 'nextWeekday' },
];

const DURATION_PATTERN = /(\d+)\s*(시간|분)/;

function parseKoreanEvent(text) {
  // chrono-node로 기본 날짜/시간 파싱
  const parsed = chrono.parse(text, new Date(), { forwardDate: true });
  
  let startDate = null;
  let endDate = null;
  let summary = text;

  if (parsed.length > 0) {
    const result = parsed[0];
    startDate = result.start.date();
    if (result.end) {
      endDate = result.end.date();
    }
  }

  // 한국어 날짜 패턴 체크
  for (const pattern of DATE_PATTERNS) {
    if (pattern.regex.test(text)) {
      const now = new Date();
      if (pattern.days !== undefined) {
        startDate = new Date(now);
        startDate.setDate(now.getDate() + pattern.days);
      } else if (pattern.type === 'nextWeekday') {
        const match = text.match(pattern.regex);
        const dayMap = { '월': 1, '화': 2, '수': 3, '목': 4, '금': 5, '토': 6, '일': 0 };
        const targetDay = dayMap[match[1]];
        startDate = new Date(now);
        const daysUntil = (targetDay + 7 - now.getDay()) % 7 || 7;
        startDate.setDate(now.getDate() + daysUntil);
      }
      break;
    }
  }

  // 한국어 시간 패턴 체크
  for (const pattern of TIME_PATTERNS) {
    const match = text.match(pattern.regex);
    if (match) {
      if (!startDate) startDate = new Date();
      let hour = parseInt(match[1]);
      const minute = match[3] ? parseInt(match[3]) : 0;

      if (pattern.ampm === 'PM' && hour < 12) hour += 12;
      if (pattern.ampm === 'AM' && hour === 12) hour = 0;

      startDate.setHours(hour, minute, 0, 0);
      break;
    }
  }

  // 기간 파싱 (1시간, 30분 등)
  const durationMatch = text.match(DURATION_PATTERN);
  if (durationMatch && startDate) {
    const amount = parseInt(durationMatch[1]);
    const unit = durationMatch[2];
    endDate = new Date(startDate);
    if (unit === '시간') {
      endDate.setHours(endDate.getHours() + amount);
    } else if (unit === '분') {
      endDate.setMinutes(endDate.getMinutes() + amount);
    }
  }

  // 종료 시간이 없으면 기본 1시간
  if (startDate && !endDate) {
    endDate = new Date(startDate);
    endDate.setHours(endDate.getHours() + 1);
  }

  // 제목 추출 (시간 표현 제거)
  summary = text
    .replace(/오늘|내일|모레/, '')
    .replace(/다음주\s*(월|화|수|목|금|토|일)요일/, '')
    .replace(/오전\s*\d{1,2}시(\s*\d{1,2}분)?/, '')
    .replace(/오후\s*\d{1,2}시(\s*\d{1,2}분)?/, '')
    .replace(/\d{1,2}시(\s*\d{1,2}분)?/, '')
    .replace(/\d{4}-\d{2}-\d{2}/, '')
    .replace(/\d{1,2}월\s*\d{1,2}일/, '')
    .replace(/\d+\s*(시간|분)/, '')
    .trim();

  if (!startDate) {
    return null; // 파싱 실패
  }

  return {
    summary: summary || '(제목 없음)',
    start: {
      dateTime: startDate.toISOString(),
      timeZone: 'Asia/Seoul',
    },
    end: {
      dateTime: endDate.toISOString(),
      timeZone: 'Asia/Seoul',
    },
  };
}

module.exports = { parseKoreanEvent };


path: scripts/list.js
content: #!/usr/bin/env node
/**
 * Google Calendar 일정 조회
 * 사용: node list.js [--date YYYY-MM-DD] [--days N] [--json]
 */

const { getCalendar } = require('./lib/gcal');
const { parseDate } = require('./lib/date-utils');
const yargs = require('yargs/yargs');
const { hideBin } = require('yargs/helpers');

const argv = yargs(hideBin(process.argv))
  .option('date', {
    type: 'string',
    description: '조회할 날짜 (today/tomorrow/YYYY-MM-DD)',
    default: 'today',
  })
  .option('days', {
    type: 'number',
    description: '조회할 일수',
    default: 1,
  })
  .option('json', {
    type: 'boolean',
    description: 'JSON 형식 출력',
    default: false,
  })
  .option('calendar', {
    type: 'string',
    description: '캘린더 ID (기본: primary)',
    default: process.env.GOOGLE_CALENDAR_ID || 'primary',
  })
  .help()
  .argv;

async function listEvents() {
  const calendar = await getCalendar();
  
  const startDate = parseDate(argv.date);
  const endDate = new Date(startDate);
  endDate.setDate(endDate.getDate() + argv.days);

  const res = await calendar.events.list({
    calendarId: argv.calendar,
    timeMin: startDate.toISOString(),
    timeMax: endDate.toISOString(),
    singleEvents: true,
    orderBy: 'startTime',
  });

  const events = res.data.items || [];

  if (argv.json) {
    console.log(JSON.stringify({ events }, null, 2));
    return;
  }

  // 텍스트 출력
  if (events.length === 0) {
    console.log('📅 일정 없음');
    return;
  }

  const grouped = {};
  events.forEach(event => {
    const start = event.start.dateTime || event.start.date;
    const dateKey = start.split('T')[0];
    if (!grouped[dateKey]) grouped[dateKey] = [];
    grouped[dateKey].push(event);
  });

  Object.keys(grouped).sort().forEach(dateKey => {
    const dayOfWeek = ['일', '월', '화', '수', '목', '금', '토'][new Date(dateKey).getDay()];
    console.log(`\n📅 ${dateKey} (${dayOfWeek})`);
    
    grouped[dateKey].forEach(event => {
      const start = event.start.dateTime || event.start.date;
      const end = event.end.dateTime || event.end.date;
      
      if (event.start.dateTime) {
        const startTime = new Date(start).toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', hour12: false });
        const endTime = new Date(end).toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', hour12: false });
        console.log(`  ${startTime}-${endTime}  ${event.summary}`);
      } else {
        console.log(`  종일  ${event.summary}`);
      }
      
      if (event.location) {
        console.log(`    📍 ${event.location}`);
      }
    });
  });
}

listEvents().catch(console.error);


path: scripts/remind.js
content: #!/usr/bin/env node
/**
 * 오늘 일정 리마인더 (cron용)
 * 사용: node remind.js [--channel DISCORD_CHANNEL_ID]
 */

const { getCalendar } = require('./lib/gcal');
const { execSync } = require('child_process');
const yargs = require('yargs/yargs');
const { hideBin } = require('yargs/helpers');

const argv = yargs(hideBin(process.argv))
  .option('channel', {
    type: 'string',
    description: 'Discord 채널 ID (메시지 전송)',
  })
  .option('calendar', {
    type: 'string',
    description: '캘린더 ID',
    default: process.env.GOOGLE_CALENDAR_ID || 'primary',
  })
  .help()
  .argv;

async function remind() {
  const calendar = await getCalendar();

  const today = new Date();
  today.setHours(0, 0, 0, 0);
  const tomorrow = new Date(today);
  tomorrow.setDate(tomorrow.getDate() + 1);

  const res = await calendar.events.list({
    calendarId: argv.calendar,
    timeMin: today.toISOString(),
    timeMax: tomorrow.toISOString(),
    singleEvents: true,
    orderBy: 'startTime',
  });

  const events = res.data.items || [];

  if (events.length === 0) {
    console.log('📅 오늘 일정 없음');
    return;
  }

  // 메시지 생성
  const dayOfWeek = ['일', '월', '화', '수', '목', '금', '토'][today.getDay()];
  const dateStr = today.toISOString().split('T')[0];
  let message = `📅 오늘 일정 (${dateStr} ${dayOfWeek})\n\n`;

  events.forEach(event => {
    const start = event.start.dateTime || event.start.date;
    if (event.start.dateTime) {
      const time = new Date(start).toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', hour12: false });
      message += `⏰ ${time} - ${event.summary}\n`;
      if (event.location) message += `   📍 ${event.location}\n`;
    } else {
      message += `📌 종일 - ${event.summary}\n`;
    }
  });

  console.log(message);

  // Discord 전송
  if (argv.channel) {
    try {
      execSync(`openclaw message send --target "${argv.channel}" --message "${message.replace(/"/g, '\\"')}"`, {
        stdio: 'inherit',
      });
      console.log('\n✅ Discord 전송 완료');
    } catch (err) {
      console.error('❌ Discord 전송 실패:', err.message);
    }
  }
}

remind().catch(console.error);


path: scripts/update.js
content: #!/usr/bin/env node
/**
 * Google Calendar 일정 수정
 * 사용: node update.js EVENT_ID --title "새 제목" --start "2026-02-20T15:00:00+09:00"
 */

const { getCalendar } = require('./lib/gcal');
const yargs = require('yargs/yargs');
const { hideBin } = require('yargs/helpers');

const argv = yargs(hideBin(process.argv))
  .demandCommand(1, '이벤트 ID 필요')
  .option('title', {
    type: 'string',
    description: '일정 제목',
  })
  .option('start', {
    type: 'string',
    description: '시작 시간 (ISO 8601)',
  })
  .option('end', {
    type: 'string',
    description: '종료 시간 (ISO 8601)',
  })
  .option('location', {
    type: 'string',
    description: '장소',
  })
  .option('description', {
    type: 'string',
    description: '설명',
  })
  .option('calendar', {
    type: 'string',
    description: '캘린더 ID',
    default: process.env.GOOGLE_CALENDAR_ID || 'primary',
  })
  .help()
  .argv;

async function updateEvent() {
  const eventId = argv._[0];
  const calendar = await getCalendar();

  // 기존 이벤트 조회
  const existing = await calendar.events.get({
    calendarId: argv.calendar,
    eventId,
  });

  // 수정할 필드만 업데이트
  const updates = {};
  if (argv.title) updates.summary = argv.title;
  if (argv.start) updates.start = { dateTime: argv.start, timeZone: 'Asia/Seoul' };
  if (argv.end) updates.end = { dateTime: argv.end, timeZone: 'Asia/Seoul' };
  if (argv.location) updates.location = argv.location;
  if (argv.description) updates.description = argv.description;

  const res = await calendar.events.patch({
    calendarId: argv.calendar,
    eventId,
    requestBody: updates,
  });

  console.log(`✅ 일정 수정 완료: ${res.data.summary}`);
  console.log(`   시작: ${res.data.start.dateTime || res.data.start.date}`);
  console.log(`   종료: ${res.data.end.dateTime || res.data.end.date}`);
}

updateEvent().catch(console.error);


Security scan verdicts — ClawScan: suspicious | static analysis:  | VirusTotal: clean | SkillSpector: suspicious (HIGH)