workplace-pua-cli 0.4.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.
Files changed (112) hide show
  1. package/.env.example +4 -0
  2. package/.eslintrc.json +21 -0
  3. package/.prettierrc.json +9 -0
  4. package/CHANGELOG.md +107 -0
  5. package/README.md +240 -0
  6. package/bin/pua +2 -0
  7. package/dist/commands/chat.d.ts +15 -0
  8. package/dist/commands/chat.d.ts.map +1 -0
  9. package/dist/commands/chat.js +262 -0
  10. package/dist/commands/chat.js.map +1 -0
  11. package/dist/commands/config.d.ts +15 -0
  12. package/dist/commands/config.d.ts.map +1 -0
  13. package/dist/commands/config.js +247 -0
  14. package/dist/commands/config.js.map +1 -0
  15. package/dist/commands/prompt.d.ts +14 -0
  16. package/dist/commands/prompt.d.ts.map +1 -0
  17. package/dist/commands/prompt.js +126 -0
  18. package/dist/commands/prompt.js.map +1 -0
  19. package/dist/config/providers.d.ts +37 -0
  20. package/dist/config/providers.d.ts.map +1 -0
  21. package/dist/config/providers.js +96 -0
  22. package/dist/config/providers.js.map +1 -0
  23. package/dist/config/session-storage.d.ts +29 -0
  24. package/dist/config/session-storage.d.ts.map +1 -0
  25. package/dist/config/session-storage.js +67 -0
  26. package/dist/config/session-storage.js.map +1 -0
  27. package/dist/config/settings.d.ts +55 -0
  28. package/dist/config/settings.d.ts.map +1 -0
  29. package/dist/config/settings.js +163 -0
  30. package/dist/config/settings.js.map +1 -0
  31. package/dist/config/storage.d.ts +69 -0
  32. package/dist/config/storage.d.ts.map +1 -0
  33. package/dist/config/storage.js +126 -0
  34. package/dist/config/storage.js.map +1 -0
  35. package/dist/history/session.d.ts +52 -0
  36. package/dist/history/session.d.ts.map +1 -0
  37. package/dist/history/session.js +122 -0
  38. package/dist/history/session.js.map +1 -0
  39. package/dist/index.d.ts +3 -0
  40. package/dist/index.d.ts.map +1 -0
  41. package/dist/index.js +157 -0
  42. package/dist/index.js.map +1 -0
  43. package/dist/llm/base.d.ts +38 -0
  44. package/dist/llm/base.d.ts.map +1 -0
  45. package/dist/llm/base.js +22 -0
  46. package/dist/llm/base.js.map +1 -0
  47. package/dist/llm/factory.d.ts +12 -0
  48. package/dist/llm/factory.d.ts.map +1 -0
  49. package/dist/llm/factory.js +26 -0
  50. package/dist/llm/factory.js.map +1 -0
  51. package/dist/llm/openai.d.ts +10 -0
  52. package/dist/llm/openai.d.ts.map +1 -0
  53. package/dist/llm/openai.js +97 -0
  54. package/dist/llm/openai.js.map +1 -0
  55. package/dist/llm/zhipu.d.ts +10 -0
  56. package/dist/llm/zhipu.d.ts.map +1 -0
  57. package/dist/llm/zhipu.js +91 -0
  58. package/dist/llm/zhipu.js.map +1 -0
  59. package/dist/prompts/boss.d.ts +6 -0
  60. package/dist/prompts/boss.d.ts.map +1 -0
  61. package/dist/prompts/boss.js +41 -0
  62. package/dist/prompts/boss.js.map +1 -0
  63. package/dist/prompts/employee.d.ts +6 -0
  64. package/dist/prompts/employee.d.ts.map +1 -0
  65. package/dist/prompts/employee.js +41 -0
  66. package/dist/prompts/employee.js.map +1 -0
  67. package/dist/prompts/index.d.ts +4 -0
  68. package/dist/prompts/index.d.ts.map +1 -0
  69. package/dist/prompts/index.js +9 -0
  70. package/dist/prompts/index.js.map +1 -0
  71. package/dist/utils/formatter.d.ts +25 -0
  72. package/dist/utils/formatter.d.ts.map +1 -0
  73. package/dist/utils/formatter.js +83 -0
  74. package/dist/utils/formatter.js.map +1 -0
  75. package/dist/utils/logger.d.ts +10 -0
  76. package/dist/utils/logger.d.ts.map +1 -0
  77. package/dist/utils/logger.js +31 -0
  78. package/dist/utils/logger.js.map +1 -0
  79. package/dist/utils/stream.d.ts +36 -0
  80. package/dist/utils/stream.d.ts.map +1 -0
  81. package/dist/utils/stream.js +74 -0
  82. package/dist/utils/stream.js.map +1 -0
  83. package/docs/OPTIMIZATION.md +772 -0
  84. package/docs/TECHNICAL_PRINCIPLES.md +663 -0
  85. package/package.json +52 -0
  86. package/sample/1.png +0 -0
  87. package/sample/2.png +0 -0
  88. package/screenshots/chat-dialogue.png +0 -0
  89. package/screenshots/chat-mode.png +0 -0
  90. package/src/__tests__/config/settings.test.ts +48 -0
  91. package/src/__tests__/prompts/boss.test.ts +35 -0
  92. package/src/commands/chat.ts +328 -0
  93. package/src/commands/config.ts +283 -0
  94. package/src/commands/prompt.ts +154 -0
  95. package/src/config/providers.ts +109 -0
  96. package/src/config/session-storage.ts +94 -0
  97. package/src/config/settings.ts +194 -0
  98. package/src/config/storage.ts +150 -0
  99. package/src/history/session.ts +141 -0
  100. package/src/index.ts +164 -0
  101. package/src/llm/base.ts +55 -0
  102. package/src/llm/factory.ts +24 -0
  103. package/src/llm/openai.ts +113 -0
  104. package/src/llm/zhipu.ts +101 -0
  105. package/src/prompts/boss.ts +43 -0
  106. package/src/prompts/employee.ts +43 -0
  107. package/src/prompts/index.ts +3 -0
  108. package/src/utils/formatter.ts +104 -0
  109. package/src/utils/logger.ts +31 -0
  110. package/src/utils/stream.ts +76 -0
  111. package/tsconfig.json +20 -0
  112. package/vitest.config.ts +18 -0
