Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,12 @@ Skills can be installed to any of these agents:
| AdaL | `adal` | `.adal/skills/` | `~/.adal/skills/` |
<!-- supported-agents:end -->

Global paths in the table are defaults. OpenClaw honors `OPENCLAW_STATE_DIR` and
`OPENCLAW_HOME`; Hermes Agent honors `HERMES_HOME`, or the platform default with
`HERMES_DATA_DIR_SUFFIX` (`%LOCALAPPDATA%/hermes` on Windows); Qwen Code honors
`QWEN_HOME`. Set these variables when running `skills` to target the same profile
as your agent. Installation, detection, listing and removal use the resolved path.

> [!NOTE]
> **Kiro CLI users:** The default agent automatically loads skills from `.kiro/skills/` and `~/.kiro/skills/` — no
> configuration needed. If you use a **custom agent**, add skills to its `resources` in `.kiro/agents/<agent>.json`:
Expand Down
156 changes: 156 additions & 0 deletions src/agents.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import { mkdirSync, mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join, resolve } from 'path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { getHermesHome, getOpenClawGlobalSkillsDir, getQwenHome } from './agents.ts';

describe('agent home resolution', () => {
const home = join(tmpdir(), 'skills-path-home');

describe('OpenClaw', () => {
it('uses an explicit state directory before existing default or legacy directories', () => {
const state = join(home, 'work');
expect(getOpenClawGlobalSkillsDir(home, () => true, { OPENCLAW_STATE_DIR: state })).toBe(
join(state, 'skills')
);
});

it('expands the state directory relative to the effective OpenClaw home', () => {
const alternateHome = join(home, 'alternate');
expect(
getOpenClawGlobalSkillsDir(home, () => true, {
OPENCLAW_HOME: alternateHome,
OPENCLAW_STATE_DIR: ' ~/work ',
})
).toBe(join(alternateHome, 'work', 'skills'));
});

it('resolves a relative state directory against cwd', () => {
expect(getOpenClawGlobalSkillsDir(home, () => false, { OPENCLAW_STATE_DIR: './work' })).toBe(
join(resolve('work'), 'skills')
);
});

it('expands OPENCLAW_HOME against the OS home', () => {
expect(getOpenClawGlobalSkillsDir(home, () => false, { OPENCLAW_HOME: '~/alternate' })).toBe(
join(home, 'alternate', '.openclaw', 'skills')
);
});

it.each(['', ' ', 'undefined', 'null'])('ignores an unset OPENCLAW_HOME value %j', (value) => {
expect(getOpenClawGlobalSkillsDir(home, () => false, { OPENCLAW_HOME: value })).toBe(
join(home, '.openclaw', 'skills')
);
});
});

describe('Hermes', () => {
it('uses the default home with a literal data-directory suffix', () => {
expect(getHermesHome(home, {}, 'linux')).toBe(join(home, '.hermes'));
expect(getHermesHome(home, { HERMES_DATA_DIR_SUFFIX: '-work' }, 'linux')).toBe(
join(home, '.hermes-work')
);
});

it('lets HERMES_HOME take precedence over the suffix and platform default', () => {
const override = join(home, 'profile');
expect(
getHermesHome(
home,
{ HERMES_HOME: ` ${override} `, HERMES_DATA_DIR_SUFFIX: '-work' },
'win32'
)
).toBe(override);
});

it('expands environment variables before a leading home prefix', () => {
expect(
getHermesHome(
home,
{ HERMES_HOME: '$PROFILE_ROOT/${PROFILE}', PROFILE_ROOT: '~/profiles', PROFILE: 'work' },
'linux'
)
).toBe(join(home, 'profiles', 'work'));
expect(getHermesHome(home, { HERMES_HOME: '$UNDEFINED_PROFILE/work' }, 'linux')).toBe(
'$UNDEFINED_PROFILE/work'
);
});

it('preserves relative homes and literal percent syntax on POSIX', () => {
expect(getHermesHome(home, { HERMES_HOME: './work' }, 'linux')).toBe('./work');
expect(
getHermesHome(home, { HERMES_HOME: '%PROFILE%/work', PROFILE: 'other' }, 'linux')
).toBe('%PROFILE%/work');
});

it('uses LOCALAPPDATA and the suffix on Windows', () => {
const local = join(home, 'Local');
expect(
getHermesHome(home, { LOCALAPPDATA: local, HERMES_DATA_DIR_SUFFIX: '-work' }, 'win32')
).toBe(join(local, 'hermes-work'));
expect(getHermesHome(home, { LOCALAPPDATA: ' ' }, 'win32')).toBe(
join(home, 'AppData', 'Local', 'hermes')
);
});

it('expands Windows environment syntax in an explicit home', () => {
expect(
getHermesHome(home, { HERMES_HOME: '%LOCALAPPDATA%/work', LOCALAPPDATA: home }, 'win32')
).toBe(`${home}/work`);
});
});

describe('Qwen Code', () => {
it('falls back only when QWEN_HOME is empty or absent', () => {
expect(getQwenHome(home, {})).toBe(join(home, '.qwen'));
expect(getQwenHome(home, { QWEN_HOME: '' })).toBe(join(home, '.qwen'));
expect(getQwenHome(home, { QWEN_HOME: ' ' })).toBe(resolve(' '));
});

it.each(['~', '~/work', '~\\work'])('expands the home prefix in %j', (value) => {
expect(getQwenHome(home, { QWEN_HOME: value })).toBe(
value === '~' ? home : join(home, 'work')
);
});

it('resolves relative paths without expanding environment variables', () => {
expect(getQwenHome(home, { QWEN_HOME: './work' })).toBe(resolve('work'));
expect(getQwenHome(home, { QWEN_HOME: '$PROFILE/work', PROFILE: 'other' })).toBe(
resolve('$PROFILE/work')
);
});
});
});

