clawsecure 1.0.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/LICENSE +21 -0
- package/README.md +127 -0
- package/bin/clawsecure.js +84 -0
- package/package.json +48 -0
- package/skill/.clawsecure-version +1 -0
- package/skill/HEARTBEAT.md +18 -0
- package/skill/README.md +146 -0
- package/skill/SKILL.md +83 -0
- package/skill/references/commands.md +40 -0
- package/skill/references/config-audit-checklist.md +81 -0
- package/skill/references/mcp-risk-classifications.md +43 -0
- package/skill/references/onboarding.md +48 -0
- package/skill/references/response-templates.md +102 -0
- package/skill/references/secure-install-guide.md +91 -0
- package/src/api-client.js +227 -0
- package/src/component-scanner.js +238 -0
- package/src/config-parser.js +352 -0
- package/src/daemon.js +452 -0
- package/src/logger.js +60 -0
- package/src/metadata-stripper.js +181 -0
- package/src/process-manager.js +220 -0
- package/src/session-parser.js +241 -0
- package/src/skill-installer.js +199 -0
- package/src/sync-manager.js +246 -0
- package/src/threat-intel.js +180 -0
- package/src/watcher.js +155 -0
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const crypto = require('crypto');
|
|
6
|
+
const logger = require('./logger');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* SHA-256 hash a single file.
|
|
10
|
+
* @param {string} filePath Absolute path
|
|
11
|
+
* @returns {string|null} Hex hash string, or null if file unreadable
|
|
12
|
+
*/
|
|
13
|
+
function hashFile(filePath) {
|
|
14
|
+
try {
|
|
15
|
+
const content = fs.readFileSync(filePath);
|
|
16
|
+
return crypto.createHash('sha256').update(content).digest('hex');
|
|
17
|
+
} catch (err) {
|
|
18
|
+
logger.debug(`Cannot hash file ${filePath}: ${err.message}`);
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Hash all files in a directory (non-recursive, top-level files only).
|
|
25
|
+
* Returns a combined hash representing the directory state.
|
|
26
|
+
* @param {string} dir Absolute directory path
|
|
27
|
+
* @returns {{ files: Map<string, string>, combined: string|null }}
|
|
28
|
+
*/
|
|
29
|
+
function hashDirectory(dir) {
|
|
30
|
+
const fileHashes = new Map();
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
34
|
+
for (const entry of entries) {
|
|
35
|
+
if (!entry.isFile()) continue;
|
|
36
|
+
if (entry.name.startsWith('.')) continue;
|
|
37
|
+
const fullPath = path.join(dir, entry.name);
|
|
38
|
+
const hash = hashFile(fullPath);
|
|
39
|
+
if (hash) {
|
|
40
|
+
fileHashes.set(fullPath, hash);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
} catch (err) {
|
|
44
|
+
logger.debug(`Cannot read directory ${dir}: ${err.message}`);
|
|
45
|
+
return { files: fileHashes, combined: null };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Combined hash from sorted file hashes
|
|
49
|
+
if (fileHashes.size === 0) return { files: fileHashes, combined: null };
|
|
50
|
+
|
|
51
|
+
const sorted = Array.from(fileHashes.entries()).sort((a, b) => a[0].localeCompare(b[0]));
|
|
52
|
+
const combinedInput = sorted.map(([p, h]) => `${p}:${h}`).join('\n');
|
|
53
|
+
const combined = crypto.createHash('sha256').update(combinedInput).digest('hex');
|
|
54
|
+
|
|
55
|
+
return { files: fileHashes, combined };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Extract name and description from SKILL.md YAML frontmatter.
|
|
60
|
+
* Uses simple regex to avoid a heavy YAML dependency.
|
|
61
|
+
* @param {string} filePath Path to SKILL.md
|
|
62
|
+
* @returns {{ name: string, description: string }|null}
|
|
63
|
+
*/
|
|
64
|
+
function extractSkillFrontmatter(filePath) {
|
|
65
|
+
try {
|
|
66
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
67
|
+
const match = content.match(/^---\s*\n([\s\S]*?)\n---/);
|
|
68
|
+
if (!match) return null;
|
|
69
|
+
|
|
70
|
+
const frontmatter = match[1];
|
|
71
|
+
const name = extractYamlField(frontmatter, 'name');
|
|
72
|
+
const description = extractYamlField(frontmatter, 'description');
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
name: name || path.basename(path.dirname(filePath)),
|
|
76
|
+
description: description || ''
|
|
77
|
+
};
|
|
78
|
+
} catch (err) {
|
|
79
|
+
logger.debug(`Cannot read frontmatter from ${filePath}: ${err.message}`);
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Extract a simple scalar YAML field value.
|
|
86
|
+
* Handles: `key: value` and `key: "quoted value"`
|
|
87
|
+
* @param {string} yaml Raw YAML string
|
|
88
|
+
* @param {string} field Field name
|
|
89
|
+
* @returns {string|null}
|
|
90
|
+
*/
|
|
91
|
+
function extractYamlField(yaml, field) {
|
|
92
|
+
const regex = new RegExp(`^${field}:\\s*["']?(.+?)["']?\\s*$`, 'm');
|
|
93
|
+
const match = yaml.match(regex);
|
|
94
|
+
return match ? match[1].trim() : null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Scan a single skill directory for SKILL.md and all component files.
|
|
99
|
+
* @param {string} dir Absolute path to a skill directory
|
|
100
|
+
* @returns {Array<object>} Discovered skill components
|
|
101
|
+
*/
|
|
102
|
+
function scanSkillDirectory(dir) {
|
|
103
|
+
const skills = [];
|
|
104
|
+
|
|
105
|
+
if (!fs.existsSync(dir)) {
|
|
106
|
+
logger.debug(`Skill directory does not exist: ${dir}`);
|
|
107
|
+
return skills;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
let entries;
|
|
111
|
+
try {
|
|
112
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
113
|
+
} catch (err) {
|
|
114
|
+
logger.debug(`Cannot read skill directory ${dir}: ${err.message}`);
|
|
115
|
+
return skills;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
for (const entry of entries) {
|
|
119
|
+
if (!entry.isDirectory()) continue;
|
|
120
|
+
if (entry.name.startsWith('.')) continue;
|
|
121
|
+
|
|
122
|
+
const skillDir = path.join(dir, entry.name);
|
|
123
|
+
const skillMdPath = path.join(skillDir, 'SKILL.md');
|
|
124
|
+
|
|
125
|
+
// A valid skill has a SKILL.md file
|
|
126
|
+
if (!fs.existsSync(skillMdPath)) {
|
|
127
|
+
logger.debug(`No SKILL.md in ${skillDir}, skipping`);
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const frontmatter = extractSkillFrontmatter(skillMdPath);
|
|
132
|
+
const dirHash = hashDirectory(skillDir);
|
|
133
|
+
|
|
134
|
+
skills.push({
|
|
135
|
+
name: frontmatter ? frontmatter.name : entry.name,
|
|
136
|
+
type: 'skill',
|
|
137
|
+
source: 'local',
|
|
138
|
+
enabled: true,
|
|
139
|
+
path: skillDir,
|
|
140
|
+
description: frontmatter ? frontmatter.description : '',
|
|
141
|
+
hash: dirHash.combined,
|
|
142
|
+
fileHashes: dirHash.files
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return skills;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Run a full disk scan across all skill directories.
|
|
151
|
+
* Merges config-based inventory with on-disk discovery.
|
|
152
|
+
*
|
|
153
|
+
* @param {object} configInventory From config-parser.extractComponents()
|
|
154
|
+
* @param {string[]} skillDirs From config-parser.extractSkillDirs()
|
|
155
|
+
* @returns {{ components: Array<object>, snapshot: Map<string, string> }}
|
|
156
|
+
*/
|
|
157
|
+
function scanAll(configInventory, skillDirs) {
|
|
158
|
+
const components = [];
|
|
159
|
+
const snapshot = new Map();
|
|
160
|
+
|
|
161
|
+
// 1. Scan skill directories on disk
|
|
162
|
+
for (const dir of skillDirs) {
|
|
163
|
+
const discovered = scanSkillDirectory(dir);
|
|
164
|
+
for (const skill of discovered) {
|
|
165
|
+
components.push(skill);
|
|
166
|
+
// Add all file hashes to snapshot
|
|
167
|
+
if (skill.fileHashes) {
|
|
168
|
+
for (const [filePath, hash] of skill.fileHashes) {
|
|
169
|
+
snapshot.set(filePath, hash);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// 2. Add config-based components (MCP servers, CLI tools, agents, plugins, repos)
|
|
176
|
+
// These don't have on-disk files to hash (they're config references)
|
|
177
|
+
for (const type of ['mcpServers', 'cliTools', 'agents', 'plugins', 'repos']) {
|
|
178
|
+
if (configInventory[type]) {
|
|
179
|
+
for (const item of configInventory[type]) {
|
|
180
|
+
components.push(Object.assign({}, item, { hash: null, fileHashes: null }));
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// 3. Hash the openclaw.json config file itself
|
|
186
|
+
const configDir = require('./config-parser').getOpenClawDir();
|
|
187
|
+
const configFilePath = path.join(configDir, 'openclaw.json');
|
|
188
|
+
const configHash = hashFile(configFilePath);
|
|
189
|
+
if (configHash) {
|
|
190
|
+
snapshot.set(configFilePath, configHash);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
logger.info(
|
|
194
|
+
`Scan complete: ${components.length} components, ${snapshot.size} files hashed`
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
return { components, snapshot };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Compute the delta between two snapshots.
|
|
202
|
+
* @param {Map<string, string>} previous Previous file hash snapshot
|
|
203
|
+
* @param {Map<string, string>} current Current file hash snapshot
|
|
204
|
+
* @returns {{ added: string[], changed: string[], removed: string[] }}
|
|
205
|
+
*/
|
|
206
|
+
function computeDelta(previous, current) {
|
|
207
|
+
const added = [];
|
|
208
|
+
const changed = [];
|
|
209
|
+
const removed = [];
|
|
210
|
+
|
|
211
|
+
// Files in current but not previous = added
|
|
212
|
+
// Files in both but hash differs = changed
|
|
213
|
+
for (const [filePath, hash] of current) {
|
|
214
|
+
if (!previous.has(filePath)) {
|
|
215
|
+
added.push(filePath);
|
|
216
|
+
} else if (previous.get(filePath) !== hash) {
|
|
217
|
+
changed.push(filePath);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Files in previous but not current = removed
|
|
222
|
+
for (const filePath of previous.keys()) {
|
|
223
|
+
if (!current.has(filePath)) {
|
|
224
|
+
removed.push(filePath);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return { added, changed, removed };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
module.exports = {
|
|
232
|
+
hashFile,
|
|
233
|
+
hashDirectory,
|
|
234
|
+
extractSkillFrontmatter,
|
|
235
|
+
scanSkillDirectory,
|
|
236
|
+
scanAll,
|
|
237
|
+
computeDelta
|
|
238
|
+
};
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
const JSON5 = require('json5');
|
|
7
|
+
const logger = require('./logger');
|
|
8
|
+
|
|
9
|
+
const DEFAULT_CONFIG_DIR = path.join(os.homedir(), '.openclaw');
|
|
10
|
+
const DEFAULT_CONFIG_FILE = 'openclaw.json';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Resolve the path to openclaw.json.
|
|
14
|
+
* Priority: OPENCLAW_CONFIG_PATH env > --profile flag > default ~/.openclaw/openclaw.json
|
|
15
|
+
* @param {{ profile?: string | null }} opts
|
|
16
|
+
* @returns {string} Absolute path to config file
|
|
17
|
+
*/
|
|
18
|
+
function findConfigPath(opts) {
|
|
19
|
+
// 1. Environment variable override
|
|
20
|
+
const envPath = process.env.OPENCLAW_CONFIG_PATH;
|
|
21
|
+
if (envPath) {
|
|
22
|
+
logger.debug(`Config path from OPENCLAW_CONFIG_PATH: ${envPath}`);
|
|
23
|
+
return path.resolve(envPath);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// 2. Profile flag (uses ~/.openclaw/profiles/<name>/openclaw.json)
|
|
27
|
+
if (opts && opts.profile) {
|
|
28
|
+
const profilePath = path.join(DEFAULT_CONFIG_DIR, 'profiles', opts.profile, DEFAULT_CONFIG_FILE);
|
|
29
|
+
logger.debug(`Config path from --profile "${opts.profile}": ${profilePath}`);
|
|
30
|
+
return profilePath;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// 3. Default
|
|
34
|
+
const defaultPath = path.join(DEFAULT_CONFIG_DIR, DEFAULT_CONFIG_FILE);
|
|
35
|
+
logger.debug(`Config path (default): ${defaultPath}`);
|
|
36
|
+
return defaultPath;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Read and parse the openclaw.json file (JSON5 format).
|
|
41
|
+
* @param {string} filePath Absolute path to config file
|
|
42
|
+
* @returns {object} Parsed config object
|
|
43
|
+
* @throws {Error} If file not found or JSON5 parse fails
|
|
44
|
+
*/
|
|
45
|
+
function parseConfig(filePath) {
|
|
46
|
+
if (!fs.existsSync(filePath)) {
|
|
47
|
+
throw new Error(
|
|
48
|
+
`OpenClaw config not found at ${filePath}. ` +
|
|
49
|
+
`Make sure OpenClaw is installed and configured. ` +
|
|
50
|
+
`You can set a custom path with the OPENCLAW_CONFIG_PATH environment variable.`
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let raw;
|
|
55
|
+
try {
|
|
56
|
+
raw = fs.readFileSync(filePath, 'utf-8');
|
|
57
|
+
} catch (err) {
|
|
58
|
+
throw new Error(`Cannot read config file at ${filePath}: ${err.message}`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
return JSON5.parse(raw);
|
|
63
|
+
} catch (err) {
|
|
64
|
+
throw new Error(`Invalid JSON5 in ${filePath}: ${err.message}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Extract all components from parsed config into a normalized inventory.
|
|
70
|
+
* Returns arrays of { name, source, type, enabled } objects.
|
|
71
|
+
* @param {object} config Parsed openclaw.json
|
|
72
|
+
* @returns {object} Component inventory
|
|
73
|
+
*/
|
|
74
|
+
function extractComponents(config) {
|
|
75
|
+
const inventory = {
|
|
76
|
+
skills: extractSkills(config),
|
|
77
|
+
mcpServers: extractMcpServers(config),
|
|
78
|
+
cliTools: extractCliTools(config),
|
|
79
|
+
agents: extractAgents(config),
|
|
80
|
+
plugins: extractPlugins(config),
|
|
81
|
+
repos: extractRepos(config)
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
return inventory;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Extract skill entries from config.
|
|
89
|
+
* Skills can be in config.skills (managed), config.skills.load.extraDirs, etc.
|
|
90
|
+
* @param {object} config
|
|
91
|
+
* @returns {Array<object>}
|
|
92
|
+
*/
|
|
93
|
+
function extractSkills(config) {
|
|
94
|
+
const skills = [];
|
|
95
|
+
|
|
96
|
+
// Skills listed directly in config
|
|
97
|
+
if (config.skills && typeof config.skills === 'object') {
|
|
98
|
+
// skills can be an array or an object with named entries
|
|
99
|
+
if (Array.isArray(config.skills)) {
|
|
100
|
+
for (const skill of config.skills) {
|
|
101
|
+
skills.push(normalizeComponent(skill, 'skill'));
|
|
102
|
+
}
|
|
103
|
+
} else {
|
|
104
|
+
// Object format: { "skill-name": { ... } }
|
|
105
|
+
for (const [name, value] of Object.entries(config.skills)) {
|
|
106
|
+
if (name === 'load') continue; // skip the load config block
|
|
107
|
+
const entry = typeof value === 'object' && value !== null ? value : {};
|
|
108
|
+
skills.push(normalizeComponent({ name, ...entry }, 'skill'));
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return skills;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Extract MCP server configurations.
|
|
118
|
+
* Looks in config.mcpServers or config.mcp.servers (per-agent or global).
|
|
119
|
+
* @param {object} config
|
|
120
|
+
* @returns {Array<object>}
|
|
121
|
+
*/
|
|
122
|
+
function extractMcpServers(config) {
|
|
123
|
+
const servers = [];
|
|
124
|
+
|
|
125
|
+
// Global mcpServers
|
|
126
|
+
const globalServers = config.mcpServers || (config.mcp && config.mcp.servers) || {};
|
|
127
|
+
if (typeof globalServers === 'object' && !Array.isArray(globalServers)) {
|
|
128
|
+
for (const [name, value] of Object.entries(globalServers)) {
|
|
129
|
+
const entry = typeof value === 'object' && value !== null ? value : {};
|
|
130
|
+
servers.push({
|
|
131
|
+
name,
|
|
132
|
+
type: 'mcp_server',
|
|
133
|
+
source: entry.command || entry.url || 'unknown',
|
|
134
|
+
enabled: entry.disabled !== true,
|
|
135
|
+
serverType: classifyMcpServerType(entry),
|
|
136
|
+
accessScope: entry.command || ''
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Per-agent MCP servers
|
|
142
|
+
if (config.agents && typeof config.agents === 'object') {
|
|
143
|
+
for (const [agentName, agentConfig] of Object.entries(config.agents)) {
|
|
144
|
+
const agentServers = agentConfig.mcpServers || (agentConfig.mcp && agentConfig.mcp.servers) || {};
|
|
145
|
+
if (typeof agentServers === 'object' && !Array.isArray(agentServers)) {
|
|
146
|
+
for (const [name, value] of Object.entries(agentServers)) {
|
|
147
|
+
const entry = typeof value === 'object' && value !== null ? value : {};
|
|
148
|
+
// Only add if not already in global list
|
|
149
|
+
if (!servers.find((s) => s.name === name)) {
|
|
150
|
+
servers.push({
|
|
151
|
+
name,
|
|
152
|
+
type: 'mcp_server',
|
|
153
|
+
source: entry.command || entry.url || 'unknown',
|
|
154
|
+
enabled: entry.disabled !== true,
|
|
155
|
+
serverType: classifyMcpServerType(entry),
|
|
156
|
+
accessScope: entry.command || '',
|
|
157
|
+
agent: agentName
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return servers;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Classify MCP server type based on config.
|
|
170
|
+
* @param {object} entry MCP server config entry
|
|
171
|
+
* @returns {string} npx | docker | sse | stdio | unknown
|
|
172
|
+
*/
|
|
173
|
+
function classifyMcpServerType(entry) {
|
|
174
|
+
if (!entry || !entry.command) return 'unknown';
|
|
175
|
+
const cmd = String(entry.command);
|
|
176
|
+
if (cmd.includes('npx') || cmd.includes('npm')) return 'npx';
|
|
177
|
+
if (cmd.includes('docker')) return 'docker';
|
|
178
|
+
if (entry.url) return 'sse';
|
|
179
|
+
return 'stdio';
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Extract CLI tool references from config.
|
|
184
|
+
* Looks in config.tools and skill metadata for required bins.
|
|
185
|
+
* @param {object} config
|
|
186
|
+
* @returns {Array<object>}
|
|
187
|
+
*/
|
|
188
|
+
function extractCliTools(config) {
|
|
189
|
+
const tools = [];
|
|
190
|
+
|
|
191
|
+
if (config.tools && typeof config.tools === 'object') {
|
|
192
|
+
for (const [name, value] of Object.entries(config.tools)) {
|
|
193
|
+
const entry = typeof value === 'object' && value !== null ? value : {};
|
|
194
|
+
tools.push(normalizeComponent({ name, ...entry }, 'cli_tool'));
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return tools;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Extract agent configurations.
|
|
203
|
+
* @param {object} config
|
|
204
|
+
* @returns {Array<object>}
|
|
205
|
+
*/
|
|
206
|
+
function extractAgents(config) {
|
|
207
|
+
const agents = [];
|
|
208
|
+
|
|
209
|
+
if (config.agents && typeof config.agents === 'object') {
|
|
210
|
+
for (const [name, value] of Object.entries(config.agents)) {
|
|
211
|
+
const entry = typeof value === 'object' && value !== null ? value : {};
|
|
212
|
+
agents.push({
|
|
213
|
+
name,
|
|
214
|
+
type: 'agent',
|
|
215
|
+
source: 'local',
|
|
216
|
+
enabled: entry.disabled !== true
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return agents;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Extract plugin entries from config.
|
|
226
|
+
* @param {object} config
|
|
227
|
+
* @returns {Array<object>}
|
|
228
|
+
*/
|
|
229
|
+
function extractPlugins(config) {
|
|
230
|
+
const plugins = [];
|
|
231
|
+
|
|
232
|
+
if (config.plugins && typeof config.plugins === 'object') {
|
|
233
|
+
const items = Array.isArray(config.plugins) ? config.plugins : Object.entries(config.plugins);
|
|
234
|
+
for (const item of items) {
|
|
235
|
+
if (Array.isArray(item)) {
|
|
236
|
+
const [name, value] = item;
|
|
237
|
+
const entry = typeof value === 'object' && value !== null ? value : {};
|
|
238
|
+
plugins.push(normalizeComponent({ name, ...entry }, 'plugin'));
|
|
239
|
+
} else if (typeof item === 'object') {
|
|
240
|
+
plugins.push(normalizeComponent(item, 'plugin'));
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return plugins;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Extract repo references from config.
|
|
250
|
+
* @param {object} config
|
|
251
|
+
* @returns {Array<object>}
|
|
252
|
+
*/
|
|
253
|
+
function extractRepos(config) {
|
|
254
|
+
const repos = [];
|
|
255
|
+
|
|
256
|
+
if (config.repos && typeof config.repos === 'object') {
|
|
257
|
+
const items = Array.isArray(config.repos) ? config.repos : Object.entries(config.repos);
|
|
258
|
+
for (const item of items) {
|
|
259
|
+
if (Array.isArray(item)) {
|
|
260
|
+
const [name, value] = item;
|
|
261
|
+
const entry = typeof value === 'object' && value !== null ? value : {};
|
|
262
|
+
repos.push(normalizeComponent({ name, ...entry }, 'repo'));
|
|
263
|
+
} else if (typeof item === 'string') {
|
|
264
|
+
repos.push({ name: item, type: 'repo', source: item, enabled: true });
|
|
265
|
+
} else if (typeof item === 'object') {
|
|
266
|
+
repos.push(normalizeComponent(item, 'repo'));
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return repos;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Normalize a component entry to a standard shape.
|
|
276
|
+
* @param {object} entry Raw config entry
|
|
277
|
+
* @param {string} type Component type
|
|
278
|
+
* @returns {{ name: string, type: string, source: string, enabled: boolean }}
|
|
279
|
+
*/
|
|
280
|
+
function normalizeComponent(entry, type) {
|
|
281
|
+
return {
|
|
282
|
+
name: entry.name || entry.id || 'unnamed',
|
|
283
|
+
type,
|
|
284
|
+
source: entry.source || entry.url || entry.repo || entry.package || 'local',
|
|
285
|
+
enabled: entry.disabled !== true && entry.enabled !== false
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Get all directories that should be watched for skill files.
|
|
291
|
+
* @param {object} config Parsed openclaw.json
|
|
292
|
+
* @returns {string[]} Array of absolute directory paths
|
|
293
|
+
*/
|
|
294
|
+
function extractSkillDirs(config) {
|
|
295
|
+
const dirs = [];
|
|
296
|
+
|
|
297
|
+
// Default shared skills directory
|
|
298
|
+
const defaultSkillsDir = path.join(DEFAULT_CONFIG_DIR, 'skills');
|
|
299
|
+
dirs.push(defaultSkillsDir);
|
|
300
|
+
|
|
301
|
+
// Extra skill directories from config
|
|
302
|
+
if (config.skills && config.skills.load && Array.isArray(config.skills.load.extraDirs)) {
|
|
303
|
+
for (const dir of config.skills.load.extraDirs) {
|
|
304
|
+
dirs.push(path.resolve(dir));
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Per-agent workspace skill directories
|
|
309
|
+
if (config.agents && typeof config.agents === 'object') {
|
|
310
|
+
for (const [, agentConfig] of Object.entries(config.agents)) {
|
|
311
|
+
if (agentConfig.workspace) {
|
|
312
|
+
const workspaceSkills = path.join(path.resolve(agentConfig.workspace), 'skills');
|
|
313
|
+
dirs.push(workspaceSkills);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Deduplicate
|
|
319
|
+
return [...new Set(dirs)];
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Get the base OpenClaw directory path.
|
|
324
|
+
* @returns {string}
|
|
325
|
+
*/
|
|
326
|
+
function getOpenClawDir() {
|
|
327
|
+
return process.env.OPENCLAW_CONFIG_PATH
|
|
328
|
+
? path.dirname(process.env.OPENCLAW_CONFIG_PATH)
|
|
329
|
+
: DEFAULT_CONFIG_DIR;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Count total components in an inventory.
|
|
334
|
+
* @param {object} inventory From extractComponents()
|
|
335
|
+
* @returns {number}
|
|
336
|
+
*/
|
|
337
|
+
function countComponents(inventory) {
|
|
338
|
+
let total = 0;
|
|
339
|
+
for (const key of Object.keys(inventory)) {
|
|
340
|
+
total += inventory[key].length;
|
|
341
|
+
}
|
|
342
|
+
return total;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
module.exports = {
|
|
346
|
+
findConfigPath,
|
|
347
|
+
parseConfig,
|
|
348
|
+
extractComponents,
|
|
349
|
+
extractSkillDirs,
|
|
350
|
+
getOpenClawDir,
|
|
351
|
+
countComponents
|
|
352
|
+
};
|