@yoonion/mimi-seed-mcp 0.12.1 → 0.13.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
@@ -96,7 +96,7 @@ export ANTHROPIC_API_KEY=sk-ant-...
96
96
  | Android 서명 | 3 | `android_signing_setup` / `android_generate_keystore` / `jenkins_upload_playstore_sa` |
97
97
  | 인증 | 4 | `mimi_seed_status` / `mimi_seed_auth_start` / `mimi_seed_auth_status` / `mimi_seed_remote_sync_credentials` |
98
98
  | AI (Claude) | 2 | `generate_release_notes_from_commits` / `generate_review_reply` |
99
- | 영상 제작 | 11 | `video_plan_from_story` / `video_research_youtube` / `video_synthesize_research` / `video_generate_image` / `video_render` |
99
+ | 영상 제작·YouTube | 14 | `youtube_upload_video` / `youtube_get_video_status` / `youtube_update_video_privacy` / `video_plan_from_story` / `video_render` |
100
100
 
101
101
  > 인앱 결제(IAP·구독) 도구는 위 Play Store·App Store 카운트에 포함됩니다 — `appstore_create_inapp_purchase` · `appstore_update_product_review_note` · `appstore_upload_product_review_screenshot` 등.
102
102
  > 전체 카탈로그(항상 최신): [`docs/domain/tool-catalog.md`](../../docs/domain/tool-catalog.md)
@@ -57,6 +57,7 @@ tools directly — but the *call order* and *safety rules* below still apply.
57
57
  | CI (GitHub/GitLab) | `select:ci_save_config,ci_list_workflows,ci_trigger_build,ci_get_build_status,ci_list_recent_builds` |
58
58
  | Service account end-to-end | `select:iam_create_service_account,iam_create_key,iam_add_iam_policy_binding,playstore_register_service_account,playstore_verify_service_account` |
59
59
  | Story → researched video | `select:video_plan_from_story,video_research_youtube,video_search_stock_assets,video_synthesize_research,video_download_stock_assets,video_generate_image,video_add_local_asset,video_build_timeline,video_render,video_job_status,video_validate` |
60
+ | YouTube upload / publish | `select:youtube_upload_video,youtube_get_video_status,youtube_update_video_privacy,mimi_seed_auth_start` |
60
61
 
61
62
  ---
62
63
 
@@ -80,7 +81,7 @@ every credential, which also tells them where to obtain each token
80
81
 
81
82
  | Service | Fix |
82
83
  |---------|-----|
83
- | Google (Firebase/AdMob/Play/Ads/GSC/GA4/IAM/BigQuery) | tool `mimi_seed_auth_start` → give the user the OAuth URL, **or** `mimi-seed auth login`. Both accept a **domain subset** (`domains=["ga4","googleads"]` / `--domains ga4,googleads`) — request only what the task needs; re-auth keeps prior grants (incremental). Omit for all domains |
84
+ | Google (Firebase/AdMob/Play/Ads/GSC/GA4/IAM/BigQuery/YouTube) | tool `mimi_seed_auth_start` → give the user the OAuth URL, **or** `mimi-seed auth login`. Both accept a **domain subset** (`domains=["youtube"]` / `--domains youtube`) — request only what the task needs; re-auth keeps prior grants (incremental). Omit for all domains |
84
85
  | App Store Connect | `mimi-seed auth appstore` → verify with `appstore_verify_credentials` |
85
86
  | Play service account | `mimi-seed auth playstore`, or register per-package with `playstore_register_service_account`. **Optional** — the OAuth token carries `androidpublisher`, so this is only needed for headless/CI |
86
87
  | BigQuery | `mimi-seed auth bigquery` (optional — OAuth works too) |
@@ -92,6 +93,11 @@ every credential, which also tells them where to obtain each token
92
93
  `mimi-seed auth meta` opens the combined social setup entry point when the user wants to review or reconnect
93
94
  all three Meta platforms in one pass.
94
95
 
96
+ Instagram and Threads can use named local profiles. Save one with
97
+ `mimi-seed auth instagram --profile <id>` or `mimi-seed auth threads --profile <id>`, then select it per project
98
+ with `.mimi-seed.json` → `socialProfiles.instagram` / `socialProfiles.threads`. MCP tools also accept an explicit
99
+ `profile`; explicit input wins over the project mapping.
100
+
95
101
  Each `mimi-seed auth <cred>` wraps the matching `npx -y @yoonion/mimi-seed-mcp mimi-seed-*-auth` binary, so
96
102
  either form works.
97
103
 
@@ -117,7 +123,8 @@ Credentials live under `~/.mimi-seed/` (legacy `~/.preseed/` is still read):
117
123
  | `bigquery-service-account.json` | BigQuery SA (exempt from Workspace reauth; OAuth is the fallback) |
118
124
  | `jenkins.json`, `ci.json` | Jenkins / GitHub-GitLab CI connection |
119
125
  | `google-ads.json` | Google Ads developer token + customer id |
120
- | `facebook.json`, `instagram.json`, `threads.json` | Page / account access tokens for the social post tools (Threads is a separate Meta account/token) |
126
+ | `facebook.json`, `instagram.json`, `threads.json` | Default/legacy Page / account tokens for social post tools |
127
+ | `social-profiles/<profile>.json` | Named Instagram/Threads tokens selected by the current project's `.mimi-seed.json` |
121
128
 
122
129
  Notes that matter in practice:
123
130
 
@@ -153,7 +160,7 @@ per-domain inventory is [`docs/domain/tool-catalog.md`](domain/tool-catalog.md).
153
160
  | **Facebook / Instagram / Threads** | `facebook_post_photo` · `instagram_post_carousel` · `threads_post` · `threads_refresh_token` |
154
161
  | **Checks** | `playstore_check_submission_risks` · `appstore_check_submission_risks` · `screenshot_validate` · `release_status` |
155
162
  | **AI / Auth** | `generate_release_notes_from_commits` · `generate_review_reply` · `mimi_seed_status` · `mimi_seed_auth_start` · `mimi_seed_auth_status` · `mimi_seed_remote_sync_credentials` |
156
- | **Video production** | `video_plan_from_story` · `video_research_youtube` · `video_search_stock_assets` · `video_synthesize_research` · `video_generate_image` · `video_build_timeline` · `video_render` · `video_job_status` · `video_validate` |
163
+ | **Video production** | `youtube_upload_video` · `youtube_get_video_status` · `youtube_update_video_privacy` · `video_plan_from_story` · `video_research_youtube` · `video_search_stock_assets` · `video_synthesize_research` · `video_generate_image` · `video_build_timeline` · `video_render` · `video_job_status` · `video_validate` |
157
164
 
158
165
  ---
159
166
 
@@ -59,6 +59,11 @@ export declare const AUTH_DOMAINS: {
59
59
  readonly scopes: readonly ["https://www.googleapis.com/auth/analytics.edit", "https://www.googleapis.com/auth/analytics.readonly"];
60
60
  readonly summary: "ga4_* — property/data stream 생성·조회 + 리포트";
61
61
  };
62
+ readonly youtube: {
63
+ readonly label: "YouTube";
64
+ readonly scopes: readonly ["https://www.googleapis.com/auth/youtube.force-ssl"];
65
+ readonly summary: "youtube_* — 영상 업로드·처리 상태 조회·공개 상태 변경";
66
+ };
62
67
  };
63
68
  export type AuthDomainId = keyof typeof AUTH_DOMAINS;
64
69
  /** z.enum 등 튜플이 필요한 자리에 쓰는 도메인 id 목록 (선언 순서 유지). */
@@ -61,6 +61,11 @@ export const AUTH_DOMAINS = {
61
61
  ],
62
62
  summary: 'ga4_* — property/data stream 생성·조회 + 리포트',
63
63
  },
64
+ youtube: {
65
+ label: 'YouTube',
66
+ scopes: ['https://www.googleapis.com/auth/youtube.force-ssl'],
67
+ summary: 'youtube_* — 영상 업로드·처리 상태 조회·공개 상태 변경',
68
+ },
64
69
  };
65
70
  /** z.enum 등 튜플이 필요한 자리에 쓰는 도메인 id 목록 (선언 순서 유지). */
66
71
  export const DOMAIN_IDS = Object.keys(AUTH_DOMAINS);
@@ -1,9 +1,10 @@
1
+ import { type SocialConfigOptions } from '../social/profile-store.js';
1
2
  export interface InstagramConfig {
2
3
  accessToken: string;
3
4
  userId: string;
4
5
  expiresAt?: string;
5
6
  username?: string;
6
7
  }
