prompt-language-shell 0.8.2 → 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,6 +46,19 @@ 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;
@@ -148,88 +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
- };
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;
188
197
  }
189
198
  // Handle introspect tool response
190
199
  if (toolName === 'introspect') {
191
- if (!input.message || typeof input.message !== 'string') {
192
- throw new Error('Invalid tool response: missing or invalid message field');
193
- }
194
- if (!input.capabilities || !Array.isArray(input.capabilities)) {
195
- throw new Error('Invalid tool response: missing or invalid capabilities array');
196
- }
197
- // Validate each capability has required fields
198
- input.capabilities.forEach((cap, i) => {
199
- if (!cap.name || typeof cap.name !== 'string') {
200
- throw new Error(`Invalid capability at index ${String(i)}: missing or invalid 'name' field`);
201
- }
202
- if (!cap.description || typeof cap.description !== 'string') {
203
- throw new Error(`Invalid capability at index ${String(i)}: missing or invalid 'description' field`);
204
- }
205
- if (typeof cap.origin !== 'string') {
206
- throw new Error(`Invalid capability at index ${String(i)}: invalid 'origin' field`);
207
- }
208
- });
209
- return {
200
+ const validation = IntrospectResultSchema.safeParse({
210
201
  message: input.message,
211
202
  capabilities: input.capabilities,
212
203
  debug,
213
- };
204
+ });
205
+ if (!validation.success) {
206
+ throw new Error(`I received an unexpected response while listing capabilities:\n${formatValidationError(validation.error)}`);
207
+ }
208
+ return validation.data;
214
209
  }
215
210
  // Handle schedule tool responses
216
- if (input.message === undefined || typeof input.message !== 'string') {
217
- throw new Error('Invalid tool response: missing or invalid message field');
218
- }
219
- if (!input.tasks || !Array.isArray(input.tasks)) {
220
- throw new Error('Invalid tool response: missing or invalid tasks array');
221
- }
222
- // Validate each task has required action field
223
- input.tasks.forEach((task, i) => {
224
- if (!task.action || typeof task.action !== 'string') {
225
- throw new Error(`Invalid task at index ${String(i)}: missing or invalid 'action' field`);
226
- }
227
- });
228
- return {
211
+ const validation = CommandResultSchema.safeParse({
229
212
  message: input.message,
230
213
  tasks: input.tasks,
231
214
  debug,
232
- };
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;
233
220
  }
234
221
  }
235
222
  export function createAnthropicService(config) {
@@ -128,6 +128,7 @@ 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,
@@ -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
  }
@@ -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
- }
@@ -1,9 +1,9 @@
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';
5
4
  import { getConfigLabel } from './config-labels.js';
6
5
  import { flattenConfig } from './config-utils.js';
