prompt-language-shell 0.8.0 → 0.8.4
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/dist/services/anthropic.js +50 -37
- package/dist/services/colors.js +21 -1
- package/dist/services/components.js +6 -14
- package/dist/services/config-labels.js +75 -0
- package/dist/services/config-utils.js +20 -0
- package/dist/services/configuration.js +38 -59
- package/dist/services/filesystem.js +114 -0
- package/dist/services/loader.js +8 -5
- package/dist/services/logger.js +24 -0
- package/dist/services/parser.js +3 -1
- package/dist/services/refinement.js +9 -9
- package/dist/services/registry.js +1 -1
- package/dist/services/router.js +29 -28
- package/dist/services/skills.js +15 -14
- package/dist/services/validator.js +4 -3
- package/dist/skills/introspect.md +52 -43
- package/dist/skills/schedule.md +8 -3
- package/dist/tools/introspect.tool.js +18 -9
- package/dist/types/guards.js +23 -0
- package/dist/types/handlers.js +1 -0
- package/dist/types/schemas.js +103 -0
- package/dist/types/types.js +7 -0
- package/dist/ui/Answer.js +11 -15
- package/dist/ui/Command.js +24 -20
- package/dist/ui/Config.js +44 -32
- package/dist/ui/Confirm.js +9 -9
- package/dist/ui/Execute.js +265 -75
- package/dist/ui/Feedback.js +1 -0
- package/dist/ui/Introspect.js +16 -61
- package/dist/ui/Main.js +6 -6
- package/dist/ui/Report.js +4 -8
- package/dist/ui/Schedule.js +12 -12
- package/dist/ui/Spinner.js +3 -1
- package/dist/ui/Subtask.js +1 -1
- package/dist/ui/Task.js +7 -6
- package/dist/ui/Validate.js +28 -21
- package/dist/ui/Workflow.js +102 -30
- package/package.json +3 -2
|
@@ -3,6 +3,7 @@ import { getAvailableConfigStructure, getConfiguredKeys, } from './configuration
|
|
|
3
3
|
import { logPrompt, logResponse } from './logger.js';
|
|
4
4
|
import { formatSkillsForPrompt, loadSkillsWithValidation } from './skills.js';
|
|
5
5
|
import { toolRegistry } from './registry.js';
|
|
6
|
+
import { CommandResultSchema, IntrospectResultSchema, } from '../types/schemas.js';
|
|
6
7
|
/**
|
|
7
8
|
* Wraps text to ensure no line exceeds the specified width.
|
|
8
9
|
* Breaks at word boundaries to maintain readability.
|
|
@@ -45,11 +46,24 @@ export function cleanAnswerText(text) {
|
|
|
45
46
|
cleaned = wrapText(cleaned, 80);
|
|
46
47
|
return cleaned;
|
|
47
48
|
}
|
|
49
|
+
/**
|
|
50
|
+
* Formats Zod validation errors into readable error messages.
|
|
51
|
+
* Provides detailed information about what failed validation.
|
|
52
|
+
*/
|
|
53
|
+
function formatValidationError(error) {
|
|
54
|
+
const issues = error.issues
|
|
55
|
+
.map((issue) => {
|
|
56
|
+
const path = issue.path.length > 0 ? issue.path.join('.') : 'root';
|
|
57
|
+
return ` - ${path}: ${issue.message}`;
|
|
58
|
+
})
|
|
59
|
+
.join('\n');
|
|
60
|
+
return `LLM response validation failed:\n${issues}`;
|
|
61
|
+
}
|
|
48
62
|
export class AnthropicService {
|
|
49
63
|
client;
|
|
50
64
|
model;
|
|
51
|
-
constructor(key, model = 'claude-haiku-4-5
|
|
52
|
-
this.client = new Anthropic({ apiKey: key });
|
|
65
|
+
constructor(key, model = 'claude-haiku-4-5', timeout = 30000) {
|
|
66
|
+
this.client = new Anthropic({ apiKey: key, timeout });
|
|
53
67
|
this.model = model;
|
|
54
68
|
}
|
|
55
69
|
async processWithTool(command, toolName, customInstructions) {
|
|
@@ -148,62 +162,61 @@ export class AnthropicService {
|
|
|
148
162
|
const input = content.input;
|
|
149
163
|
// Handle execute tool response
|
|
150
164
|
if (toolName === 'execute') {
|
|
151
|
-
|
|
152
|
-
throw new Error('Invalid tool response: missing or invalid message field');
|
|
153
|
-
}
|
|
154
|
-
if (!input.commands || !Array.isArray(input.commands)) {
|
|
155
|
-
throw new Error('Invalid tool response: missing or invalid commands array');
|
|
156
|
-
}
|
|
157
|
-
// Validate each command has required fields
|
|
158
|
-
input.commands.forEach((cmd, i) => {
|
|
159
|
-
if (!cmd.description || typeof cmd.description !== 'string') {
|
|
160
|
-
throw new Error(`Invalid command at index ${String(i)}: missing or invalid 'description' field`);
|
|
161
|
-
}
|
|
162
|
-
if (!cmd.command || typeof cmd.command !== 'string') {
|
|
163
|
-
throw new Error(`Invalid command at index ${String(i)}: missing or invalid 'command' field`);
|
|
164
|
-
}
|
|
165
|
-
});
|
|
166
|
-
return {
|
|
165
|
+
const validation = CommandResultSchema.safeParse({
|
|
167
166
|
message: input.message,
|
|
168
167
|
summary: input.summary,
|
|
169
168
|
tasks: [],
|
|
170
169
|
commands: input.commands,
|
|
171
170
|
debug,
|
|
172
|
-
};
|
|
171
|
+
});
|
|
172
|
+
if (!validation.success) {
|
|
173
|
+
throw new Error(`I received an unexpected response while preparing to execute commands:\n${formatValidationError(validation.error)}`);
|
|
174
|
+
}
|
|
175
|
+
return validation.data;
|
|
173
176
|
}
|
|
174
177
|
// Handle answer tool response
|
|
175
178
|
if (toolName === 'answer') {
|
|
179
|
+
// Validate question and answer fields exist
|
|
176
180
|
if (!input.question || typeof input.question !== 'string') {
|
|
177
|
-
throw new Error('
|
|
181
|
+
throw new Error('I received an unexpected response while answering your question:\nLLM response validation failed:\n - question: missing or invalid');
|
|
178
182
|
}
|
|
179
183
|
if (!input.answer || typeof input.answer !== 'string') {
|
|
180
|
-
throw new Error('
|
|
184
|
+
throw new Error('I received an unexpected response while answering your question:\nLLM response validation failed:\n - answer: missing or invalid');
|
|
181
185
|
}
|
|
182
|
-
|
|
186
|
+
// Validate the result structure with Zod
|
|
187
|
+
const validation = CommandResultSchema.safeParse({
|
|
183
188
|
message: '',
|
|
184
189
|
tasks: [],
|
|
185
190
|
answer: cleanAnswerText(input.answer),
|
|
186
191
|
debug,
|
|
187
|
-
};
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
}
|
|
193
|
-
if (!input.tasks || !Array.isArray(input.tasks)) {
|
|
194
|
-
throw new Error('Invalid tool response: missing or invalid tasks array');
|
|
192
|
+
});
|
|
193
|
+
if (!validation.success) {
|
|
194
|
+
throw new Error(`I received an unexpected response while answering your question:\n${formatValidationError(validation.error)}`);
|
|
195
|
+
}
|
|
196
|
+
return validation.data;
|
|
195
197
|
}
|
|
196
|
-
//
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
198
|
+
// Handle introspect tool response
|
|
199
|
+
if (toolName === 'introspect') {
|
|
200
|
+
const validation = IntrospectResultSchema.safeParse({
|
|
201
|
+
message: input.message,
|
|
202
|
+
capabilities: input.capabilities,
|
|
203
|
+
debug,
|
|
204
|
+
});
|
|
205
|
+
if (!validation.success) {
|
|
206
|
+
throw new Error(`I received an unexpected response while listing capabilities:\n${formatValidationError(validation.error)}`);
|
|
200
207
|
}
|
|
201
|
-
|
|
202
|
-
|
|
208
|
+
return validation.data;
|
|
209
|
+
}
|
|
210
|
+
// Handle schedule tool responses
|
|
211
|
+
const validation = CommandResultSchema.safeParse({
|
|
203
212
|
message: input.message,
|
|
204
213
|
tasks: input.tasks,
|
|
205
214
|
debug,
|
|
206
|
-
};
|
|
215
|
+
});
|
|
216
|
+
if (!validation.success) {
|
|
217
|
+
throw new Error(`I received an unexpected response while planning tasks:\n${formatValidationError(validation.error)}`);
|
|
218
|
+
}
|
|
219
|
+
return validation.data;
|
|
207
220
|
}
|
|
208
221
|
}
|
|
209
222
|
export function createAnthropicService(config) {
|
package/dist/services/colors.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { FeedbackType, TaskType } from '../types/types.js';
|
|
1
|
+
import { FeedbackType, Origin, TaskType } from '../types/types.js';
|
|
2
2
|
import { DebugLevel } from './configuration.js';
|
|
3
3
|
import { ExecutionStatus } from './shell.js';
|
|
4
4
|
/**
|
|
@@ -128,10 +128,19 @@ const taskColors = {
|
|
|
128
128
|
*/
|
|
129
129
|
const feedbackColors = {
|
|
130
130
|
[FeedbackType.Info]: Colors.Status.Info,
|
|
131
|
+
[FeedbackType.Warning]: Palette.Yellow,
|
|
131
132
|
[FeedbackType.Succeeded]: Colors.Status.Success,
|
|
132
133
|
[FeedbackType.Aborted]: Palette.MediumOrange,
|
|
133
134
|
[FeedbackType.Failed]: Colors.Status.Error,
|
|
134
135
|
};
|
|
136
|
+
/**
|
|
137
|
+
* Origin-specific color mappings (internal)
|
|
138
|
+
*/
|
|
139
|
+
const originColors = {
|
|
140
|
+
[Origin.BuiltIn]: Colors.Origin.BuiltIn,
|
|
141
|
+
[Origin.UserProvided]: Colors.Origin.UserProvided,
|
|
142
|
+
[Origin.Indirect]: Colors.Origin.Indirect,
|
|
143
|
+
};
|
|
135
144
|
/**
|
|
136
145
|
* Process null color values based on current/historical state.
|
|
137
146
|
*
|
|
@@ -170,6 +179,17 @@ export function getTaskColors(type, isCurrent) {
|
|
|
170
179
|
export function getFeedbackColor(type, isCurrent) {
|
|
171
180
|
return processColor(feedbackColors[type], isCurrent);
|
|
172
181
|
}
|
|
182
|
+
/**
|
|
183
|
+
* Get color for capability origin.
|
|
184
|
+
*
|
|
185
|
+
* Returns the color associated with each origin type:
|
|
186
|
+
* - BuiltIn: Cyan
|
|
187
|
+
* - UserProvided: Green
|
|
188
|
+
* - Indirect: Purple
|
|
189
|
+
*/
|
|
190
|
+
export function getOriginColor(origin) {
|
|
191
|
+
return originColors[origin];
|
|
192
|
+
}
|
|
173
193
|
/**
|
|
174
194
|
* Get text color based on current/historical state.
|
|
175
195
|
*
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
3
2
|
import { parse as parseYaml } from 'yaml';
|
|
4
3
|
import { ComponentStatus, } from '../types/components.js';
|
|
5
4
|
import { ComponentName } from '../types/types.js';
|
|
6
5
|
import { ConfigDefinitionType, getConfigPath, getConfigSchema, loadConfig, } from './configuration.js';
|
|
6
|
+
import { defaultFileSystem } from './filesystem.js';
|
|
7
7
|
import { getConfirmationMessage } from './messages.js';
|
|
8
8
|
import { StepType } from '../ui/Config.js';
|
|
9
9
|
export function createWelcomeDefinition(app) {
|
|
@@ -55,13 +55,13 @@ function getValidator(definition) {
|
|
|
55
55
|
/**
|
|
56
56
|
* Create config steps from schema for specified keys
|
|
57
57
|
*/
|
|
58
|
-
export function createConfigStepsFromSchema(keys) {
|
|
58
|
+
export function createConfigStepsFromSchema(keys, fs = defaultFileSystem) {
|
|
59
59
|
const schema = getConfigSchema();
|
|
60
60
|
let currentConfig = null;
|
|
61
61
|
let rawConfig = null;
|
|
62
62
|
// Load validated config (may fail if config has validation errors)
|
|
63
63
|
try {
|
|
64
|
-
currentConfig = loadConfig();
|
|
64
|
+
currentConfig = loadConfig(fs);
|
|
65
65
|
}
|
|
66
66
|
catch {
|
|
67
67
|
// Config doesn't exist or has validation errors, use defaults
|
|
@@ -69,8 +69,8 @@ export function createConfigStepsFromSchema(keys) {
|
|
|
69
69
|
// Load raw config separately (for discovered keys not in schema)
|
|
70
70
|
try {
|
|
71
71
|
const configFile = getConfigPath();
|
|
72
|
-
if (
|
|
73
|
-
const content =
|
|
72
|
+
if (fs.exists(configFile)) {
|
|
73
|
+
const content = fs.readFile(configFile, 'utf-8');
|
|
74
74
|
rawConfig = parseYaml(content);
|
|
75
75
|
}
|
|
76
76
|
}
|
|
@@ -78,7 +78,7 @@ export function createConfigStepsFromSchema(keys) {
|
|
|
78
78
|
// Config file doesn't exist or can't be parsed
|
|
79
79
|
}
|
|
80
80
|
return keys.map((key) => {
|
|
81
|
-
// Check if key is in schema (
|
|
81
|
+
// Check if key is in schema (system config)
|
|
82
82
|
if (!(key in schema)) {
|
|
83
83
|
// Key is not in schema - it's from a skill or discovered config
|
|
84
84
|
// Create a simple text step with the full path as description
|
|
@@ -382,11 +382,3 @@ export function createValidateDefinition(missingConfig, userRequest, service, on
|
|
|
382
382
|
},
|
|
383
383
|
};
|
|
384
384
|
}
|
|
385
|
-
/**
|
|
386
|
-
* Add debug components to timeline if present in result
|
|
387
|
-
*/
|
|
388
|
-
export function addDebugToTimeline(debugComponents, handlers) {
|
|
389
|
-
if (debugComponents && debugComponents.length > 0 && handlers) {
|
|
390
|
-
handlers.addToTimeline(...debugComponents);
|
|
391
|
-
}
|
|
392
|
-
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
|
2
|
+
import { homedir } from 'os';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
/**
|
|
5
|
+
* Get the path to the config labels cache file
|
|
6
|
+
*/
|
|
7
|
+
export function getConfigLabelsCachePath() {
|
|
8
|
+
return join(homedir(), '.pls', 'cache', 'config.json');
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Get the cache directory path
|
|
12
|
+
*/
|
|
13
|
+
function getCacheDirectoryPath() {
|
|
14
|
+
return join(homedir(), '.pls', 'cache');
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Ensure the cache directory exists
|
|
18
|
+
*/
|
|
19
|
+
function ensureCacheDirectoryExists() {
|
|
20
|
+
const cacheDir = getCacheDirectoryPath();
|
|
21
|
+
if (!existsSync(cacheDir)) {
|
|
22
|
+
mkdirSync(cacheDir, { recursive: true });
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Load config labels from cache file
|
|
27
|
+
* Returns empty object if file doesn't exist or is corrupted
|
|
28
|
+
*/
|
|
29
|
+
export function loadConfigLabels() {
|
|
30
|
+
try {
|
|
31
|
+
const cachePath = getConfigLabelsCachePath();
|
|
32
|
+
if (!existsSync(cachePath)) {
|
|
33
|
+
return {};
|
|
34
|
+
}
|
|
35
|
+
const content = readFileSync(cachePath, 'utf-8');
|
|
36
|
+
const parsed = JSON.parse(content);
|
|
37
|
+
// Validate that parsed content is an object
|
|
38
|
+
if (typeof parsed !== 'object' ||
|
|
39
|
+
parsed === null ||
|
|
40
|
+
Array.isArray(parsed)) {
|
|
41
|
+
return {};
|
|
42
|
+
}
|
|
43
|
+
return parsed;
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
// Return empty object on any error (parse error, read error, etc.)
|
|
47
|
+
return {};
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Save multiple config labels to cache
|
|
52
|
+
*/
|
|
53
|
+
export function saveConfigLabels(labels) {
|
|
54
|
+
ensureCacheDirectoryExists();
|
|
55
|
+
// Load existing labels and merge with new ones
|
|
56
|
+
const existing = loadConfigLabels();
|
|
57
|
+
const merged = { ...existing, ...labels };
|
|
58
|
+
const cachePath = getConfigLabelsCachePath();
|
|
59
|
+
const content = JSON.stringify(merged, null, 2);
|
|
60
|
+
writeFileSync(cachePath, content, 'utf-8');
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Save a single config label to cache
|
|
64
|
+
*/
|
|
65
|
+
export function saveConfigLabel(key, label) {
|
|
66
|
+
saveConfigLabels({ [key]: label });
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Get a config label from cache
|
|
70
|
+
* Returns undefined if label doesn't exist
|
|
71
|
+
*/
|
|
72
|
+
export function getConfigLabel(key) {
|
|
73
|
+
const labels = loadConfigLabels();
|
|
74
|
+
return labels[key];
|
|
75
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Utility functions for config manipulation
|
|
3
|
+
*/
|
|
4
|
+
/**
|
|
5
|
+
* Flatten nested config object to dot notation
|
|
6
|
+
* Example: { a: { b: 1 } } => { 'a.b': 1 }
|
|
7
|
+
*/
|
|
8
|
+
export function flattenConfig(obj, prefix = '') {
|
|
9
|
+
const result = {};
|
|
10
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
11
|
+
const fullKey = prefix ? `${prefix}.${key}` : key;
|
|
12
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
13
|
+
Object.assign(result, flattenConfig(value, fullKey));
|
|
14
|
+
}
|
|
15
|
+
else {
|
|
16
|
+
result[fullKey] = value;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return result;
|
|
20
|
+
}
|
|
@@ -1,7 +1,19 @@
|
|
|
1
|
-
import { existsSync, readFileSync, writeFileSync } from 'fs';
|
|
2
1
|
import { homedir } from 'os';
|
|
3
2
|
import { join } from 'path';
|
|
4
3
|
import YAML from 'yaml';
|
|
4
|
+
import { getConfigLabel } from './config-labels.js';
|
|
5
|
+
import { flattenConfig } from './config-utils.js';
|
|
6
|
+
import { defaultFileSystem } from './filesystem.js';
|
|
7
|
+
/**
|
|
8
|
+
* Convert a dotted config key to a readable label
|
|
9
|
+
* Example: "project.alpha.repo" -> "Project Alpha Repo"
|
|
10
|
+
*/
|
|
11
|
+
function keyToLabel(key) {
|
|
12
|
+
return key
|
|
13
|
+
.split('.')
|
|
14
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
15
|
+
.join(' ');
|
|
16
|
+
}
|
|
5
17
|
export var AnthropicModel;
|
|
6
18
|
(function (AnthropicModel) {
|
|
7
19
|
AnthropicModel["Sonnet"] = "claude-sonnet-4-5";
|
|
@@ -84,20 +96,20 @@ function validateConfig(parsed) {
|
|
|
84
96
|
}
|
|
85
97
|
return validatedConfig;
|
|
86
98
|
}
|
|
87
|
-
export function loadConfig() {
|
|
99
|
+
export function loadConfig(fs = defaultFileSystem) {
|
|
88
100
|
const configFile = getConfigFile();
|
|
89
|
-
if (!
|
|
101
|
+
if (!fs.exists(configFile)) {
|
|
90
102
|
throw new ConfigError('Configuration not found');
|
|
91
103
|
}
|
|
92
|
-
const content =
|
|
104
|
+
const content = fs.readFile(configFile, 'utf-8');
|
|
93
105
|
const parsed = parseYamlConfig(content);
|
|
94
106
|
return validateConfig(parsed);
|
|
95
107
|
}
|
|
96
108
|
export function getConfigPath() {
|
|
97
109
|
return getConfigFile();
|
|
98
110
|
}
|
|
99
|
-
export function configExists() {
|
|
100
|
-
return
|
|
111
|
+
export function configExists(fs = defaultFileSystem) {
|
|
112
|
+
return fs.exists(getConfigFile());
|
|
101
113
|
}
|
|
102
114
|
export function isValidAnthropicApiKey(key) {
|
|
103
115
|
// Anthropic API keys format: sk-ant-api03-XXXXX (108 chars total)
|
|
@@ -137,24 +149,24 @@ export function mergeConfig(existingContent, sectionName, newValues) {
|
|
|
137
149
|
// Convert back to YAML
|
|
138
150
|
return YAML.stringify(sortedConfig);
|
|
139
151
|
}
|
|
140
|
-
export function saveConfig(section, config) {
|
|
152
|
+
export function saveConfig(section, config, fs = defaultFileSystem) {
|
|
141
153
|
const configFile = getConfigFile();
|
|
142
|
-
const existingContent =
|
|
143
|
-
?
|
|
154
|
+
const existingContent = fs.exists(configFile)
|
|
155
|
+
? fs.readFile(configFile, 'utf-8')
|
|
144
156
|
: '';
|
|
145
157
|
const newContent = mergeConfig(existingContent, section, config);
|
|
146
|
-
|
|
158
|
+
fs.writeFile(configFile, newContent);
|
|
147
159
|
}
|
|
148
|
-
export function saveAnthropicConfig(config) {
|
|
149
|
-
saveConfig('anthropic', config);
|
|
150
|
-
return loadConfig();
|
|
160
|
+
export function saveAnthropicConfig(config, fs = defaultFileSystem) {
|
|
161
|
+
saveConfig('anthropic', config, fs);
|
|
162
|
+
return loadConfig(fs);
|
|
151
163
|
}
|
|
152
|
-
export function saveDebugSetting(debug) {
|
|
153
|
-
saveConfig('settings', { debug });
|
|
164
|
+
export function saveDebugSetting(debug, fs = defaultFileSystem) {
|
|
165
|
+
saveConfig('settings', { debug }, fs);
|
|
154
166
|
}
|
|
155
|
-
export function loadDebugSetting() {
|
|
167
|
+
export function loadDebugSetting(fs = defaultFileSystem) {
|
|
156
168
|
try {
|
|
157
|
-
const config = loadConfig();
|
|
169
|
+
const config = loadConfig(fs);
|
|
158
170
|
return config.settings?.debug ?? DebugLevel.None;
|
|
159
171
|
}
|
|
160
172
|
catch {
|
|
@@ -192,7 +204,7 @@ export function getConfigurationRequiredMessage(forFutureUse = false) {
|
|
|
192
204
|
return messages[Math.floor(Math.random() * messages.length)];
|
|
193
205
|
}
|
|
194
206
|
/**
|
|
195
|
-
* Core configuration schema - defines structure and types for
|
|
207
|
+
* Core configuration schema - defines structure and types for system settings
|
|
196
208
|
*/
|
|
197
209
|
const coreConfigSchema = {
|
|
198
210
|
'anthropic.key': {
|
|
@@ -292,28 +304,15 @@ export function getMissingConfigKeys() {
|
|
|
292
304
|
* Get list of configured keys from config file
|
|
293
305
|
* Returns array of dot-notation keys that exist in the config file
|
|
294
306
|
*/
|
|
295
|
-
export function getConfiguredKeys() {
|
|
307
|
+
export function getConfiguredKeys(fs = defaultFileSystem) {
|
|
296
308
|
try {
|
|
297
309
|
const configFile = getConfigFile();
|
|
298
|
-
if (!
|
|
310
|
+
if (!fs.exists(configFile)) {
|
|
299
311
|
return [];
|
|
300
312
|
}
|
|
301
|
-
const content =
|
|
313
|
+
const content = fs.readFile(configFile, 'utf-8');
|
|
302
314
|
const parsed = YAML.parse(content);
|
|
303
315
|
// Flatten nested config to dot notation
|
|
304
|
-
function flattenConfig(obj, prefix = '') {
|
|
305
|
-
const result = {};
|
|
306
|
-
for (const [key, value] of Object.entries(obj)) {
|
|
307
|
-
const fullKey = prefix ? `${prefix}.${key}` : key;
|
|
308
|
-
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
309
|
-
Object.assign(result, flattenConfig(value, fullKey));
|
|
310
|
-
}
|
|
311
|
-
else {
|
|
312
|
-
result[fullKey] = value;
|
|
313
|
-
}
|
|
314
|
-
}
|
|
315
|
-
return result;
|
|
316
|
-
}
|
|
317
316
|
const flatConfig = flattenConfig(parsed);
|
|
318
317
|
return Object.keys(flatConfig);
|
|
319
318
|
}
|
|
@@ -326,30 +325,17 @@ export function getConfiguredKeys() {
|
|
|
326
325
|
* Returns keys with descriptions only (no values for privacy)
|
|
327
326
|
* Marks optional keys as "(optional)"
|
|
328
327
|
*/
|
|
329
|
-
export function getAvailableConfigStructure() {
|
|
328
|
+
export function getAvailableConfigStructure(fs = defaultFileSystem) {
|
|
330
329
|
const schema = getConfigSchema();
|
|
331
330
|
const structure = {};
|
|
332
331
|
// Try to load existing config to see which keys are already set
|
|
333
332
|
let flatConfig = {};
|
|
334
333
|
try {
|
|
335
334
|
const configFile = getConfigFile();
|
|
336
|
-
if (
|
|
337
|
-
const content =
|
|
335
|
+
if (fs.exists(configFile)) {
|
|
336
|
+
const content = fs.readFile(configFile, 'utf-8');
|
|
338
337
|
const parsed = YAML.parse(content);
|
|
339
338
|
// Flatten nested config to dot notation
|
|
340
|
-
function flattenConfig(obj, prefix = '') {
|
|
341
|
-
const result = {};
|
|
342
|
-
for (const [key, value] of Object.entries(obj)) {
|
|
343
|
-
const fullKey = prefix ? `${prefix}.${key}` : key;
|
|
344
|
-
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
345
|
-
Object.assign(result, flattenConfig(value, fullKey));
|
|
346
|
-
}
|
|
347
|
-
else {
|
|
348
|
-
result[fullKey] = value;
|
|
349
|
-
}
|
|
350
|
-
}
|
|
351
|
-
return result;
|
|
352
|
-
}
|
|
353
339
|
flatConfig = flattenConfig(parsed);
|
|
354
340
|
}
|
|
355
341
|
}
|
|
@@ -357,20 +343,13 @@ export function getAvailableConfigStructure() {
|
|
|
357
343
|
// Config file doesn't exist or can't be read
|
|
358
344
|
}
|
|
359
345
|
// Add schema keys with descriptions
|
|
360
|
-
// Mark optional keys as (optional)
|
|
361
346
|
for (const [key, definition] of Object.entries(schema)) {
|
|
362
|
-
|
|
363
|
-
if (isOptional) {
|
|
364
|
-
structure[key] = `${definition.description} (optional)`;
|
|
365
|
-
}
|
|
366
|
-
else {
|
|
367
|
-
structure[key] = definition.description;
|
|
368
|
-
}
|
|
347
|
+
structure[key] = definition.description;
|
|
369
348
|
}
|
|
370
349
|
// Add discovered keys that aren't in schema
|
|
371
350
|
for (const key of Object.keys(flatConfig)) {
|
|
372
351
|
if (!(key in structure)) {
|
|
373
|
-
structure[key] =
|
|
352
|
+
structure[key] = getConfigLabel(key) || keyToLabel(key);
|
|
374
353
|
}
|
|
375
354
|
}
|
|
376
355
|
return structure;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, } from 'fs';
|
|
2
|
+
import { dirname } from 'path';
|
|
3
|
+
/**
|
|
4
|
+
* Real filesystem implementation using Node's fs module
|
|
5
|
+
*/
|
|
6
|
+
export class RealFileSystem {
|
|
7
|
+
exists(path) {
|
|
8
|
+
return existsSync(path);
|
|
9
|
+
}
|
|
10
|
+
readFile(path, encoding) {
|
|
11
|
+
return readFileSync(path, encoding);
|
|
12
|
+
}
|
|
13
|
+
writeFile(path, data) {
|
|
14
|
+
writeFileSync(path, data, 'utf-8');
|
|
15
|
+
}
|
|
16
|
+
readDirectory(path) {
|
|
17
|
+
return readdirSync(path);
|
|
18
|
+
}
|
|
19
|
+
createDirectory(path, options) {
|
|
20
|
+
mkdirSync(path, options);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* In-memory filesystem implementation for testing
|
|
25
|
+
* Simulates filesystem behavior without touching disk
|
|
26
|
+
*/
|
|
27
|
+
export class MemoryFileSystem {
|
|
28
|
+
files = new Map();
|
|
29
|
+
directories = new Set();
|
|
30
|
+
exists(path) {
|
|
31
|
+
return this.files.has(path) || this.directories.has(path);
|
|
32
|
+
}
|
|
33
|
+
readFile(path, _encoding) {
|
|
34
|
+
const content = this.files.get(path);
|
|
35
|
+
if (content === undefined) {
|
|
36
|
+
throw new Error(`ENOENT: no such file or directory, open '${path}'`);
|
|
37
|
+
}
|
|
38
|
+
return content;
|
|
39
|
+
}
|
|
40
|
+
writeFile(path, data) {
|
|
41
|
+
// Auto-create parent directories
|
|
42
|
+
const dir = dirname(path);
|
|
43
|
+
if (dir !== '.' && dir !== path) {
|
|
44
|
+
this.createDirectory(dir, { recursive: true });
|
|
45
|
+
}
|
|
46
|
+
this.files.set(path, data);
|
|
47
|
+
}
|
|
48
|
+
readDirectory(path) {
|
|
49
|
+
if (!this.directories.has(path)) {
|
|
50
|
+
throw new Error(`ENOENT: no such file or directory, scandir '${path}'`);
|
|
51
|
+
}
|
|
52
|
+
const results = [];
|
|
53
|
+
const prefix = path.endsWith('/') ? path : `${path}/`;
|
|
54
|
+
// Find all direct children (files and directories)
|
|
55
|
+
for (const filePath of this.files.keys()) {
|
|
56
|
+
if (filePath.startsWith(prefix)) {
|
|
57
|
+
const relative = filePath.slice(prefix.length);
|
|
58
|
+
const firstSlash = relative.indexOf('/');
|
|
59
|
+
if (firstSlash === -1) {
|
|
60
|
+
// Direct file child
|
|
61
|
+
results.push(relative);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
for (const dirPath of this.directories) {
|
|
66
|
+
if (dirPath.startsWith(prefix) && dirPath !== path) {
|
|
67
|
+
const relative = dirPath.slice(prefix.length);
|
|
68
|
+
const firstSlash = relative.indexOf('/');
|
|
69
|
+
if (firstSlash === -1) {
|
|
70
|
+
// Direct directory child
|
|
71
|
+
results.push(relative);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return results;
|
|
76
|
+
}
|
|
77
|
+
createDirectory(path, options) {
|
|
78
|
+
if (options?.recursive) {
|
|
79
|
+
// Create all parent directories
|
|
80
|
+
const parts = path.split('/').filter((p) => p);
|
|
81
|
+
let current = path.startsWith('/') ? '/' : '';
|
|
82
|
+
for (const part of parts) {
|
|
83
|
+
current = current === '/' ? `/${part}` : `${current}/${part}`;
|
|
84
|
+
this.directories.add(current);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
// Non-recursive: parent must exist
|
|
89
|
+
const parent = dirname(path);
|
|
90
|
+
if (parent !== '.' && parent !== path && !this.directories.has(parent)) {
|
|
91
|
+
throw new Error(`ENOENT: no such file or directory, mkdir '${path}'`);
|
|
92
|
+
}
|
|
93
|
+
this.directories.add(path);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Clear all files and directories (useful for test cleanup)
|
|
98
|
+
*/
|
|
99
|
+
clear() {
|
|
100
|
+
this.files.clear();
|
|
101
|
+
this.directories.clear();
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Get all files for debugging
|
|
105
|
+
*/
|
|
106
|
+
getFiles() {
|
|
107
|
+
return new Map(this.files);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Default filesystem instance (uses real fs)
|
|
112
|
+
* Services can accept optional FileSystem parameter for testing
|
|
113
|
+
*/
|
|
114
|
+
export const defaultFileSystem = new RealFileSystem();
|
package/dist/services/loader.js
CHANGED
|
@@ -1,24 +1,27 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from 'fs';
|
|
2
1
|
import { homedir } from 'os';
|
|
3
2
|
import { join } from 'path';
|
|
4
3
|
import YAML from 'yaml';
|
|
4
|
+
import { defaultFileSystem } from './filesystem.js';
|
|
5
|
+
import { displayWarning } from './logger.js';
|
|
5
6
|
/**
|
|
6
7
|
* Load user config from ~/.plsrc
|
|
7
8
|
*/
|
|
8
|
-
export function loadUserConfig() {
|
|
9
|
+
export function loadUserConfig(fs = defaultFileSystem) {
|
|
9
10
|
const configPath = join(homedir(), '.plsrc');
|
|
10
|
-
if (!
|
|
11
|
+
if (!fs.exists(configPath)) {
|
|
11
12
|
return {};
|
|
12
13
|
}
|
|
13
14
|
try {
|
|
14
|
-
const content =
|
|
15
|
+
const content = fs.readFile(configPath, 'utf-8');
|
|
15
16
|
const parsed = YAML.parse(content);
|
|
16
17
|
if (parsed && typeof parsed === 'object') {
|
|
17
18
|
return parsed;
|
|
18
19
|
}
|
|
20
|
+
displayWarning('User config file exists but is not a valid object');
|
|
19
21
|
return {};
|
|
20
22
|
}
|
|
21
|
-
catch {
|
|
23
|
+
catch (error) {
|
|
24
|
+
displayWarning('Failed to load user config', error);
|
|
22
25
|
return {};
|
|
23
26
|
}
|
|
24
27
|
}
|