7
- export declare function loadInstagramConfig(): InstagramConfig | null;
8
- export declare function saveInstagramConfig(cfg: InstagramConfig): void;
9
- export declare function requireInstagramConfig(): InstagramConfig;
8
+ export declare function loadInstagramConfig(options?: SocialConfigOptions): InstagramConfig | null;
9
+ export declare function saveInstagramConfig(cfg: InstagramConfig, options?: SocialConfigOptions): void;
10
+ export declare function requireInstagramConfig(options?: SocialConfigOptions): InstagramConfig;
@@ -1,35 +1,22 @@
1
- import fs from 'node:fs';
2
- import path from 'node:path';
3
- import os from 'node:os';
4
- const CONFIG_PATH = path.join(os.homedir(), '.mimi-seed', 'instagram.json');
5
- export function loadInstagramConfig() {
6
- try {
7
- const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
8
- if (typeof cfg.accessToken !== 'string' || !cfg.accessToken ||
9
- typeof cfg.userId !== 'string' || !cfg.userId)
10
- return null;
11
- return cfg;
12
- }
13
- catch {
1
+ import { loadSocialPlatformConfig, resolveSocialConfigTarget, saveSocialPlatformConfig, } from '../social/profile-store.js';
2
+ export function loadInstagramConfig(options = {}) {
3
+ const cfg = loadSocialPlatformConfig('instagram', options);
4
+ if (typeof cfg?.accessToken !== 'string' || !cfg.accessToken ||
5
+ typeof cfg.userId !== 'string' || !cfg.userId)
14
6
  return null;
15
- }
7
+ return cfg;
16
8
  }
17
- export function saveInstagramConfig(cfg) {
18
- const dir = path.dirname(CONFIG_PATH);
19
- if (!fs.existsSync(dir)) {
20
- fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
21
- }
22
- fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2));
23
- if (process.platform !== 'win32') {
24
- fs.chmodSync(CONFIG_PATH, 0o600);
25
- }
9
+ export function saveInstagramConfig(cfg, options = {}) {
10
+ saveSocialPlatformConfig('instagram', cfg, options);
26
11
  }
27
- export function requireInstagramConfig() {
28
- const cfg = loadInstagramConfig();
12
+ export function requireInstagramConfig(options = {}) {
13
+ const cfg = loadInstagramConfig(options);
29
14
  if (!cfg) {
15
+ const target = resolveSocialConfigTarget('instagram', options);
16
+ const profileHint = target.profile ? `, profile="${target.profile}"` : '';
30
17
  throw new Error('Instagram 설정이 없습니다.\n' +
31
18
  'instagram_save_config 도구로 먼저 설정해주세요.\n' +
32
- '예: instagram_save_config(accessToken="...", userId="...")');
19
+ `예: instagram_save_config(accessToken="...", userId="..."${profileHint})`);
33
20
  }
34
21
  return cfg;
35
22
  }
@@ -1,4 +1,5 @@
1
1
  import type { ConnectResult } from '../facebook/setup.js';
2
+ import type { SocialConfigOptions } from '../social/profile-store.js';
2
3
  /** IGAA… = Instagram Login(신규) · EAA… = Facebook Login(FB Page + IG Business 연결 필요) */
3
4
  export declare function detectApiType(accessToken: string): string;
4
- export declare function connectInstagram(accessToken: string, userId?: string, assumeIssuedNow?: boolean): Promise<ConnectResult>;
5
+ export declare function connectInstagram(accessToken: string, userId?: string, assumeIssuedNow?: boolean, options?: SocialConfigOptions): Promise<ConnectResult>;
@@ -2,12 +2,15 @@
2
2
  // MCP 도구(instagram_save_config)와 setup CLI(mimi-seed-social-auth)가 **공유**하는 구현.
3
3
  import { saveInstagramConfig } from './config.js';
4
4
  import * as api from './api.js';
5
+ import { resolveSocialConfigTarget, socialTargetLabel } from '../social/profile-store.js';
5
6
  const SIXTY_DAYS_MS = 60 * 24 * 60 * 60 * 1000;
6
7
  /** IGAA… = Instagram Login(신규) · EAA… = Facebook Login(FB Page + IG Business 연결 필요) */
7
8
  export function detectApiType(accessToken) {
8
9
  return accessToken.startsWith('IGAA') ? 'Instagram Login' : 'Facebook Login';
9
10
  }
