Skip to content

Commit 5c27c00

Browse files
Add root skill-install flag for Claude, Codex, and OpenClaw (#3137)
## Summary - Adds a root-level `--instal-skill` / `--install-skill` flag so users can install the composio skill without going through a subcommand. - Supports selecting the target agent (`claude`, `codex`, or `openclaw`) and optionally overriding the installed skill name. - Updates the install flow to place the skill under the chosen agent’s skills directory and report the selected target in success/warning messages. - Documents the new root flag in the CLI README and root help output. - Adds coverage for flag parsing, target path resolution, and skill-name validation. ## Testing - `pnpm test ts/packages/cli/test/src/commands/install-skill-root-flag.test.ts` - `pnpm test ts/packages/cli/test/src/effects/install-skill.test.ts` - Not run: full repository test suite
1 parent 2fe9370 commit 5c27c00

File tree

6 files changed

+285
-14
lines changed

6 files changed

+285
-14
lines changed

ts/packages/cli/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ composio [--log-level all|trace|debug|info|warning|error|fatal|none]
2222
### Optional Flags
2323

2424
- `--log-level`: Set the log verbosity level. Accepted values: all, trace, debug, info, warning, error, fatal, none
25+
- `--instal-skill [skill-name] <claude|codex|openclaw>`: Manually install the composio skill for a supported agent when automatic installation fails. `--install-skill` is accepted as an alias.
2526

2627
## 🧭 Commands
2728

@@ -43,6 +44,7 @@ composio [--log-level all|trace|debug|info|warning|error|fatal|none]
4344
- `composio generate py [-o, --output-dir <directory>] [--toolkits <toolkit>]`: Generate Python type stubs for toolkits, tools, and triggers from the Composio API.
4445
- `composio generate ts [-o, --output-dir <directory>] [--compact] [--transpiled] [--type-tools] [--toolkits <toolkit>]`: Generate TypeScript types for toolkits, tools, and triggers from the Composio API.
4546
- `composio upgrade [--beta]`: Self-update the Composio CLI from the stable channel, or from the beta channel with `--beta`.
47+
- `composio --instal-skill [skill-name] <claude|codex|openclaw>`: Manually install the composio skill for Claude, Codex, or OpenClaw.
4648

4749
## Configuration
4850

ts/packages/cli/src/commands/index.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import {
3838
resolveCommandProject,
3939
} from 'src/services/command-project';
4040
import { CLI_EXPERIMENTAL_FEATURES } from 'src/constants';
41+
import { installSkill, type SkillInstallTarget } from 'src/effects/install-skill';
4142
import {
4243
experimental,
4344
type CommandVisibility,
@@ -75,6 +76,94 @@ export const buildRootCommand = (visibility: CommandVisibility) => {
7576
return $defaultCmd.pipe(Command.withSubcommands(subcommands as any));
7677
};
7778

79+
const ROOT_INSTALL_SKILL_FLAGS = ['--instal-skill', '--install-skill'] as const;
80+
const SKILL_INSTALL_TARGETS = ['claude', 'codex', 'openclaw'] as const satisfies ReadonlyArray<
81+
SkillInstallTarget
82+
>;
83+
84+
type RootInstallSkillRequest =
85+
| {
86+
_tag: 'parsed';
87+
skillName?: string;
88+
target: SkillInstallTarget;
89+
}
90+
| { _tag: 'error'; message: string };
91+
92+
const isSkillInstallTarget = (value: string): value is SkillInstallTarget =>
93+
(SKILL_INSTALL_TARGETS as ReadonlyArray<string>).includes(value);
94+
95+
export const parseRootInstallSkillRequest = (
96+
argv: ReadonlyArray<string>
97+
): RootInstallSkillRequest | undefined => {
98+
const args = argv.slice(2);
99+
for (let i = 0; i < args.length; i += 1) {
100+
const token = args[i];
101+
if (!token) continue;
102+
103+
if ((ROOT_INSTALL_SKILL_FLAGS as ReadonlyArray<string>).includes(token)) {
104+
const rawValues: string[] = [];
105+
for (let j = i + 1; j < args.length; j += 1) {
106+
const next = args[j];
107+
if (!next) continue;
108+
if (next.startsWith('-')) break;
109+
rawValues.push(next);
110+
}
111+
112+
if (rawValues.length === 0) {
113+
return {
114+
_tag: 'error',
115+
message:
116+
'Missing target for --instal-skill. Usage: composio --instal-skill [skill-name] <claude|codex|openclaw>',
117+
};
118+
}
119+
120+
if (rawValues.length === 1) {
121+
const [target] = rawValues;
122+
if (!isSkillInstallTarget(target)) {
123+
return {
124+
_tag: 'error',
125+
message:
126+
'Invalid target for --instal-skill. Expected one of: claude, codex, openclaw.',
127+
};
128+
}
129+
return { _tag: 'parsed', target };
130+
}
131+
132+
if (rawValues.length === 2) {
133+
const [skillName, target] = rawValues;
134+
if (!isSkillInstallTarget(target)) {
135+
return {
136+
_tag: 'error',
137+
message:
138+
'Invalid target for --instal-skill. Expected one of: claude, codex, openclaw.',
139+
};
140+
}
141+
return { _tag: 'parsed', skillName, target };
142+
}
143+
144+
return {
145+
_tag: 'error',
146+
message:
147+
'Too many arguments for --instal-skill. Usage: composio --instal-skill [skill-name] <claude|codex|openclaw>',
148+
};
149+
}
150+
151+
if (token === '--log-level') {
152+
i += 1;
153+
continue;
154+
}
155+
156+
if (token.startsWith('--log-level=')) {
157+
continue;
158+
}
159+
160+
if (!token.startsWith('-')) {
161+
return undefined;
162+
}
163+
}
164+
return undefined;
165+
};
166+
78167
const parseExecuteInputHelpSlug = (argv: ReadonlyArray<string>): string | undefined => {
79168
const args = argv.slice(2);
80169
const isRootExecute = args[0] === 'execute';
@@ -269,6 +358,16 @@ export const runWithConfig = Effect.gen(function* () {
269358
const normalizedArgv = normalizeHiddenDebugFlags(
270359
normalizeListenStreamFlag(normalizeVersionShortFlag(argv))
271360
);
361+
const installSkillRequest = parseRootInstallSkillRequest(normalizedArgv);
362+
if (installSkillRequest) {
363+
if (installSkillRequest._tag === 'error') {
364+
return Effect.fail(new Error(installSkillRequest.message));
365+
}
366+
return installSkill({
367+
skillName: installSkillRequest.skillName,
368+
target: installSkillRequest.target,
369+
});
370+
}
272371
if (isRootHelp(normalizedArgv)) {
273372
return printRootHelp(visibility);
274373
}

ts/packages/cli/src/commands/root-help.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1175,6 +1175,10 @@ export function printRootHelp(visibility: CommandVisibility): Effect.Effect<void
11751175
` ${dim('# Run a script with injected helpers')}`,
11761176
` ${name} run 'const me = await execute("GITHUB_GET_THE_AUTHENTICATED_USER"); console.log(me)'`,
11771177
'',
1178+
` ${dim('# Manually install the composio skill when auto-install fails')}`,
1179+
` ${name} --instal-skill claude`,
1180+
` ${name} --instal-skill composio-cli codex`,
1181+
'',
11781182
` ${dim('# Run a multi-step script with Promise.all')}`,
11791183
` ${name} run '`,
11801184
` const [emails, issues] = await Promise.all([`,
@@ -1209,6 +1213,8 @@ export function printRootHelp(visibility: CommandVisibility): Effect.Effect<void
12091213
bold('FLAGS'),
12101214
' -h, --help Show help for command',
12111215
` --version Show ${name} version`,
1216+
' --instal-skill [skill-name] <claude|codex|openclaw>',
1217+
' Manually install the composio skill for a supported agent',
12121218
'',
12131219
bold('LEARN MORE'),
12141220
` Use \`${name} <command> --help\` for more information about a command.`,

ts/packages/cli/src/effects/install-skill.ts

Lines changed: 68 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,15 @@ import decompress from 'decompress';
1616
const SKILL_NAME = 'composio-cli';
1717
const SKILL_ASSET_NAME = 'composio-skill.zip';
1818
export type SkillReleaseChannel = CliReleaseChannel;
19+
export type SkillInstallTarget = 'claude' | 'codex' | 'openclaw';
20+
21+
const SKILL_NAME_PATTERN = /^[A-Za-z0-9._-]+$/;
22+
23+
const SKILL_TARGET_LABELS: Record<SkillInstallTarget, string> = {
24+
claude: 'Claude Code',
25+
codex: 'Codex',
26+
openclaw: 'OpenClaw',
27+
};
1928

2029
type GitHubConfig = GitHubRepoConfig & {
2130
TAG: Option.Option<string>;
@@ -25,6 +34,44 @@ type GitHubConfig = GitHubRepoConfig & {
2534
const hasSkillAsset = (release: GitHubRelease) =>
2635
release.assets.some(asset => asset.name === SKILL_ASSET_NAME);
2736

37+
export const resolveInstalledSkillName = (skillName?: string): string => {
38+
const normalized = skillName?.trim();
39+
if (!normalized) {
40+
return SKILL_NAME;
41+
}
42+
if (
43+
normalized === '.' ||
44+
normalized === '..' ||
45+
!SKILL_NAME_PATTERN.test(normalized)
46+
) {
47+
throw new Error(
48+
'Invalid skill name. Use letters, numbers, dots, underscores, or hyphens only.'
49+
);
50+
}
51+
return normalized;
52+
};
53+
54+
export const resolveTargetSkillPath = ({
55+
home,
56+
skillName,
57+
target,
58+
}: {
59+
home: string;
60+
skillName: string;
61+
target: SkillInstallTarget;
62+
}): string => {
63+
switch (target) {
64+
case 'claude':
65+
return path.join(home, '.claude', 'skills', skillName);
66+
case 'codex':
67+
return path.join(home, '.codex', 'skills', skillName);
68+
case 'openclaw':
69+
return path.join(home, '.openclaw', 'skills', skillName);
70+
}
71+
};
72+
73+
const resolveTargetLabel = (target: SkillInstallTarget): string => SKILL_TARGET_LABELS[target];
74+
2875
export const inferSkillReleaseChannel = (tagOrVersion: string): SkillReleaseChannel =>
2976
tagOrVersion.includes('-beta.') ? 'beta' : 'stable';
3077

@@ -68,24 +115,28 @@ export const resolveSkillReleaseTag = ({
68115
* Install the composio-cli skill into the user's global agent skills directory.
69116
*
70117
* - Downloads composio-skill.zip from the matching CLI GitHub release
71-
* - Extracts to ~/.agents/skills/composio-cli/
72-
* - Creates a symlink at ~/.claude/skills/composio-cli → ../../.agents/skills/composio-cli
118+
* - Extracts to ~/.agents/skills/<skill-name>/
119+
* - Creates a symlink in the selected agent's skills directory
73120
*
74121
* Non-fatal: wrapped version catches all errors.
75122
*/
76123
export const installSkill = (options?: {
77124
readonly releaseTag?: string;
78125
readonly channel?: SkillReleaseChannel;
126+
readonly target?: SkillInstallTarget;
127+
readonly skillName?: string;
79128
}) =>
80129
Effect.gen(function* () {
81130
const os = yield* NodeOs;
82131
const ui = yield* TerminalUI;
83132
const httpClient = yield* HttpClient.HttpClient;
84133
const githubConfig = yield* Config.all(GITHUB_CONFIG);
85134
const home = os.homedir;
135+
const target = options?.target ?? 'claude';
136+
const skillName = resolveInstalledSkillName(options?.skillName);
86137

87-
const agentSkillDir = path.join(home, '.agents', 'skills', SKILL_NAME);
88-
const claudeSkillLink = path.join(home, '.claude', 'skills', SKILL_NAME);
138+
const agentSkillDir = path.join(home, '.agents', 'skills', skillName);
139+
const targetSkillPath = resolveTargetSkillPath({ home, skillName, target });
89140

90141
const tag = yield* resolveSkillReleaseTag({
91142
channel: options?.channel,
@@ -161,25 +212,25 @@ export const installSkill = (options?: {
161212
fs.rmSync(tmpDir, { recursive: true, force: true });
162213
}
163214

164-
// Create symlink for Claude Code — always replace any existing entry
165-
fs.mkdirSync(path.join(home, '.claude', 'skills'), { recursive: true });
215+
// Create agent-specific symlink — always replace any existing entry.
216+
fs.mkdirSync(path.dirname(targetSkillPath), { recursive: true });
166217
try {
167-
const stat = fs.lstatSync(claudeSkillLink);
218+
const stat = fs.lstatSync(targetSkillPath);
168219
// Entry exists (symlink, broken symlink, or directory) — remove it
169220
if (stat.isSymbolicLink()) {
170-
fs.unlinkSync(claudeSkillLink);
221+
fs.unlinkSync(targetSkillPath);
171222
} else if (stat.isDirectory()) {
172-
fs.rmSync(claudeSkillLink, { recursive: true, force: true });
223+
fs.rmSync(targetSkillPath, { recursive: true, force: true });
173224
} else {
174-
fs.unlinkSync(claudeSkillLink);
225+
fs.unlinkSync(targetSkillPath);
175226
}
176227
} catch {
177228
// lstatSync throws if nothing exists at the path — that's fine
178229
}
179-
const relativeTarget = path.relative(path.dirname(claudeSkillLink), agentSkillDir);
180-
fs.symlinkSync(relativeTarget, claudeSkillLink);
230+
const relativeTarget = path.relative(path.dirname(targetSkillPath), agentSkillDir);
231+
fs.symlinkSync(relativeTarget, targetSkillPath);
181232

182-
yield* ui.log.success('Installed composio-cli skill for Claude Code');
233+
yield* ui.log.success(`Installed ${skillName} skill for ${resolveTargetLabel(target)}`);
183234
});
184235

185236
/**
@@ -188,14 +239,17 @@ export const installSkill = (options?: {
188239
export const installSkillSafe = (options?: {
189240
readonly releaseTag?: string;
190241
readonly channel?: SkillReleaseChannel;
242+
readonly target?: SkillInstallTarget;
243+
readonly skillName?: string;
191244
}) =>
192245
installSkill(options).pipe(
193246
Effect.sandbox,
194247
Effect.catchAll(cause =>
195248
Effect.gen(function* () {
196249
const ui = yield* TerminalUI;
197250
yield* Effect.logDebug('Skill install failed:', cause);
198-
yield* ui.log.warn('Could not install Claude Code skill (non-fatal)');
251+
const targetLabel = resolveTargetLabel(options?.target ?? 'claude');
252+
yield* ui.log.warn(`Could not install ${targetLabel} skill (non-fatal)`);
199253
})
200254
)
201255
);
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { describe, expect, it } from '@effect/vitest';
2+
import { parseRootInstallSkillRequest } from 'src/commands';
3+
4+
describe('CLI: --instal-skill', () => {
5+
it('parses the default skill name when only a target is provided', () => {
6+
expect(parseRootInstallSkillRequest(['node', 'composio', '--instal-skill', 'claude'])).toEqual({
7+
_tag: 'parsed',
8+
target: 'claude',
9+
});
10+
});
11+
12+
it('parses an explicit skill name and target', () => {
13+
expect(
14+
parseRootInstallSkillRequest([
15+
'node',
16+
'composio',
17+
'--instal-skill',
18+
'composio-cli',
19+
'codex',
20+
])
21+
).toEqual({
22+
_tag: 'parsed',
23+
skillName: 'composio-cli',
24+
target: 'codex',
25+
});
26+
});
27+
28+
it('accepts the --install-skill alias', () => {
29+
expect(parseRootInstallSkillRequest(['node', 'composio', '--install-skill', 'openclaw'])).toEqual(
30+
{
31+
_tag: 'parsed',
32+
target: 'openclaw',
33+
}
34+
);
35+
});
36+
37+
it('accepts the root flag after leading global options', () => {
38+
expect(
39+
parseRootInstallSkillRequest([
40+
'node',
41+
'composio',
42+
'--log-level',
43+
'debug',
44+
'--instal-skill',
45+
'claude',
46+
])
47+
).toEqual({
48+
_tag: 'parsed',
49+
target: 'claude',
50+
});
51+
});
52+
53+
it('does not intercept subcommand flags after a positional command', () => {
54+
expect(
55+
parseRootInstallSkillRequest(['node', 'composio', 'upgrade', '--install-skill', 'claude'])
56+
).toBeUndefined();
57+
});
58+
59+
it('returns a helpful error when the target is missing', () => {
60+
expect(parseRootInstallSkillRequest(['node', 'composio', '--instal-skill'])).toEqual({
61+
_tag: 'error',
62+
message:
63+
'Missing target for --instal-skill. Usage: composio --instal-skill [skill-name] <claude|codex|openclaw>',
64+
});
65+
});
66+
67+
it('returns a helpful error for invalid targets', () => {
68+
expect(parseRootInstallSkillRequest(['node', 'composio', '--instal-skill', 'cursor'])).toEqual({
69+
_tag: 'error',
70+
message: 'Invalid target for --instal-skill. Expected one of: claude, codex, openclaw.',
71+
});
72+
});
73+
});

0 commit comments

Comments
 (0)