Skip to content

Commit 7c0b4aa

Browse files
feat: add opencode-directory-files listing service and consolidate settings editors
1 parent 6fc1de6 commit 7c0b4aa

12 files changed

Lines changed: 200 additions & 8 deletions

backend/src/routes/settings.test.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
22
import { Hono } from 'hono'
33
import { Database } from 'bun:sqlite'
4-
import { mkdtemp, readFile, rm } from 'fs/promises'
4+
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'fs/promises'
55
import { tmpdir } from 'os'
66
import { join } from 'path'
77
import { migrate } from '../db/migration-runner'
@@ -344,4 +344,24 @@ describe('settings routes — OpenCode directory file upload', () => {
344344
await expect(readFile(join(workspacePath, '.config/opencode/commands/git/commit.md'), 'utf8')).resolves.toBe('commit body')
345345
expect(restart).toHaveBeenCalledTimes(1)
346346
})
347+
348+
it('lists uploaded command and agent directory files', async () => {
349+
await mkdir(join(workspacePath, '.config/opencode/commands/git'), { recursive: true })
350+
await mkdir(join(workspacePath, '.config/opencode/agents/team'), { recursive: true })
351+
await writeFile(join(workspacePath, '.config/opencode/commands/git/commit.md'), 'commit body')
352+
await writeFile(join(workspacePath, '.config/opencode/commands/git/.DS_Store'), 'metadata')
353+
await writeFile(join(workspacePath, '.config/opencode/agents/team/planner.md'), 'planner body')
354+
355+
const commandsRes = await app.request('/settings/opencode-directory-files?kind=commands')
356+
const agentsRes = await app.request('/settings/opencode-directory-files?kind=agents')
357+
358+
expect(commandsRes.status).toBe(200)
359+
expect(agentsRes.status).toBe(200)
360+
await expect(commandsRes.json()).resolves.toEqual([
361+
{ kind: 'commands', name: 'commit', relativePath: 'git/commit.md' },
362+
])
363+
await expect(agentsRes.json()).resolves.toEqual([
364+
{ kind: 'agents', name: 'planner', relativePath: 'team/planner.md' },
365+
])
366+
})
347367
})

backend/src/routes/settings.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ import {
4444
installSkillFromGithubTree,
4545
installSkillFromUploadedFiles,
4646
} from '../services/skills'
47-
import { installOpenCodeDirectoryFiles } from '../services/opencode-directory-files'
47+
import { installOpenCodeDirectoryFiles, listOpenCodeDirectoryFiles } from '../services/opencode-directory-files'
4848
import { parseUploadManifest, readUploadedManifestFiles, UploadValidationError } from './upload-utils'
4949
import { getRepoById } from '../db/queries'
5050
import { githubFetch } from '../utils/github'
@@ -1275,6 +1275,21 @@ export function createSettingsRoutes(db: Database, gitAuthService: GitAuthServic
12751275
}
12761276
})
12771277