describe('active profile detection', () => {
let root: string;

afterEach(() => {
vi.unstubAllEnvs();
vi.resetModules();
if (root) rmSync(root, { recursive: true, force: true });
});

it.each([
{ agent: 'openclaw', key: 'OPENCLAW_STATE_DIR', defaultDir: '.openclaw' },
{ agent: 'hermes-agent', key: 'HERMES_HOME', defaultDir: '.hermes' },
{ agent: 'qwen-code', key: 'QWEN_HOME', defaultDir: '.qwen' },
] as const)(
'$agent does not detect the inactive default profile',
async ({ agent, key, defaultDir }) => {
root = mkdtempSync(join(tmpdir(), 'skills-profile-detection-'));
vi.stubEnv('HOME', root);
vi.stubEnv('USERPROFILE', root);
vi.stubEnv('OPENCLAW_HOME', root);
const selected = join(root, 'active');
vi.stubEnv(key, selected);
mkdirSync(join(root, defaultDir));
vi.resetModules();
const { agents } = await import('./agents.ts');
expect(agents[agent].globalSkillsDir).toBe(join(selected, 'skills'));
await expect(agents[agent].detectInstalled()).resolves.toBe(false);
mkdirSync(selected);
await expect(agents[agent].detectInstalled()).resolves.toBe(true);
}
);
});
70 changes: 59 additions & 11 deletions src/agents.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { homedir } from 'os';
import { join } from 'path';
import { dirname, join, resolve } from 'path';
import { existsSync, readFileSync, readdirSync } from 'fs';
import { xdgConfig } from 'xdg-basedir';
import type { AgentConfig, AgentType } from './types.ts';
Expand All @@ -10,7 +10,9 @@ const configHome = xdgConfig ?? join(home, '.config');
const codexHome = process.env.CODEX_HOME?.trim() || join(home, '.codex');
const claudeHome = process.env.CLAUDE_CONFIG_DIR?.trim() || join(home, '.claude');
const vibeHome = process.env.VIBE_HOME?.trim() || join(home, '.vibe');
const hermesHome = process.env.HERMES_HOME?.trim() || join(home, '.hermes');
const hermesHome = getHermesHome();
const qwenHome = getQwenHome();
const openClawSkillsDir = getOpenClawGlobalSkillsDir();
const autohandHome = process.env.AUTOHAND_HOME?.trim() || join(home, '.autohand');
const grokHome = process.env.GROK_HOME?.trim() || join(home, '.grok');
const sarvamHome = process.env.SARVAM_HOME?.trim() || join(home, '.sarvam');
Expand All @@ -31,10 +33,60 @@ function packageJsonHasDependency(packageJsonPath: string, dependencyName: strin
}
}