6
+ import { defaultFileSystem } from './filesystem.js';
7
7
  /**
8
8
  * Convert a dotted config key to a readable label
9
9
  * Example: "project.alpha.repo" -> "Project Alpha Repo"
@@ -96,20 +96,20 @@ function validateConfig(parsed) {
96
96
  }
97
97
  return validatedConfig;
98
98
  }
99
- export function loadConfig() {
99
+ export function loadConfig(fs = defaultFileSystem) {
100
100
  const configFile = getConfigFile();
101
- if (!existsSync(configFile)) {
101
+ if (!fs.exists(configFile)) {
102
102
  throw new ConfigError('Configuration not found');
103
103
  }
104
- const content = readFileSync(configFile, 'utf-8');
104
+ const content = fs.readFile(configFile, 'utf-8');
105
105
  const parsed = parseYamlConfig(content);
106
106
  return validateConfig(parsed);
107
107
  }
108
108
  export function getConfigPath() {
109
109
  return getConfigFile();
110
110
  }
111
- export function configExists() {
112
- return existsSync(getConfigFile());
111
+ export function configExists(fs = defaultFileSystem) {
112
+ return fs.exists(getConfigFile());
113
113
  }
114
114
  export function isValidAnthropicApiKey(key) {
115
115
  // Anthropic API keys format: sk-ant-api03-XXXXX (108 chars total)
@@ -149,24 +149,24 @@ export function mergeConfig(existingContent, sectionName, newValues) {
149
149
  // Convert back to YAML
150
150
  return YAML.stringify(sortedConfig);
151
151
  }
152
- export function saveConfig(section, config) {
152
+ export function saveConfig(section, config, fs = defaultFileSystem) {
153
153
  const configFile = getConfigFile();
154
- const existingContent = existsSync(configFile)
155
- ? readFileSync(configFile, 'utf-8')
154
+ const existingContent = fs.exists(configFile)
155
+ ? fs.readFile(configFile, 'utf-8')
156
156
  : '';
157
157
  const newContent = mergeConfig(existingContent, section, config);
158
- writeFileSync(configFile, newContent, 'utf-8');
158
+ fs.writeFile(configFile, newContent);
159
159
  }
160
- export function saveAnthropicConfig(config) {
161
- saveConfig('anthropic', config);
162
- return loadConfig();
160
+ export function saveAnthropicConfig(config, fs = defaultFileSystem) {
161
+ saveConfig('anthropic', config, fs);
162
+ return loadConfig(fs);
163
163
  }
164
- export function saveDebugSetting(debug) {
165
- saveConfig('settings', { debug });
164
+ export function saveDebugSetting(debug, fs = defaultFileSystem) {
165
+ saveConfig('settings', { debug }, fs);
166
166
  }
167
- export function loadDebugSetting() {
167
+ export function loadDebugSetting(fs = defaultFileSystem) {
168
168
  try {
169
- const config = loadConfig();
169
+ const config = loadConfig(fs);
170
170
  return config.settings?.debug ?? DebugLevel.None;
171
171
  }
172
172
  catch {
@@ -304,13 +304,13 @@ export function getMissingConfigKeys() {
304
304
  * Get list of configured keys from config file
305
305
  * Returns array of dot-notation keys that exist in the config file
306
306
  */