1278+
app.get('/opencode-directory-files', async (c) => {
1279+
try {
1280+
const kind = z.enum(['agents', 'commands']).parse(c.req.query('kind'))
1281+
return c.json(await listOpenCodeDirectoryFiles(kind))
1282+
} catch (error) {
1283+
logger.error('Failed to list OpenCode directory files:', error)
1284+
1285+
if (error instanceof z.ZodError) {
1286+
return c.json({ error: 'Invalid file kind', details: error.issues }, 400)
1287+
}
1288+
1289+
return c.json({ error: 'Failed to list OpenCode directory files' }, 500)
1290+
}
1291+
})
1292+
12781293
app.post('/skills/install', async (c) => {
12791294
try {
12801295
const contentType = c.req.header('content-type') || ''

backend/src/services/opencode-directory-files.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,40 @@ export interface InstallOpenCodeDirectoryFilesResult {
1515
filesInstalled: string[]
1616
}
1717

18+
export interface OpenCodeDirectoryFileInfo {
19+
kind: OpenCodeDirectoryFileKind
20+
name: string
21+
relativePath: string
22+
}
23+
1824
function getOpenCodeDirectoryRoot(kind: OpenCodeDirectoryFileKind): string {
1925
return path.join(getWorkspacePath(), '.config', 'opencode', kind)
2026
}
2127

28+
function getNameFromRelativePath(relativePath: string): string {
29+
return relativePath.replace(/\.md$/i, '').split('/').pop() ?? relativePath
30+
}
31+
32+
async function listMarkdownFiles(rootDir: string, currentDir = rootDir): Promise<string[]> {
33+
let entries
34+
try {
35+
entries = await fs.readdir(currentDir, { withFileTypes: true })
36+
} catch (error) {
37+
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') return []
38+
throw error
39+
}
40+
41+
const files = await Promise.all(entries.map(async entry => {
42+
const absolutePath = path.join(currentDir, entry.name)
43+
if (entry.isDirectory()) return listMarkdownFiles(rootDir, absolutePath)
44+
if (!entry.isFile() || !entry.name.toLowerCase().endsWith('.md')) return []
45+
46+
return [path.relative(rootDir, absolutePath).replace(/\\/g, '/')]
47+
}))
48+
49+
return files.flat().sort((a, b) => a.localeCompare(b))
50+
}
51+
2252
function getTargetRelativePath(relativePath: string, kind: OpenCodeDirectoryFileKind): string | null {
2353
const normalized = normalizeUploadRelativePath(relativePath, { collapseEmptySegments: true })
2454
if (!normalized.toLowerCase().endsWith('.md')) return null
@@ -66,3 +96,14 @@ export async function installOpenCodeDirectoryFiles(
6696
filesInstalled: preparedFiles.map(file => file.relativePath),
6797
}
6898
}
99+
100+
export async function listOpenCodeDirectoryFiles(kind: OpenCodeDirectoryFileKind): Promise<OpenCodeDirectoryFileInfo[]> {
101+
const rootDir = getOpenCodeDirectoryRoot(kind)
102+
const files = await listMarkdownFiles(rootDir)
103+
104+
return files.map(relativePath => ({
105+
kind,
106+
name: getNameFromRelativePath(relativePath),
107+
relativePath,
108+
}))
109+
}

frontend/src/api/settings.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import type {
1313
SkillScope,
1414
InstallSkillFromGithubRequest,
1515
InstallSkillResponse,
16+
OpenCodeDirectoryFileInfo,
1617
} from './types/settings'
1718
import { API_BASE_URL } from '@/config'
1819
import { fetchWrapper, FetchError } from './fetchWrapper'
@@ -326,6 +327,12 @@ export const settingsApi = {
326327
body: formData,
327328
})
328329
},
330+
331+
listOpenCodeDirectoryFiles: async (kind: 'agents' | 'commands'): Promise<OpenCodeDirectoryFileInfo[]> => {
332+
return fetchWrapper(`${API_BASE_URL}/api/settings/opencode-directory-files`, {
333+
params: { kind },
334+
})
335+
},
329336
}
330337

331338
export interface VersionInfo {

frontend/src/api/types/settings.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,3 +137,9 @@ export interface SyncOpenCodeImportResponse extends OpenCodeImportStatus {
137137
errors: Array<{ path: string; error: string }>
138138
}
139139
}
140+
141+
export interface OpenCodeDirectoryFileInfo {
142+
kind: 'agents' | 'commands'
143+
name: string
144+
relativePath: string
145+
}

frontend/src/components/settings/AgentsEditor.test.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,23 @@ describe('AgentsEditor', () => {
4949
expect(screen.getByText('helper')).toBeInTheDocument()
5050
})
5151