10
- export async function connectInstagram(accessToken, userId, assumeIssuedNow = true) {
11
+ export async function connectInstagram(accessToken, userId, assumeIssuedNow = true, options = {}) {
12
+ // 프로필 ID를 네트워크 호출 전에 검증하고, 성공 응답에 실제 저장 대상을 남긴다.
13
+ const target = resolveSocialConfigTarget('instagram', options);
11
14
  const apiType = detectApiType(accessToken);
12
15
  let resolvedUserId = userId;
13
16
  if (!resolvedUserId) {
@@ -36,11 +39,12 @@ export async function connectInstagram(accessToken, userId, assumeIssuedNow = tr
36
39
  userId: resolvedUserId,
37
40
  expiresAt,
38
41
  username: account.username,
39
- });
42
+ }, options);
40
43
  return {
41
44
  ok: true,
42
45
  text: [
43
46
  `✅ Instagram 연결 확인 완료 (${apiType})`,
47
+ ` 저장 대상: ${socialTargetLabel(target)}`,
44
48
  ` 계정: @${account.username}${account.name ? ` (${account.name})` : ''}`,
45
49
  ` ID: ${account.id}`,
46
50
  account.account_type ? ` 타입: ${account.account_type}` : '',
@@ -1,4 +1,6 @@
1
1
  export declare const MANIFEST_FILENAME = ".mimi-seed.json";
2
+ export declare const SOCIAL_PROFILE_ID_PATTERN: RegExp;
3
+ export type SocialPlatform = 'instagram' | 'threads';
2
4
  /** 매니페스트가 인지하는 서비스 id. status 매칭 로직이 이 키를 기준으로 동작한다. */
3
5
  export type ManifestServiceId = 'oauth' | 'bigquery' | 'playstore' | 'appstore' | 'jenkins';
4
6
  export interface ManifestService {
@@ -20,6 +22,8 @@ export interface ProjectManifest {
20
22
  displayName?: string;
21
23
  description?: string;
22
24
  services?: Partial<Record<ManifestServiceId, ManifestService>>;
25
+ /** 플랫폼별로 ~/.mimi-seed/social-profiles/<id>.json 을 선택한다. */
26
+ socialProfiles?: Partial<Record<SocialPlatform, string>>;
23
27
  }
24
28
  export interface LoadedManifest {
25
29
  manifest: ProjectManifest;
@@ -34,3 +38,7 @@ export interface LoadedManifest {
34
38
  export declare function findProjectManifest(startDir?: string, maxDepth?: number): LoadedManifest | null;
35
39
  /** 매니페스트 services 를 [id, service] 배열로. 없으면 빈 배열. */
36
40
  export declare function manifestServiceEntries(m: ProjectManifest): Array<[ManifestServiceId, ManifestService]>;
41
+ /** 파일명으로 안전하게 사용할 수 있는 공개 프로필 id인지 확인한다. */
42
+ export declare function isValidSocialProfileId(value: string): boolean;
43
+ /** 매니페스트에 지정된 플랫폼 프로필. 잘못된 값은 조용히 무시하지 않고 오류로 막는다. */
44
+ export declare function manifestSocialProfile(m: ProjectManifest, platform: SocialPlatform): string | null;
@@ -9,6 +9,7 @@
9
9
  import fs from 'node:fs';
10
10
  import path from 'node:path';
11
11
  export const MANIFEST_FILENAME = '.mimi-seed.json';
12
+ export const SOCIAL_PROFILE_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
12
13
  /**
13
14
  * startDir 에서 위로 올라가며 첫 번째 `.mimi-seed.json` 을 찾는다.
14
15
  * MCP stdio 서버의 cwd 는 보통 프로젝트 루트지만, 하위 폴더에서 실행돼도 잡히게 상향 탐색한다.
@@ -50,3 +51,24 @@ export function manifestServiceEntries(m) {
50
51
  .filter((k) => svc[k] != null)
51
52
  .map((k) => [k, svc[k]]);
52
53
  }
54
+ /** 파일명으로 안전하게 사용할 수 있는 공개 프로필 id인지 확인한다. */
55
+ export function isValidSocialProfileId(value) {
56
+ return SOCIAL_PROFILE_ID_PATTERN.test(value);
57
+ }
58
+ /** 매니페스트에 지정된 플랫폼 프로필. 잘못된 값은 조용히 무시하지 않고 오류로 막는다. */
59
+ export function manifestSocialProfile(m, platform) {
60
+ const profiles = m.socialProfiles;
61
+ if (profiles === undefined)
62
+ return null;
63
+ if (!profiles || typeof profiles !== 'object' || Array.isArray(profiles)) {
64
+ throw new Error(`${MANIFEST_FILENAME}의 socialProfiles는 객체여야 합니다.`);
65
+ }
66
+ const value = profiles[platform];
67
+ if (value === undefined)
68
+ return null;
69
+ if (typeof value !== 'string' || !isValidSocialProfileId(value)) {
70
+ throw new Error(`${MANIFEST_FILENAME}의 socialProfiles.${platform} 값이 올바르지 않습니다. ` +
71
+ '영문자·숫자로 시작하는 1~64자의 영문자/숫자/점/밑줄/하이픈만 사용할 수 있습니다.');
72
+ }
73
+ return value;
74
+ }
@@ -15,6 +15,7 @@ import { metaTokenFreshness } from '../lib/meta-auth.js';
15
15
  import { readPackageRootText } from '../lib/package-root.js';
16
16
  import { resolveBigQueryAuth } from '../auth/bigquery-auth.js';
17
17
  import { syncRemoteCredentials } from '../remote-sync.js';
18
+ import { resolveSocialConfigTarget } from '../social/profile-store.js';
18
19
  import { findProjectManifest, manifestServiceEntries, } from '../lib/project-manifest.js';
19
20
  /**
20
21
  * tokens.json mtime → "Nd Hh ago" 형식 문자열 + 재인증 권고.
@@ -132,6 +133,13 @@ export function registerAuthTools(server) {
132
133
  else {
133
134
  lines.push('❌ Google OAuth — 미연결 → mimi_seed_auth_start');
134
135
  }
136
+ const youtubeGranted = summarizeGrantedDomains(getStoredTokens()?.scope).granted.includes('youtube');
137
+ if (youtubeGranted && (oauthResult.status === 'fresh' || oauthResult.status === 'refreshed')) {
138
+ lines.push('✅ YouTube — 업로드·상태 관리 권한 연결됨');
139
+ }
140
+ else {
141
+ lines.push('❌ YouTube — 미연결 → mimi_seed_auth_start(domains=["youtube"]) (선택)');
142
+ }
135
143
  // 2. Play Store SA
136
144
  const saInfo = listRegisteredServiceAccounts();
137
145
  const saCount = saInfo.perPackage.length + (saInfo.default ? 1 : 0);
@@ -181,11 +189,13 @@ export function registerAuthTools(server) {
181
189
  const fb = loadFacebookConfig();
182
190
  lines.push(renderMetaConnection('Facebook', fb, `pageId: ${fb?.pageId ?? ''}`, 'mimi-seed auth facebook'));
183
191
  // 8. Instagram
192
+ const igTarget = resolveSocialConfigTarget('instagram');
184
193
  const ig = loadInstagramConfig();
185
- lines.push(renderMetaConnection('Instagram', ig, `userId: ${ig?.userId ?? ''}`, 'mimi-seed auth instagram'));
194
+ lines.push(renderMetaConnection('Instagram', ig, `${igTarget.profile ? `profile: ${igTarget.profile}, ` : ''}userId: ${ig?.userId ?? ''}`, `mimi-seed auth instagram${igTarget.profile ? ` --profile ${igTarget.profile}` : ''}`));
186
195
  // 9. Threads
196
+ const threadsTarget = resolveSocialConfigTarget('threads');
187
197
  const threads = loadThreadsConfig();
188
- lines.push(renderMetaConnection('Threads', threads, `userId: ${threads?.userId ?? ''}`, 'mimi-seed auth threads'));
198
+ lines.push(renderMetaConnection('Threads', threads, `${threadsTarget.profile ? `profile: ${threadsTarget.profile}, ` : ''}userId: ${threads?.userId ?? ''}`, `mimi-seed auth threads${threadsTarget.profile ? ` --profile ${threadsTarget.profile}` : ''}`));
189
199
  // 10. BigQuery — resolveBigQueryAuth 기준(서비스 계정 우선, OAuth fallback).
190
200
  // 이전엔 SA 파일만 검사해 OAuth fallback 이 살아있어도 ❌ 로 오표기했다.
191
201
  const bqAuth = resolveBigQueryAuth();
@@ -3,25 +3,29 @@ import { requireInstagramConfig } from '../instagram/config.js';
3
3
  import { connectInstagram } from '../instagram/setup.js';
4
4
  import * as api from '../instagram/api.js';
5
5
  import { metaExpiryMessage } from '../lib/meta-auth.js';
6
+ import { SOCIAL_PROFILE_ID_PATTERN } from '../lib/project-manifest.js';
6
7
  export function registerInstagramTools(server) {
8
+ const profileSchema = z.string().regex(SOCIAL_PROFILE_ID_PATTERN).optional().describe('저장/사용할 소셜 프로필 ID. 생략 시 .mimi-seed.json의 socialProfiles.instagram, 없으면 기존 기본 설정');
7
9
  server.tool('instagram_save_config', [
8
10
  'Instagram 토큰을 ~/.mimi-seed/instagram.json (mode 0600)에 저장합니다.',
9
11
  'accessToken은 두 형식 모두 자동 감지:',
10
12
  ' IGAA... = Instagram API with Instagram Login (Meta 신규, FB Page 불필요)',
11
13
  ' EAA... = Instagram Graph API via Facebook Login (FB Page+IG Business 연결 필요)',
12
14
  'userId 미입력 시 토큰으로 자동 조회 (/me 또는 /me/accounts).',
15
+ 'profile 지정 시 ~/.mimi-seed/social-profiles/<profile>.json에 저장합니다.',
13
16
  '저장 직후 토큰 유효성도 자동 검증.',
14
17
  ].join(' '), {
15
18
  accessToken: z.string().describe('Long-lived access token (60일)'),
16
19
  userId: z.string().optional().describe('Instagram Business Account ID (생략 시 자동 조회)'),
17
20
  assumeIssuedNow: z.boolean().default(true).describe('expiresAt = 지금 + 60일 자동 계산'),
18
- }, async ({ accessToken, userId, assumeIssuedNow }) => {
21
+ profile: profileSchema,
22
+ }, async ({ accessToken, userId, assumeIssuedNow, profile }) => {
19
23
  // 구현은 instagram/setup.ts 에 있다 — mimi-seed-social-auth CLI 와 공유한다.
20
- const result = await connectInstagram(accessToken, userId, assumeIssuedNow);
24
+ const result = await connectInstagram(accessToken, userId, assumeIssuedNow, { profile });
21
25
  return { content: [{ type: 'text', text: result.text }] };
22
26
  });
23
- server.tool('instagram_get_account', 'Instagram 계정 정보 조회 + 저장된 토큰 유효성 검증.', {}, async () => {
24
- const cfg = requireInstagramConfig();
27
+ server.tool('instagram_get_account', 'Instagram 계정 정보 조회 + 저장된 토큰 유효성 검증.', { profile: profileSchema }, async ({ profile }) => {
28
+ const cfg = requireInstagramConfig({ profile });
25
29
  const account = await api.getAccount(cfg);
26
30
  return {
27
31
  content: [{
@@ -45,8 +49,9 @@ export function registerInstagramTools(server) {
45
49
  ].join(' '), {
46
50
  imageUrl: z.string().url().describe('이미지의 public URL (HTTPS 권장)'),
47
51
  caption: z.string().describe('캡션 — 해시태그/멘션/줄바꿈 포함 가능'),
48
- }, async ({ imageUrl, caption }) => {
49
- const cfg = requireInstagramConfig();
52
+ profile: profileSchema,
53
+ }, async ({ imageUrl, caption, profile }) => {
54
+ const cfg = requireInstagramConfig({ profile });
50
55
  const result = await api.postImage(cfg, imageUrl, caption);
51
56
  return {
52
57
  content: [{
@@ -67,8 +72,9 @@ export function registerInstagramTools(server) {
67
72
  ].join(' '), {
68
73
  imageUrls: z.array(z.string().url()).min(2).max(10).describe('이미지 URL 배열 (2~10장)'),
69
74
  caption: z.string().describe('캡션'),
70
- }, async ({ imageUrls, caption }) => {
71
- const cfg = requireInstagramConfig();
75
+ profile: profileSchema,
76
+ }, async ({ imageUrls, caption, profile }) => {
77
+ const cfg = requireInstagramConfig({ profile });
72
78
  const result = await api.postCarousel(cfg, imageUrls, caption);
73
79
  return {
74
80
  content: [{
@@ -3,33 +3,38 @@ import { loadThreadsConfig, requireThreadsConfig } from '../threads/config.js';
3
3
  import { connectThreads, refreshThreadsToken } from '../threads/setup.js';
4
4
  import * as api from '../threads/api.js';
5
5
  import { metaExpiryMessage } from '../lib/meta-auth.js';
6
+ import { resolveSocialConfigTarget, socialTargetLabel } from '../social/profile-store.js';
7
+ import { SOCIAL_PROFILE_ID_PATTERN } from '../lib/project-manifest.js';
6
8
  export function registerThreadsTools(server) {
9
+ const profileSchema = z.string().regex(SOCIAL_PROFILE_ID_PATTERN).optional().describe('저장/사용할 소셜 프로필 ID. 생략 시 .mimi-seed.json의 socialProfiles.threads, 없으면 기존 기본 설정');
7
10
  server.tool('threads_save_config', [
8
11
  'Threads 토큰을 ~/.mimi-seed/threads.json (mode 0600)에 저장합니다.',
9
12
  'accessToken은 Threads Graph API long-lived 토큰 (약 60일).',
10
13
  'userId 미입력 시 토큰으로 자동 조회 (GET /me).',
14
+ 'profile 지정 시 ~/.mimi-seed/social-profiles/<profile>.json에 저장합니다.',
11
15
  '저장 직후 토큰 유효성도 자동 검증.',
12
16
  'Instagram 과 별개 계정·별개 토큰입니다 (threads_basic, threads_content_publish 권한 필요).',
13
17
  ].join(' '), {
14
18
  accessToken: z.string().describe('Threads Graph API long-lived access token (약 60일)'),
15
19
  userId: z.string().optional().describe('Threads user ID (생략 시 자동 조회)'),
16
20
  assumeIssuedNow: z.boolean().default(true).describe('expiresAt = 지금 + 60일 자동 계산'),
17
- }, async ({ accessToken, userId, assumeIssuedNow }) => {
21
+ profile: profileSchema,
22
+ }, async ({ accessToken, userId, assumeIssuedNow, profile }) => {
18
23
  // 구현은 threads/setup.ts 에 있다 — mimi-seed-social-auth CLI 와 공유한다.
19
- const result = await connectThreads(accessToken, userId, assumeIssuedNow);
24
+ const result = await connectThreads(accessToken, userId, assumeIssuedNow, { profile });
20
25
  return { content: [{ type: 'text', text: result.text }] };
21
26
  });
22
27
  server.tool('threads_refresh_token', [
23
28
  '저장된 Threads long-lived 토큰을 만료 전에 공식 refresh_access_token endpoint로 갱신합니다.',
24
29
  '성공하면 새 토큰과 실제 expires_in을 저장하고 계정을 다시 검증합니다.',
25
30
  '이미 만료·철회된 토큰은 갱신할 수 없으므로 mimi-seed auth threads로 재연결하세요.',
26
- ].join(' '), {}, async () => {
27
- const cfg = requireThreadsConfig();
28
- const result = await refreshThreadsToken(cfg.accessToken);
31
+ ].join(' '), { profile: profileSchema }, async ({ profile }) => {
32
+ const cfg = requireThreadsConfig({ profile });
33
+ const result = await refreshThreadsToken(cfg.accessToken, { profile });
29
34
  return { content: [{ type: 'text', text: result.text }], isError: !result.ok };
30
35
  });
31
- server.tool('threads_get_account', 'Threads 계정 정보 조회 + 저장된 토큰 유효성 검증.', {}, async () => {
32
- const cfg = requireThreadsConfig();
36
+ server.tool('threads_get_account', 'Threads 계정 정보 조회 + 저장된 토큰 유효성 검증.', { profile: profileSchema }, async ({ profile }) => {
37
+ const cfg = requireThreadsConfig({ profile });
33
38
  const account = await api.getAccount(cfg);
34
39
  return {
35
40
  content: [{
@@ -51,8 +56,9 @@ export function registerThreadsTools(server) {
51
56
  ].join(' '), {
52
57
  text: z.string().max(500).describe('게시할 텍스트 (최대 500자, 멘션/해시태그/줄바꿈 가능)'),
53
58
  imageUrl: z.string().url().optional().describe('이미지의 public URL (생략 시 텍스트 전용 게시)'),
54
- }, async ({ text, imageUrl }) => {
55
- const cfg = requireThreadsConfig();
59
+ profile: profileSchema,
60
+ }, async ({ text, imageUrl, profile }) => {
61
+ const cfg = requireThreadsConfig({ profile });
56
62
  const result = await api.postText(cfg, text, imageUrl);
57
63
  return {
58
64
  content: [{
@@ -72,8 +78,9 @@ export function registerThreadsTools(server) {
72
78
  ].join(' '), {
73
79
  imageUrls: z.array(z.string().url()).min(2).max(20).describe('이미지 URL 배열 (2~20장)'),
74
80
  text: z.string().max(500).describe('캡션 텍스트 (최대 500자)'),
75
- }, async ({ imageUrls, text }) => {
76
- const cfg = requireThreadsConfig();
81
+ profile: profileSchema,
82
+ }, async ({ imageUrls, text, profile }) => {
83
+ const cfg = requireThreadsConfig({ profile });
77
84
  const result = await api.postCarousel(cfg, imageUrls, text);
78
85
  return {
79
86
  content: [{
@@ -87,11 +94,16 @@ export function registerThreadsTools(server) {
87
94
  };
88
95
  });
89
96
  // 진단용 — 현재 저장된 Threads 설정 요약 (facebook_current_config 와 동일한 패턴).
90
- server.tool('threads_current_config', '현재 저장된 Threads 연결 설정을 확인합니다 (~/.mimi-seed/threads.json).', {}, async () => {
91
- const cfg = loadThreadsConfig();
97
+ server.tool('threads_current_config', '현재 선택된 Threads 연결 설정을 확인합니다. 프로젝트 매핑 또는 profile 인자를 따릅니다.', { profile: profileSchema }, async ({ profile }) => {
98
+ const options = { profile };
99
+ const target = resolveSocialConfigTarget('threads', options);
100
+ const cfg = loadThreadsConfig(options);
92
101
  if (!cfg) {
93
102
  return {
94
- content: [{ type: 'text', text: '❌ Threads 미설정 → threads_save_config 또는 mimi-seed auth threads' }],
103
+ content: [{
104
+ type: 'text',
105
+ text: `❌ Threads ${socialTargetLabel(target)} 미설정 → threads_save_config 또는 mimi-seed auth threads`,
106
+ }],
95
107
  };
96
108
  }
97
109
  return {
@@ -99,6 +111,7 @@ export function registerThreadsTools(server) {
99
111
  type: 'text',
100
112
  text: [
101
113
  '✅ Threads 연결됨',
114
+ ` 대상: ${socialTargetLabel(target)}`,
102
115
  ` @${cfg.username ?? '(username 미저장)'}`,
103
116
  ` userId: ${cfg.userId}`,
104
117
  ` ${metaExpiryMessage(cfg.expiresAt, 'mimi-seed auth threads')}`,
@@ -3,6 +3,8 @@ import { addLocalAsset, buildTimeline, loadProject, loadTimeline, planStory, } f
3
3
  import { downloadStockAssets, generateImage, researchYouTube, searchStock, } from '../video/providers.js';
4
4
  import { getRenderJob, startRender, validateVideo } from '../video/render.js';
5
5
  import { synthesizeResearch } from '../video/research.js';
6
+ import { requireAuth } from '../helpers.js';
7
+ import { YOUTUBE_SCOPE, getYouTubeVideoStatus, updateYouTubeVideoPrivacy, uploadYouTubeVideo, } from '../video/youtube-publish.js';
6
8
  function text(value) {
7
9
  return { content: [{ type: 'text', text: JSON.stringify(value, null, 2) }] };
8
10
  }
@@ -12,6 +14,45 @@ const httpUrl = z.string().url().refine((value) => {
12
14
  return protocol === 'https:' || protocol === 'http:';
13
15
  }, 'HTTP(S) URL만 허용합니다.');
14
16
  export function registerVideoTools(server) {
17
+ server.tool('youtube_upload_video', [
18
+ '로컬 영상 파일을 현재 Google OAuth 계정의 YouTube 채널에 업로드합니다.',
19
+ '기본 공개 상태는 private이며 public/unlisted는 같은 턴의 명시 승인 후 confirmVisible=true가 필요합니다.',
20
+ '업로드 후 youtube_get_video_status로 처리 완료 여부와 실제 공개 상태를 확인하세요.',
21
+ '요청이 타임아웃되면 업로드가 뒤늦게 완료될 수 있으므로 YouTube Studio를 먼저 확인하고 중복 재시도하지 마세요.',
22
+ 'YouTube OAuth 권한이 없으면 mimi_seed_auth_start(domains=["youtube"])로 증분 연결하세요.',
23
+ ].join(' '), {
24
+ filePath: absolutePath.describe('업로드할 .mp4/.mov/.webm 영상 절대경로'),
25
+ title: z.string().min(1).max(100).describe('YouTube 영상 제목'),
26
+ description: z.string().max(5_000).optional().describe('영상 설명과 음원 출처/라이선스 표기'),
27
+ tags: z.array(z.string().min(1).max(500)).max(100).optional(),
28
+ categoryId: z.string().regex(/^\d+$/).default('24').describe('YouTube 카테고리 ID'),
29
+ privacyStatus: z.enum(['private', 'unlisted', 'public']).default('private'),
30
+ madeForKids: z.boolean().default(false),
31
+ containsSyntheticMedia: z.boolean().optional().describe('현실적으로 보이는 AI 생성·변형 콘텐츠 여부를 YouTube에 고지'),
32
+ notifySubscribers: z.boolean().default(false),
33
+ shortsOnly: z.boolean().default(false).describe('true면 세로/정사각형·180초 이하 쇼츠 규격을 강제'),
34
+ confirmVisible: z.boolean().default(false).describe('public/unlisted 게시에 대한 명시 확인'),
35
+ }, async (input) => {
36
+ const auth = await requireAuth(YOUTUBE_SCOPE);
37
+ return text(await uploadYouTubeVideo(auth, input));
38
+ });
39
+ server.tool('youtube_get_video_status', '내 YouTube 영상의 업로드 처리 상태와 현재 공개 상태를 조회합니다.', {
40
+ videoId: z.string().min(1).max(64).describe('YouTube video ID'),
41
+ }, async ({ videoId }) => {
42
+ const auth = await requireAuth(YOUTUBE_SCOPE);
43
+ return text(await getYouTubeVideoStatus(auth, videoId));
44
+ });
45
+ server.tool('youtube_update_video_privacy', [
46
+ '내 YouTube 영상의 공개 상태를 변경하고 실제 반영 상태를 다시 조회합니다.',
47
+ 'public/unlisted 변경은 같은 턴의 명시 승인 후 confirmVisible=true가 필요합니다.',
48
+ ].join(' '), {
49
+ videoId: z.string().min(1).max(64).describe('YouTube video ID'),
50
+ privacyStatus: z.enum(['private', 'unlisted', 'public']),
51
+ confirmVisible: z.boolean().default(false).describe('public/unlisted 변경에 대한 명시 확인'),
52
+ }, async (input) => {
53
+ const auth = await requireAuth(YOUTUBE_SCOPE);
54
+ return text(await updateYouTubeVideoPrivacy(auth, input));
55
+ });
15
56
  server.tool('video_plan_from_story', [
16
57
  '정리된 story를 장면별 스토리보드로 변환하고 로컬 영상 프로젝트를 만듭니다.',
17
58
  'ANTHROPIC_API_KEY가 필요합니다. projectDir에는 project.json과 자산/리서치/렌더 폴더가 생성됩니다.',
@@ -0,0 +1,17 @@
1
+ import { type SocialPlatform } from '../lib/project-manifest.js';
2
+ export interface SocialConfigOptions {
3
+ /** 명시하면 프로젝트 매니페스트 매핑보다 우선한다. */
4
+ profile?: string;
5
+ /** 매니페스트 상향 탐색 시작점. 기본값은 현재 작업 디렉터리. */
6
+ startDir?: string;
7
+ /** 테스트 및 격리 실행용 홈 디렉터리. */
8
+ homeDir?: string;
9
+ }
10
+ export interface SocialConfigTarget {
11
+ filePath: string;
12
+ profile: string | null;
13
+ }
14
+ export declare function resolveSocialConfigTarget(platform: SocialPlatform, options?: SocialConfigOptions): SocialConfigTarget;
15
+ export declare function loadSocialPlatformConfig<T>(platform: SocialPlatform, options?: SocialConfigOptions): T | null;
16
+ export declare function saveSocialPlatformConfig<T extends object>(platform: SocialPlatform, config: T, options?: SocialConfigOptions): SocialConfigTarget;
17
+ export declare function socialTargetLabel(target: SocialConfigTarget): string;
@@ -0,0 +1,72 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { findProjectManifest, isValidSocialProfileId, manifestSocialProfile, } from '../lib/project-manifest.js';
5
+ function validateProfileId(profile) {
6
+ if (!isValidSocialProfileId(profile)) {
7
+ throw new Error('소셜 프로필 ID가 올바르지 않습니다. ' +
8
+ '영문자·숫자로 시작하는 1~64자의 영문자/숫자/점/밑줄/하이픈만 사용할 수 있습니다.');
9
+ }
10
+ return profile;
11
+ }
12
+ export function resolveSocialConfigTarget(platform, options = {}) {
13
+ const homeDir = options.homeDir ?? os.homedir();
14
+ const root = path.join(homeDir, '.mimi-seed');
15
+ let profile = null;
16
+ if (options.profile !== undefined) {
17
+ profile = validateProfileId(options.profile);
18
+ }
19
+ else {
20
+ const loaded = findProjectManifest(options.startDir ?? process.cwd());
21
+ if (loaded)
22
+ profile = manifestSocialProfile(loaded.manifest, platform);
23
+ }
24
+ return profile
25
+ ? { profile, filePath: path.join(root, 'social-profiles', `${profile}.json`) }
26
+ : { profile: null, filePath: path.join(root, `${platform}.json`) };
27
+ }
28
+ export function loadSocialPlatformConfig(platform, options = {}) {
29
+ const target = resolveSocialConfigTarget(platform, options);
30
+ try {
31
+ const parsed = JSON.parse(fs.readFileSync(target.filePath, 'utf-8'));
32
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
33
+ return null;
34
+ if (target.profile) {
35
+ return parsed[platform] ?? null;
36
+ }
37
+ return parsed;
38
+ }
39
+ catch {
40
+ return null;
41
+ }
42
+ }
43
+ export function saveSocialPlatformConfig(platform, config, options = {}) {
44
+ const target = resolveSocialConfigTarget(platform, options);
45
+ const dir = path.dirname(target.filePath);
46
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
47
+ let value = config;
48
+ if (target.profile) {
49
+ let existing = {};
50
+ if (fs.existsSync(target.filePath)) {
51
+ try {
52
+ const parsed = JSON.parse(fs.readFileSync(target.filePath, 'utf-8'));
53
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
54
+ throw new Error('객체 형식이 아닙니다.');
55
+ }
56
+ existing = parsed;
57
+ }
58
+ catch (error) {
59
+ throw new Error(`기존 소셜 프로필 파일을 읽을 수 없어 덮어쓰지 않았습니다: ${target.filePath} ` +
60
+ `(${error instanceof Error ? error.message : String(error)})`);
61
+ }
62
+ }
63
+ value = { ...existing, [platform]: config };
64
+ }
65
+ fs.writeFileSync(target.filePath, JSON.stringify(value, null, 2), { mode: 0o600 });
66
+ if (process.platform !== 'win32')
67
+ fs.chmodSync(target.filePath, 0o600);
68
+ return target;
69
+ }
70
+ export function socialTargetLabel(target) {
71
+ return target.profile ? `프로필 '${target.profile}'` : '기본 프로필';
72
+ }
@@ -11,6 +11,7 @@
11
11
  // mimi-seed-social-auth instagram → Instagram 만
12
12
  // mimi-seed-social-auth threads → Threads 만
13
13
  // mimi-seed-social-auth all → 세 플랫폼을 순서대로
14
+ // mimi-seed-social-auth instagram --profile my-app → 명시한 프로필에 저장
14
15
  //
15
16
  // 주의: connectFacebook / connectInstagram / connectThreads 이 돌려주는 result.text 는 MCP 도구와
16
17
  // 공유하는 텍스트라 여기서 번역하지 않는다 (그대로 출력한다).
@@ -22,6 +23,7 @@ import { connectFacebook } from '../facebook/setup.js';
22
23
  import { connectInstagram } from '../instagram/setup.js';
23
24
  import { connectThreads, refreshThreadsToken } from '../threads/setup.js';
24
25
  import { resolveLang } from '../lib/lang.js';
26
+ import { resolveSocialConfigTarget } from './profile-store.js';
25
27
  // ko 가 원본이고 en 은 `typeof ko` 를 만족해야 한다 — 키를 빠뜨리면 컴파일이 깨진다.
26
28
  const ko = {
27
29
  title: ' 🤖 Mimi Seed — 소셜 계정 연결 (Facebook / Instagram / Threads)',
@@ -59,6 +61,8 @@ const ko = {
59
61
  thExistingAction: ' [Enter/y] 기존 토큰 자동 갱신 [r] 새 토큰으로 재연결 [n] 건너뛰기: ',
60
62
  thRefreshing: ' 🔄 기존 Threads 토큰 갱신 중...',
61
63
  thRefreshFallback: ' 자동 갱신에 실패했습니다. 새 토큰을 입력해 재연결할 수 있습니다.',
64
+ profile: (id) => ` 소셜 프로필: ${id}`,
65
+ invalidArgs: ' 사용법: mimi-seed-social-auth [facebook|instagram|threads|all] [--profile <id>]',
62
66
  };
63
67
  const en = {
64
68
  title: ' 🤖 Mimi Seed — Connect social accounts (Facebook / Instagram / Threads)',
@@ -96,6 +100,8 @@ const en = {
96
100
  thExistingAction: ' [Enter/y] refresh current token [r] reconnect with a new token [n] skip: ',
97
101
  thRefreshing: ' 🔄 Refreshing the existing Threads token...',
98
102
  thRefreshFallback: ' Automatic refresh failed. You can reconnect with a new token.',
103
+ profile: (id) => ` Social profile: ${id}`,
104
+ invalidArgs: ' Usage: mimi-seed-social-auth [facebook|instagram|threads|all] [--profile <id>]',
99
105
  };
100
106
  const M = resolveLang() === 'en' ? en : ko;
101
107
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
@@ -145,10 +151,14 @@ async function setupFacebook() {
145
151
  console.log(M.notSaved);
146
152
  return result.ok;
147
153
  }
148
- async function setupInstagram() {
154
+ async function setupInstagram(profile) {
149
155
  console.log('');
150
156
  console.log(M.igHeader);
151
- const existing = loadInstagramConfig();
157
+ const options = { profile };
158
+ const target = resolveSocialConfigTarget('instagram', options);
159
+ if (target.profile)
160
+ console.log(M.profile(target.profile));
161
+ const existing = loadInstagramConfig(options);
152
162
  if (existing && !(await confirmReconnect('Instagram', existing.username ?? existing.userId))) {
153
163
  return true;
154
164
  }
@@ -164,17 +174,21 @@ async function setupInstagram() {
164
174
  }
165
175
  const userId = await ask(M.igAskUserId);
166
176
  console.log(M.verifying);
167
- const result = await connectInstagram(token, userId || undefined);
177
+ const result = await connectInstagram(token, userId || undefined, true, options);
168
178
  console.log('');
169
179
  console.log(indent(result.text));
170
180
  if (!result.ok)
171
181
  console.log(M.notSaved);
172
182
  return result.ok;
173
183
  }
174
- async function setupThreads() {
184
+ async function setupThreads(profile) {
175
185
  console.log('');
176
186
  console.log(M.thHeader);
177
- const existing = loadThreadsConfig();
187
+ const options = { profile };
188
+ const target = resolveSocialConfigTarget('threads', options);
189
+ if (target.profile)
190
+ console.log(M.profile(target.profile));
191
+ const existing = loadThreadsConfig(options);
178
192
  if (existing) {
179
193
  console.log(M.already('Threads', existing.username ?? existing.userId));
180
194
  const action = (await ask(M.thExistingAction)).toLowerCase();
@@ -182,7 +196,7 @@ async function setupThreads() {
182
196
  return true;
183
197
  if (action !== 'r') {
184
198
  console.log(M.thRefreshing);
185
- const refreshed = await refreshThreadsToken(existing.accessToken);
199
+ const refreshed = await refreshThreadsToken(existing.accessToken, options);
186
200
  console.log('');
187
201
  console.log(indent(refreshed.text));
188
202
  if (refreshed.ok)
@@ -203,7 +217,7 @@ async function setupThreads() {
203
217
  }
204
218
  const userId = await ask(M.thAskUserId);
205
219
  console.log(M.verifying);
206
- const result = await connectThreads(token, userId || undefined);
220
+ const result = await connectThreads(token, userId || undefined, true, options);
207
221
  console.log('');
208
222
  console.log(indent(result.text));
209
223
  if (!result.ok)
@@ -217,7 +231,19 @@ function indent(text) {
217
231
  .join('\n');
218
232
  }
219
233
  async function main() {
220
- const target = (process.argv[2] ?? '').toLowerCase();
234
+ const args = process.argv.slice(2);
235
+ const profileIndex = args.indexOf('--profile');
236
+ const profile = profileIndex >= 0 ? args[profileIndex + 1] : undefined;
237
+ if (profileIndex >= 0 && !profile) {
238
+ console.error(M.invalidArgs);
239
+ process.exit(1);
240
+ }
241
+ const positional = args.filter((arg, index) => arg !== '--profile' && (profileIndex < 0 || index !== profileIndex + 1));
242
+ if (positional.length > 1 || args.some((arg) => arg.startsWith('--') && arg !== '--profile')) {
243
+ console.error(M.invalidArgs);
244
+ process.exit(1);
245
+ }
246
+ const target = (positional[0] ?? '').toLowerCase();
221
247
  console.log('');
222
248
  console.log(M.title);
223
249
  let ok = true;
@@ -225,15 +251,15 @@ async function main() {
225
251
  ok = await setupFacebook();
226
252
  }
227
253
  else if (target === 'instagram' || target === 'ig') {
228
- ok = await setupInstagram();
254
+ ok = await setupInstagram(profile);
229
255
  }
230
256
  else if (target === 'threads' || target === 'th') {
231
- ok = await setupThreads();
257
+ ok = await setupThreads(profile);
232
258
  }
233
259
  else if (target === 'all' || target === 'meta') {
234
260
  const fb = await setupFacebook();
235
- const ig = await setupInstagram();
236
- const th = await setupThreads();
261
+ const ig = await setupInstagram(profile);
262
+ const th = await setupThreads(profile);
237
263
  ok = fb && ig && th;
238
264
  }
239
265
  else {
@@ -242,19 +268,19 @@ async function main() {
242
268
  if (c === 'f')
243
269
  ok = await setupFacebook();
244
270
  else if (c === 'i')
245
- ok = await setupInstagram();
271
+ ok = await setupInstagram(profile);
246
272
  else if (c === 't')
247
- ok = await setupThreads();
273
+ ok = await setupThreads(profile);
248
274
  else if (c === 'b') {
249
275
  // 기존 b=Facebook+Instagram 동작을 유지한다.
250
276
  const fb = await setupFacebook();
251
- const ig = await setupInstagram();
277
+ const ig = await setupInstagram(profile);
252
278
  ok = fb && ig;
253
279
  }
254
280
  else if (c === 'a') {
255
281
  const fb = await setupFacebook();
256
- const ig = await setupInstagram();
257
- const th = await setupThreads();
282
+ const ig = await setupInstagram(profile);
283
+ const th = await setupThreads(profile);
258
284
  ok = fb && ig && th;
259
285
  }
260
286
  else {
@@ -1,9 +1,10 @@
1
+ import { type SocialConfigOptions } from '../social/profile-store.js';
1
2
  export interface ThreadsConfig {
2
3
  accessToken: string;
3
4
  userId: string;
4
5
  expiresAt?: string;
5
6
  username?: string;
6
7
  }
7
- export declare function loadThreadsConfig(): ThreadsConfig | null;
8
- export declare function saveThreadsConfig(cfg: ThreadsConfig): void;
9
- export declare function requireThreadsConfig(): ThreadsConfig;
8
+ export declare function loadThreadsConfig(options?: SocialConfigOptions): ThreadsConfig | null;
9
+ export declare function saveThreadsConfig(cfg: ThreadsConfig, options?: SocialConfigOptions): void;
10
+ export declare function requireThreadsConfig(options?: SocialConfigOptions): ThreadsConfig;
@@ -1,35 +1,22 @@
1
- import fs from 'node:fs';
2
- import path from 'node:path';
3
- import os from 'node:os';
4
- const CONFIG_PATH = path.join(os.homedir(), '.mimi-seed', 'threads.json');
5
- export function loadThreadsConfig() {
6
- try {
7
- const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
8
- if (typeof cfg.accessToken !== 'string' || !cfg.accessToken ||
9
- typeof cfg.userId !== 'string' || !cfg.userId)
10
- return null;
11
- return cfg;
12
- }
13
- catch {
1
+ import { loadSocialPlatformConfig, resolveSocialConfigTarget, saveSocialPlatformConfig, } from '../social/profile-store.js';
2
+ export function loadThreadsConfig(options = {}) {
3
+ const cfg = loadSocialPlatformConfig('threads', options);
4
+ if (typeof cfg?.accessToken !== 'string' || !cfg.accessToken ||
5
+ typeof cfg.userId !== 'string' || !cfg.userId)
14
6
  return null;
15
- }
7
+ return cfg;
16
8
  }
17
- export function saveThreadsConfig(cfg) {
18
- const dir = path.dirname(CONFIG_PATH);
19
- if (!fs.existsSync(dir)) {
20
- fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
21
- }
22
- fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2));
23
- if (process.platform !== 'win32') {
24
- fs.chmodSync(CONFIG_PATH, 0o600);
25
- }
9
+ export function saveThreadsConfig(cfg, options = {}) {
10
+ saveSocialPlatformConfig('threads', cfg, options);
26
11
  }
27
- export function requireThreadsConfig() {
28
- const cfg = loadThreadsConfig();
12
+ export function requireThreadsConfig(options = {}) {
13
+ const cfg = loadThreadsConfig(options);
29
14
  if (!cfg) {
15
+ const target = resolveSocialConfigTarget('threads', options);
16
+ const profileHint = target.profile ? `, profile="${target.profile}"` : '';
30
17
  throw new Error('Threads 설정이 없습니다.\n' +
31
18
  'threads_save_config 도구로 먼저 설정해주세요.\n' +
32
- '예: threads_save_config(accessToken="...", userId="...")');
19
+ `예: threads_save_config(accessToken="...", userId="..."${profileHint})`);
33
20
  }
34
21
  return cfg;
35
22
  }
@@ -1,4 +1,5 @@
1
1
  import type { ConnectResult } from '../facebook/setup.js';
2
- export declare function connectThreads(accessToken: string, userId?: string, assumeIssuedNow?: boolean): Promise<ConnectResult>;
2
+ import type { SocialConfigOptions } from '../social/profile-store.js';
3
+ export declare function connectThreads(accessToken: string, userId?: string, assumeIssuedNow?: boolean, options?: SocialConfigOptions): Promise<ConnectResult>;
3
4
  /** 기존 long-lived 토큰을 만료 전에 갱신하고, 계정 검증 성공 시에만 덮어쓴다. */
4
- export declare function refreshThreadsToken(currentAccessToken: string): Promise<ConnectResult>;
5
+ export declare function refreshThreadsToken(currentAccessToken: string, options?: SocialConfigOptions): Promise<ConnectResult>;
@@ -2,8 +2,11 @@
2
2
  // MCP 도구(threads_save_config)와 setup CLI(mimi-seed-social-auth)가 **공유**하는 구현.
3
3
  import { saveThreadsConfig } from './config.js';
4
4
  import * as api from './api.js';
5
+ import { resolveSocialConfigTarget, socialTargetLabel } from '../social/profile-store.js';
5
6
  const SIXTY_DAYS_MS = 60 * 24 * 60 * 60 * 1000;
6
- export async function connectThreads(accessToken, userId, assumeIssuedNow = true) {
7
+ export async function connectThreads(accessToken, userId, assumeIssuedNow = true, options = {}) {
8
+ // 프로필 ID를 네트워크 호출 전에 검증하고, 성공 응답에 실제 저장 대상을 남긴다.
9
+ const target = resolveSocialConfigTarget('threads', options);
7
10
  let resolvedUserId = userId;
8
11
  if (!resolvedUserId) {
9
12
  try {
@@ -31,11 +34,12 @@ export async function connectThreads(accessToken, userId, assumeIssuedNow = true
31
34
  userId: resolvedUserId,
32
35
  expiresAt,
33
36
  username: account.username,
34
- });
37
+ }, options);
35
38
  return {
36
39
  ok: true,
37
40
  text: [
38
41
  '✅ Threads 연결 확인 완료',
42
+ ` 저장 대상: ${socialTargetLabel(target)}`,
39
43
  ` 계정: @${account.username}${account.name ? ` (${account.name})` : ''}`,
40
44
  ` ID: ${account.id}`,
41
45
  expiresAt ? ` 토큰 만료(추정): ${expiresAt.slice(0, 10)}` : '',
@@ -59,7 +63,8 @@ export async function connectThreads(accessToken, userId, assumeIssuedNow = true
59
63
  }
60
64
  }
61
65
  /** 기존 long-lived 토큰을 만료 전에 갱신하고, 계정 검증 성공 시에만 덮어쓴다. */
62
- export async function refreshThreadsToken(currentAccessToken) {
66
+ export async function refreshThreadsToken(currentAccessToken, options = {}) {
67
+ const target = resolveSocialConfigTarget('threads', options);
63
68
  try {
64
69
  const refreshed = await api.refreshAccessToken(currentAccessToken);
65
70
  const userId = await api.fetchUserId(refreshed.accessToken);
@@ -70,11 +75,12 @@ export async function refreshThreadsToken(currentAccessToken) {
70
75
  userId,
71
76
  username: account.username,
72
77
  expiresAt,
73
- });
78
+ }, options);
74
79
  return {
75
80
  ok: true,
76
81
  text: [
77
82
  '✅ Threads 토큰 갱신 완료',
83
+ ` 저장 대상: ${socialTargetLabel(target)}`,
78
84
  ` 계정: @${account.username}`,
79
85
  ` 새 만료일: ${expiresAt.slice(0, 10)}`,
80
86
  ].join('\n'),
@@ -0,0 +1,88 @@
1
+ import { type youtube_v3 } from 'googleapis';
2
+ import type { OAuth2Client } from 'google-auth-library';
3
+ import { validateVideo } from './render.js';
4
+ export declare const YOUTUBE_SCOPE = "https://www.googleapis.com/auth/youtube.force-ssl";
5
+ export type YouTubePrivacyStatus = 'private' | 'unlisted' | 'public';
6
+ export interface UploadYouTubeVideoInput {
7
+ filePath: string;
8
+ title: string;
9
+ description?: string;
10
+ tags?: string[];
11
+ categoryId?: string;
12
+ privacyStatus?: YouTubePrivacyStatus;
13
+ madeForKids?: boolean;
14
+ containsSyntheticMedia?: boolean;
15
+ notifySubscribers?: boolean;
16
+ shortsOnly?: boolean;
17
+ confirmVisible?: boolean;
18
+ }
19
+ export interface UpdateYouTubePrivacyInput {
20
+ videoId: string;
21
+ privacyStatus: YouTubePrivacyStatus;
22
+ confirmVisible?: boolean;
23
+ }
24
+ declare function assertVisibilityConfirmed(privacyStatus: YouTubePrivacyStatus, confirmVisible?: boolean): void;
25
+ declare function assertUploadFile(filePath: string): {
26
+ size: number;
27
+ mimeType: string;
28
+ };
29
+ declare function assertTags(tags?: string[]): void;
30
+ declare function videoFacts(validation: Awaited<ReturnType<typeof validateVideo>>): {
31
+ width?: number;
32
+ height?: number;
33
+ durationSec?: number;
34
+ };
35
+ declare function assertShortsCompatible(facts: ReturnType<typeof videoFacts>): void;
36
+ export declare function getYouTubeVideoStatus(auth: OAuth2Client, videoId: string): Promise<{
37
+ videoId: string | null;
38
+ title: string | null;
39
+ privacyStatus: string | null;
40
+ uploadStatus: string | null;
41
+ processingStatus: string | null;
42
+ processingProgress: youtube_v3.Schema$VideoProcessingDetailsProcessingProgress | null;
43
+ madeForKids: boolean | null;
44
+ containsSyntheticMedia: boolean | null;
45
+ watchUrl: string | null;
46
+ studioUrl: string | null;
47
+ }>;
48
+ export declare function uploadYouTubeVideo(auth: OAuth2Client, input: UploadYouTubeVideoInput): Promise<{
49
+ requestedPrivacyStatus: YouTubePrivacyStatus;
50
+ file: {
51
+ width?: number;
52
+ height?: number;
53
+ durationSec?: number;
54
+ path: string;
55
+ size: number;
56
+ };
57
+ validationWarnings: string[];
58
+ videoId: string | null;
59
+ title: string | null;
60
+ privacyStatus: string | null;
61
+ uploadStatus: string | null;
62
+ processingStatus: string | null;
63
+ processingProgress: youtube_v3.Schema$VideoProcessingDetailsProcessingProgress | null;
64
+ madeForKids: boolean | null;
65
+ containsSyntheticMedia: boolean | null;
66
+ watchUrl: string | null;
67
+ studioUrl: string | null;
68
+ }>;
69
+ export declare function updateYouTubeVideoPrivacy(auth: OAuth2Client, input: UpdateYouTubePrivacyInput): Promise<{
70
+ videoId: string | null;
71
+ title: string | null;
72
+ privacyStatus: string | null;
73
+ uploadStatus: string | null;
74
+ processingStatus: string | null;
75
+ processingProgress: youtube_v3.Schema$VideoProcessingDetailsProcessingProgress | null;
76
+ madeForKids: boolean | null;
77
+ containsSyntheticMedia: boolean | null;
78
+ watchUrl: string | null;
79
+ studioUrl: string | null;
80
+ }>;
81
+ export declare const __testing: {
82
+ assertVisibilityConfirmed: typeof assertVisibilityConfirmed;
83
+ assertUploadFile: typeof assertUploadFile;
84
+ assertTags: typeof assertTags;
85
+ assertShortsCompatible: typeof assertShortsCompatible;
86
+ videoFacts: typeof videoFacts;
87
+ };
88
+ export {};
@@ -0,0 +1,179 @@
1
+ import { createReadStream, existsSync, statSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { google } from 'googleapis';
4
+ import { friendlyGoogleError } from '../lib/google-errors.js';
5
+ import { validateVideo } from './render.js';
6
+ export const YOUTUBE_SCOPE = 'https://www.googleapis.com/auth/youtube.force-ssl';
7
+ function assertVisibilityConfirmed(privacyStatus, confirmVisible) {
8
+ if (privacyStatus !== 'private' && confirmVisible !== true) {
9
+ throw new Error(`${privacyStatus} 게시에는 confirmVisible=true가 필요합니다.`);
10
+ }
11
+ }
12
+ function assertUploadFile(filePath) {
13
+ if (!path.isAbsolute(filePath) || !existsSync(filePath)) {
14
+ throw new Error('filePath는 존재하는 절대경로여야 합니다.');
15
+ }
16
+ const stat = statSync(filePath);
17
+ if (!stat.isFile() || stat.size <= 0)
18
+ throw new Error('업로드할 영상 파일이 비어 있거나 파일이 아닙니다.');
19
+ const ext = path.extname(filePath).toLowerCase();
20
+ const mimeType = ext === '.mp4'
21
+ ? 'video/mp4'
22
+ : ext === '.mov'
23
+ ? 'video/quicktime'
24
+ : ext === '.webm'
25
+ ? 'video/webm'
26
+ : null;
27
+ if (!mimeType)
28
+ throw new Error('지원 영상 형식은 .mp4, .mov, .webm입니다.');
29
+ return { size: stat.size, mimeType };
30
+ }
31
+ function assertTags(tags = []) {
32
+ if (tags.join(',').length > 500)
33
+ throw new Error('tags의 전체 길이는 쉼표를 포함해 500자 이하여야 합니다.');
34
+ }
35
+ function videoFacts(validation) {
36
+ const stream = validation.streams.find((item) => typeof item === 'object' && item !== null && item.codec_type === 'video');
37
+ const format = validation.format;
38
+ const width = Number(stream?.width);
39
+ const height = Number(stream?.height);
40
+ const durationSec = Number(format.duration);
41
+ return {
42
+ width: Number.isFinite(width) ? width : undefined,
43
+ height: Number.isFinite(height) ? height : undefined,
44
+ durationSec: Number.isFinite(durationSec) ? durationSec : undefined,
45
+ };
46
+ }
47
+ function assertShortsCompatible(facts) {
48
+ if (!facts.width || !facts.height || !facts.durationSec) {
49
+ throw new Error('쇼츠 규격을 확인할 영상 정보를 읽지 못했습니다.');
50
+ }
51
+ if (facts.width > facts.height)
52
+ throw new Error('shortsOnly=true일 때 영상은 정사각형 또는 세로형이어야 합니다.');
53
+ if (facts.durationSec > 180)
54
+ throw new Error('YouTube Shorts 영상은 180초 이하여야 합니다.');
55
+ }
56
+ function safeStatus(video) {
57
+ return {
58
+ videoId: video.id ?? null,
59
+ title: video.snippet?.title ?? null,
60
+ privacyStatus: video.status?.privacyStatus ?? null,
61
+ uploadStatus: video.status?.uploadStatus ?? null,
62
+ processingStatus: video.processingDetails?.processingStatus ?? null,
63
+ processingProgress: video.processingDetails?.processingProgress ?? null,
64
+ madeForKids: video.status?.madeForKids ?? null,
65
+ containsSyntheticMedia: video.status?.containsSyntheticMedia ?? null,
66
+ watchUrl: video.id ? `https://youtu.be/${video.id}` : null,
67
+ studioUrl: video.id ? `https://studio.youtube.com/video/${video.id}/edit` : null,
68
+ };
69
+ }
70
+ export async function getYouTubeVideoStatus(auth, videoId) {
71
+ const youtube = google.youtube({ version: 'v3', auth });
72
+ try {
73
+ const response = await youtube.videos.list({
74
+ part: ['snippet', 'status', 'processingDetails'],
75
+ id: [videoId],
76
+ });
77
+ const video = response.data.items?.[0];
78
+ if (!video)
79
+ throw new Error(`YouTube 영상을 찾지 못했습니다: ${videoId}`);
80
+ return safeStatus(video);
81
+ }
82
+ catch (error) {
83
+ if (error instanceof Error && error.message.startsWith('YouTube 영상을 찾지 못했습니다'))
84
+ throw error;
85
+ throw friendlyGoogleError(error);
86
+ }
87
+ }
88
+ export async function uploadYouTubeVideo(auth, input) {
89
+ const privacyStatus = input.privacyStatus ?? 'private';
90
+ assertVisibilityConfirmed(privacyStatus, input.confirmVisible);
91
+ const file = assertUploadFile(input.filePath);
92
+ if (!input.title.trim() || input.title.length > 100)
93
+ throw new Error('title은 1~100자여야 합니다.');
94
+ if ((input.description?.length ?? 0) > 5_000)
95
+ throw new Error('description은 5,000자 이하여야 합니다.');
96
+ assertTags(input.tags);
97
+ const validation = await validateVideo(input.filePath);
98
+ const facts = videoFacts(validation);
99
+ if (!validation.streams.some((item) => typeof item === 'object' && item !== null && item.codec_type === 'video')) {
100
+ throw new Error('업로드 파일에 비디오 스트림이 없습니다.');
101
+ }
102
+ if (input.shortsOnly)
103
+ assertShortsCompatible(facts);
104
+ const youtube = google.youtube({ version: 'v3', auth });
105
+ try {
106
+ const response = await youtube.videos.insert({
107
+ part: ['snippet', 'status'],
108
+ notifySubscribers: input.notifySubscribers ?? false,
109
+ requestBody: {
110
+ snippet: {
111
+ title: input.title.trim(),
112
+ description: input.description ?? '',
113
+ tags: input.tags ?? [],
114
+ categoryId: input.categoryId ?? '24',
115
+ },
116
+ status: {
117
+ privacyStatus,
118
+ selfDeclaredMadeForKids: input.madeForKids ?? false,
119
+ ...(typeof input.containsSyntheticMedia === 'boolean'
120
+ ? { containsSyntheticMedia: input.containsSyntheticMedia }
121
+ : {}),
122
+ },
123
+ },
124
+ media: {
125
+ mimeType: file.mimeType,
126
+ body: createReadStream(input.filePath),
127
+ },
128
+ });
129
+ const videoId = response.data.id;
130
+ if (!videoId)
131
+ throw new Error('YouTube 업로드 응답에 videoId가 없습니다.');
132
+ return {
133
+ ...safeStatus(response.data),
134
+ requestedPrivacyStatus: privacyStatus,
135
+ file: { path: input.filePath, size: file.size, ...facts },
136
+ validationWarnings: validation.issues,
137
+ };
138
+ }
139
+ catch (error) {
140
+ if (error instanceof Error && error.message.includes('videoId'))
141
+ throw error;
142
+ throw friendlyGoogleError(error);
143
+ }
144
+ }
145
+ export async function updateYouTubeVideoPrivacy(auth, input) {
146
+ assertVisibilityConfirmed(input.privacyStatus, input.confirmVisible);
147
+ const youtube = google.youtube({ version: 'v3', auth });
148
+ try {
149
+ const currentResponse = await youtube.videos.list({ part: ['status'], id: [input.videoId] });
150
+ const current = currentResponse.data.items?.[0];
151
+ if (!current)
152
+ throw new Error(`YouTube 영상을 찾지 못했습니다: ${input.videoId}`);
153
+ await youtube.videos.update({
154
+ part: ['status'],
155
+ requestBody: {
156
+ id: input.videoId,
157
+ status: {
158
+ privacyStatus: input.privacyStatus,
159
+ embeddable: current.status?.embeddable ?? true,
160
+ license: current.status?.license ?? 'youtube',
161
+ publicStatsViewable: current.status?.publicStatsViewable ?? true,
162
+ selfDeclaredMadeForKids: current.status?.selfDeclaredMadeForKids ?? false,
163
+ containsSyntheticMedia: current.status?.containsSyntheticMedia ?? false,
164
+ },
165
+ },
166
+ });
167
+ return await getYouTubeVideoStatus(auth, input.videoId);
168
+ }
169
+ catch (error) {
170
+ throw friendlyGoogleError(error);
171
+ }
172
+ }
173
+ export const __testing = {
174
+ assertVisibilityConfirmed,
175
+ assertUploadFile,
176
+ assertTags,
177
+ assertShortsCompatible,
178
+ videoFacts,
179
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yoonion/mimi-seed-mcp",
3
- "version": "0.12.1",
3
+ "version": "0.13.1",
4
4
  "description": "Mimi Seed MCP server — Firebase + AdMob + Google Play + App Store management for Claude Code / Codex / Cursor / any MCP client.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "$comment": "등록된 MCP 도구 + 도메인 메타데이터(label·credential·summary)의 SSOT. src/__tests__/tool-manifest.test.ts 가 실제 서버 등록 목록과 diff 하고 메타데이터 정합성을 검사한다. 도구 추가/삭제/개명 시 이 파일을 함께 갱신할 것 — 산문 문서에는 정확한 개수를 쓰지 말고 이 파일을 가리킬 것. mimi-seed://tools/catalog 리소스가 이 파일을 그대로 서빙한다.",
3
- "total": 174,
3
+ "total": 177,
4
4
  "domains": {
5
5
  "admob": {
6
6
  "label": "AdMob",
@@ -293,9 +293,12 @@
293
293
  },
294
294
  "video": {
295
295
  "label": "영상 제작",
296
- "credential": "ANTHROPIC_API_KEY / YOUTUBE_API_KEY / PEXELS_API_KEY / OPENAI_API_KEY + FFmpeg (기능별 선택)",
297
- "summary": "story 스토리보드·유사 영상 리서치·라이선스 자산/이미지 생성·타임라인·MP4 렌더",
296
+ "credential": "기능별 API + Google OAuth(YouTube 게시) + FFmpeg",
297
+ "summary": "story 스토리보드·영상 리서치/제작·YouTube 업로드와 공개 상태 관리",
298
298
  "tools": [
299
+ "youtube_upload_video",
300
+ "youtube_get_video_status",
301
+ "youtube_update_video_privacy",
299
302
  "video_plan_from_story",
300
303
  "video_research_youtube",
301
304
  "video_search_stock_assets",