autocrew 0.1.0 → 0.3.0

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.
@@ -0,0 +1,111 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import fs from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { migrateProfileToServices } from "./migrate.js";
6
+
7
+ let testDir: string;
8
+
9
+ beforeEach(async () => {
10
+ testDir = await fs.mkdtemp(path.join(os.tmpdir(), "autocrew-migrate-test-"));
11
+ });
12
+
13
+ afterEach(async () => {
14
+ await fs.rm(testDir, { recursive: true, force: true });
15
+ });
16
+
17
+ describe("migrateProfileToServices", () => {
18
+ it("moves omniConfig and videoCrawler from profile to services", async () => {
19
+ const profile = {
20
+ industry: "tech",
21
+ platforms: ["xhs"],
22
+ contentTypes: ["video"],
23
+ tone: "casual",
24
+ omniConfig: {
25
+ baseUrl: "https://api.xiaomimimo.com/v1",
26
+ model: "mimo-v2-omni",
27
+ apiKey: "sk-omni-test-123",
28
+ },
29
+ videoCrawler: {
30
+ type: "mediacrawl",
31
+ command: "python3 /opt/mediacrawl/main.py",
32
+ },
33
+ createdAt: "2026-01-01T00:00:00.000Z",
34
+ updatedAt: "2026-01-01T00:00:00.000Z",
35
+ };
36
+ await fs.writeFile(
37
+ path.join(testDir, "creator-profile.json"),
38
+ JSON.stringify(profile, null, 2),
39
+ "utf-8",
40
+ );
41
+
42
+ const result = await migrateProfileToServices(testDir);
43
+ expect(result.migrated).toBe(true);
44
+
45
+ // Verify services.json was created with correct data
46
+ const svcRaw = await fs.readFile(path.join(testDir, "services.json"), "utf-8");
47
+ const svc = JSON.parse(svcRaw);
48
+ expect(svc.omni.provider).toBe("xiaomi");
49
+ expect(svc.omni.apiKey).toBe("sk-omni-test-123");
50
+ expect(svc.omni.baseUrl).toBe("https://api.xiaomimimo.com/v1");
51
+ expect(svc.omni.model).toBe("mimo-v2-omni");
52
+ expect(svc.videoCrawler.type).toBe("mediacrawl");
53
+ expect(svc.videoCrawler.command).toBe("python3 /opt/mediacrawl/main.py");
54
+
55
+ // Verify profile was cleaned up
56
+ const profileRaw = await fs.readFile(path.join(testDir, "creator-profile.json"), "utf-8");
57
+ const updatedProfile = JSON.parse(profileRaw);
58
+ expect(updatedProfile.omniConfig).toBeUndefined();
59
+ expect(updatedProfile.videoCrawler).toBeUndefined();
60
+ expect(updatedProfile.industry).toBe("tech");
61
+ expect(updatedProfile.updatedAt).not.toBe("2026-01-01T00:00:00.000Z");
62
+ });
63
+
64
+ it("skips migration when services.json already exists", async () => {
65
+ const profile = {
66
+ industry: "tech",
67
+ platforms: ["xhs"],
68
+ omniConfig: {
69
+ baseUrl: "https://api.xiaomimimo.com/v1",
70
+ model: "mimo-v2-omni",
71
+ apiKey: "sk-omni-test-123",
72
+ },
73
+ createdAt: "2026-01-01T00:00:00.000Z",
74
+ updatedAt: "2026-01-01T00:00:00.000Z",
75
+ };
76
+ await fs.writeFile(
77
+ path.join(testDir, "creator-profile.json"),
78
+ JSON.stringify(profile, null, 2),
79
+ "utf-8",
80
+ );
81
+ await fs.writeFile(
82
+ path.join(testDir, "services.json"),
83
+ JSON.stringify({ configuredAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z" }),
84
+ "utf-8",
85
+ );
86
+
87
+ const result = await migrateProfileToServices(testDir);
88
+ expect(result.migrated).toBe(false);
89
+ expect(result.reason).toContain("already exists");
90
+ });
91
+
92
+ it("handles profile without old fields gracefully", async () => {
93
+ const profile = {
94
+ industry: "tech",
95
+ platforms: ["xhs"],
96
+ contentTypes: ["video"],
97
+ tone: "casual",
98
+ createdAt: "2026-01-01T00:00:00.000Z",
99
+ updatedAt: "2026-01-01T00:00:00.000Z",
100
+ };
101
+ await fs.writeFile(
102
+ path.join(testDir, "creator-profile.json"),
103
+ JSON.stringify(profile, null, 2),
104
+ "utf-8",
105
+ );
106
+
107
+ const result = await migrateProfileToServices(testDir);
108
+ expect(result.migrated).toBe(false);
109
+ expect(result.reason).toContain("nothing to migrate");
110
+ });
111
+ });
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Migration — moves legacy omniConfig/videoCrawler fields from
3
+ * creator-profile.json into the new services.json format.
4
+ */
5
+ import fs from "node:fs/promises";
6
+ import path from "node:path";
7
+ import { saveServiceConfig, type ServiceConfig } from "./service-config.js";
8
+
9
+ const PROFILE_FILE = "creator-profile.json";
10
+ const SERVICE_FILE = "services.json";
11
+
12
+ function getDataDir(customDir?: string): string {
13
+ if (customDir) return customDir;
14
+ const home = process.env.HOME || process.env.USERPROFILE || "~";
15
+ return path.join(home, ".autocrew");
16
+ }
17
+
18
+ export async function migrateProfileToServices(
19
+ dataDir?: string,
20
+ ): Promise<{ migrated: boolean; reason?: string }> {
21
+ const dir = getDataDir(dataDir);
22
+ const servicesPath = path.join(dir, SERVICE_FILE);
23
+ const profilePath = path.join(dir, PROFILE_FILE);
24
+
25
+ // 1. Bail if services.json already exists
26
+ try {
27
+ await fs.access(servicesPath);
28
+ return { migrated: false, reason: "services.json already exists" };
29
+ } catch {
30
+ // File doesn't exist — continue
31
+ }
32
+
33
+ // 2. Load profile as raw JSON
34
+ let raw: Record<string, unknown>;
35
+ try {
36
+ const content = await fs.readFile(profilePath, "utf-8");
37
+ raw = JSON.parse(content) as Record<string, unknown>;
38
+ } catch {
39
+ return { migrated: false, reason: "nothing to migrate" };
40
+ }
41
+
42
+ // 3. Check if there's anything to migrate
43
+ if (!raw.omniConfig && !raw.videoCrawler) {
44
+ return { migrated: false, reason: "nothing to migrate" };
45
+ }
46
+
47
+ // 4. Build ServiceConfig from old fields
48
+ const now = new Date().toISOString();
49
+ const svcConfig: ServiceConfig = {
50
+ configuredAt: now,
51
+ updatedAt: now,
52
+ };
53
+
54
+ if (raw.omniConfig) {
55
+ const old = raw.omniConfig as Record<string, string>;
56
+ svcConfig.omni = {
57
+ provider: "xiaomi",
58
+ baseUrl: old.baseUrl,
59
+ model: old.model,
60
+ apiKey: old.apiKey,
61
+ };
62
+ }
63
+
64
+ if (raw.videoCrawler) {
65
+ const old = raw.videoCrawler as Record<string, string>;
66
+ svcConfig.videoCrawler = {
67
+ type: old.type as "mediacrawl" | "playwright" | "manual",
68
+ command: old.command,
69
+ };
70
+ }
71
+
72
+ // 5. Save new services.json
73
+ await saveServiceConfig(svcConfig, dataDir);
74
+
75
+ // 6. Clean up profile
76
+ delete raw.omniConfig;
77
+ delete raw.videoCrawler;
78
+ raw.updatedAt = now;
79
+ await fs.writeFile(profilePath, JSON.stringify(raw, null, 2), "utf-8");
80
+
81
+ // 7. Done
82
+ return { migrated: true };
83
+ }
@@ -0,0 +1,140 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import fs from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import {
6
+ loadServiceConfig,
7
+ saveServiceConfig,
8
+ detectConfigGaps,
9
+ type ServiceConfig,
10
+ } from "./service-config.js";
11
+
12
+ let testDir: string;
13
+
14
+ beforeEach(async () => {
15
+ testDir = await fs.mkdtemp(path.join(os.tmpdir(), "autocrew-svcconfig-test-"));
16
+ });
17
+
18
+ afterEach(async () => {
19
+ await fs.rm(testDir, { recursive: true, force: true });
20
+ });
21
+
22
+ describe("loadServiceConfig", () => {
23
+ it("returns empty config when file does not exist", async () => {
24
+ const config = await loadServiceConfig(testDir);
25
+ expect(config.configuredAt).toBeTruthy();
26
+ expect(config.omni).toBeUndefined();
27
+ expect(config.coverGen).toBeUndefined();
28
+ });
29
+ });
30
+
31
+ describe("saveServiceConfig / loadServiceConfig round-trip", () => {
32
+ it("saves and loads config with omni and coverGen", async () => {
33
+ const config = await loadServiceConfig(testDir);
34
+ const full: ServiceConfig = {
35
+ ...config,
36
+ omni: {
37
+ provider: "mimo",
38
+ baseUrl: "https://api.xiaomimimo.com/v1",
39
+ model: "mimo-v2-omni",
40
+ apiKey: "sk-omni-123",
41
+ },
42
+ coverGen: {
43
+ provider: "flux",
44
+ apiKey: "sk-cover-456",
45
+ },
46
+ };
47
+ await saveServiceConfig(full, testDir);
48
+ const loaded = await loadServiceConfig(testDir);
49
+ expect(loaded.omni?.provider).toBe("mimo");
50
+ expect(loaded.omni?.apiKey).toBe("sk-omni-123");
51
+ expect(loaded.coverGen?.provider).toBe("flux");
52
+ expect(loaded.coverGen?.apiKey).toBe("sk-cover-456");
53
+ });
54
+
55
+ it("saves and loads all service modules", async () => {
56
+ const config = await loadServiceConfig(testDir);
57
+ const full: ServiceConfig = {
58
+ ...config,
59
+ omni: {
60
+ provider: "mimo",
61
+ baseUrl: "https://api.xiaomimimo.com/v1",
62
+ model: "mimo-v2-omni",
63
+ apiKey: "sk-omni-123",
64
+ },
65
+ coverGen: {
66
+ provider: "flux",
67
+ apiKey: "sk-cover-456",
68
+ model: "flux-pro",
69
+ },
70
+ videoCrawler: {
71
+ type: "mediacrawl",
72
+ command: "python3 /opt/mediacrawl/main.py",
73
+ },
74
+ tts: {
75
+ provider: "fish-audio",
76
+ apiKey: "sk-tts-789",
77
+ voice: "zh-female-1",
78
+ },
79
+ platforms: {
80
+ xhs: { configured: true, lastAuth: "2026-04-01T00:00:00.000Z" },
81
+ douyin: { configured: false },
82
+ },
83
+ intelSources: {
84
+ rssConfigured: true,
85
+ trendsConfigured: false,
86
+ competitorsConfigured: true,
87
+ },
88
+ };
89
+ await saveServiceConfig(full, testDir);
90
+ const loaded = await loadServiceConfig(testDir);
91
+ expect(loaded.omni?.apiKey).toBe("sk-omni-123");
92
+ expect(loaded.coverGen?.model).toBe("flux-pro");
93
+ expect(loaded.videoCrawler?.type).toBe("mediacrawl");
94
+ expect(loaded.tts?.voice).toBe("zh-female-1");
95
+ expect(loaded.platforms?.xhs.configured).toBe(true);
96
+ expect(loaded.intelSources?.rssConfigured).toBe(true);
97
+ expect(loaded.intelSources?.competitorsConfigured).toBe(true);
98
+ });
99
+ });
100
+
101
+ describe("detectConfigGaps", () => {
102
+ it("reports all gaps when nothing is configured", async () => {
103
+ const gaps = await detectConfigGaps(testDir);
104
+ expect(gaps).toHaveLength(6);
105
+ const modules = gaps.map((g) => g.module);
106
+ expect(modules).toContain("omni");
107
+ expect(modules).toContain("coverGen");
108
+ expect(modules).toContain("videoCrawler");
109
+ expect(modules).toContain("tts");
110
+ expect(modules).toContain("platforms");
111
+ expect(modules).toContain("intelSources");
112
+ });
113
+
114
+ it("reports no gap for configured modules", async () => {
115
+ const config = await loadServiceConfig(testDir);
116
+ const withOmni: ServiceConfig = {
117
+ ...config,
118
+ omni: {
119
+ provider: "mimo",
120
+ baseUrl: "https://api.xiaomimimo.com/v1",
121
+ model: "mimo-v2-omni",
122
+ apiKey: "sk-omni-123",
123
+ },
124
+ };
125
+ await saveServiceConfig(withOmni, testDir);
126
+ const gaps = await detectConfigGaps(testDir);
127
+ const modules = gaps.map((g) => g.module);
128
+ expect(modules).not.toContain("omni");
129
+ expect(modules).toContain("coverGen");
130
+ });
131
+
132
+ it("each gap has module, feature, and impact", async () => {
133
+ const gaps = await detectConfigGaps(testDir);
134
+ for (const gap of gaps) {
135
+ expect(gap.module).toBeTruthy();
136
+ expect(gap.feature).toBeTruthy();
137
+ expect(gap.impact).toBeTruthy();
138
+ }
139
+ });
140
+ });
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Service Config — Tool/API configurations stored at ~/.autocrew/services.json
3
+ *
4
+ * Separate from creator-profile.json. This file tracks which external services
5
+ * (LLM, cover gen, TTS, platforms, etc.) are configured and ready to use.
6
+ */
7
+ import fs from "node:fs/promises";
8
+ import path from "node:path";
9
+
10
+ export interface OmniServiceConfig {
11
+ provider: string;
12
+ baseUrl: string;
13
+ model: string;
14
+ apiKey: string;
15
+ }
16
+
17
+ export interface CoverGenConfig {
18
+ provider: string;
19
+ apiKey: string;
20
+ model?: string;
21
+ }
22
+
23
+ export interface VideoCrawlerServiceConfig {
24
+ type: "mediacrawl" | "playwright" | "manual";
25
+ command?: string;
26
+ }
27
+
28
+ export interface TTSConfig {
29
+ provider: string;
30
+ baseUrl?: string;
31
+ apiKey: string;
32
+ voice?: string;
33
+ }
34
+
35
+ export interface PlatformAuthStatus {
36
+ configured: boolean;
37
+ lastAuth?: string;
38
+ }
39
+
40
+ export interface IntelSourcesStatus {
41
+ rssConfigured: boolean;
42
+ trendsConfigured: boolean;
43
+ competitorsConfigured: boolean;
44
+ }
45
+
46
+ export interface ServiceConfig {
47
+ omni?: OmniServiceConfig;
48
+ coverGen?: CoverGenConfig;
49
+ videoCrawler?: VideoCrawlerServiceConfig;
50
+ tts?: TTSConfig;
51
+ platforms?: Record<string, PlatformAuthStatus>;
52
+ intelSources?: IntelSourcesStatus;
53
+ configuredAt: string;
54
+ updatedAt: string;
55
+ }
56
+
57
+ export interface ConfigGap {
58
+ module: string;
59
+ feature: string;
60
+ impact: string;
61
+ }
62
+
63
+ const SERVICE_FILE = "services.json";
64
+
65
+ function getDataDir(customDir?: string): string {
66
+ if (customDir) return customDir;
67
+ const home = process.env.HOME || process.env.USERPROFILE || "~";
68
+ return path.join(home, ".autocrew");
69
+ }
70
+
71
+ function emptyServiceConfig(): ServiceConfig {
72
+ const now = new Date().toISOString();
73
+ return {
74
+ configuredAt: now,
75
+ updatedAt: now,
76
+ };
77
+ }
78
+
79
+ /**
80
+ * Load the service config. Returns empty config if file doesn't exist.
81
+ */
82
+ export async function loadServiceConfig(dataDir?: string): Promise<ServiceConfig> {
83
+ const filePath = path.join(getDataDir(dataDir), SERVICE_FILE);
84
+ try {
85
+ const raw = await fs.readFile(filePath, "utf-8");
86
+ return JSON.parse(raw) as ServiceConfig;
87
+ } catch {
88
+ return emptyServiceConfig();
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Save the service config (overwrite). Updates the updatedAt timestamp.
94
+ */
95
+ export async function saveServiceConfig(config: ServiceConfig, dataDir?: string): Promise<void> {
96
+ const dir = getDataDir(dataDir);
97
+ await fs.mkdir(dir, { recursive: true });
98
+ config.updatedAt = new Date().toISOString();
99
+ await fs.writeFile(path.join(dir, SERVICE_FILE), JSON.stringify(config, null, 2), "utf-8");
100
+ }
101
+
102
+ /**
103
+ * Detect which service modules are unconfigured.
104
+ * Returns a list of gaps with module name, feature description, and impact.
105
+ */
106
+ export async function detectConfigGaps(dataDir?: string): Promise<ConfigGap[]> {
107
+ const c = await loadServiceConfig(dataDir);
108
+ const gaps: ConfigGap[] = [];
109
+
110
+ if (!c.omni?.apiKey) {
111
+ gaps.push({ module: "omni", feature: "视频分析 (Omni)", impact: "视频拆解功能不可用" });
112
+ }
113
+
114
+ if (!c.coverGen?.apiKey) {
115
+ gaps.push({ module: "coverGen", feature: "封面生成", impact: "AI 封面生成不可用" });
116
+ }
117
+
118
+ if (!(c.videoCrawler && c.videoCrawler.type !== "manual")) {
119
+ gaps.push({ module: "videoCrawler", feature: "视频采集器", impact: "视频链接下载需手动操作" });
120
+ }
121
+
122
+ if (!c.tts?.apiKey) {
123
+ gaps.push({ module: "tts", feature: "TTS 语音合成", impact: "视频配音不可用" });
124
+ }
125
+
126
+ const hasConfiguredPlatform = c.platforms
127
+ && Object.values(c.platforms).some((p) => p.configured);
128
+ if (!hasConfiguredPlatform) {
129
+ gaps.push({ module: "platforms", feature: "发布平台", impact: "自动发布不可用" });
130
+ }
131
+
132
+ const hasIntelSource = c.intelSources
133
+ && (c.intelSources.rssConfigured || c.intelSources.trendsConfigured || c.intelSources.competitorsConfigured);
134
+ if (!hasIntelSource) {
135
+ gaps.push({ module: "intelSources", feature: "情报源", impact: "RSS/趋势/竞品监控为空" });
136
+ }
137
+
138
+ return gaps;
139
+ }
@@ -112,24 +112,27 @@ describe("Pipeline Integration — full flow", () => {
112
112
 
113
113
  const files = await fs.readdir(projectDir);
114
114
  expect(files).toContain("meta.yaml");
115
- expect(files).toContain("draft-v1.md");
116
115
  expect(files).toContain("draft.md");
116
+ // No draft-v*.md exists on initial create — only after revisions replace draft.md
117
+ expect(files.filter((f) => f.startsWith("draft-v"))).toHaveLength(0);
117
118
 
118
119
  const projectName = slugify("高分选题");
119
120
 
120
- // 5. Add draft version → meta.yaml updated with v2
121
+ // 5. Add draft version → original content archived to draft-v1.md, draft.md updated
122
+ // Content must be ≥100 chars to pass the drafting→production gate
121
123
  await addDraftVersion(
122
124
  projectName,
123
- "# 高分选题\n\n第二版内容",
125
+ "# 高分选题\n\n这是一篇经过深度调研的内容,包含具体案例分析、数据支撑和可操作的建议。第二版在第一版基础上增加了更多具体的AI工具评测数据和用户反馈,覆盖了Cursor、Claude Code、Windsurf三款主流AI编程工具的横向对比。",
124
126
  "improved draft",
125
127
  testDir,
126
128
  );
127
129
 
128
130
  let meta = await getProjectMeta(projectName, testDir);
129
131
  expect(meta).not.toBeNull();
130
- expect(meta!.versions.length).toBe(2);
131
- expect(meta!.current).toBe("draft-v2.md");
132
- expect(meta!.versions[1].note).toBe("improved draft");
132
+ expect(meta!.versions.length).toBe(1);
133
+ expect(meta!.versions[0].file).toBe("draft-v1.md");
134
+ expect(meta!.current).toBe("draft.md");
135
+ expect(meta!.versions[0].note).toBe("improved draft");
133
136
 
134
137
  // 6. Advance: drafting → production → published
135
138
  await advanceProject(projectName, testDir);
@@ -183,8 +186,8 @@ describe("Pipeline Integration — trash and restore", () => {
183
186
  // Verify state is preserved
184
187
  const meta = await getProjectMeta(projectName, testDir);
185
188
  expect(meta).not.toBeNull();
186
- expect(meta!.versions.length).toBe(2);
187
- expect(meta!.current).toBe("draft-v2.md");
189
+ expect(meta!.versions.length).toBe(1);
190
+ expect(meta!.current).toBe("draft.md");
188
191
  expect(meta!.title).toBe("回收还原测试");
189
192
 
190
193
  // History should show: drafting → trash → drafting
@@ -142,6 +142,57 @@ describe("addCompetitor", () => {
142
142
  });
143
143
  });
144
144
 
145
+ describe("videoCrawler and omniConfig", () => {
146
+ it("saves and loads videoCrawler and omniConfig", async () => {
147
+ const profile = await initProfile(testDir);
148
+ const full: CreatorProfile = {
149
+ ...profile,
150
+ videoCrawler: { type: "mediacrawl", command: "python3 /opt/mediacrawl/main.py" },
151
+ omniConfig: {
152
+ baseUrl: "https://api.xiaomimimo.com/v1",
153
+ model: "mimo-v2-omni",
154
+ apiKey: "sk-test-key-123",
155
+ },
156
+ };
157
+ await saveProfile(full, testDir);
158
+ const loaded = await loadProfile(testDir);
159
+ expect(loaded).not.toBeNull();
160
+ expect(loaded!.videoCrawler).toEqual({ type: "mediacrawl", command: "python3 /opt/mediacrawl/main.py" });
161
+ expect(loaded!.omniConfig).toEqual({
162
+ baseUrl: "https://api.xiaomimimo.com/v1",
163
+ model: "mimo-v2-omni",
164
+ apiKey: "sk-test-key-123",
165
+ });
166
+ });
167
+
168
+ it("loads profile without video config (backward compatible)", async () => {
169
+ // Write a minimal JSON file without the new fields
170
+ const filePath = path.join(testDir, "creator-profile.json");
171
+ const legacy = {
172
+ industry: "科技",
173
+ platforms: ["xhs"],
174
+ audiencePersona: null,
175
+ creatorPersona: null,
176
+ writingRules: [],
177
+ styleBoundaries: { never: [], always: [] },
178
+ competitorAccounts: [],
179
+ performanceHistory: [],
180
+ expressionPersona: "",
181
+ secondaryPersonas: [],
182
+ styleCalibrated: false,
183
+ createdAt: "2025-01-01T00:00:00.000Z",
184
+ updatedAt: "2025-01-01T00:00:00.000Z",
185
+ };
186
+ await fs.writeFile(filePath, JSON.stringify(legacy, null, 2), "utf-8");
187
+
188
+ const loaded = await loadProfile(testDir);
189
+ expect(loaded).not.toBeNull();
190
+ expect(loaded!.industry).toBe("科技");
191
+ expect(loaded!.videoCrawler).toBeUndefined();
192
+ expect(loaded!.omniConfig).toBeUndefined();
193
+ });
194
+ });
195
+
145
196
  describe("detectMissingInfo", () => {
146
197
  it("reports all missing fields on empty profile", async () => {
147
198
  const profile = await initProfile(testDir);
@@ -63,6 +63,21 @@ export interface ContentPillar {
63
63
  exampleAngles: string[];
64
64
  }
65
65
 
66
+ export interface VideoCrawlerConfig {
67
+ type: "mediacrawl" | "playwright" | "manual";
68
+ /** Command to run for mediacrawl mode, e.g. "python3 /path/to/main.py" */
69
+ command?: string;
70
+ }
71
+
72
+ export interface OmniConfig {
73
+ /** API base URL, default "https://api.xiaomimimo.com/v1" */
74
+ baseUrl: string;
75
+ /** Model ID, default "mimo-v2-omni" */
76
+ model: string;
77
+ /** API key */
78
+ apiKey: string;
79
+ }
80
+
66
81
  export interface CreatorProfile {
67
82
  /** User's content industry/niche */
68
83
  industry: string;
@@ -86,6 +101,10 @@ export interface CreatorProfile {
86
101
  secondaryPersonas: AudiencePersona[];
87
102
  /** Content pillars defining the creator's content strategy */
88
103
  contentPillars?: ContentPillar[];
104
+ /** Video crawler configuration for teardown video acquisition */
105
+ videoCrawler?: VideoCrawlerConfig;
106
+ /** Omni model configuration for multimodal video analysis */
107
+ omniConfig?: OmniConfig;
89
108
  /** Whether style calibration has been completed */
90
109
  styleCalibrated: boolean;
91
110
  /** Profile creation timestamp */
@@ -158,15 +177,26 @@ export async function saveProfile(profile: CreatorProfile, dataDir?: string): Pr
158
177
  }
159
178
 
160
179
  /**
161
- * Initialize a new empty profile. No-op if one already exists.
180
+ * Initialize a new empty profile. No-op if the file already exists on disk.
162
181
  * Returns the profile (existing or newly created).
182
+ *
183
+ * IMPORTANT: We check file existence, not parsability. If the file exists but
184
+ * can't be parsed, we return an empty in-memory profile but do NOT overwrite
185
+ * the file — protecting user data from being clobbered by a re-init.
163
186
  */
164
187
  export async function initProfile(dataDir?: string): Promise<CreatorProfile> {
165
- const existing = await loadProfile(dataDir);
166
- if (existing) return existing;
167
- const profile = emptyProfile();
168
- await saveProfile(profile, dataDir);
169
- return profile;
188
+ const filePath = path.join(getDataDir(dataDir), PROFILE_FILE);
189
+ try {
190
+ await fs.access(filePath);
191
+ // File exists — try to load it, but never overwrite
192
+ const existing = await loadProfile(dataDir);
193
+ return existing ?? emptyProfile();
194
+ } catch {
195
+ // File does not exist — safe to create
196
+ const profile = emptyProfile();
197
+ await saveProfile(profile, dataDir);
198
+ return profile;
199
+ }
170
200
  }
171
201
 
172
202
  /**