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.
@@ -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-20251001') {
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
- if (!input.message || typeof input.message !== 'string') {
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('Invalid tool response: missing or invalid question field');
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('Invalid tool response: missing or invalid answer field');
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
- return {
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
- // Handle schedule and introspect tool responses
190
- if (input.message === undefined || typeof input.message !== 'string') {
191
- throw new Error('Invalid tool response: missing or invalid message field');
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
- // Validate each task has required action field
197
- input.tasks.forEach((task, i) => {
198
- if (!task.action || typeof task.action !== 'string') {
199
- throw new Error(`Invalid task at index ${String(i)}: missing or invalid 'action' field`);
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
- return {
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) {
@@ -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 (existsSync(configFile)) {
73
- const content = readFileSync(configFile, 'utf-8');
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 (built-in config)
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 (!existsSync(configFile)) {
101
+ if (!fs.exists(configFile)) {
90
102
  throw new ConfigError('Configuration not found');
91
103
  }
92
- const content = readFileSync(configFile, 'utf-8');
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 existsSync(getConfigFile());
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 = existsSync(configFile)
143
- ? readFileSync(configFile, 'utf-8')
154
+ const existingContent = fs.exists(configFile)
155
+ ? fs.readFile(configFile, 'utf-8')
144
156
  : '';
145
157
  const newContent = mergeConfig(existingContent, section, config);
146
- writeFileSync(configFile, newContent, 'utf-8');
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 built-in settings
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 (!existsSync(configFile)) {
310
+ if (!fs.exists(configFile)) {
299
311
  return [];
300
312
  }
301
- const content = readFileSync(configFile, 'utf-8');
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 (existsSync(configFile)) {
337
- const content = readFileSync(configFile, 'utf-8');
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
- const isOptional = !definition.required;
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] = `${key} (discovered)`;
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();
@@ -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 (!existsSync(configPath)) {
11
+ if (!fs.exists(configPath)) {
11
12
  return {};
12
13
  }
13
14
  try {
14
- const content = readFileSync(configPath, 'utf-8');
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
  }