prompt-language-shell 0.4.8 → 0.5.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.
- package/dist/config/EXECUTE.md +279 -0
- package/dist/config/INTROSPECT.md +9 -6
- package/dist/config/PLAN.md +57 -6
- package/dist/config/VALIDATE.md +139 -0
- package/dist/handlers/answer.js +13 -20
- package/dist/handlers/command.js +26 -30
- package/dist/handlers/config.js +32 -24
- package/dist/handlers/execute.js +46 -0
- package/dist/handlers/execution.js +133 -81
- package/dist/handlers/introspect.js +13 -20
- package/dist/handlers/plan.js +31 -34
- package/dist/services/anthropic.js +28 -2
- package/dist/services/colors.js +3 -3
- package/dist/services/components.js +50 -1
- package/dist/services/config-loader.js +67 -0
- package/dist/services/execution-validator.js +110 -0
- package/dist/services/messages.js +1 -0
- package/dist/services/placeholder-resolver.js +120 -0
- package/dist/services/shell.js +118 -0
- package/dist/services/skill-expander.js +91 -0
- package/dist/services/skill-parser.js +169 -0
- package/dist/services/skills.js +26 -0
- package/dist/services/timing.js +38 -0
- package/dist/services/tool-registry.js +10 -0
- package/dist/services/utils.js +21 -0
- package/dist/tools/execute.tool.js +44 -0
- package/dist/tools/validate.tool.js +43 -0
- package/dist/types/handlers.js +1 -0
- package/dist/types/skills.js +4 -0
- package/dist/types/types.js +2 -0
- package/dist/ui/Answer.js +3 -9
- package/dist/ui/Command.js +3 -6
- package/dist/ui/Component.js +13 -1
- package/dist/ui/Config.js +2 -2
- package/dist/ui/Confirm.js +2 -2
- package/dist/ui/Execute.js +262 -0
- package/dist/ui/Introspect.js +5 -7
- package/dist/ui/Main.js +30 -69
- package/dist/ui/Spinner.js +10 -5
- package/dist/ui/Validate.js +120 -0
- package/package.json +7 -7
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'fs';
|
|
2
|
+
import { homedir } from 'os';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import YAML from 'yaml';
|
|
5
|
+
/**
|
|
6
|
+
* Load user config from ~/.plsrc
|
|
7
|
+
*/
|
|
8
|
+
export function loadUserConfig() {
|
|
9
|
+
const configPath = join(homedir(), '.plsrc');
|
|
10
|
+
if (!existsSync(configPath)) {
|
|
11
|
+
return {};
|
|
12
|
+
}
|
|
13
|
+
try {
|
|
14
|
+
const content = readFileSync(configPath, 'utf-8');
|
|
15
|
+
const parsed = YAML.parse(content);
|
|
16
|
+
if (parsed && typeof parsed === 'object') {
|
|
17
|
+
return parsed;
|
|
18
|
+
}
|
|
19
|
+
return {};
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return {};
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Check if config has a specific path
|
|
27
|
+
*/
|
|
28
|
+
export function hasConfigPath(config, path) {
|
|
29
|
+
const parts = path.split('.');
|
|
30
|
+
let current = config;
|
|
31
|
+
for (const part of parts) {
|
|
32
|
+
if (current === null || current === undefined) {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
if (typeof current !== 'object') {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
current = current[part];
|
|
39
|
+
}
|
|
40
|
+
return (current !== null &&
|
|
41
|
+
current !== undefined &&
|
|
42
|
+
(typeof current === 'string' ||
|
|
43
|
+
typeof current === 'boolean' ||
|
|
44
|
+
typeof current === 'number'));
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Get config value at path
|
|
48
|
+
*/
|
|
49
|
+
export function getConfigValue(config, path) {
|
|
50
|
+
const parts = path.split('.');
|
|
51
|
+
let current = config;
|
|
52
|
+
for (const part of parts) {
|
|
53
|
+
if (current === null || current === undefined) {
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
if (typeof current !== 'object') {
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
current = current[part];
|
|
60
|
+
}
|
|
61
|
+
if (typeof current === 'string' ||
|
|
62
|
+
typeof current === 'boolean' ||
|
|
63
|
+
typeof current === 'number') {
|
|
64
|
+
return current;
|
|
65
|
+
}
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { extractPlaceholders, pathToString, resolveVariant, } from './placeholder-resolver.js';
|
|
2
|
+
import { loadUserConfig, hasConfigPath } from './config-loader.js';
|
|
3
|
+
import { loadSkills } from './skills.js';
|
|
4
|
+
import { expandSkillReferences } from './skill-expander.js';
|
|
5
|
+
import { getConfigType, parseSkillMarkdown } from './skill-parser.js';
|
|
6
|
+
/**
|
|
7
|
+
* Validate config requirements for execute tasks
|
|
8
|
+
* Returns missing config requirements
|
|
9
|
+
*/
|
|
10
|
+
export function validateExecuteTasks(tasks) {
|
|
11
|
+
const userConfig = loadUserConfig();
|
|
12
|
+
const missing = [];
|
|
13
|
+
const seenPaths = new Set();
|
|
14
|
+
// Load and parse all skills for validation
|
|
15
|
+
const skillContents = loadSkills();
|
|
16
|
+
const parsedSkills = skillContents
|
|
17
|
+
.map((content) => parseSkillMarkdown(content))
|
|
18
|
+
.filter((s) => s !== null);
|
|
19
|
+
const skillLookup = (name) => parsedSkills.find((s) => s.name === name) || null;
|
|
20
|
+
for (const task of tasks) {
|
|
21
|
+
// Check if task originates from a skill
|
|
22
|
+
const skillName = typeof task.params?.skill === 'string' ? task.params.skill : null;
|
|
23
|
+
if (skillName) {
|
|
24
|
+
// Task comes from a skill - check skill's Execution section
|
|
25
|
+
const skill = skillLookup(skillName);
|
|
26
|
+
if (!skill || !skill.execution) {
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
// Get variant from task params (if any)
|
|
30
|
+
// Try params.variant first, then look for other param keys that might be the variant
|
|
31
|
+
let variant = null;
|
|
32
|
+
if (typeof task.params?.variant === 'string') {
|
|
33
|
+
variant = task.params.variant.toLowerCase();
|
|
34
|
+
}
|
|
35
|
+
else if (task.params && typeof task.params === 'object') {
|
|
36
|
+
// Look for other params that could be the variant (e.g., product, target, option, etc.)
|
|
37
|
+
// Exclude known non-variant params
|
|
38
|
+
const excludeKeys = new Set(['skill', 'type']);
|
|
39
|
+
for (const [key, value] of Object.entries(task.params)) {
|
|
40
|
+
if (!excludeKeys.has(key) && typeof value === 'string') {
|
|
41
|
+
variant = value.toLowerCase();
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
// Expand skill references to get actual commands
|
|
47
|
+
const expanded = expandSkillReferences(skill.execution, skillLookup);
|
|
48
|
+
// Extract placeholders from actual commands
|
|
49
|
+
for (const line of expanded) {
|
|
50
|
+
const placeholders = extractPlaceholders(line);
|
|
51
|
+
for (const placeholder of placeholders) {
|
|
52
|
+
let resolvedPath;
|
|
53
|
+
if (placeholder.hasVariant) {
|
|
54
|
+
// Variant placeholder - resolve with variant from params
|
|
55
|
+
if (!variant) {
|
|
56
|
+
// No variant provided - skip this placeholder
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
const resolvedPathArray = resolveVariant(placeholder.path, variant);
|
|
60
|
+
resolvedPath = pathToString(resolvedPathArray);
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
// Strict placeholder - use as-is
|
|
64
|
+
resolvedPath = pathToString(placeholder.path);
|
|
65
|
+
}
|
|
66
|
+
// Skip if already processed
|
|
67
|
+
if (seenPaths.has(resolvedPath)) {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
seenPaths.add(resolvedPath);
|
|
71
|
+
// Check if config exists
|
|
72
|
+
if (!hasConfigPath(userConfig, resolvedPath)) {
|
|
73
|
+
// Get type from skill config
|
|
74
|
+
const type = skill.config
|
|
75
|
+
? getConfigType(skill.config, resolvedPath)
|
|
76
|
+
: undefined;
|
|
77
|
+
missing.push({
|
|
78
|
+
path: resolvedPath,
|
|
79
|
+
type: type || 'string',
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
// Task doesn't come from a skill - check task action for placeholders
|
|
87
|
+
const placeholders = extractPlaceholders(task.action);
|
|
88
|
+
for (const placeholder of placeholders) {
|
|
89
|
+
// Skip variant placeholders - they should have been resolved during planning
|
|
90
|
+
if (placeholder.hasVariant) {
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
const path = placeholder.path.join('.');
|
|
94
|
+
// Skip if already processed
|
|
95
|
+
if (seenPaths.has(path)) {
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
seenPaths.add(path);
|
|
99
|
+
// Check if config exists
|
|
100
|
+
if (!hasConfigPath(userConfig, path)) {
|
|
101
|
+
missing.push({
|
|
102
|
+
path,
|
|
103
|
+
type: 'string', // Default to string for now
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return missing;
|
|
110
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Check if a string is all uppercase (variant placeholder indicator)
|
|
3
|
+
*/
|
|
4
|
+
function isUpperCase(str) {
|
|
5
|
+
return str === str.toUpperCase() && str !== str.toLowerCase();
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Parse placeholder from string
|
|
9
|
+
* Returns placeholder info or null if no valid placeholder found
|
|
10
|
+
*/
|
|
11
|
+
export function parsePlaceholder(text) {
|
|
12
|
+
// Match {path.to.property} format
|
|
13
|
+
const match = text.match(/\{([^}]+)\}/);
|
|
14
|
+
if (!match) {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
const original = match[0];
|
|
18
|
+
const pathString = match[1];
|
|
19
|
+
const path = pathString.split('.');
|
|
20
|
+
// Check if any path component is uppercase (variant placeholder)
|
|
21
|
+
const variantIndex = path.findIndex((part) => isUpperCase(part));
|
|
22
|
+
const hasVariant = variantIndex !== -1;
|
|
23
|
+
return {
|
|
24
|
+
original,
|
|
25
|
+
path,
|
|
26
|
+
hasVariant,
|
|
27
|
+
variantIndex: hasVariant ? variantIndex : undefined,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Extract all placeholders from text
|
|
32
|
+
*/
|
|
33
|
+
export function extractPlaceholders(text) {
|
|
34
|
+
const placeholders = [];
|
|
35
|
+
const regex = /\{([^}]+)\}/g;
|
|
36
|
+
let match;
|
|
37
|
+
while ((match = regex.exec(text)) !== null) {
|
|
38
|
+
const pathString = match[1];
|
|
39
|
+
const path = pathString.split('.');
|
|
40
|
+
const variantIndex = path.findIndex((part) => isUpperCase(part));
|
|
41
|
+
const hasVariant = variantIndex !== -1;
|
|
42
|
+
placeholders.push({
|
|
43
|
+
original: match[0],
|
|
44
|
+
path,
|
|
45
|
+
hasVariant,
|
|
46
|
+
variantIndex: hasVariant ? variantIndex : undefined,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
return placeholders;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Replace uppercase component in path with actual variant name
|
|
53
|
+
* Returns new path with variant replaced
|
|
54
|
+
*/
|
|
55
|
+
export function resolveVariant(path, variantName) {
|
|
56
|
+
return path.map((part) => (isUpperCase(part) ? variantName : part));
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Convert path array to dot notation string
|
|
60
|
+
*/
|
|
61
|
+
export function pathToString(path) {
|
|
62
|
+
return path.join('.');
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Resolve placeholder value from config
|
|
66
|
+
* Returns the value at the specified path or undefined if not found
|
|
67
|
+
*/
|
|
68
|
+
export function resolveFromConfig(config, path) {
|
|
69
|
+
let current = config;
|
|
70
|
+
for (const part of path) {
|
|
71
|
+
if (current === null || current === undefined) {
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
if (typeof current !== 'object') {
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
current = current[part];
|
|
78
|
+
}
|
|
79
|
+
if (typeof current === 'string' ||
|
|
80
|
+
typeof current === 'boolean' ||
|
|
81
|
+
typeof current === 'number') {
|
|
82
|
+
return current;
|
|
83
|
+
}
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Replace all placeholders in text with values from config
|
|
88
|
+
* Note: Variant placeholders (with uppercase components) must be resolved first
|
|
89
|
+
*/
|
|
90
|
+
export function replacePlaceholders(text, config) {
|
|
91
|
+
return text.replace(/\{([^}]+)\}/g, (_match, pathString) => {
|
|
92
|
+
const path = pathString.split('.');
|
|
93
|
+
const value = resolveFromConfig(config, path);
|
|
94
|
+
if (value === undefined) {
|
|
95
|
+
// Keep placeholder if not found in config
|
|
96
|
+
return `{${pathString}}`;
|
|
97
|
+
}
|
|
98
|
+
return String(value);
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Check if text contains any placeholders
|
|
103
|
+
*/
|
|
104
|
+
export function hasPlaceholders(text) {
|
|
105
|
+
return /\{[^}]+\}/.test(text);
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Get all unique config paths required by placeholders in text
|
|
109
|
+
* Note: Variant placeholders (with uppercase components) must be resolved first
|
|
110
|
+
*/
|
|
111
|
+
export function getRequiredConfigPaths(text) {
|
|
112
|
+
const placeholders = extractPlaceholders(text);
|
|
113
|
+
const paths = new Set();
|
|
114
|
+
for (const placeholder of placeholders) {
|
|
115
|
+
if (!placeholder.hasVariant) {
|
|
116
|
+
paths.add(pathToString(placeholder.path));
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return Array.from(paths);
|
|
120
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
export var ExecutionStatus;
|
|
2
|
+
(function (ExecutionStatus) {
|
|
3
|
+
ExecutionStatus["Pending"] = "pending";
|
|
4
|
+
ExecutionStatus["Running"] = "running";
|
|
5
|
+
ExecutionStatus["Success"] = "success";
|
|
6
|
+
ExecutionStatus["Failed"] = "failed";
|
|
7
|
+
ExecutionStatus["Aborted"] = "aborted";
|
|
8
|
+
})(ExecutionStatus || (ExecutionStatus = {}));
|
|
9
|
+
export var ExecutionResult;
|
|
10
|
+
(function (ExecutionResult) {
|
|
11
|
+
ExecutionResult["Success"] = "success";
|
|
12
|
+
ExecutionResult["Error"] = "error";
|
|
13
|
+
ExecutionResult["Aborted"] = "aborted";
|
|
14
|
+
})(ExecutionResult || (ExecutionResult = {}));
|
|
15
|
+
const DEFAULT_DELAY_GENERATOR = (index) => (Math.pow(3, index + 1) * Math.max(Math.random(), Math.random()) + 1) * 1000;
|
|
16
|
+
/**
|
|
17
|
+
* Dummy executor that simulates command execution with configurable delays.
|
|
18
|
+
* Supports mocked responses for testing different scenarios.
|
|
19
|
+
*/
|
|
20
|
+
export class DummyExecutor {
|
|
21
|
+
mockedResponses = new Map();
|
|
22
|
+
delayGenerator;
|
|
23
|
+
constructor(delayGenerator = DEFAULT_DELAY_GENERATOR) {
|
|
24
|
+
this.delayGenerator = delayGenerator;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Set a mocked response for a specific command
|
|
28
|
+
*/
|
|
29
|
+
mock(command, response) {
|
|
30
|
+
this.mockedResponses.set(command, response);
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Clear all mocked responses
|
|
34
|
+
*/
|
|
35
|
+
clearMocks() {
|
|
36
|
+
this.mockedResponses.clear();
|
|
37
|
+
}
|
|
38
|
+
execute(cmd, onProgress, index = 0) {
|
|
39
|
+
return new Promise((resolve) => {
|
|
40
|
+
onProgress?.(ExecutionStatus.Running);
|
|
41
|
+
const delay = this.delayGenerator(index);
|
|
42
|
+
setTimeout(() => {
|
|
43
|
+
const mocked = this.mockedResponses.get(cmd.command);
|
|
44
|
+
const commandResult = {
|
|
45
|
+
description: cmd.description,
|
|
46
|
+
command: cmd.command,
|
|
47
|
+
output: mocked?.output ?? '',
|
|
48
|
+
errors: mocked?.errors ?? '',
|
|
49
|
+
result: mocked?.result ?? ExecutionResult.Success,
|
|
50
|
+
error: mocked?.error,
|
|
51
|
+
};
|
|
52
|
+
onProgress?.(commandResult.result === ExecutionResult.Success
|
|
53
|
+
? ExecutionStatus.Success
|
|
54
|
+
: ExecutionStatus.Failed);
|
|
55
|
+
resolve(commandResult);
|
|
56
|
+
}, delay);
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Default executor uses DummyExecutor for development and testing.
|
|
62
|
+
* To implement real shell execution, create a RealExecutor class that:
|
|
63
|
+
* - Spawns process with cmd.command in shell mode using child_process.spawn()
|
|
64
|
+
* - Sets working directory from cmd.workdir
|
|
65
|
+
* - Handles cmd.timeout for command timeout
|
|
66
|
+
* - Captures stdout and stderr streams
|
|
67
|
+
* - Calls onProgress with Running/Success/Failed status
|
|
68
|
+
* - Returns CommandOutput with actual stdout, stderr, exitCode
|
|
69
|
+
* - Handles errors (spawn failures, timeouts, non-zero exit codes)
|
|
70
|
+
*/
|
|
71
|
+
const executor = new DummyExecutor();
|
|
72
|
+
/**
|
|
73
|
+
* Execute a single shell command
|
|
74
|
+
*/
|
|
75
|
+
export function executeCommand(cmd, onProgress, index = 0) {
|
|
76
|
+
return executor.execute(cmd, onProgress, index);
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Execute multiple commands sequentially
|
|
80
|
+
*/
|
|
81
|
+
export async function executeCommands(commands, onProgress) {
|
|
82
|
+
const results = [];
|
|
83
|
+
for (let i = 0; i < commands.length; i++) {
|
|
84
|
+
const cmd = commands[i];
|
|
85
|
+
onProgress?.({
|
|
86
|
+
currentIndex: i,
|
|
87
|
+
total: commands.length,
|
|
88
|
+
command: cmd,
|
|
89
|
+
status: ExecutionStatus.Running,
|
|
90
|
+
});
|
|
91
|
+
const output = await executeCommand(cmd, (status) => {
|
|
92
|
+
onProgress?.({
|
|
93
|
+
currentIndex: i,
|
|
94
|
+
total: commands.length,
|
|
95
|
+
command: cmd,
|
|
96
|
+
status,
|
|
97
|
+
output: status !== ExecutionStatus.Running ? results[i] : undefined,
|
|
98
|
+
});
|
|
99
|
+
}, i);
|
|
100
|
+
results.push(output);
|
|
101
|
+
// Update with final status
|
|
102
|
+
onProgress?.({
|
|
103
|
+
currentIndex: i,
|
|
104
|
+
total: commands.length,
|
|
105
|
+
command: cmd,
|
|
106
|
+
status: output.result === ExecutionResult.Success
|
|
107
|
+
? ExecutionStatus.Success
|
|
108
|
+
: ExecutionStatus.Failed,
|
|
109
|
+
output,
|
|
110
|
+
});
|
|
111
|
+
// Stop if critical command failed
|
|
112
|
+
const isCritical = cmd.critical !== false;
|
|
113
|
+
if (output.result !== ExecutionResult.Success && isCritical) {
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return results;
|
|
118
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse skill reference from execution line
|
|
3
|
+
* Returns skill name if line is a reference, otherwise null
|
|
4
|
+
*/
|
|
5
|
+
export function parseSkillReference(line) {
|
|
6
|
+
const match = line.match(/^\[(.+)\]$/);
|
|
7
|
+
return match ? match[1].trim() : null;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Check if execution line is a skill reference
|
|
11
|
+
*/
|
|
12
|
+
export function isSkillReference(line) {
|
|
13
|
+
return /^\[.+\]$/.test(line.trim());
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Expand skill references in execution commands
|
|
17
|
+
* Returns expanded execution lines with references replaced
|
|
18
|
+
* Throws error if circular reference detected
|
|
19
|
+
*/
|
|
20
|
+
export function expandSkillReferences(execution, skillLookup, visited = new Set()) {
|
|
21
|
+
const expanded = [];
|
|
22
|
+
for (const line of execution) {
|
|
23
|
+
const skillName = parseSkillReference(line);
|
|
24
|
+
if (!skillName) {
|
|
25
|
+
// Not a reference, keep as-is
|
|
26
|
+
expanded.push(line);
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
// Check for circular reference
|
|
30
|
+
if (visited.has(skillName)) {
|
|
31
|
+
throw new Error(`Circular skill reference detected: ${Array.from(visited).join(' → ')} → ${skillName}`);
|
|
32
|
+
}
|
|
33
|
+
// Look up referenced skill
|
|
34
|
+
const skill = skillLookup(skillName);
|
|
35
|
+
if (!skill) {
|
|
36
|
+
// Referenced skill not found, keep as-is
|
|
37
|
+
expanded.push(line);
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (!skill.execution || skill.execution.length === 0) {
|
|
41
|
+
// Referenced skill has no execution, skip
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
// Recursively expand referenced skill's execution
|
|
45
|
+
const newVisited = new Set(visited);
|
|
46
|
+
newVisited.add(skillName);
|
|
47
|
+
const referencedExecution = expandSkillReferences(skill.execution, skillLookup, newVisited);
|
|
48
|
+
expanded.push(...referencedExecution);
|
|
49
|
+
}
|
|
50
|
+
return expanded;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Get all skill names referenced in execution (including nested)
|
|
54
|
+
* Returns unique set of skill names
|
|
55
|
+
*/
|
|
56
|
+
export function getReferencedSkills(execution, skillLookup, visited = new Set()) {
|
|
57
|
+
const referenced = new Set();
|
|
58
|
+
for (const line of execution) {
|
|
59
|
+
const skillName = parseSkillReference(line);
|
|
60
|
+
if (!skillName || visited.has(skillName)) {
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
referenced.add(skillName);
|
|
64
|
+
const skill = skillLookup(skillName);
|
|
65
|
+
if (skill && skill.execution) {
|
|
66
|
+
const newVisited = new Set(visited);
|
|
67
|
+
newVisited.add(skillName);
|
|
68
|
+
const nested = getReferencedSkills(skill.execution, skillLookup, newVisited);
|
|
69
|
+
for (const name of nested) {
|
|
70
|
+
referenced.add(name);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return referenced;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Validate skill references don't form cycles
|
|
78
|
+
* Returns true if valid, false if circular reference detected
|
|
79
|
+
*/
|
|
80
|
+
export function validateNoCycles(execution, skillLookup, visited = new Set()) {
|
|
81
|
+
try {
|
|
82
|
+
expandSkillReferences(execution, skillLookup, visited);
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
if (error instanceof Error && error.message.includes('Circular')) {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
throw error;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import YAML from 'yaml';
|
|
2
|
+
/**
|
|
3
|
+
* Parse a skill markdown file into structured definition
|
|
4
|
+
*/
|
|
5
|
+
export function parseSkillMarkdown(content) {
|
|
6
|
+
const sections = extractSections(content);
|
|
7
|
+
// Name is required
|
|
8
|
+
if (!sections.name) {
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
// Description is required
|
|
12
|
+
if (!sections.description) {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
// Steps are required
|
|
16
|
+
if (!sections.steps || sections.steps.length === 0) {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
// Validate execution and steps have same count (if execution exists)
|
|
20
|
+
if (sections.execution &&
|
|
21
|
+
sections.execution.length !== sections.steps.length) {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
const skill = {
|
|
25
|
+
name: sections.name,
|
|
26
|
+
description: sections.description,
|
|
27
|
+
steps: sections.steps,
|
|
28
|
+
};
|
|
29
|
+
if (sections.aliases && sections.aliases.length > 0) {
|
|
30
|
+
skill.aliases = sections.aliases;
|
|
31
|
+
}
|
|
32
|
+
if (sections.config) {
|
|
33
|
+
skill.config = sections.config;
|
|
34
|
+
}
|
|
35
|
+
if (sections.execution && sections.execution.length > 0) {
|
|
36
|
+
skill.execution = sections.execution;
|
|
37
|
+
}
|
|
38
|
+
return skill;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Extract sections from markdown content
|
|
42
|
+
*/
|
|
43
|
+
function extractSections(content) {
|
|
44
|
+
const lines = content.split('\n');
|
|
45
|
+
const sections = {};
|
|
46
|
+
let currentSection = null;
|
|
47
|
+
let sectionLines = [];
|
|
48
|
+
for (const line of lines) {
|
|
49
|
+
// Check for section headers (### SectionName)
|
|
50
|
+
const headerMatch = line.match(/^###\s+(.+)$/);
|
|
51
|
+
if (headerMatch) {
|
|
52
|
+
// Process previous section
|
|
53
|
+
if (currentSection) {
|
|
54
|
+
processSectionContent(currentSection, sectionLines, sections);
|
|
55
|
+
}
|
|
56
|
+
// Start new section
|
|
57
|
+
currentSection = headerMatch[1].trim().toLowerCase();
|
|
58
|
+
sectionLines = [];
|
|
59
|
+
}
|
|
60
|
+
else if (currentSection) {
|
|
61
|
+
// Accumulate lines for current section
|
|
62
|
+
sectionLines.push(line);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
// Process final section
|
|
66
|
+
if (currentSection) {
|
|
67
|
+
processSectionContent(currentSection, sectionLines, sections);
|
|
68
|
+
}
|
|
69
|
+
return sections;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Process accumulated section content
|
|
73
|
+
*/
|
|
74
|
+
function processSectionContent(sectionName, lines, sections) {
|
|
75
|
+
const content = lines.join('\n').trim();
|
|
76
|
+
if (!content) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
switch (sectionName) {
|
|
80
|
+
case 'name':
|
|
81
|
+
sections.name = content;
|
|
82
|
+
break;
|
|
83
|
+
case 'description':
|
|
84
|
+
sections.description = content;
|
|
85
|
+
break;
|
|
86
|
+
case 'aliases':
|
|
87
|
+
sections.aliases = extractBulletList(content);
|
|
88
|
+
break;
|
|
89
|
+
case 'config':
|
|
90
|
+
sections.config = parseConfigSchema(content);
|
|
91
|
+
break;
|
|
92
|
+
case 'steps':
|
|
93
|
+
sections.steps = extractBulletList(content);
|
|
94
|
+
break;
|
|
95
|
+
case 'execution':
|
|
96
|
+
sections.execution = extractBulletList(content);
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Extract bullet list items from content
|
|
102
|
+
*/
|
|
103
|
+
function extractBulletList(content) {
|
|
104
|
+
const lines = content.split('\n');
|
|
105
|
+
const items = [];
|
|
106
|
+
for (const line of lines) {
|
|
107
|
+
const trimmed = line.trim();
|
|
108
|
+
// Match bullet points: "- text" or "* text"
|
|
109
|
+
const bulletMatch = trimmed.match(/^[-*]\s+(.+)$/);
|
|
110
|
+
if (bulletMatch) {
|
|
111
|
+
items.push(bulletMatch[1].trim());
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return items;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Parse YAML config schema
|
|
118
|
+
*/
|
|
119
|
+
function parseConfigSchema(content) {
|
|
120
|
+
try {
|
|
121
|
+
const parsed = YAML.parse(content);
|
|
122
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
return parsed;
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
return undefined;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Generate all config paths from schema
|
|
133
|
+
*/
|
|
134
|
+
export function generateConfigPaths(schema, prefix = '') {
|
|
135
|
+
const paths = [];
|
|
136
|
+
for (const [key, value] of Object.entries(schema)) {
|
|
137
|
+
const fullKey = prefix ? `${prefix}.${key}` : key;
|
|
138
|
+
if (typeof value === 'string') {
|
|
139
|
+
// Leaf node with type annotation
|
|
140
|
+
paths.push(fullKey);
|
|
141
|
+
}
|
|
142
|
+
else if (typeof value === 'object') {
|
|
143
|
+
// Nested object - recurse
|
|
144
|
+
paths.push(...generateConfigPaths(value, fullKey));
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return paths;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Get config type for a specific path
|
|
151
|
+
*/
|
|
152
|
+
export function getConfigType(schema, path) {
|
|
153
|
+
const parts = path.split('.');
|
|
154
|
+
let current = schema;
|
|
155
|
+
for (const part of parts) {
|
|
156
|
+
if (typeof current === 'string') {
|
|
157
|
+
return undefined;
|
|
158
|
+
}
|
|
159
|
+
if (typeof current !== 'object') {
|
|
160
|
+
return undefined;
|
|
161
|
+
}
|
|
162
|
+
current = current[part];
|
|
163
|
+
}
|
|
164
|
+
if (typeof current === 'string' &&
|
|
165
|
+
(current === 'string' || current === 'boolean' || current === 'number')) {
|
|
166
|
+
return current;
|
|
167
|
+
}
|
|
168
|
+
return undefined;
|
|
169
|
+
}
|