@@ -0,0 +1,154 @@
1
+ import readline from 'readline';
2
+ import chalk from 'chalk';
3
+ import { Message } from '../llm/base';
4
+ import { getBossSystemMessage, getEmployeeSystemMessage } from '../prompts';
5
+ import { createLLM } from '../llm/factory';
6
+ import { getProviderBaseUrl } from '../config/settings';
7
+ import { StreamPrinter } from '../utils/stream';
8
+ import { OutputFormatter, type OutputFormat } from '../utils/formatter';
9
+ import { logger } from '../utils/logger';
10
+ import { type ProviderType } from '../config/providers';
11
+
12
+ export interface PromptOptions {
13
+ apiKey: string;
14
+ provider: ProviderType;
15
+ model: string;
16
+ role: 'boss' | 'employee';
17
+ severity: 'mild' | 'medium' | 'extreme';
18
+ input?: string;
19
+ format?: OutputFormat;
20
+ }
21
+
22
+ export async function promptCommand(options: PromptOptions): Promise<void> {
23
+ // Get input from argument or stdin
24
+ let userInput = options.input;
25
+
26
+ if (!userInput) {
27
+ // Read from stdin if available (pipe mode)
28
+ if (!process.stdin.isTTY) {
29
+ userInput = await readFromStdin();
30
+ }
31
+ }
32
+
33
+ if (!userInput) {
34
+ logger.error('请提供输入内容');
35
+ console.log(chalk.gray('用法: pua prompt --role boss "你的问题"'));
36
+ console.log(chalk.gray('或者: echo "你的问题" | pua prompt --role boss'));
37
+ process.exit(1);
38
+ }
39
+
40
+ // Set up system message
41
+ const systemMessage =
42
+ options.role === 'boss'
43
+ ? getBossSystemMessage(options.severity)
44
+ : getEmployeeSystemMessage(options.severity);
45
+
46
+ const messages: Message[] = [
47
+ { role: 'system', content: systemMessage },
48
+ { role: 'user', content: userInput }
49
+ ];
50
+
51
+ // Create LLM instance
52
+ const llm = createLLM(options.provider, {
53
+ apiKey: options.apiKey,
54
+ model: options.model,
55
+ baseUrl: getProviderBaseUrl(options.provider),
56
+ });
57
+
58
+ const printer = new StreamPrinter(
59
+ options.role === 'boss' ? chalk.red : chalk.yellow
60
+ );
61
+
62
+ const formatter = new OutputFormatter(options.format);
63
+
64
+ try {
65
+ const roleLabel = options.role === 'boss' ? '老板' : '员工';
66
+ console.log();
67
+ console.log(chalk.gray(`┌─ ${roleLabel} ─────────────────────────────`));
68
+
69
+ let fullResponse = '';
70
+
71
+ await llm.chatStream(messages, (chunk) => {
72
+ if (chunk.content) {
73
+ fullResponse += chunk.content;
74
+ // 只有在文本格式时才流式输出
75
+ if (!options.format || options.format === 'text') {
76
+ process.stdout.write(chunk.content);
77
+ }
78
+ }
79
+ });
80
+
81
+ console.log();
82
+ console.log(chalk.gray('└─────────────────────────────────────'));
83
+ console.log();
84
+
85
+ // 使用格式化器输出
86
+ if (options.format && options.format !== 'text') {
87
+ formatter.print({
88
+ format: options.format,
89
+ content: fullResponse,
90
+ metadata: {
91
+ role: options.role,
92
+ severity: options.severity,
93
+ provider: options.provider,
94
+ model: options.model
95
+ }
96
+ });
97
+ } else {
98
+ // Print just the response for piping purposes
99
+ console.log(fullResponse);
100
+ }
101
+
102
+ } catch (error) {
103
+ printer.printError(error instanceof Error ? error.message : String(error));
104
+ process.exit(1);
105
+ }
106
+ }
107
+
108
+ function readFromStdin(): Promise<string> {
109
+ return new Promise((resolve) => {
110
+ let data = '';
111
+
112
+ const rl = readline.createInterface({
113
+ input: process.stdin,
114
+ crlfDelay: Infinity
115
+ });
116
+
117
+ rl.on('line', (line) => {
118
+ data += line + '\n';
119
+ });
120
+
121
+ rl.on('close', () => {
122
+ resolve(data.trim());
123
+ });
124
+ });
125
+ }
126
+
127
+ export async function promptBatchCommand(inputs: string[], options: PromptOptions): Promise<void> {
128
+ // Set up system message
129
+ const systemMessage =
130
+ options.role === 'boss'
131
+ ? getBossSystemMessage(options.severity)
132
+ : getEmployeeSystemMessage(options.severity);
133
+
134
+ // Create LLM instance
135
+ const llm = createLLM(options.provider, {
136
+ apiKey: options.apiKey,
137
+ model: options.model,
138
+ baseUrl: getProviderBaseUrl(options.provider),
139
+ });
140
+
141
+ for (const input of inputs) {
142
+ const messages: Message[] = [
143
+ { role: 'system', content: systemMessage },
144
+ { role: 'user', content: input }
145
+ ];
146
+
147
+ try {
148
+ const response = await llm.chat(messages);
149
+ console.log(response);
150
+ } catch (error) {
151
+ logger.error(error instanceof Error ? error.message : String(error));
152
+ }
153
+ }
154
+ }
@@ -0,0 +1,109 @@
1
+ export type ProviderType = 'zhipu' | 'openai';
2
+
3
+ export interface ProviderDefinition {
4
+ id: ProviderType;
5
+ name: string;
6
+ description: string;
7
+ defaultBaseUrl: string;
8
+ defaultModels: string[];
9
+ envKeyNames: string[];
10
+ }
11
+
12
+ export const PROVIDERS: Record<ProviderType, ProviderDefinition> = {
13
+ zhipu: {
14
+ id: 'zhipu',
15
+ name: '智谱 AI',
16
+ description: '国产大模型,稳定可靠,响应快速',
17
+ defaultBaseUrl: '',
18
+ defaultModels: ['glm-4.7', 'glm-4.7-flash'],
19
+ envKeyNames: ['ZHIPUAI_API_KEY'],
20
+ },
21
+ openai: {
22
+ id: 'openai',
23
+ name: 'OpenAI',
24
+ description: '国际通用,支持 GPT-4o、GPT-4o-mini 等模型',
25
+ defaultBaseUrl: 'https://api.openai.com/v1',
26
+ defaultModels: ['gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo', 'gpt-3.5-turbo'],
27
+ envKeyNames: ['OPENAI_API_KEY'],
28
+ },
29
+ };
30
+
31
+ /**
32
+ * Get provider definition by ID
33
+ */
34
+ export function getProvider(id: string): ProviderDefinition | null {
35
+ return PROVIDERS[id as ProviderType] ?? null;
36
+ }
37
+
38
+ /**
39
+ * Get all provider IDs
40
+ */
41
+ export function getProviderIds(): ProviderType[] {
42
+ return Object.keys(PROVIDERS) as ProviderType[];
43
+ }
44
+
45
+ /**
46
+ * Validate API key format (basic validation)
47
+ */
48
+ export function validateApiKey(provider: ProviderType, apiKey: string): { valid: boolean; error?: string } {
49
+ if (!apiKey || apiKey.trim().length === 0) {
50
+ return { valid: false, error: 'API Key 不能为空' };
51
+ }
52
+
53
+ // Provider-specific validation
54
+ switch (provider) {
55
+ case 'zhipu':
56
+ // 智谱 API key 通常以特定格式开头
57
+ if (apiKey.length < 10) {
58
+ return { valid: false, error: '智谱 API Key 格式不正确(太短)' };
59
+ }
60
+ break;
61
+
62
+ case 'openai':
63
+ // OpenAI API key 通常以 sk- 开头
64
+ if (!apiKey.startsWith('sk-')) {
65
+ return { valid: false, error: 'OpenAI API Key 通常以 sk- 开头' };
66
+ }
67
+ if (apiKey.length < 20) {
68
+ return { valid: false, error: 'OpenAI API Key 格式不正确(太短)' };
69
+ }
70
+ break;
71
+ }
72
+
73
+ return { valid: true };
74
+ }
75
+
76
+ /**
77
+ * Validate base URL format
78
+ */
79
+ export function validateBaseUrl(baseUrl: string): { valid: boolean; error?: string } {
80
+ if (!baseUrl || baseUrl.trim().length === 0) {
81
+ return { valid: true }; // Empty is OK (will use default)
82
+ }
83
+
84
+ try {
85
+ const url = new URL(baseUrl);
86
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') {
87
+ return { valid: false, error: 'Base URL 必须以 http:// 或 https:// 开头' };
88
+ }
89
+ return { valid: true };
90
+ } catch {
91
+ return { valid: false, error: 'Base URL 格式不正确' };
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Get API key from environment variables
97
+ */
98
+ export function getApiKeyFromEnv(provider: ProviderType): string | null {
99
+ const providerDef = PROVIDERS[provider];
100
+
101
+ for (const envKey of providerDef.envKeyNames) {
102
+ const value = process.env[envKey];
103
+ if (value) {
104
+ return value;
105
+ }
106
+ }
107
+
108
+ return null;
109
+ }
@@ -0,0 +1,94 @@
1
+ import path from 'path';
2
+ import fs from 'fs';
3
+ import os from 'os';
4
+
5
+ export interface SessionData {
6
+ id: string;
7
+ name: string;
8
+ description?: string;
9
+ createdAt: string;
10
+ updatedAt: string;
11
+ messages: Array<{ role: string; content: string }>;
12
+ metadata?: {
13
+ role?: string;
14
+ severity?: string;
15
+ provider?: string;
16
+ model?: string;
17
+ };
18
+ }
19
+
20
+ export class SessionStorage {
21
+ private sessionsDir: string;
22
+
23
+ constructor() {
24
+ this.sessionsDir = path.join(os.homedir(), '.pua-cli', 'sessions');
25
+ this.ensureDirectory();
26
+ }
27
+
28
+ private ensureDirectory(): void {
29
+ if (!fs.existsSync(this.sessionsDir)) {
30
+ fs.mkdirSync(this.sessionsDir, { recursive: true });
31
+ }
32
+ }
33
+
34
+ listSessions(): SessionData[] {
35
+ const files = fs.readdirSync(this.sessionsDir)
36
+ .filter(f => f.endsWith('.json'));
37
+
38
+ return files.map(file => {
39
+ const filePath = path.join(this.sessionsDir, file);
40
+ const content = fs.readFileSync(filePath, 'utf-8');
41
+ return JSON.parse(content);
42
+ })
43
+ .sort((a, b) =>
44
+ new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
45
+ );
46
+ }
47
+
48
+ loadSession(sessionId: string): SessionData | null {
49
+ const filePath = path.join(this.sessionsDir, `${sessionId}.json`);
50
+
51
+ if (!fs.existsSync(filePath)) {
52
+ return null;
53
+ }
54
+
55
+ const content = fs.readFileSync(filePath, 'utf-8');
56
+ return JSON.parse(content);
57
+ }
58
+
59
+ saveSession(sessionData: Omit<SessionData, 'id' | 'createdAt' | 'updatedAt'>): SessionData {
60
+ const sessionId = this.generateId();
61
+ const now = new Date().toISOString();
62
+
63
+ const fullSession: SessionData = {
64
+ ...sessionData,
65
+ id: sessionId,
66
+ createdAt: now,
67
+ updatedAt: now
68
+ };
69
+
70
+ const filePath = path.join(this.sessionsDir, `${sessionId}.json`);
71
+ fs.writeFileSync(filePath, JSON.stringify(fullSession, null, 2));
72
+
73
+ return fullSession;
74
+ }
75
+
76
+ deleteSession(sessionId: string): boolean {
77
+ const filePath = path.join(this.sessionsDir, `${sessionId}.json`);
78
+
79
+ if (fs.existsSync(filePath)) {
80
+ fs.unlinkSync(filePath);
81
+ return true;
82
+ }
83
+
84
+ return false;
85
+ }
86
+
87
+ private generateId(): string {
88
+ return `session-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`;
89
+ }
90
+
91
+ getSessionsDir(): string {
92
+ return this.sessionsDir;
93
+ }
94
+ }
@@ -0,0 +1,194 @@
1
+ import dotenv from 'dotenv';
2
+ import path from 'path';
3
+ import {
4
+ loadGlobalConfig,
5
+ loadProjectConfig,
6
+ getProviderApiKey,
7
+ getCurrentProvider,
8
+ hasGlobalConfig,
9
+ type GlobalConfig,
10
+ type ProjectConfig,
11
+ } from './storage';
12
+ import { getProvider, validateApiKey, getApiKeyFromEnv, type ProviderType } from './providers';
13
+
14
+ // Load environment variables
15
+ dotenv.config();
16
+
17
+ export interface RuntimeConfig {
18
+ apiKey: string;
19
+ provider: ProviderType;
20
+ model: string;
21
+ role: 'boss' | 'employee';
22
+ severity: 'mild' | 'medium' | 'extreme';
23
+ }
24
+
25
+ export const DEFAULTS = {
26
+ provider: 'zhipu' as ProviderType,
27
+ model: 'glm-4.7',
28
+ role: 'boss' as 'boss' | 'employee',
29
+ severity: 'medium' as 'mild' | 'medium' | 'extreme',
30
+ };
31
+
32
+ /**
33
+ * Load configuration with priority:
34
+ * 1. Command line options (highest)
35
+ * 2. Environment variables
36
+ * 3. Project config (.pua.json)
37
+ * 4. Global config (~/.config/pua-cli/config.json)
38
+ * 5. Defaults (lowest)
39
+ */
40
+ export function loadConfig(options?: {
41
+ provider?: ProviderType;
42
+ model?: string;
43
+ role?: 'boss' | 'employee';
44
+ severity?: 'mild' | 'medium' | 'extreme';
45
+ apiKey?: string;
46
+ }): RuntimeConfig {
47
+ // Start with defaults
48
+ let provider = DEFAULTS.provider;
49
+ let model = DEFAULTS.model;
50
+ let role = DEFAULTS.role;
51
+ let severity = DEFAULTS.severity;
52
+ let apiKey = '';
53
+
54
+ // Load global config
55
+ const globalConfig = loadGlobalConfig();
56
+
57
+ // Load project config
58
+ const projectConfig = loadProjectConfig();
59
+
60
+ // Apply global config
61
+ if (globalConfig) {
62
+ provider = globalConfig.defaults.provider as ProviderType || provider;
63
+ model = globalConfig.defaults.model || model;
64
+ role = globalConfig.defaults.role as 'boss' | 'employee' || role;
65
+ severity = globalConfig.defaults.severity as 'mild' | 'medium' | 'extreme' || severity;
66
+ apiKey = globalConfig.providers[globalConfig.currentProvider]?.apiKey || '';
67
+ }
68
+
69
+ // Apply project config
70
+ if (projectConfig) {
71
+ if (projectConfig.provider) provider = projectConfig.provider as ProviderType;
72
+ if (projectConfig.model) model = projectConfig.model;
73
+ if (projectConfig.role) role = projectConfig.role as 'boss' | 'employee';
74
+ if (projectConfig.severity) severity = projectConfig.severity as 'mild' | 'medium' | 'extreme';
75
+ }
76
+
77
+ // Apply environment variables
78
+ const envApiKey = getApiKeyFromEnv(provider);
79
+ if (envApiKey) {
80
+ apiKey = envApiKey;
81
+ }
82
+
83
+ // Apply command line options (highest priority)
84
+ if (options) {
85
+ if (options.provider) provider = options.provider;
86
+ if (options.model) model = options.model;
87
+ if (options.role) role = options.role;
88
+ if (options.severity) severity = options.severity;
89
+ if (options.apiKey) apiKey = options.apiKey;
90
+ }
91
+
92
+ // Ensure API key is available
93
+ if (!apiKey) {
94
+ throw new MissingApiKeyError(provider);
95
+ }
96
+
97
+ return {
98
+ apiKey,
99
+ provider,
100
+ model,
101
+ role,
102
+ severity,
103
+ };
104
+ }
105
+
106
+ /**
107
+ * Error thrown when API key is missing
108
+ */
109
+ export class MissingApiKeyError extends Error {
110
+ constructor(provider: ProviderType) {
111
+ const providerDef = getProvider(provider);
112
+ const envKey = providerDef?.envKeyNames[0] || 'API_KEY';
113
+
114
+ super(
115
+ `未找到 ${providerDef?.name || provider} 的 API Key\n\n` +
116
+ `请使用以下方式之一配置:\n` +
117
+ ` 1. 运行: pua config\n` +
118
+ ` 2. 设置环境变量: export ${envKey}="your-api-key"\n` +
119
+ ` 3. 创建 .env 文件并添加: ${envKey}=your-api-key`
120
+ );
121
+ this.name = 'MissingApiKeyError';
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Check if user needs onboarding
127
+ */
128
+ export function needsOnboarding(): boolean {
129
+ return !hasGlobalConfig();
130
+ }
131
+
132
+ /**
133
+ * Get provider's base URL
134
+ */
135
+ export function getProviderBaseUrl(provider: ProviderType): string {
136
+ const globalConfig = loadGlobalConfig();
137
+ const providerConfig = globalConfig?.providers[provider];
138
+
139
+ if (providerConfig?.baseUrl) {
140
+ return providerConfig.baseUrl;
141
+ }
142
+
143
+ const providerDef = getProvider(provider);
144
+ return providerDef?.defaultBaseUrl || '';
145
+ }
146
+
147
+ /**
148
+ * Get available models for a provider
149
+ */
150
+ export function getProviderModels(provider: ProviderType): string[] {
151
+ const globalConfig = loadGlobalConfig();
152
+ const providerConfig = globalConfig?.providers[provider];
153
+
154
+ if (providerConfig?.models && providerConfig.models.length > 0) {
155
+ return providerConfig.models;
156
+ }
157
+
158
+ const providerDef = getProvider(provider);
159
+ return providerDef?.defaultModels || [];
160
+ }
161
+
162
+ /**
163
+ * Validate a config object
164
+ */
165
+ export function validateConfig(config: RuntimeConfig): { valid: boolean; errors: string[] } {
166
+ const errors: string[] = [];
167
+
168
+ // Validate provider
169
+ const providerDef = getProvider(config.provider);
170
+ if (!providerDef) {
171
+ errors.push(`无效的 provider: ${config.provider}`);
172
+ }
173
+
174
+ // Validate API key
175
+ if (providerDef) {
176
+ const keyValidation = validateApiKey(config.provider, config.apiKey);
177
+ if (!keyValidation.valid) {
178
+ errors.push(keyValidation.error || 'API Key 无效');
179
+ }
180
+ }
181
+
182
+ // Validate model
183
+ const availableModels = getProviderModels(config.provider);
184
+ if (availableModels.length > 0 && !availableModels.includes(config.model)) {
185
+ errors.push(
186
+ `模型 ${config.model} 在 provider ${config.provider} 中不可用。可用模型: ${availableModels.join(', ')}`
187
+ );
188
+ }
189
+
190
+ return {
191
+ valid: errors.length === 0,
192
+ errors,
193
+ };
194
+ }
@@ -0,0 +1,150 @@
1
+ import os from 'os';
2
+ import path from 'path';
3
+ import fs from 'fs';
4
+
5
+ export interface ProviderConfig {
6
+ apiKey: string;
7
+ baseUrl: string;
8
+ models: string[];
9
+ }
10
+
11
+ export interface GlobalConfig {
12
+ currentProvider: string;
13
+ providers: {
14
+ [key: string]: ProviderConfig;
15
+ };
16
+ defaults: {
17
+ provider: string;
18
+ model: string;
19
+ role: 'boss' | 'employee';
20
+ severity: 'mild' | 'medium' | 'extreme';
21
+ };
22
+ onboardingCompleted: boolean;
23
+ }
24
+
25
+ export interface ProjectConfig {
26
+ provider?: string;
27
+ model?: string;
28
+ role?: 'boss' | 'employee';
29
+ severity?: 'mild' | 'medium' | 'extreme';
30
+ }
31
+
32
+ /**
33
+ * Get the config directory path following XDG Base Directory Specification
34
+ */
35
+ export function getConfigDir(): string {
36
+ const platform = process.platform;
37
+
38
+ if (platform === 'win32') {
39
+ // Windows: %APPDATA%\pua-cli
40
+ return path.join(process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), 'pua-cli');
41
+ } else {
42
+ // Linux/macOS: ~/.config/pua-cli
43
+ return path.join(os.homedir(), '.config', 'pua-cli');
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Get the global config file path
49
+ */
50
+ export function getGlobalConfigPath(): string {
51
+ return path.join(getConfigDir(), 'config.json');
52
+ }
53
+
54
+ /**
55
+ * Get the project config file path
56
+ */
57
+ export function getProjectConfigPath(): string {
58
+ return path.join(process.cwd(), '.pua.json');
59
+ }
60
+
61
+ /**
62
+ * Ensure config directory exists
63
+ */
64
+ export function ensureConfigDir(): void {
65
+ const configDir = getConfigDir();
66
+ if (!fs.existsSync(configDir)) {
67
+ fs.mkdirSync(configDir, { recursive: true });
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Load global config from file
73
+ */
74
+ export function loadGlobalConfig(): GlobalConfig | null {
75
+ const configPath = getGlobalConfigPath();
76
+
77
+ if (!fs.existsSync(configPath)) {
78
+ return null;
79
+ }
80
+
81
+ try {
82
+ const content = fs.readFileSync(configPath, 'utf-8');
83
+ return JSON.parse(content) as GlobalConfig;
84
+ } catch (error) {
85
+ throw new Error(`Failed to load config from ${configPath}: ${error instanceof Error ? error.message : String(error)}`);
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Save global config to file
91
+ */
92
+ export function saveGlobalConfig(config: GlobalConfig): void {
93
+ ensureConfigDir();
94
+ const configPath = getGlobalConfigPath();
95
+
96
+ try {
97
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
98
+ } catch (error) {
99
+ throw new Error(`Failed to save config to ${configPath}: ${error instanceof Error ? error.message : String(error)}`);
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Load project config from file
105
+ */
106
+ export function loadProjectConfig(): ProjectConfig | null {
107
+ const configPath = getProjectConfigPath();
108
+
109
+ if (!fs.existsSync(configPath)) {
110
+ return null;
111
+ }
112
+
113
+ try {
114
+ const content = fs.readFileSync(configPath, 'utf-8');
115
+ return JSON.parse(content) as ProjectConfig;
116
+ } catch (error) {
117
+ throw new Error(`Failed to load project config from ${configPath}: ${error instanceof Error ? error.message : String(error)}`);
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Check if global config exists
123
+ */
124
+ export function hasGlobalConfig(): boolean {
125
+ return fs.existsSync(getGlobalConfigPath());
126
+ }
127
+
128
+ /**
129
+ * Check if onboarding has been completed
130
+ */
131
+ export function isOnboardingCompleted(): boolean {
132
+ const config = loadGlobalConfig();
133
+ return config?.onboardingCompleted ?? false;
134
+ }
135
+
136
+ /**
137
+ * Get API key for a provider from config
138
+ */
139
+ export function getProviderApiKey(provider: string): string | null {
140
+ const config = loadGlobalConfig();
141
+ return config?.providers[provider]?.apiKey ?? null;
142
+ }
143
+
144
+ /**
145
+ * Get current provider
146
+ */
147
+ export function getCurrentProvider(): string | null {
148
+ const config = loadGlobalConfig();
149
+ return config?.currentProvider ?? null;
150
+ }