307
- export function getConfiguredKeys() {
307
+ export function getConfiguredKeys(fs = defaultFileSystem) {
308
308
  try {
309
309
  const configFile = getConfigFile();
310
- if (!existsSync(configFile)) {
310
+ if (!fs.exists(configFile)) {
311
311
  return [];
312
312
  }
313
- const content = readFileSync(configFile, 'utf-8');
313
+ const content = fs.readFile(configFile, 'utf-8');
314
314
  const parsed = YAML.parse(content);
315
315
  // Flatten nested config to dot notation
316
316
  const flatConfig = flattenConfig(parsed);
@@ -325,15 +325,15 @@ export function getConfiguredKeys() {
325
325
  * Returns keys with descriptions only (no values for privacy)
326
326
  * Marks optional keys as "(optional)"
327
327
  */
328
- export function getAvailableConfigStructure() {
328
+ export function getAvailableConfigStructure(fs = defaultFileSystem) {
329
329
  const schema = getConfigSchema();
330
330
  const structure = {};
331
331
  // Try to load existing config to see which keys are already set
332
332
  let flatConfig = {};
333
333
  try {
334
334
  const configFile = getConfigFile();
335
- if (existsSync(configFile)) {
336
- const content = readFileSync(configFile, 'utf-8');
335
+ if (fs.exists(configFile)) {
336
+ const content = fs.readFile(configFile, 'utf-8');
337
337
  const parsed = YAML.parse(content);
338
338
  // Flatten nested config to dot notation
339
339
  flatConfig = flattenConfig(parsed);
@@ -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
  }
@@ -6,6 +6,10 @@ import { Palette } from './colors.js';
6
6
  * Logs information based on the current debug level setting
7
7
  */
8
8
  let currentDebugLevel = DebugLevel.None;
9
+ /**
10
+ * Accumulated warnings to be displayed in the timeline
11
+ */
12
+ const warnings = [];
9
13
  /**
10
14
  * Initialize the logger with the current debug level from config
11
15
  */
@@ -24,6 +28,26 @@ export function setDebugLevel(debug) {
24
28
  export function getDebugLevel() {
25
29
  return currentDebugLevel;
26
30
  }
31
+ /**
32
+ * Store a warning message to be displayed in the timeline
33
+ * Only stores warnings at Info or Verbose debug levels
34
+ */
35
+ export function displayWarning(message, error) {
36
+ if (currentDebugLevel === DebugLevel.None) {
37
+ return;
38
+ }
39
+ const errorDetails = error instanceof Error ? `: ${error.message}` : '';
40
+ warnings.push(`${message}${errorDetails}`);
41
+ }
42
+ /**
43
+ * Get all accumulated warnings and clear the list
44
+ * Returns array of warning messages
45
+ */
46
+ export function getWarnings() {
47
+ const result = [...warnings];
48
+ warnings.length = 0;
49
+ return result;
50
+ }
27
51
  /**
28
52
  * Create debug component for system prompts sent to the LLM
29
53
  * Only creates at Verbose level
@@ -1,4 +1,5 @@
1
1
  import YAML from 'yaml';
2
+ import { displayWarning } from './logger.js';
2
3
  /**
3
4
  * Validate a skill without parsing it fully
4
5
  * Returns validation error if skill is invalid, null if valid
@@ -188,7 +189,8 @@ function parseConfigSchema(content) {
188
189
  }
189
190
  return parsed;
190
191
  }
191
- catch {
192
+ catch (error) {
193
+ displayWarning('Failed to parse config schema in skill', error);
192
194
  return undefined;
193
195
  }
194
196
  }
@@ -5,12 +5,12 @@ import { routeTasksWithConfirm } from './router.js';
5
5
  * Handle refinement flow for DEFINE tasks
6
6
  * Called when user selects options from a plan with DEFINE tasks
7
7
  */
8
- export async function handleRefinement(selectedTasks, service, originalCommand, handlers) {
8
+ export async function handleRefinement(selectedTasks, service, originalCommand, queueHandlers, lifecycleHandlers, workflowHandlers, errorHandlers) {
9
9
  // Create and add refinement component to queue
10
10
  const refinementDef = createRefinement(getRefiningMessage(), (operation) => {
11
- handlers.onAborted(operation);
11
+ errorHandlers.onAborted(operation);
12
12
  });
13
- handlers.addToQueue(refinementDef);
13
+ queueHandlers.addToQueue(refinementDef);
14
14
  try {
15
15
  // Build refined command from selected tasks
16
16
  const refinedCommand = selectedTasks
@@ -23,18 +23,18 @@ export async function handleRefinement(selectedTasks, service, originalCommand,
23
23
  // Call LLM to refine plan with selected tasks
24
24
  const refinedResult = await service.processWithTool(refinedCommand, 'schedule');
25
25
  // Complete the Refinement component
26
- handlers.completeActive();
26
+ lifecycleHandlers.completeActive();
27
27
  // Add debug components to timeline if present
28
- if (refinedResult.debug && refinedResult.debug.length > 0) {
29
- handlers.addToTimeline(...refinedResult.debug);
28
+ if (refinedResult.debug?.length) {
29
+ workflowHandlers.addToTimeline(...refinedResult.debug);
30
30
  }
31
31
  // Route refined tasks to appropriate components
32
- routeTasksWithConfirm(refinedResult.tasks, refinedResult.message, service, originalCommand, handlers, false // No DEFINE tasks in refined result
32
+ routeTasksWithConfirm(refinedResult.tasks, refinedResult.message, service, originalCommand, queueHandlers, workflowHandlers, errorHandlers, false // No DEFINE tasks in refined result
33
33
  );
34
34
  }
35
35
  catch (err) {
36
- handlers.completeActive();
36
+ lifecycleHandlers.completeActive();
37
37
  const errorMessage = formatErrorMessage(err);
38
- handlers.onError(errorMessage);
38
+ errorHandlers.onError(errorMessage);
39
39
  }
40
40
  }