// Only expand a standalone home prefix; paths such as ~other-user stay literal.
function expandHomePrefix(value: string, homeDir: string): string {
if (value === '~') return homeDir;
if (value.startsWith('~/') || value.startsWith('~\\')) {
return join(homeDir, ...value.slice(2).split(/[/\\]+/));
}
return value;
}

export function getQwenHome(homeDir = home, env: NodeJS.ProcessEnv = process.env): string {
// Qwen resolves QWEN_HOME against cwd, without trimming or expanding env variables.
return env.QWEN_HOME ? resolve(expandHomePrefix(env.QWEN_HOME, homeDir)) : join(homeDir, '.qwen');
}

export function getHermesHome(
homeDir = home,
env: NodeJS.ProcessEnv = process.env,
platform: NodeJS.Platform = process.platform
): string {
const override = env.HERMES_HOME?.trim();
if (override) {
// Hermes applies expandvars before expanduser. Leave undefined variables literal.
const expanded = override.replace(
/\$(?:\{([^}]+)\}|(\w+))/g,
(match, braced, name) => env[braced ?? name] ?? match
);
const platformExpanded =
platform === 'win32'
? expanded.replace(/%([^%]+)%/g, (match, name) => env[name] ?? match)
: expanded;
return expandHomePrefix(platformExpanded, homeDir);
}
const suffix = env.HERMES_DATA_DIR_SUFFIX ?? '';
if (platform === 'win32') {
const localAppData = env.LOCALAPPDATA?.trim() || join(homeDir, 'AppData', 'Local');
return join(localAppData, `hermes${suffix}`);
}
return join(homeDir, `.hermes${suffix}`);
}

export function getOpenClawGlobalSkillsDir(
homeDir = home,
pathExists: (path: string) => boolean = existsSync
pathExists: (path: string) => boolean = existsSync,
env: NodeJS.ProcessEnv = process.env
) {
const overrideHome = env.OPENCLAW_HOME?.trim();
if (overrideHome && overrideHome !== 'undefined' && overrideHome !== 'null') {
homeDir = resolve(expandHomePrefix(overrideHome, homeDir));
}
const stateDir = env.OPENCLAW_STATE_DIR?.trim();
if (stateDir) {
return join(resolve(expandHomePrefix(stateDir, homeDir)), 'skills');
}
// Preserve legacy discovery only when no explicit state directory was selected.
if (pathExists(join(homeDir, '.openclaw'))) {
return join(homeDir, '.openclaw/skills');
}
Expand Down Expand Up @@ -167,13 +219,9 @@ export const agents: Record<AgentType, AgentConfig> = {
name: 'openclaw',
displayName: 'OpenClaw',
skillsDir: 'skills',
globalSkillsDir: getOpenClawGlobalSkillsDir(),
globalSkillsDir: openClawSkillsDir,
detectInstalled: async () => {
return (
existsSync(join(home, '.openclaw')) ||
existsSync(join(home, '.clawdbot')) ||
existsSync(join(home, '.moltbot'))
);
return existsSync(dirname(openClawSkillsDir));
},
},
cline: {
Expand Down Expand Up @@ -620,9 +668,9 @@ export const agents: Record<AgentType, AgentConfig> = {
name: 'qwen-code',
displayName: 'Qwen Code',
skillsDir: '.qwen/skills',
globalSkillsDir: join(home, '.qwen/skills'),
globalSkillsDir: join(qwenHome, 'skills'),
detectInstalled: async () => {
return existsSync(join(home, '.qwen'));
return existsSync(qwenHome);
},
},
replit: {
Expand Down
4 changes: 4 additions & 0 deletions src/test-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,10 @@ export function createTestHomeEnvironment(home: string): Record<string, string>
CLAUDE_CONFIG_DIR: join(home, '.claude'),
VIBE_HOME: join(home, '.vibe'),
HERMES_HOME: join(home, '.hermes'),
HERMES_DATA_DIR_SUFFIX: '',
OPENCLAW_HOME: home,
OPENCLAW_STATE_DIR: '',
QWEN_HOME: join(home, '.qwen'),
AUTOHAND_HOME: join(home, '.autohand'),
FLATPAK_XDG_CONFIG_HOME: join(home, '.var', 'app'),
DISABLE_TELEMETRY: '1',
Expand Down
Loading