@phnx-labs/agents-cli 1.20.60 → 1.20.62
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/CHANGELOG.md +18 -2
- package/README.md +1 -1
- package/dist/bin/agents +0 -0
- package/dist/commands/exec.js +10 -1
- package/dist/commands/routines.js +2 -0
- package/dist/lib/agents.js +129 -11
- package/dist/lib/commands.js +29 -0
- package/dist/lib/convert.d.ts +11 -0
- package/dist/lib/convert.js +22 -0
- package/dist/lib/doctor-diff.js +17 -1
- package/dist/lib/goose-commands.d.ts +41 -0
- package/dist/lib/goose-commands.js +176 -0
- package/dist/lib/hooks.js +134 -0
- package/dist/lib/permissions.d.ts +15 -0
- package/dist/lib/permissions.js +99 -0
- package/dist/lib/plugins.d.ts +20 -0
- package/dist/lib/plugins.js +118 -0
- package/dist/lib/resources/permissions.js +2 -0
- package/dist/lib/routines.d.ts +7 -0
- package/dist/lib/routines.js +18 -0
- package/dist/lib/runner.d.ts +7 -0
- package/dist/lib/runner.js +34 -6
- package/dist/lib/session/active.d.ts +8 -1
- package/dist/lib/session/active.js +1 -0
- package/dist/lib/session/parse.js +12 -0
- package/dist/lib/session/state.d.ts +33 -0
- package/dist/lib/session/state.js +40 -0
- package/dist/lib/staleness/detectors/permissions.js +23 -0
- package/dist/lib/staleness/detectors/subagents.js +36 -0
- package/dist/lib/staleness/detectors/workflows.js +29 -0
- package/dist/lib/staleness/writers/commands.js +7 -0
- package/dist/lib/staleness/writers/hooks.js +1 -1
- package/dist/lib/staleness/writers/subagents.js +22 -3
- package/dist/lib/subagents.d.ts +32 -0
- package/dist/lib/subagents.js +238 -0
- package/dist/lib/tmux/session.d.ts +13 -7
- package/dist/lib/tmux/session.js +23 -8
- package/dist/lib/versions.js +72 -0
- package/dist/lib/workflows.d.ts +9 -0
- package/dist/lib/workflows.js +84 -2
- package/package.json +1 -1
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Goose slash-command install/remove/list/compare.
|
|
3
|
+
*
|
|
4
|
+
* Goose has no native slash-command file format — a slash command is a recipe
|
|
5
|
+
* YAML file registered in `~/.config/goose/config.yaml` under a `slash_commands`
|
|
6
|
+
* array: `[{ command: "<name>", recipe_path: "<abs path to recipe.yaml>" }]`.
|
|
7
|
+
* (See goose-docs.ai context-engineering/slash-commands.)
|
|
8
|
+
*
|
|
9
|
+
* agents-cli writes each command's recipe to `<versionHome>/.config/goose/commands/
|
|
10
|
+
* <name>.yaml` — a dir distinct from the workflow recipes dir
|
|
11
|
+
* (`.config/goose/recipes/`) so the workflow detector never treats a command
|
|
12
|
+
* recipe as a workflow — and registers/unregisters the `slash_commands` entry in
|
|
13
|
+
* `config.yaml` via a read-modify-write that preserves every other key
|
|
14
|
+
* (`mcp_servers`, `extensions`, …). Under agents-cli version isolation HOME is the
|
|
15
|
+
* version home, so both files live under it and the absolute `recipe_path`
|
|
16
|
+
* resolves correctly at goose runtime.
|
|
17
|
+
*/
|
|
18
|
+
import * as fs from 'fs';
|
|
19
|
+
import * as path from 'path';
|
|
20
|
+
import * as yaml from 'yaml';
|
|
21
|
+
import { safeJoin } from './paths.js';
|
|
22
|
+
import { markdownToGooseRecipe } from './convert.js';
|
|
23
|
+
/** Directory holding Goose slash-command recipe YAML files in a version home. */
|
|
24
|
+
export function gooseCommandsDir(versionHome) {
|
|
25
|
+
return path.join(versionHome, '.config', 'goose', 'commands');
|
|
26
|
+
}
|
|
27
|
+
/** Path to the Goose config.yaml (holds the `slash_commands` registry) in a version home. */
|
|
28
|
+
export function gooseCommandConfigPath(versionHome) {
|
|
29
|
+
return path.join(versionHome, '.config', 'goose', 'config.yaml');
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Read the goose config.yaml as a mutable object. Throws — rather than returning
|
|
33
|
+
* `{}` — when a NON-EMPTY file fails to parse or isn't a mapping, so the caller
|
|
34
|
+
* (which rewrites the whole file) never silently clobbers a real user config
|
|
35
|
+
* (`mcp_servers`, `GOOSE_MODEL`, `extensions`, …). A missing or genuinely empty
|
|
36
|
+
* file returns `{}`.
|
|
37
|
+
*/
|
|
38
|
+
function readGooseConfig(configPath) {
|
|
39
|
+
if (!fs.existsSync(configPath))
|
|
40
|
+
return {};
|
|
41
|
+
const raw = fs.readFileSync(configPath, 'utf-8');
|
|
42
|
+
if (raw.trim() === '')
|
|
43
|
+
return {};
|
|
44
|
+
let parsed;
|
|
45
|
+
try {
|
|
46
|
+
parsed = yaml.parse(raw);
|
|
47
|
+
}
|
|
48
|
+
catch (err) {
|
|
49
|
+
throw new Error(`Refusing to rewrite goose config at ${configPath}: existing file is not valid YAML ` +
|
|
50
|
+
`(${err.message}). Fix or remove it, then re-sync.`);
|
|
51
|
+
}
|
|
52
|
+
if (parsed === null || parsed === undefined)
|
|
53
|
+
return {}; // comments/whitespace only
|
|
54
|
+
if (typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
55
|
+
throw new Error(`Refusing to rewrite goose config at ${configPath}: expected a YAML mapping but found ` +
|
|
56
|
+
`${Array.isArray(parsed) ? 'a list' : typeof parsed}.`);
|
|
57
|
+
}
|
|
58
|
+
return parsed;
|
|
59
|
+
}
|
|
60
|
+
function readSlashCommands(config) {
|
|
61
|
+
const raw = config.slash_commands;
|
|
62
|
+
if (!Array.isArray(raw))
|
|
63
|
+
return [];
|
|
64
|
+
return raw.filter((e) => !!e && typeof e === 'object' && typeof e.command === 'string');
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Register (or update) a `slash_commands` entry for `commandName` pointing at
|
|
68
|
+
* `recipePath`, preserving every other config key and other entries. Idempotent.
|
|
69
|
+
*/
|
|
70
|
+
function registerSlashCommand(configPath, commandName, recipePath) {
|
|
71
|
+
const config = readGooseConfig(configPath);
|
|
72
|
+
const entries = readSlashCommands(config);
|
|
73
|
+
const existing = entries.find((e) => e.command === commandName);
|
|
74
|
+
if (existing && existing.recipe_path === recipePath)
|
|
75
|
+
return; // already current — no rewrite
|
|
76
|
+
const next = entries.filter((e) => e.command !== commandName);
|
|
77
|
+
next.push({ command: commandName, recipe_path: recipePath });
|
|
78
|
+
next.sort((a, b) => a.command.localeCompare(b.command));
|
|
79
|
+
config.slash_commands = next;
|
|
80
|
+
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
81
|
+
fs.writeFileSync(configPath, yaml.stringify(config), 'utf-8');
|
|
82
|
+
}
|
|
83
|
+
/** Remove the `slash_commands` entry for `commandName`, preserving all other config. */
|
|
84
|
+
function unregisterSlashCommand(configPath, commandName) {
|
|
85
|
+
if (!fs.existsSync(configPath))
|
|
86
|
+
return;
|
|
87
|
+
const config = readGooseConfig(configPath);
|
|
88
|
+
const entries = readSlashCommands(config);
|
|
89
|
+
if (!entries.some((e) => e.command === commandName))
|
|
90
|
+
return; // nothing to do
|
|
91
|
+
const next = entries.filter((e) => e.command !== commandName);
|
|
92
|
+
if (next.length > 0) {
|
|
93
|
+
config.slash_commands = next;
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
delete config.slash_commands;
|
|
97
|
+
}
|
|
98
|
+
fs.writeFileSync(configPath, yaml.stringify(config), 'utf-8');
|
|
99
|
+
}
|
|
100
|
+
/** Serialize a Goose command recipe from a central Markdown command file. */
|
|
101
|
+
function buildGooseCommandRecipe(commandName, sourcePath) {
|
|
102
|
+
const markdown = fs.readFileSync(sourcePath, 'utf-8');
|
|
103
|
+
return yaml.stringify(markdownToGooseRecipe(commandName, markdown));
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Install a command into a Goose version home: write its recipe YAML and register
|
|
107
|
+
* the `slash_commands` entry in config.yaml.
|
|
108
|
+
*/
|
|
109
|
+
export function installGooseCommandToVersion(versionHome, commandName, sourcePath) {
|
|
110
|
+
try {
|
|
111
|
+
const dir = gooseCommandsDir(versionHome);
|
|
112
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
113
|
+
const recipePath = safeJoin(dir, `${commandName}.yaml`);
|
|
114
|
+
fs.writeFileSync(recipePath, buildGooseCommandRecipe(commandName, sourcePath), 'utf-8');
|
|
115
|
+
registerSlashCommand(gooseCommandConfigPath(versionHome), commandName, recipePath);
|
|
116
|
+
return { success: true };
|
|
117
|
+
}
|
|
118
|
+
catch (err) {
|
|
119
|
+
return { success: false, error: err.message };
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
/** List Goose command names (recipe files) installed in a version home. */
|
|
123
|
+
export function listGooseCommandsInVersion(versionHome) {
|
|
124
|
+
const dir = gooseCommandsDir(versionHome);
|
|
125
|
+
if (!fs.existsSync(dir))
|
|
126
|
+
return [];
|
|
127
|
+
return fs.readdirSync(dir)
|
|
128
|
+
.filter((f) => f.endsWith('.yaml'))
|
|
129
|
+
.map((f) => f.slice(0, -'.yaml'.length))
|
|
130
|
+
.sort();
|
|
131
|
+
}
|
|
132
|
+
/** Whether an installed Goose command recipe matches the central Markdown source. */
|
|
133
|
+
export function gooseCommandMatches(versionHome, commandName, sourcePath) {
|
|
134
|
+
const recipePath = safeJoin(gooseCommandsDir(versionHome), `${commandName}.yaml`);
|
|
135
|
+
if (!fs.existsSync(recipePath) || !fs.existsSync(sourcePath))
|
|
136
|
+
return false;
|
|
137
|
+
try {
|
|
138
|
+
// The slash_commands entry must also be registered for the command to be live.
|
|
139
|
+
// (An unparseable config.yaml surfaces here as "not a match" → a re-sync, which
|
|
140
|
+
// fails loudly rather than clobbering, instead of a crash during a read-only diff.)
|
|
141
|
+
const registered = readSlashCommands(readGooseConfig(gooseCommandConfigPath(versionHome)))
|
|
142
|
+
.some((e) => e.command === commandName);
|
|
143
|
+
if (!registered)
|
|
144
|
+
return false;
|
|
145
|
+
const installed = fs.readFileSync(recipePath, 'utf-8').trim();
|
|
146
|
+
const expected = buildGooseCommandRecipe(commandName, sourcePath).trim();
|
|
147
|
+
return installed === expected;
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Remove a Goose command from a version home: soft-delete the recipe to `trashDir`
|
|
155
|
+
* (when provided) and unregister its `slash_commands` entry.
|
|
156
|
+
*/
|
|
157
|
+
export function removeGooseCommandFromVersion(versionHome, commandName, trashDir) {
|
|
158
|
+
try {
|
|
159
|
+
const recipePath = safeJoin(gooseCommandsDir(versionHome), `${commandName}.yaml`);
|
|
160
|
+
if (fs.existsSync(recipePath)) {
|
|
161
|
+
if (trashDir) {
|
|
162
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
163
|
+
fs.mkdirSync(trashDir, { recursive: true, mode: 0o700 });
|
|
164
|
+
fs.renameSync(recipePath, path.join(trashDir, `${commandName}.yaml.${stamp}`));
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
fs.unlinkSync(recipePath);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
unregisterSlashCommand(gooseCommandConfigPath(versionHome), commandName);
|
|
171
|
+
return { success: true };
|
|
172
|
+
}
|
|
173
|
+
catch (err) {
|
|
174
|
+
return { success: false, error: err.message };
|
|
175
|
+
}
|
|
176
|
+
}
|
package/dist/lib/hooks.js
CHANGED
|
@@ -1006,6 +1006,9 @@ export function registerHooksToSettings(agentId, versionHome, hookManifest, agen
|
|
|
1006
1006
|
if (agentId === 'cursor') {
|
|
1007
1007
|
return registerHooksForCursor(versionHome, manifest, resolveScript, managedPrefixes);
|
|
1008
1008
|
}
|
|
1009
|
+
if (agentId === 'hermes') {
|
|
1010
|
+
return registerHooksForHermes(versionHome, manifest, resolveScript, managedPrefixes);
|
|
1011
|
+
}
|
|
1009
1012
|
return { registered: [], errors: [] };
|
|
1010
1013
|
}
|
|
1011
1014
|
/**
|
|
@@ -2186,3 +2189,134 @@ function registerHooksForCursor(versionHome, manifest, resolveScript, managedPre
|
|
|
2186
2189
|
}
|
|
2187
2190
|
return { registered, errors };
|
|
2188
2191
|
}
|
|
2192
|
+
/**
|
|
2193
|
+
* Canonical → Hermes (Nous Research) snake_case lifecycle events.
|
|
2194
|
+
*
|
|
2195
|
+
* Hermes ≥ 0.11.0 runs configurable hooks declared under `hooks:` in
|
|
2196
|
+
* ~/.hermes/config.yaml. Only events with a documented Hermes equivalent are
|
|
2197
|
+
* mapped; unmapped canonical events (the manifest may declare events for other
|
|
2198
|
+
* agents) are skipped silently. UserPromptSubmit maps to `pre_llm_call` (the
|
|
2199
|
+
* closest pre-turn phase) and Stop to `on_session_finalize`.
|
|
2200
|
+
*/
|
|
2201
|
+
const HERMES_EVENT_MAP = {
|
|
2202
|
+
SessionStart: 'on_session_start',
|
|
2203
|
+
SessionEnd: 'on_session_end',
|
|
2204
|
+
PreToolUse: 'pre_tool_call',
|
|
2205
|
+
PostToolUse: 'post_tool_call',
|
|
2206
|
+
SubagentStop: 'subagent_stop',
|
|
2207
|
+
UserPromptSubmit: 'pre_llm_call',
|
|
2208
|
+
Stop: 'on_session_finalize',
|
|
2209
|
+
};
|
|
2210
|
+
/** Hermes caps hook timeouts at 300s (default 60s). */
|
|
2211
|
+
const HERMES_TIMEOUT_CAP = 300;
|
|
2212
|
+
const HERMES_TIMEOUT_DEFAULT = 60;
|
|
2213
|
+
/**
|
|
2214
|
+
* Register hooks for Hermes Agent (Nous Research ≥ 0.11.0).
|
|
2215
|
+
*
|
|
2216
|
+
* Read-modify-writes the shared `~/.hermes/config.yaml` (under the version
|
|
2217
|
+
* home): it merges a `hooks:` block of the form
|
|
2218
|
+
* hooks: { <event>: [ { command, timeout, matcher? } ] }
|
|
2219
|
+
* into the YAML doc WITHOUT touching sibling keys (`mcp_servers` in
|
|
2220
|
+
* particular — a naive overwrite would wipe the user's MCP servers). No
|
|
2221
|
+
* `version` wrapper: Hermes' config is a flat YAML map.
|
|
2222
|
+
*
|
|
2223
|
+
* GC: rewrite managed entries by command path under managedPrefixes; preserve
|
|
2224
|
+
* user-authored entries whose command is outside managed roots. Keyed by
|
|
2225
|
+
* event|command|matcher so a matcher or event change drops the stale entry.
|
|
2226
|
+
*/
|
|
2227
|
+
function registerHooksForHermes(versionHome, manifest, resolveScript, managedPrefixes) {
|
|
2228
|
+
const registered = [];
|
|
2229
|
+
const errors = [];
|
|
2230
|
+
const configDir = path.join(versionHome, '.hermes');
|
|
2231
|
+
const configPath = path.join(configDir, 'config.yaml');
|
|
2232
|
+
// Read-modify-write: preserve the full existing YAML doc (mcp_servers, etc.).
|
|
2233
|
+
let config = {};
|
|
2234
|
+
if (fs.existsSync(configPath)) {
|
|
2235
|
+
try {
|
|
2236
|
+
const parsed = yaml.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
2237
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
2238
|
+
config = parsed;
|
|
2239
|
+
}
|
|
2240
|
+
}
|
|
2241
|
+
catch {
|
|
2242
|
+
errors.push('Failed to parse existing config.yaml');
|
|
2243
|
+
return { registered, errors };
|
|
2244
|
+
}
|
|
2245
|
+
}
|
|
2246
|
+
const existingHooks = config.hooks && typeof config.hooks === 'object' && !Array.isArray(config.hooks)
|
|
2247
|
+
? config.hooks
|
|
2248
|
+
: {};
|
|
2249
|
+
// Desired managed entries keyed by event|command|matcher so a matcher or
|
|
2250
|
+
// event change drops the stale entry instead of retaining it by command alone.
|
|
2251
|
+
const desiredManaged = new Set();
|
|
2252
|
+
for (const [hookName, hookDef] of Object.entries(manifest)) {
|
|
2253
|
+
if (!hookDef.events || hookDef.events.length === 0)
|
|
2254
|
+
continue;
|
|
2255
|
+
const resolved = resolveHookCommand(hookName, hookDef, resolveScript);
|
|
2256
|
+
if (!resolved)
|
|
2257
|
+
continue;
|
|
2258
|
+
for (const event of hookDef.events) {
|
|
2259
|
+
const hermesEvent = HERMES_EVENT_MAP[event];
|
|
2260
|
+
if (!hermesEvent)
|
|
2261
|
+
continue;
|
|
2262
|
+
desiredManaged.add(`${hermesEvent}|${resolved}|${hookDef.matcher ?? ''}`);
|
|
2263
|
+
}
|
|
2264
|
+
}
|
|
2265
|
+
// GC managed entries that are no longer in the manifest; preserve user entries.
|
|
2266
|
+
const hooks = {};
|
|
2267
|
+
for (const [event, entries] of Object.entries(existingHooks)) {
|
|
2268
|
+
if (!Array.isArray(entries))
|
|
2269
|
+
continue;
|
|
2270
|
+
hooks[event] = entries.filter((e) => {
|
|
2271
|
+
if (typeof e?.command !== 'string')
|
|
2272
|
+
return true;
|
|
2273
|
+
if (!isManagedHookCommand(e.command, managedPrefixes))
|
|
2274
|
+
return true;
|
|
2275
|
+
return desiredManaged.has(`${event}|${e.command}|${e.matcher ?? ''}`);
|
|
2276
|
+
});
|
|
2277
|
+
if (hooks[event].length === 0)
|
|
2278
|
+
delete hooks[event];
|
|
2279
|
+
}
|
|
2280
|
+
for (const [name, hookDef] of Object.entries(manifest)) {
|
|
2281
|
+
if (!hookDef.events || hookDef.events.length === 0)
|
|
2282
|
+
continue;
|
|
2283
|
+
const commandPath = resolveHookCommand(name, hookDef, resolveScript);
|
|
2284
|
+
if (!commandPath) {
|
|
2285
|
+
errors.push(`${name}: script not found in user or system hooks dir`);
|
|
2286
|
+
continue;
|
|
2287
|
+
}
|
|
2288
|
+
const timeout = Math.min(HERMES_TIMEOUT_CAP, hookDef.timeout ?? HERMES_TIMEOUT_DEFAULT);
|
|
2289
|
+
for (const event of hookDef.events) {
|
|
2290
|
+
const hermesEvent = HERMES_EVENT_MAP[event];
|
|
2291
|
+
if (!hermesEvent)
|
|
2292
|
+
continue;
|
|
2293
|
+
if (!hooks[hermesEvent])
|
|
2294
|
+
hooks[hermesEvent] = [];
|
|
2295
|
+
const entry = { command: commandPath, timeout };
|
|
2296
|
+
if (hookDef.matcher)
|
|
2297
|
+
entry.matcher = hookDef.matcher;
|
|
2298
|
+
const existingIdx = hooks[hermesEvent].findIndex((h) => h.command === entry.command && (h.matcher ?? '') === (entry.matcher ?? ''));
|
|
2299
|
+
if (existingIdx >= 0) {
|
|
2300
|
+
hooks[hermesEvent][existingIdx] = entry;
|
|
2301
|
+
}
|
|
2302
|
+
else {
|
|
2303
|
+
hooks[hermesEvent].push(entry);
|
|
2304
|
+
}
|
|
2305
|
+
registered.push(`${name} -> ${hermesEvent}`);
|
|
2306
|
+
}
|
|
2307
|
+
}
|
|
2308
|
+
if (Object.keys(hooks).length > 0) {
|
|
2309
|
+
config.hooks = hooks;
|
|
2310
|
+
}
|
|
2311
|
+
else {
|
|
2312
|
+
delete config.hooks;
|
|
2313
|
+
}
|
|
2314
|
+
try {
|
|
2315
|
+
fs.mkdirSync(configDir, { recursive: true });
|
|
2316
|
+
fs.writeFileSync(configPath, yaml.stringify(config), 'utf-8');
|
|
2317
|
+
}
|
|
2318
|
+
catch (err) {
|
|
2319
|
+
errors.push(`Failed to write config.yaml: ${err.message}`);
|
|
2320
|
+
}
|
|
2321
|
+
return { registered, errors };
|
|
2322
|
+
}
|
|
@@ -122,6 +122,21 @@ export declare function convertToDroidFormat(set: PermissionSet): {
|
|
|
122
122
|
commandAllowlist: string[];
|
|
123
123
|
commandDenylist: string[];
|
|
124
124
|
};
|
|
125
|
+
/**
|
|
126
|
+
* Convert canonical permission set to OpenClaw's `tools.alsoAllow`/`tools.deny`.
|
|
127
|
+
*
|
|
128
|
+
* OpenClaw's allowlist is tool-level only, so ONLY blanket (whole-tool) rules
|
|
129
|
+
* map — a rule is blanket iff it's a bare tool with no parens (`Bash`), it's in
|
|
130
|
+
* BLANKET_BASH_FORMS, or its pattern is `*`/`**` (`Read(**)`, `Write(*)`).
|
|
131
|
+
* Sub-command/path/domain rules (`Bash(git:*)`, `Write(secrets/**)`,
|
|
132
|
+
* `WebFetch(domain:x)`) are SKIPPED — coarse-mapping a specific deny to a whole
|
|
133
|
+
* tool would wrongly gate every use of that tool. Output arrays are deduped and
|
|
134
|
+
* sorted for deterministic writes/tests.
|
|
135
|
+
*/
|
|
136
|
+
export declare function convertToOpenClawFormat(set: PermissionSet): {
|
|
137
|
+
alsoAllow: string[];
|
|
138
|
+
deny: string[];
|
|
139
|
+
};
|
|
125
140
|
/**
|
|
126
141
|
* Convert canonical permission set to Antigravity format.
|
|
127
142
|
* Antigravity reads ~/.gemini/antigravity-cli/settings.json with
|
package/dist/lib/permissions.js
CHANGED
|
@@ -560,6 +560,60 @@ export function convertToDroidFormat(set) {
|
|
|
560
560
|
commandDenylist: commands(set.deny ?? []),
|
|
561
561
|
};
|
|
562
562
|
}
|
|
563
|
+
/**
|
|
564
|
+
* Canonical tool -> OpenClaw tool id. OpenClaw gates at TOOL granularity only
|
|
565
|
+
* (no sub-command/path/domain patterns), so only these whole-tool ids exist.
|
|
566
|
+
* Any canonical tool not in this map is unsupported and skipped.
|
|
567
|
+
*/
|
|
568
|
+
const CANONICAL_TO_OPENCLAW_TOOL = {
|
|
569
|
+
bash: 'exec',
|
|
570
|
+
read: 'read',
|
|
571
|
+
write: 'write',
|
|
572
|
+
edit: 'write',
|
|
573
|
+
webfetch: 'web_fetch',
|
|
574
|
+
websearch: 'web_search',
|
|
575
|
+
};
|
|
576
|
+
/**
|
|
577
|
+
* Convert canonical permission set to OpenClaw's `tools.alsoAllow`/`tools.deny`.
|
|
578
|
+
*
|
|
579
|
+
* OpenClaw's allowlist is tool-level only, so ONLY blanket (whole-tool) rules
|
|
580
|
+
* map — a rule is blanket iff it's a bare tool with no parens (`Bash`), it's in
|
|
581
|
+
* BLANKET_BASH_FORMS, or its pattern is `*`/`**` (`Read(**)`, `Write(*)`).
|
|
582
|
+
* Sub-command/path/domain rules (`Bash(git:*)`, `Write(secrets/**)`,
|
|
583
|
+
* `WebFetch(domain:x)`) are SKIPPED — coarse-mapping a specific deny to a whole
|
|
584
|
+
* tool would wrongly gate every use of that tool. Output arrays are deduped and
|
|
585
|
+
* sorted for deterministic writes/tests.
|
|
586
|
+
*/
|
|
587
|
+
export function convertToOpenClawFormat(set) {
|
|
588
|
+
const map = (permissions) => {
|
|
589
|
+
const tools = new Set();
|
|
590
|
+
for (const perm of permissions) {
|
|
591
|
+
// Bare tool name with no parens (e.g. "Bash", "Read") is a blanket grant;
|
|
592
|
+
// parseCanonicalPattern requires parens, so handle it first.
|
|
593
|
+
const bare = perm.match(/^(\w+)$/);
|
|
594
|
+
if (bare) {
|
|
595
|
+
const id = CANONICAL_TO_OPENCLAW_TOOL[bare[1].toLowerCase()];
|
|
596
|
+
if (id)
|
|
597
|
+
tools.add(id);
|
|
598
|
+
continue;
|
|
599
|
+
}
|
|
600
|
+
const parsed = parseCanonicalPattern(perm);
|
|
601
|
+
if (!parsed)
|
|
602
|
+
continue;
|
|
603
|
+
const isBlanket = BLANKET_BASH_FORMS.has(perm) || parsed.pattern === '*' || parsed.pattern === '**';
|
|
604
|
+
if (!isBlanket)
|
|
605
|
+
continue;
|
|
606
|
+
const id = CANONICAL_TO_OPENCLAW_TOOL[parsed.tool];
|
|
607
|
+
if (id)
|
|
608
|
+
tools.add(id);
|
|
609
|
+
}
|
|
610
|
+
return Array.from(tools).sort();
|
|
611
|
+
};
|
|
612
|
+
return {
|
|
613
|
+
alsoAllow: map(set.allow),
|
|
614
|
+
deny: map(set.deny ?? []),
|
|
615
|
+
};
|
|
616
|
+
}
|
|
563
617
|
/**
|
|
564
618
|
* Convert canonical permission set to Antigravity format.
|
|
565
619
|
* Antigravity reads ~/.gemini/antigravity-cli/settings.json with
|
|
@@ -1570,6 +1624,51 @@ export function applyPermissionsToVersion(agentId, set, versionHome, merge = tru
|
|
|
1570
1624
|
fs.writeFileSync(permissionsPath, yaml.stringify(config), 'utf-8');
|
|
1571
1625
|
return { success: true };
|
|
1572
1626
|
}
|
|
1627
|
+
if (agentId === 'openclaw') {
|
|
1628
|
+
// OpenClaw's allowlist lives in ~/.openclaw/openclaw.json under `tools`.
|
|
1629
|
+
// Only blanket tool-level rules map (see convertToOpenClawFormat). We
|
|
1630
|
+
// read-modify-write to preserve all other keys (mcp, exec, agents, …) and
|
|
1631
|
+
// never touch `tools.allow` (the absolute allowlist that replaces defaults).
|
|
1632
|
+
const configPath = path.join(versionHome, '.openclaw', 'openclaw.json');
|
|
1633
|
+
let config = {};
|
|
1634
|
+
if (fs.existsSync(configPath)) {
|
|
1635
|
+
config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
1636
|
+
}
|
|
1637
|
+
const converted = convertToOpenClawFormat(set);
|
|
1638
|
+
const existingTools = (typeof config.tools === 'object' && config.tools !== null && !Array.isArray(config.tools))
|
|
1639
|
+
? config.tools
|
|
1640
|
+
: {};
|
|
1641
|
+
let alsoAllow;
|
|
1642
|
+
let deny;
|
|
1643
|
+
if (merge) {
|
|
1644
|
+
const existingAllow = Array.isArray(existingTools.alsoAllow) ? existingTools.alsoAllow : [];
|
|
1645
|
+
const existingDeny = Array.isArray(existingTools.deny) ? existingTools.deny : [];
|
|
1646
|
+
alsoAllow = Array.from(new Set([...existingAllow, ...converted.alsoAllow]));
|
|
1647
|
+
deny = Array.from(new Set([...existingDeny, ...converted.deny]));
|
|
1648
|
+
}
|
|
1649
|
+
else {
|
|
1650
|
+
alsoAllow = converted.alsoAllow;
|
|
1651
|
+
deny = converted.deny;
|
|
1652
|
+
}
|
|
1653
|
+
// Set or delete each key: avoid writing empty arrays (churn). On a
|
|
1654
|
+
// non-merge replace with nothing to write, delete the stale key.
|
|
1655
|
+
const tools = { ...existingTools };
|
|
1656
|
+
if (alsoAllow.length > 0)
|
|
1657
|
+
tools.alsoAllow = alsoAllow;
|
|
1658
|
+
else
|
|
1659
|
+
delete tools.alsoAllow;
|
|
1660
|
+
if (deny.length > 0)
|
|
1661
|
+
tools.deny = deny;
|
|
1662
|
+
else
|
|
1663
|
+
delete tools.deny;
|
|
1664
|
+
if (Object.keys(tools).length > 0)
|
|
1665
|
+
config.tools = tools;
|
|
1666
|
+
else
|
|
1667
|
+
delete config.tools;
|
|
1668
|
+
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
1669
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
|
|
1670
|
+
return { success: true };
|
|
1671
|
+
}
|
|
1573
1672
|
return { success: false, error: `Agent '${agentId}' does not support permissions` };
|
|
1574
1673
|
}
|
|
1575
1674
|
catch (err) {
|
package/dist/lib/plugins.d.ts
CHANGED
|
@@ -211,6 +211,26 @@ export declare function goosePluginsDir(versionHome: string): string;
|
|
|
211
211
|
export declare function installGoosePlugin(plugin: DiscoveredPlugin, versionHome: string): boolean;
|
|
212
212
|
export declare function isGoosePluginInstalled(pluginName: string, versionHome: string): boolean;
|
|
213
213
|
export declare function removeGoosePlugin(pluginName: string, versionHome: string): boolean;
|
|
214
|
+
/**
|
|
215
|
+
* Hermes (Nous Research) loads plugins from a flat `$HOME/.hermes/plugins/<name>/`
|
|
216
|
+
* directory holding a `plugin.yaml` manifest — NOT the Claude marketplace layout.
|
|
217
|
+
* Under agents-cli version isolation HOME is the version home, so we install to:
|
|
218
|
+
* {versionHome}/.hermes/plugins/<name>/
|
|
219
|
+
* A plugin does not load until its name is added to `plugins.enabled` (a YAML
|
|
220
|
+
* array) in `{versionHome}/.hermes/config.yaml`; a deny-list `plugins.disabled`
|
|
221
|
+
* wins on conflict, so agents-cli only manages the `enabled` allowlist and never
|
|
222
|
+
* touches `disabled` (user-owned).
|
|
223
|
+
*/
|
|
224
|
+
export declare function hermesPluginsDir(versionHome: string): string;
|
|
225
|
+
/**
|
|
226
|
+
* Add or remove a plugin name in `plugins.enabled` within ~/.hermes/config.yaml,
|
|
227
|
+
* preserving every other key (read → mutate → write). Never touches
|
|
228
|
+
* `plugins.disabled`. No-op (no rewrite) when the desired state already holds.
|
|
229
|
+
*/
|
|
230
|
+
export declare function setHermesPluginEnabled(pluginName: string, versionHome: string, enabled: boolean): void;
|
|
231
|
+
export declare function installHermesPlugin(plugin: DiscoveredPlugin, versionHome: string, enable: boolean): boolean;
|
|
232
|
+
export declare function isHermesPluginInstalled(pluginName: string, versionHome: string): boolean;
|
|
233
|
+
export declare function removeHermesPlugin(pluginName: string, versionHome: string): boolean;
|
|
214
234
|
/**
|
|
215
235
|
* Check if a plugin is synced to a version. True when the plugin lives at the
|
|
216
236
|
* native marketplace install path. Legacy dual-dash entries are not counted —
|
package/dist/lib/plugins.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import * as fs from 'fs';
|
|
12
12
|
import * as path from 'path';
|
|
13
|
+
import * as yaml from 'yaml';
|
|
13
14
|
import { execFileSync } from 'child_process';
|
|
14
15
|
import { getPluginsDir, getTrashPluginsDir, getExtraPluginsDir, getProjectPluginsDir, getSystemPluginsDir } from './state.js';
|
|
15
16
|
import { IS_WINDOWS, isWindowsAbsolutePath, homeDir } from './platform/index.js';
|
|
@@ -22,6 +23,7 @@ import { copyPluginToMarketplace, syncMarketplaceManifest, registerMarketplace,
|
|
|
22
23
|
const PLUGIN_MANIFEST_DIR = '.claude-plugin';
|
|
23
24
|
const PLUGIN_MANIFEST_FILE = 'plugin.json';
|
|
24
25
|
const GEMINI_EXTENSION_MANIFEST_FILE = 'gemini-extension.json';
|
|
26
|
+
const HERMES_PLUGIN_MANIFEST_FILE = 'plugin.yaml';
|
|
25
27
|
const USER_CONFIG_FILE = '.user-config.json';
|
|
26
28
|
const SOURCE_FILE = '.source';
|
|
27
29
|
export const PLUGIN_EXEC_SURFACE_LABELS = {
|
|
@@ -533,6 +535,16 @@ export function syncPluginToVersion(plugin, agent, versionHome, options = {}) {
|
|
|
533
535
|
result.skills.push(plugin.name);
|
|
534
536
|
return result;
|
|
535
537
|
}
|
|
538
|
+
// Hermes loads plugins from a flat $HOME/.hermes/plugins/<name>/ dir with a
|
|
539
|
+
// plugin.yaml manifest, gated by a plugins.enabled allowlist in config.yaml.
|
|
540
|
+
if (agent === 'hermes') {
|
|
541
|
+
const enablePlugin = options.allowExecSurfaces === true || !hasPluginExecSurfaces(inspectPluginCapabilities(plugin.root));
|
|
542
|
+
const ok = installHermesPlugin(plugin, versionHome, enablePlugin);
|
|
543
|
+
result.success = ok;
|
|
544
|
+
if (ok)
|
|
545
|
+
result.skills.push(plugin.name);
|
|
546
|
+
return result;
|
|
547
|
+
}
|
|
536
548
|
const userConfig = loadUserConfig(plugin.name);
|
|
537
549
|
// Route every marketplace op through the plugin's own marketplace, so a plugin
|
|
538
550
|
// discovered in an extra/project repo installs under its own
|
|
@@ -1035,6 +1047,102 @@ export function removeGoosePlugin(pluginName, versionHome) {
|
|
|
1035
1047
|
fs.rmSync(destRoot, { recursive: true, force: true });
|
|
1036
1048
|
return true;
|
|
1037
1049
|
}
|
|
1050
|
+
// ─── Hermes plugins (flat ~/.hermes/plugins/ + config.yaml enable toggle) ─────
|
|
1051
|
+
/**
|
|
1052
|
+
* Hermes (Nous Research) loads plugins from a flat `$HOME/.hermes/plugins/<name>/`
|
|
1053
|
+
* directory holding a `plugin.yaml` manifest — NOT the Claude marketplace layout.
|
|
1054
|
+
* Under agents-cli version isolation HOME is the version home, so we install to:
|
|
1055
|
+
* {versionHome}/.hermes/plugins/<name>/
|
|
1056
|
+
* A plugin does not load until its name is added to `plugins.enabled` (a YAML
|
|
1057
|
+
* array) in `{versionHome}/.hermes/config.yaml`; a deny-list `plugins.disabled`
|
|
1058
|
+
* wins on conflict, so agents-cli only manages the `enabled` allowlist and never
|
|
1059
|
+
* touches `disabled` (user-owned).
|
|
1060
|
+
*/
|
|
1061
|
+
export function hermesPluginsDir(versionHome) {
|
|
1062
|
+
return path.join(versionHome, '.hermes', 'plugins');
|
|
1063
|
+
}
|
|
1064
|
+
function hermesConfigPath(versionHome) {
|
|
1065
|
+
return path.join(versionHome, '.hermes', 'config.yaml');
|
|
1066
|
+
}
|
|
1067
|
+
function writeHermesPluginManifest(plugin, destRoot) {
|
|
1068
|
+
const manifest = {
|
|
1069
|
+
name: plugin.manifest.name,
|
|
1070
|
+
version: plugin.manifest.version,
|
|
1071
|
+
description: plugin.manifest.description,
|
|
1072
|
+
};
|
|
1073
|
+
fs.writeFileSync(path.join(destRoot, HERMES_PLUGIN_MANIFEST_FILE), yaml.stringify(manifest), 'utf-8');
|
|
1074
|
+
}
|
|
1075
|
+
/**
|
|
1076
|
+
* Add or remove a plugin name in `plugins.enabled` within ~/.hermes/config.yaml,
|
|
1077
|
+
* preserving every other key (read → mutate → write). Never touches
|
|
1078
|
+
* `plugins.disabled`. No-op (no rewrite) when the desired state already holds.
|
|
1079
|
+
*/
|
|
1080
|
+
export function setHermesPluginEnabled(pluginName, versionHome, enabled) {
|
|
1081
|
+
const configPath = hermesConfigPath(versionHome);
|
|
1082
|
+
let config = {};
|
|
1083
|
+
if (fs.existsSync(configPath)) {
|
|
1084
|
+
const parsed = yaml.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
1085
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
1086
|
+
config = parsed;
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
if (!config.plugins || typeof config.plugins !== 'object' || Array.isArray(config.plugins)) {
|
|
1090
|
+
config.plugins = {};
|
|
1091
|
+
}
|
|
1092
|
+
const plugins = config.plugins;
|
|
1093
|
+
const current = Array.isArray(plugins.enabled) ? plugins.enabled.filter((n) => typeof n === 'string') : [];
|
|
1094
|
+
const has = current.includes(pluginName);
|
|
1095
|
+
if (enabled && !has) {
|
|
1096
|
+
plugins.enabled = [...current, pluginName];
|
|
1097
|
+
}
|
|
1098
|
+
else if (!enabled && has) {
|
|
1099
|
+
plugins.enabled = current.filter((n) => n !== pluginName);
|
|
1100
|
+
}
|
|
1101
|
+
else {
|
|
1102
|
+
return; // desired state already holds — no rewrite
|
|
1103
|
+
}
|
|
1104
|
+
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
1105
|
+
fs.writeFileSync(configPath, yaml.stringify(config), 'utf-8');
|
|
1106
|
+
}
|
|
1107
|
+
export function installHermesPlugin(plugin, versionHome, enable) {
|
|
1108
|
+
const destRoot = path.join(hermesPluginsDir(versionHome), plugin.name);
|
|
1109
|
+
try {
|
|
1110
|
+
if (fs.existsSync(destRoot)) {
|
|
1111
|
+
fs.rmSync(destRoot, { recursive: true, force: true });
|
|
1112
|
+
}
|
|
1113
|
+
fs.cpSync(plugin.root, destRoot, { recursive: true });
|
|
1114
|
+
const userConfig = loadUserConfig(plugin.name);
|
|
1115
|
+
if (Object.keys(userConfig).length > 0) {
|
|
1116
|
+
expandUserConfigInDir(destRoot, userConfig);
|
|
1117
|
+
}
|
|
1118
|
+
writeHermesPluginManifest(plugin, destRoot);
|
|
1119
|
+
fs.writeFileSync(path.join(destRoot, '.agents-cli-managed'), `plugin=${plugin.name}\n`, 'utf-8');
|
|
1120
|
+
// Enable only when trusted — never DOWN-toggle here. An un-flagged background
|
|
1121
|
+
// re-sync of an exec-surface plugin passes enable=false; forcing the allowlist
|
|
1122
|
+
// to false then would clobber a plugin the user deliberately enabled with
|
|
1123
|
+
// --allow-exec-surfaces. Mirror addPluginToSettings: add-if-trusted, else leave
|
|
1124
|
+
// the existing enabled state untouched. (Removal still unregisters explicitly.)
|
|
1125
|
+
if (enable) {
|
|
1126
|
+
setHermesPluginEnabled(plugin.name, versionHome, true);
|
|
1127
|
+
}
|
|
1128
|
+
return true;
|
|
1129
|
+
}
|
|
1130
|
+
catch {
|
|
1131
|
+
return false;
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
export function isHermesPluginInstalled(pluginName, versionHome) {
|
|
1135
|
+
return fs.existsSync(path.join(hermesPluginsDir(versionHome), pluginName, HERMES_PLUGIN_MANIFEST_FILE));
|
|
1136
|
+
}
|
|
1137
|
+
export function removeHermesPlugin(pluginName, versionHome) {
|
|
1138
|
+
const destRoot = path.join(hermesPluginsDir(versionHome), pluginName);
|
|
1139
|
+
const existed = fs.existsSync(destRoot);
|
|
1140
|
+
if (existed)
|
|
1141
|
+
fs.rmSync(destRoot, { recursive: true, force: true });
|
|
1142
|
+
// Always drop it from the enabled allowlist, even if the dir was already gone.
|
|
1143
|
+
setHermesPluginEnabled(pluginName, versionHome, false);
|
|
1144
|
+
return existed;
|
|
1145
|
+
}
|
|
1038
1146
|
// ─── Sync status ──────────────────────────────────────────────────────────────
|
|
1039
1147
|
/**
|
|
1040
1148
|
* Check if a plugin is synced to a version. True when the plugin lives at the
|
|
@@ -1053,6 +1161,9 @@ export function isPluginSynced(plugin, agent, versionHome) {
|
|
|
1053
1161
|
if (agent === 'goose') {
|
|
1054
1162
|
return isGoosePluginInstalled(plugin.name, versionHome);
|
|
1055
1163
|
}
|
|
1164
|
+
if (agent === 'hermes') {
|
|
1165
|
+
return isHermesPluginInstalled(plugin.name, versionHome);
|
|
1166
|
+
}
|
|
1056
1167
|
const spec = marketplaceSpecForName(plugin.marketplace);
|
|
1057
1168
|
if (!isInstalledInMarketplace(plugin.name, spec, agent, versionHome))
|
|
1058
1169
|
return false;
|
|
@@ -1101,6 +1212,13 @@ export function removePluginFromVersion(pluginName, pluginRoot, agent, versionHo
|
|
|
1101
1212
|
}
|
|
1102
1213
|
return result;
|
|
1103
1214
|
}
|
|
1215
|
+
// Hermes: remove the flat plugin dir and drop it from config.yaml plugins.enabled.
|
|
1216
|
+
if (agent === 'hermes') {
|
|
1217
|
+
if (removeHermesPlugin(pluginName, versionHome)) {
|
|
1218
|
+
result.skills.push(pluginName);
|
|
1219
|
+
}
|
|
1220
|
+
return result;
|
|
1221
|
+
}
|
|
1104
1222
|
// 1. Remove the plugin from every marketplace it's installed under. A name can
|
|
1105
1223
|
// appear in more than one (collision across repos), so we sweep them all.
|
|
1106
1224
|
let removedAny = false;
|
|
@@ -75,6 +75,8 @@ function getAgentConfigPath(agent, versionHome) {
|
|
|
75
75
|
return path.join(versionHome, '.factory', 'settings.json');
|
|
76
76
|
case 'kiro':
|
|
77
77
|
return path.join(versionHome, '.kiro', 'settings', 'permissions.yaml');
|
|
78
|
+
case 'openclaw':
|
|
79
|
+
return path.join(versionHome, '.openclaw', 'openclaw.json');
|
|
78
80
|
case 'goose':
|
|
79
81
|
return path.join(versionHome, '.config', 'goose', 'permission.yaml');
|
|
80
82
|
default:
|
package/dist/lib/routines.d.ts
CHANGED
|
@@ -75,6 +75,13 @@ export interface JobConfig {
|
|
|
75
75
|
version?: string;
|
|
76
76
|
runOnce?: boolean;
|
|
77
77
|
endAt?: string;
|
|
78
|
+
/**
|
|
79
|
+
* When set, the job resumes this existing agent session id at fire time
|
|
80
|
+
* (`agents run <agent> --resume <id>`) instead of starting a fresh conversation,
|
|
81
|
+
* so the actual session reopens with full context and `prompt` becomes its next
|
|
82
|
+
* turn. Powers self-scheduled wake-ups (e.g. /hibernate). claude/codex only.
|
|
83
|
+
*/
|
|
84
|
+
resume?: string;
|
|
78
85
|
/** When set, executeJob runs this job through the loop driver instead of once. */
|
|
79
86
|
loop?: LoopConfig;
|
|
80
87
|
}
|