Skip to content

Commit 35eb48b

Browse files
committed
feat(cli): add implicit command support for slashless invocation
- Add implicitCommand property to SlashCommand interface - Enable init, new, help, exit, and logout commands to work without slash prefix - Create unified parseCommandInput() function for consistent command parsing - Add IMPLICIT_COMMAND_IDS derived Set for efficient lookups
1 parent 88c097e commit 35eb48b

File tree

4 files changed

+161
-16
lines changed

4 files changed

+161
-16
lines changed

cli/src/commands/__tests__/router-input.test.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
parseCommand,
77
isSlashCommand,
88
isReferralCode,
9+
parseCommandInput,
910
} from '../router-utils'
1011
import { SLASH_COMMANDS } from '../../data/slash-commands'
1112

@@ -138,6 +139,84 @@ describe('router-utils', () => {
138139
})
139140
})
140141

142+
describe('parseCommandInput', () => {
143+
test('returns command info for exact slashless matches', () => {
144+
expect(parseCommandInput('init')).toEqual({
145+
command: 'init',
146+
args: '',
147+
implicitCommand: true,
148+
})
149+
expect(parseCommandInput('new')).toEqual({
150+
command: 'new',
151+
args: '',
152+
implicitCommand: true,
153+
})
154+
})
155+
156+
test('is case-insensitive and trims whitespace for slashless matches', () => {
157+
expect(parseCommandInput('INIT')).toEqual({
158+
command: 'init',
159+
args: '',
160+
implicitCommand: true,
161+
})
162+
expect(parseCommandInput(' new ')).toEqual({
163+
command: 'new',
164+
args: '',
165+
implicitCommand: true,
166+
})
167+
})
168+
169+
test('returns null for slashless commands with arguments', () => {
170+
expect(parseCommandInput('init something')).toBe(null)
171+
expect(parseCommandInput('new my message')).toBe(null)
172+
})
173+
174+
test('returns null for commands not configured for slashless invocation', () => {
175+
expect(parseCommandInput('usage')).toBe(null)
176+
expect(parseCommandInput('bash')).toBe(null)
177+
expect(parseCommandInput('feedback')).toBe(null)
178+
})
179+
180+
test('distinguishes slashed and slashless invocation', () => {
181+
expect(parseCommandInput('/init')).toEqual({
182+
command: 'init',
183+
args: '',
184+
implicitCommand: false,
185+
})
186+
})
187+
188+
test('does not match aliases for slashless commands', () => {
189+
const newCmd = SLASH_COMMANDS.find((cmd) => cmd.id === 'new')
190+
for (const alias of newCmd?.aliases ?? []) {
191+
expect(parseCommandInput(alias)).toBe(null)
192+
}
193+
})
194+
195+
test('returns null for empty input', () => {
196+
expect(parseCommandInput('')).toBe(null)
197+
expect(parseCommandInput(' ')).toBe(null)
198+
})
199+
200+
test('commands with implicitCommand are configured correctly', () => {
201+
const initCmd = SLASH_COMMANDS.find((cmd) => cmd.id === 'init')
202+
const newCmd = SLASH_COMMANDS.find((cmd) => cmd.id === 'new')
203+
204+
expect(initCmd?.implicitCommand).toBe(true)
205+
expect(newCmd?.implicitCommand).toBe(true)
206+
})
207+
208+
test('parseCommandInput matches all implicitCommand commands', () => {
209+
const implicitCommands = SLASH_COMMANDS.filter((cmd) => cmd.implicitCommand)
210+
for (const cmd of implicitCommands) {
211+
expect(parseCommandInput(cmd.id)).toEqual({
212+
command: cmd.id.toLowerCase(),
213+
args: '',
214+
implicitCommand: true,
215+
})
216+
}
217+
})
218+
})
219+
141220
describe('slash commands only work with / prefix', () => {
142221
const slashCommands = [
143222
'login',

cli/src/commands/router-utils.ts

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { SLASHLESS_COMMAND_IDS } from '../data/slash-commands'
2+
13
/**
24
* Normalize user input by stripping the leading slash if present.
35
* This is used for referral codes which work with or without the slash.
@@ -87,6 +89,54 @@ const REFERRAL_PREFIX = 'ref-'
8789
export function normalizeReferralCode(code: string): string {
8890
const trimmed = code.trim()
8991
const hasPrefix = trimmed.toLowerCase().startsWith(REFERRAL_PREFIX)
90-
const codeWithoutPrefix = hasPrefix ? trimmed.slice(REFERRAL_PREFIX.length) : trimmed
92+
const codeWithoutPrefix = hasPrefix
93+
? trimmed.slice(REFERRAL_PREFIX.length)
94+
: trimmed
9195
return `${REFERRAL_PREFIX}${codeWithoutPrefix}`
9296
}
97+
98+
/**
99+
* Result of parsing a command-like input.
100+
*/
101+
export type ParsedCommandInput = {
102+
command: string
103+
args: string
104+
implicitCommand: boolean
105+
}
106+
107+
/**
108+
* Parse a command from user input.
109+
* Supports:
110+
* - Standard slash commands: "/command args"
111+
* - Slashless exact commands: "init" (only if configured)
112+
*
113+
* Returns null when the input should be treated as a normal message.
114+
*
115+
* @example
116+
* parseCommandInput('/help') // => { command: 'help', args: '', implicitCommand: false }
117+
* parseCommandInput('/usage stats') // => { command: 'usage', args: 'stats', implicitCommand: false }
118+
* parseCommandInput('init') // => { command: 'init', args: '', implicitCommand: true }
119+
* parseCommandInput('init something') // => null
120+
*/
121+
export function parseCommandInput(input: string): ParsedCommandInput | null {
122+
const trimmed = input.trim()
123+
if (!trimmed) return null
124+
125+
if (trimmed.startsWith('/')) {
126+
const command = parseCommand(trimmed)
127+
if (!command) return null
128+
const args = trimmed.slice(1 + command.length).trim()
129+
return { command, args, implicitCommand: false }
130+
}
131+
132+
if (/\s/.test(trimmed)) {
133+
return null
134+
}
135+
136+
const normalized = trimmed.toLowerCase()
137+
if (!SLASHLESS_COMMAND_IDS.has(normalized)) {
138+
return null
139+
}
140+
141+
return { command: normalized, args: '', implicitCommand: true }
142+
}

cli/src/commands/router.ts

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,11 @@ import {
99
} from './command-registry'
1010
import { handleReferralCode } from './referral'
1111
import {
12-
parseCommand,
1312
isSlashCommand,
1413
isReferralCode,
1514
extractReferralCode,
1615
normalizeReferralCode,
16+
parseCommandInput,
1717
} from './router-utils'
1818
import { handleClaudeAuthCode } from '../components/claude-connect-banner'
1919
import { getProjectRoot } from '../project-files'
@@ -413,25 +413,25 @@ export async function routeUserPrompt(
413413
return
414414
}
415415

416-
// Only process slash commands if input starts with '/'
417-
if (isSlashCommand(trimmed)) {
418-
const cmd = parseCommand(trimmed)
419-
const args = trimmed.slice(1 + cmd.length).trim()
420-
421-
// Look up command in registry
422-
const commandDef = findCommand(cmd)
416+
// Handle slash commands or configured slashless exact commands.
417+
const parsedCommand = parseCommandInput(trimmed)
418+
if (parsedCommand) {
419+
const commandDef = findCommand(parsedCommand.command)
423420
if (commandDef) {
424-
// Track slash command usage
425-
trackEvent(AnalyticsEvent.SLASH_COMMAND_USED, {
421+
const argsLength = parsedCommand.args.length
422+
const analyticsPayload = {
426423
command: commandDef.name,
427-
hasArgs: args.trim().length > 0,
428-
argsLength: args.trim().length,
424+
hasArgs: argsLength > 0,
425+
argsLength,
429426
agentMode,
430-
})
427+
...(parsedCommand.implicitCommand ? { implicitCommand: true } : {}),
428+
}
429+
430+
trackEvent(AnalyticsEvent.SLASH_COMMAND_USED, analyticsPayload)
431431

432432
// The command handler (via defineCommand/defineCommandWithArgs factories)
433433
// is responsible for validating and handling args
434-
return await commandDef.handler(params, args)
434+
return await commandDef.handler(params, parsedCommand.args)
435435
}
436436
}
437437

@@ -465,7 +465,7 @@ export async function routeUserPrompt(
465465
// Unknown slash command - show error
466466
if (isSlashCommand(trimmed)) {
467467
// Track invalid/unknown command (only log command name, not full input for privacy)
468-
const attemptedCmd = parseCommand(trimmed)
468+
const attemptedCmd = trimmed.slice(1).split(/\s+/)[0]?.toLowerCase() || ''
469469
trackEvent(AnalyticsEvent.INVALID_COMMAND, {
470470
attemptedCommand: attemptedCmd,
471471
inputLength: trimmed.length,

cli/src/data/slash-commands.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@ export interface SlashCommand {
55
label: string
66
description: string
77
aliases?: string[]
8+
/**
9+
* If true, this command can be invoked without a leading slash when the
10+
* input matches the command id exactly (no arguments).
11+
*/
12+
implicitCommand?: boolean
813
}
914

1015
// Generate mode commands from the AGENT_MODES constant
@@ -35,6 +40,7 @@ export const SLASH_COMMANDS: SlashCommand[] = [
3540
id: 'init',
3641
label: 'init',
3742
description: 'Create a starter knowledge.md file',
43+
implicitCommand: true,
3844
},
3945
// {
4046
// id: 'undo',
@@ -62,6 +68,7 @@ export const SLASH_COMMANDS: SlashCommand[] = [
6268
label: 'new',
6369
description: 'Start a fresh conversation session',
6470
aliases: ['n', 'clear', 'c', 'reset'],
71+
implicitCommand: true,
6572
},
6673
{
6774
id: 'history',
@@ -91,6 +98,7 @@ export const SLASH_COMMANDS: SlashCommand[] = [
9198
label: 'help',
9299
description: 'Display keyboard shortcuts and tips',
93100
aliases: ['h', '?'],
101+
implicitCommand: true,
94102
},
95103
...MODE_COMMANDS,
96104
{
@@ -109,11 +117,19 @@ export const SLASH_COMMANDS: SlashCommand[] = [
109117
label: 'logout',
110118
description: 'Sign out of your session',
111119
aliases: ['signout'],
120+
implicitCommand: true,
112121
},
113122
{
114123
id: 'exit',
115124
label: 'exit',
116125
description: 'Quit the CLI',
117126
aliases: ['quit', 'q'],
127+
implicitCommand: true,
118128
},
119129
]
130+
131+
export const SLASHLESS_COMMAND_IDS = new Set(
132+
SLASH_COMMANDS.filter((cmd) => cmd.implicitCommand).map((cmd) =>
133+
cmd.id.toLowerCase(),
134+
),
135+
)

0 commit comments

Comments
 (0)