52+
it('renders uploaded directory agent files', () => {
53+
const onChange = vi.fn()
54+
render(
55+
<AgentsEditor
56+
agents={{}}
57+
directoryAgents={[{ kind: 'agents', name: 'planner', relativePath: 'team/planner.md' }]}
58+
onChange={onChange}
59+
/>,
60+
{ wrapper: createWrapper() },
61+
)
62+
63+
expect(screen.getByText('planner')).toBeInTheDocument()
64+
expect(screen.getByText('Uploaded file: team/planner.md')).toBeInTheDocument()
65+
expect(screen.getByText('File')).toBeInTheDocument()
66+
expect(screen.queryByText('No agents configured')).not.toBeInTheDocument()
67+
})
68+
5269
it('opens AgentDialog when clicking Edit on a row', async () => {
5370
const user = userEvent.setup()
5471
const onChange = vi.fn()

frontend/src/components/settings/AgentsEditor.tsx

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { Dialog, DialogTrigger } from '@/components/ui/dialog'
66
import { SettingsList, SettingsListRow } from '@/components/ui/settings-list'
77
import { AgentDialog } from './AgentDialog'
88
import { UploadFolderButton } from './UploadFolderButton'
9+
import type { OpenCodeDirectoryFileInfo } from '@/api/types/settings'
910

1011
interface Agent {
1112
prompt?: string
@@ -27,12 +28,14 @@ interface Agent {
2728

2829
interface AgentsEditorProps {
2930
agents: Record<string, Agent>
31+
directoryAgents?: OpenCodeDirectoryFileInfo[]
3032
onChange: (agents: Record<string, Agent>) => void
3133
}
3234

33-
export function AgentsEditor({ agents, onChange }: AgentsEditorProps) {
35+
export function AgentsEditor({ agents, directoryAgents = [], onChange }: AgentsEditorProps) {
3436
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false)
3537
const [editingAgent, setEditingAgent] = useState<{ name: string; agent: Agent } | null>(null)
38+
const hasAgents = Object.keys(agents).length > 0 || directoryAgents.length > 0
3639

3740
const handleAgentSubmit = (name: string, agent: Agent) => {
3841
if (editingAgent) {
@@ -81,7 +84,7 @@ export function AgentsEditor({ agents, onChange }: AgentsEditorProps) {
8184
</div>
8285

8386
<SettingsList
84-
isEmpty={Object.keys(agents).length === 0}
87+
isEmpty={!hasAgents}
8588
emptyTitle="No agents configured"
8689
emptyHint="Add your first agent to get started."
8790
maxHeightClassName="max-h-[calc(100dvh-300px)] sm:max-h-[420px]"
@@ -103,6 +106,14 @@ export function AgentsEditor({ agents, onChange }: AgentsEditorProps) {
103106
actionsLabel={`Actions for ${name}`}
104107
/>
105108
))}
109+
{directoryAgents.map((agent) => (
110+
<SettingsListRow
111+
key={`file:${agent.relativePath}`}
112+
title={agent.name}
113+
description={`Uploaded file: ${agent.relativePath}`}
114+
badges={<Badge variant="secondary" className="shrink-0">File</Badge>}
115+
/>
116+
))}
106117
</SettingsList>
107118

108119
<AgentDialog

frontend/src/components/settings/CommandsEditor.test.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,23 @@ describe('CommandsEditor', () => {
6969
expect(screen.getByText('/build')).toBeInTheDocument()
7070
})
7171

72+
it('renders uploaded directory command files', () => {
73+
const onChange = vi.fn()
74+
render(
75+
<CommandsEditor
76+
commands={{}}
77+
directoryCommands={[{ kind: 'commands', name: 'deploy', relativePath: 'project/deploy.md' }]}
78+
onChange={onChange}
79+
/>,
80+
{ wrapper: createWrapper() },
81+
)
82+
83+
expect(screen.getByText('/deploy')).toBeInTheDocument()
84+
expect(screen.getByText('Uploaded file: project/deploy.md')).toBeInTheDocument()
85+
expect(screen.getByText('File')).toBeInTheDocument()
86+
expect(screen.queryByText('No commands configured')).not.toBeInTheDocument()
87+
})
88+
7289
it('opens CommandDialog when clicking Edit on a row', async () => {
7390
const user = userEvent.setup()
7491
const onChange = vi.fn()

frontend/src/components/settings/CommandsEditor.tsx

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { Dialog, DialogTrigger } from '@/components/ui/dialog'
66
import { SettingsList, SettingsListRow } from '@/components/ui/settings-list'
77
import { CommandDialog } from './CommandDialog'
88
import { UploadFolderButton } from './UploadFolderButton'
9+
import type { OpenCodeDirectoryFileInfo } from '@/api/types/settings'
910

1011
interface Command {
1112
template: string
@@ -18,12 +19,14 @@ interface Command {
1819

1920
interface CommandsEditorProps {
2021
commands: Record<string, Command>
22+
directoryCommands?: OpenCodeDirectoryFileInfo[]
2123
onChange: (commands: Record<string, Command>) => void
2224
}
2325

24-
export function CommandsEditor({ commands, onChange }: CommandsEditorProps) {
26+
export function CommandsEditor({ commands, directoryCommands = [], onChange }: CommandsEditorProps) {
2527
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false)
2628
const [editingCommand, setEditingCommand] = useState<{ name: string; command: Command } | null>(null)
29+
const hasCommands = Object.keys(commands).length > 0 || directoryCommands.length > 0
2730

2831
const handleCommandSubmit = (name: string, command: Command) => {
2932
if (editingCommand) {
@@ -72,7 +75,7 @@ export function CommandsEditor({ commands, onChange }: CommandsEditorProps) {
7275
</div>
7376

7477
<SettingsList
75-
isEmpty={Object.keys(commands).length === 0}
78+
isEmpty={!hasCommands}
7679
emptyTitle="No commands configured"
7780
emptyHint="Add your first command to get started."
7881
maxHeightClassName="max-h-[calc(100dvh-300px)] sm:max-h-[420px]"
@@ -91,6 +94,14 @@ export function CommandsEditor({ commands, onChange }: CommandsEditorProps) {
9194
actionsLabel={`Actions for /${name}`}
9295
/>
9396
))}
97+
{directoryCommands.map((command) => (
98+
<SettingsListRow
99+
key={`file:${command.relativePath}`}
100+
title={`/${command.name}`}
101+
description={`Uploaded file: ${command.relativePath}`}
102+
badges={<Badge variant="secondary" className="shrink-0">File</Badge>}
103+
/>
104+
))}
94105
</SettingsList>
95106

96107
<CommandDialog

frontend/src/components/settings/OpenCodeConfigManager.test.tsx

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,14 @@ const {
1111
mockRestartOpenCodeServer,
1212
mockGetOpenCodeImportStatus,
1313
mockListManagedSkills,
14+
mockListOpenCodeDirectoryFiles,
1415
} = vi.hoisted(() => ({
1516
mockGetOpenCodeConfigs: vi.fn(),
1617
mockUpdateOpenCodeConfig: vi.fn(),
1718
mockRestartOpenCodeServer: vi.fn(),
1819
mockGetOpenCodeImportStatus: vi.fn(),
1920
mockListManagedSkills: vi.fn(),
21+
mockListOpenCodeDirectoryFiles: vi.fn(),
2022
}))
2123

2224
vi.mock('@/hooks/useServerHealth', () => ({
@@ -34,6 +36,7 @@ vi.mock('@/api/settings', () => ({
3436
restartOpenCodeServer: mockRestartOpenCodeServer,
3537
getOpenCodeImportStatus: mockGetOpenCodeImportStatus,
3638
listManagedSkills: mockListManagedSkills,
39+
listOpenCodeDirectoryFiles: mockListOpenCodeDirectoryFiles,
3740
syncOpenCodeImport: vi.fn(),
3841
upgradeOpenCode: vi.fn(),
3942
},
@@ -73,10 +76,39 @@ describe('OpenCodeConfigManager', () => {
7376
mockGetOpenCodeConfigs.mockResolvedValue({ configs: [defaultConfig] })
7477
mockGetOpenCodeImportStatus.mockResolvedValue({})
7578
mockListManagedSkills.mockResolvedValue([])
79+
mockListOpenCodeDirectoryFiles.mockImplementation((kind: 'agents' | 'commands') => {
80+
if (kind === 'commands') return Promise.resolve([])
81+
return Promise.resolve([])
82+
})
7683
mockUpdateOpenCodeConfig.mockResolvedValue(defaultConfig)
7784
mockRestartOpenCodeServer.mockResolvedValue({ success: true, message: 'ok' })
7885
})
7986

87+
it('shows uploaded command and agent directory files in settings', async () => {
88+
mockListOpenCodeDirectoryFiles.mockImplementation((kind: 'agents' | 'commands') => {
89+
if (kind === 'commands') return Promise.resolve([{ kind, name: 'deploy', relativePath: 'project/deploy.md' }])
90+
return Promise.resolve([{ kind, name: 'planner', relativePath: 'team/planner.md' }])
91+
})
92+
93+
const user = userEvent.setup()
94+
renderWithQuery(<OpenCodeConfigManager hideHealthStatus />)
95+
96+
await screen.findByText('Commands')
97+
await vi.waitFor(() => {
98+
expect(screen.getAllByText('1 configured').length).toBeGreaterThanOrEqual(2)
99+
})
100+
101+
await user.click(screen.getByRole('button', { name: /Commands/i }))
102+
expect(await screen.findByText('/deploy')).toBeInTheDocument()
103+
expect(screen.getByText('Uploaded file: project/deploy.md')).toBeInTheDocument()
104+
105+
const agentsButton = screen.getAllByRole('button', { name: /Agents/i }).find(button => button.textContent?.startsWith('Agents'))
106+
expect(agentsButton).toBeDefined()
107+
await user.click(agentsButton!)
108+
expect(await screen.findByText('planner')).toBeInTheDocument()
109+
expect(screen.getByText('Uploaded file: team/planner.md')).toBeInTheDocument()
110+
})
111+
80112
it('optimistic delete + restart prompt', async () => {
81113
let resolveUpdate: (config: OpenCodeConfig) => void = () => {}
82114
mockUpdateOpenCodeConfig.mockImplementationOnce(() => new Promise<OpenCodeConfig>((resolve) => {

0 commit comments

Comments
 (0)