Skip to content

Commit a3b2d3b

Browse files
Merge pull request #206 from laststance/codex/cli-command-toggle
Add CLI command toggle
2 parents ac036f9 + 354dede commit a3b2d3b

14 files changed

Lines changed: 928 additions & 2 deletions

File tree

.storybook/storybook-utils.tsx

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -424,6 +424,34 @@ export function installStorybookElectronMock(): void {
424424
shell: {
425425
openExternal: async () => undefined,
426426
},
427+
cliCommand: {
428+
getStatus: async () => ({
429+
status: 'not-installed',
430+
commandName: 'skills-desktop',
431+
commandPath: '/Users/story/.local/bin/skills-desktop',
432+
message: 'Command is not installed.',
433+
}),
434+
install: async () => ({
435+
ok: true,
436+
status: {
437+
status: 'installed',
438+
commandName: 'skills-desktop',
439+
commandPath: '/Users/story/.local/bin/skills-desktop',
440+
message: 'Command is installed.',
441+
},
442+
message: 'Command installed at /Users/story/.local/bin/skills-desktop.',
443+
}),
444+
remove: async () => ({
445+
ok: true,
446+
status: {
447+
status: 'not-installed',
448+
commandName: 'skills-desktop',
449+
commandPath: '/Users/story/.local/bin/skills-desktop',
450+
message: 'Command is not installed.',
451+
},
452+
message: 'Command removed.',
453+
}),
454+
},
427455
skills: {
428456
getAll: async () => storySkills,
429457
unlinkFromAgent: async () => ({ success: true }),
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest'
2+
3+
const handleMock = vi.fn()
4+
const getCliCommandStatusMock = vi.fn()
5+
const installCliCommandMock = vi.fn()
6+
const removeCliCommandMock = vi.fn()
7+
8+
vi.mock('electron', () => ({
9+
ipcMain: {
10+
handle: (...args: unknown[]) => handleMock(...args),
11+
},
12+
}))
13+
14+
vi.mock('../services/cliCommandService', () => ({
15+
getCliCommandStatus: () => getCliCommandStatusMock(),
16+
installCliCommand: () => installCliCommandMock(),
17+
removeCliCommand: () => removeCliCommandMock(),
18+
}))
19+
20+
/**
21+
* Find a registered IPC handler by channel name so tests can invoke it directly.
22+
* @param channel - IPC channel name captured from ipcMain.handle.
23+
* @returns The registered handler function.
24+
* @example getRegisteredHandler('cliCommand:getStatus')
25+
*/
26+
function getRegisteredHandler(
27+
channel: string,
28+
): (event: unknown, ...args: unknown[]) => Promise<unknown> {
29+
const registration = handleMock.mock.calls.find(([name]) => name === channel)
30+
if (!registration) throw new Error(`No handler registered for ${channel}`)
31+
return registration[1] as (
32+
event: unknown,
33+
...args: unknown[]
34+
) => Promise<unknown>
35+
}
36+
37+
describe('cliCommand IPC handlers', () => {
38+
beforeEach(async () => {
39+
vi.resetModules()
40+
handleMock.mockReset()
41+
getCliCommandStatusMock.mockReset()
42+
installCliCommandMock.mockReset()
43+
removeCliCommandMock.mockReset()
44+
const { registerCliCommandHandlers } = await import('./cliCommand')
45+
registerCliCommandHandlers()
46+
})
47+
48+
it('returns command status through the no-arg status channel', async () => {
49+
// Arrange
50+
const expectedStatus = {
51+
status: 'not-installed',
52+
commandName: 'skills-desktop',
53+
commandPath: '/Users/test/.local/bin/skills-desktop',
54+
message: 'Command is not installed.',
55+
}
56+
getCliCommandStatusMock.mockResolvedValue(expectedStatus)
57+
const handler = getRegisteredHandler('cliCommand:getStatus')
58+
59+
// Act
60+
const result = await handler({})
61+
62+
// Assert
63+
expect(result).toEqual(expectedStatus)
64+
expect(getCliCommandStatusMock).toHaveBeenCalledTimes(1)
65+
})
66+
67+
it('installs the command through the no-arg install channel', async () => {
68+
// Arrange
69+
const expectedResult = {
70+
ok: true,
71+
status: {
72+
status: 'installed',
73+
commandName: 'skills-desktop',
74+
commandPath: '/Users/test/.local/bin/skills-desktop',
75+
message: 'Command is installed.',
76+
},
77+
message: 'Command installed.',
78+
}
79+
installCliCommandMock.mockResolvedValue(expectedResult)
80+
const handler = getRegisteredHandler('cliCommand:install')
81+
82+
// Act
83+
const result = await handler({})
84+
85+
// Assert
86+
expect(result).toEqual(expectedResult)
87+
expect(installCliCommandMock).toHaveBeenCalledTimes(1)
88+
})
89+
90+
it('removes the command through the no-arg remove channel', async () => {
91+
// Arrange
92+
const expectedResult = {
93+
ok: true,
94+
status: {
95+
status: 'not-installed',
96+
commandName: 'skills-desktop',
97+
commandPath: '/Users/test/.local/bin/skills-desktop',
98+
message: 'Command is not installed.',
99+
},
100+
message: 'Command removed.',
101+
}
102+
removeCliCommandMock.mockResolvedValue(expectedResult)
103+
const handler = getRegisteredHandler('cliCommand:remove')
104+
105+
// Act
106+
const result = await handler({})
107+
108+
// Assert
109+
expect(result).toEqual(expectedResult)
110+
expect(removeCliCommandMock).toHaveBeenCalledTimes(1)
111+
})
112+
})

src/main/ipc/cliCommand.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { IPC_CHANNELS } from '@/shared/ipc-channels'
2+
3+
import {
4+
getCliCommandStatus,
5+
installCliCommand,
6+
removeCliCommand,
7+
} from '../services/cliCommandService'
8+
9+
import { typedHandle } from './typedHandle'
10+
11+
/**
12+
* Registers Settings-facing handlers for managing the app-level CLI shim.
13+
* @returns void after the three no-arg channels are attached to ipcMain.
14+
* @example registerCliCommandHandlers()
15+
*/
16+
export function registerCliCommandHandlers(): void {
17+
typedHandle(IPC_CHANNELS.CLI_COMMAND_GET_STATUS, async () =>
18+
getCliCommandStatus(),
19+
)
20+
typedHandle(IPC_CHANNELS.CLI_COMMAND_INSTALL, async () => installCliCommand())
21+
typedHandle(IPC_CHANNELS.CLI_COMMAND_REMOVE, async () => removeCliCommand())
22+
}

src/main/ipc/handlers.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { registerAgentsHandlers } from './agents'
2+
import { registerCliCommandHandlers } from './cliCommand'
23
import { registerFilesHandlers } from './files'
34
import { registerFolderHandlers } from './folder'
45
import { registerLeaderboardHandlers } from './leaderboard'
@@ -18,6 +19,7 @@ import { registerWindowHandlers } from './window'
1819
export function registerAllHandlers(): void {
1920
registerSkillsHandlers()
2021
registerSkillsCliHandlers()
22+
registerCliCommandHandlers()
2123
registerLeaderboardHandlers()
2224
registerAgentsHandlers()
2325
registerSourceHandlers()
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
import { mkdtempSync, realpathSync } from 'node:fs'
2+
import {
3+
lstat,
4+
mkdir,
5+
readFile,
6+
rm,
7+
stat,
8+
symlink,
9+
writeFile,
10+
} from 'node:fs/promises'
11+
import type * as NodeOs from 'node:os'
12+
import { tmpdir } from 'node:os'
13+
import { join } from 'node:path'
14+
15+
import { afterAll, afterEach, describe, expect, it, vi } from 'vitest'
16+
17+
const sharedHome = realpathSync(
18+
mkdtempSync(join(tmpdir(), 'skills-cli-command-')),
19+
)
20+
const commandPath = join(sharedHome, '.local', 'bin', 'skills-desktop')
21+
22+
vi.mock('node:os', async () => {
23+
const actual = await vi.importActual<typeof NodeOs>('node:os')
24+
return {
25+
...actual,
26+
homedir: () => sharedHome,
27+
}
28+
})
29+
30+
const servicePromise = (async () => import('./cliCommandService'))()
31+
32+
describe('cliCommandService', () => {
33+
afterEach(async () => {
34+
await rm(join(sharedHome, '.local'), { recursive: true, force: true })
35+
})
36+
37+
afterAll(async () => {
38+
await rm(sharedHome, { recursive: true, force: true })
39+
})
40+
41+
it('reports the command as not installed when ~/.local/bin/skills-desktop is missing', async () => {
42+
// Arrange
43+
const { getCliCommandStatus } = await servicePromise
44+
45+
// Act
46+
const status = await getCliCommandStatus()
47+
48+
// Assert
49+
expect(status).toEqual({
50+
status: 'not-installed',
51+
commandName: 'skills-desktop',
52+
commandPath,
53+
message: 'Command is not installed.',
54+
})
55+
})
56+
57+
it('installs an executable shim that opens the Skills Desktop bundle', async () => {
58+
// Arrange
59+
const { installCliCommand } = await servicePromise
60+
61+
// Act
62+
const result = await installCliCommand()
63+
64+
// Assert
65+
expect(result.ok).toBe(true)
66+
expect(result.status.status).toBe('installed')
67+
expect(result.message).toBe(`Command installed at ${commandPath}.`)
68+
expect(await readFile(commandPath, 'utf-8')).toBe(`#!/bin/sh
69+
# >>> Skills Desktop CLI shim >>>
70+
# This file is managed by Skills Desktop from Settings.
71+
exec open -b "io.laststance.skills-desktop"
72+
# <<< Skills Desktop CLI shim <<<
73+
`)
74+
expect((await stat(commandPath)).mode & 0o777).toBe(0o755)
75+
})
76+
77+
it('refuses to overwrite an unmanaged file that already uses the command path', async () => {
78+
// Arrange
79+
const { installCliCommand } = await servicePromise
80+
await mkdir(join(sharedHome, '.local', 'bin'), { recursive: true })
81+
await writeFile(commandPath, '#!/bin/sh\necho unmanaged\n', 'utf-8')
82+
83+
// Act
84+
const result = await installCliCommand()
85+
86+
// Assert
87+
expect(result).toEqual({
88+
ok: false,
89+
status: {
90+
status: 'blocked',
91+
commandName: 'skills-desktop',
92+
commandPath,
93+
message: `${commandPath} is already occupied by an unmanaged file.`,
94+
},
95+
message: `${commandPath} is already occupied by an unmanaged file.`,
96+
})
97+
expect(await readFile(commandPath, 'utf-8')).toBe(
98+
'#!/bin/sh\necho unmanaged\n',
99+
)
100+
})
101+
102+
it('refuses to remove a user-edited script that contains the managed markers', async () => {
103+
// Arrange
104+
const { removeCliCommand } = await servicePromise
105+
const editedScript = `#!/bin/sh
106+
# >>> Skills Desktop CLI shim >>>
107+
# This file is managed by Skills Desktop from Settings.
108+
echo "custom logic"
109+
exec open -b "io.laststance.skills-desktop"
110+
# <<< Skills Desktop CLI shim <<<
111+
`
112+
await mkdir(join(sharedHome, '.local', 'bin'), { recursive: true })
113+
await writeFile(commandPath, editedScript, 'utf-8')
114+
115+
// Act
116+
const result = await removeCliCommand()
117+
118+
// Assert
119+
expect(result).toEqual({
120+
ok: false,
121+
status: {
122+
status: 'blocked',
123+
commandName: 'skills-desktop',
124+
commandPath,
125+
message: `${commandPath} is already occupied by an unmanaged file.`,
126+
},
127+
message: `${commandPath} is already occupied by an unmanaged file.`,
128+
})
129+
expect(await readFile(commandPath, 'utf-8')).toBe(editedScript)
130+
})
131+
132+
it('removes the managed shim and leaves the command path missing afterward', async () => {
133+
// Arrange
134+
const { installCliCommand, removeCliCommand } = await servicePromise
135+
await installCliCommand()
136+
137+
// Act
138+
const result = await removeCliCommand()
139+
140+
// Assert
141+
expect(result).toEqual({
142+
ok: true,
143+
status: {
144+
status: 'not-installed',
145+
commandName: 'skills-desktop',
146+
commandPath,
147+
message: 'Command is not installed.',
148+
},
149+
message: 'Command removed.',
150+
})
151+
await expect(lstat(commandPath)).rejects.toMatchObject({ code: 'ENOENT' })
152+
})
153+
154+
it('refuses to remove an unmanaged symlink that occupies the command path', async () => {
155+
// Arrange
156+
const { removeCliCommand } = await servicePromise
157+
await mkdir(join(sharedHome, '.local', 'bin'), { recursive: true })
158+
await symlink('/usr/bin/open', commandPath)
159+
160+
// Act
161+
const result = await removeCliCommand()
162+
163+
// Assert
164+
expect(result).toEqual({
165+
ok: false,
166+
status: {
167+
status: 'blocked',
168+
commandName: 'skills-desktop',
169+
commandPath,
170+
message: `${commandPath} is already occupied by an unmanaged symlink.`,
171+
},
172+
message: `${commandPath} is already occupied by an unmanaged symlink.`,
173+
})
174+
expect((await lstat(commandPath)).isSymbolicLink()).toBe(true)
175+
})
176+
})

0 commit comments

Comments
 (0)