opencode-manager 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/PROJECT-SUMMARY.md +104 -24
- package/README.md +335 -7
- package/bun.lock +17 -1
- package/manage_opencode_projects.py +71 -66
- package/package.json +6 -3
- package/src/bin/opencode-manager.ts +133 -3
- package/src/cli/backup.ts +324 -0
- package/src/cli/commands/chat.ts +322 -0
- package/src/cli/commands/projects.ts +222 -0
- package/src/cli/commands/sessions.ts +495 -0
- package/src/cli/commands/tokens.ts +168 -0
- package/src/cli/commands/tui.ts +36 -0
- package/src/cli/errors.ts +259 -0
- package/src/cli/formatters/json.ts +184 -0
- package/src/cli/formatters/ndjson.ts +71 -0
- package/src/cli/formatters/table.ts +837 -0
- package/src/cli/index.ts +169 -0
- package/src/cli/output.ts +661 -0
- package/src/cli/resolvers.ts +249 -0
- package/src/lib/clipboard.ts +37 -0
- package/src/lib/opencode-data.ts +380 -1
- package/src/lib/search.ts +170 -0
- package/src/{opencode-tui.tsx → tui/app.tsx} +739 -105
- package/src/tui/args.ts +92 -0
- package/src/tui/index.tsx +46 -0
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chat CLI subcommands.
|
|
3
|
+
*
|
|
4
|
+
* Provides commands for listing, showing, and searching chat messages
|
|
5
|
+
* across OpenCode sessions.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { Command, type OptionValues } from "commander"
|
|
9
|
+
import { parseGlobalOptions, type GlobalOptions } from "../index"
|
|
10
|
+
import {
|
|
11
|
+
loadSessionChatIndex,
|
|
12
|
+
loadSessionRecords,
|
|
13
|
+
hydrateChatMessageParts,
|
|
14
|
+
searchSessionsChat,
|
|
15
|
+
type ChatMessage,
|
|
16
|
+
} from "../../lib/opencode-data"
|
|
17
|
+
import { copyToClipboard } from "../../lib/clipboard"
|
|
18
|
+
import { resolveSessionId } from "../resolvers"
|
|
19
|
+
import { withErrorHandling, UsageError, NotFoundError } from "../errors"
|
|
20
|
+
import {
|
|
21
|
+
getOutputOptions,
|
|
22
|
+
printChatOutput,
|
|
23
|
+
printChatMessageOutput,
|
|
24
|
+
printChatSearchOutput,
|
|
25
|
+
type IndexedChatMessage,
|
|
26
|
+
type IndexedChatSearchResult,
|
|
27
|
+
type OutputFormat,
|
|
28
|
+
} from "../output"
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Collect all options from a command and its ancestors.
|
|
32
|
+
* Commander stores global options on the root program, not on subcommands.
|
|
33
|
+
*/
|
|
34
|
+
function collectOptions(cmd: Command): OptionValues {
|
|
35
|
+
const opts: OptionValues = {}
|
|
36
|
+
let current: Command | null = cmd
|
|
37
|
+
while (current) {
|
|
38
|
+
Object.assign(opts, current.opts())
|
|
39
|
+
current = current.parent
|
|
40
|
+
}
|
|
41
|
+
return opts
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Options specific to the chat list command.
|
|
46
|
+
*/
|
|
47
|
+
export interface ChatListOptions {
|
|
48
|
+
/** Session ID to list messages from */
|
|
49
|
+
session: string
|
|
50
|
+
/** Include message parts in output */
|
|
51
|
+
includeParts: boolean
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Options specific to the chat show command.
|
|
56
|
+
*/
|
|
57
|
+
export interface ChatShowOptions {
|
|
58
|
+
/** Session ID containing the message */
|
|
59
|
+
session: string
|
|
60
|
+
/** Message ID to show */
|
|
61
|
+
message?: string
|
|
62
|
+
/** Message index (1-based) to show */
|
|
63
|
+
index?: number
|
|
64
|
+
/** Copy message content to clipboard */
|
|
65
|
+
clipboard?: boolean
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Options specific to the chat search command.
|
|
70
|
+
*/
|
|
71
|
+
export interface ChatSearchOptions {
|
|
72
|
+
/** Search query */
|
|
73
|
+
query: string
|
|
74
|
+
/** Filter by project ID */
|
|
75
|
+
project?: string
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Register chat subcommands on the given parent command.
|
|
80
|
+
*/
|
|
81
|
+
export function registerChatCommands(parent: Command): void {
|
|
82
|
+
const chat = parent
|
|
83
|
+
.command("chat")
|
|
84
|
+
.description("View and search chat messages")
|
|
85
|
+
|
|
86
|
+
chat
|
|
87
|
+
.command("list")
|
|
88
|
+
.description("List messages in a session")
|
|
89
|
+
.requiredOption("--session <sessionId>", "Session ID to list messages from")
|
|
90
|
+
.option("--include-parts", "Include message parts in output", false)
|
|
91
|
+
.action(async function (this: Command) {
|
|
92
|
+
const globalOpts = parseGlobalOptions(collectOptions(this))
|
|
93
|
+
const cmdOpts = this.opts()
|
|
94
|
+
const listOpts: ChatListOptions = {
|
|
95
|
+
session: String(cmdOpts.session),
|
|
96
|
+
includeParts: Boolean(cmdOpts.includeParts),
|
|
97
|
+
}
|
|
98
|
+
await withErrorHandling(handleChatList, globalOpts.format)(
|
|
99
|
+
globalOpts,
|
|
100
|
+
listOpts
|
|
101
|
+
)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
chat
|
|
105
|
+
.command("show")
|
|
106
|
+
.description("Show a specific message")
|
|
107
|
+
.requiredOption("--session <sessionId>", "Session ID containing the message")
|
|
108
|
+
.option("-m, --message <messageId>", "Message ID to show")
|
|
109
|
+
.option("-i, --index <number>", "Message index (1-based) to show")
|
|
110
|
+
.action(async function (this: Command) {
|
|
111
|
+
const allOpts = collectOptions(this)
|
|
112
|
+
const globalOpts = parseGlobalOptions(allOpts)
|
|
113
|
+
const cmdOpts = this.opts()
|
|
114
|
+
const showOpts: ChatShowOptions = {
|
|
115
|
+
session: String(cmdOpts.session),
|
|
116
|
+
message: cmdOpts.message as string | undefined,
|
|
117
|
+
index: cmdOpts.index ? parseInt(String(cmdOpts.index), 10) : undefined,
|
|
118
|
+
// Use global --clipboard option since it's defined at root level
|
|
119
|
+
clipboard: globalOpts.clipboard,
|
|
120
|
+
}
|
|
121
|
+
await withErrorHandling(handleChatShow, globalOpts.format)(
|
|
122
|
+
globalOpts,
|
|
123
|
+
showOpts
|
|
124
|
+
)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
chat
|
|
128
|
+
.command("search")
|
|
129
|
+
.description("Search chat content across sessions")
|
|
130
|
+
.requiredOption("-q, --query <query>", "Search query")
|
|
131
|
+
.option("-p, --project <projectId>", "Filter by project ID")
|
|
132
|
+
.action(async function (this: Command) {
|
|
133
|
+
const globalOpts = parseGlobalOptions(collectOptions(this))
|
|
134
|
+
const cmdOpts = this.opts()
|
|
135
|
+
const searchOpts: ChatSearchOptions = {
|
|
136
|
+
query: String(cmdOpts.query),
|
|
137
|
+
project: cmdOpts.project as string | undefined,
|
|
138
|
+
}
|
|
139
|
+
await withErrorHandling(handleChatSearch, globalOpts.format)(
|
|
140
|
+
globalOpts,
|
|
141
|
+
searchOpts
|
|
142
|
+
)
|
|
143
|
+
})
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Handle the chat list command.
|
|
148
|
+
*
|
|
149
|
+
* Lists messages in a session, ordered by createdAt (ascending).
|
|
150
|
+
* Optionally includes message parts for full content.
|
|
151
|
+
*/
|
|
152
|
+
async function handleChatList(
|
|
153
|
+
globalOpts: GlobalOptions,
|
|
154
|
+
listOpts: ChatListOptions
|
|
155
|
+
): Promise<void> {
|
|
156
|
+
// Resolve session ID (with prefix matching)
|
|
157
|
+
const { session } = await resolveSessionId(listOpts.session, {
|
|
158
|
+
root: globalOpts.root,
|
|
159
|
+
allowPrefix: true,
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
// Load message index for the session
|
|
163
|
+
let messages = await loadSessionChatIndex(session.sessionId, globalOpts.root)
|
|
164
|
+
|
|
165
|
+
// Hydrate parts if requested
|
|
166
|
+
if (listOpts.includeParts) {
|
|
167
|
+
messages = await Promise.all(
|
|
168
|
+
messages.map((msg) => hydrateChatMessageParts(msg, globalOpts.root))
|
|
169
|
+
)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Apply limit
|
|
173
|
+
if (globalOpts.limit && messages.length > globalOpts.limit) {
|
|
174
|
+
messages = messages.slice(0, globalOpts.limit)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Add 1-based index for display
|
|
178
|
+
const indexedMessages: IndexedChatMessage[] = messages.map((msg, i) => ({
|
|
179
|
+
...msg,
|
|
180
|
+
index: i + 1,
|
|
181
|
+
}))
|
|
182
|
+
|
|
183
|
+
// Output using configured format
|
|
184
|
+
const outputOpts = getOutputOptions(globalOpts)
|
|
185
|
+
printChatOutput(indexedMessages, outputOpts)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Handle the chat show command.
|
|
190
|
+
*
|
|
191
|
+
* Shows a specific message by ID or 1-based index.
|
|
192
|
+
* Optionally copies the message content to clipboard.
|
|
193
|
+
*/
|
|
194
|
+
async function handleChatShow(
|
|
195
|
+
globalOpts: GlobalOptions,
|
|
196
|
+
showOpts: ChatShowOptions
|
|
197
|
+
): Promise<void> {
|
|
198
|
+
// Validate that either --message or --index is provided
|
|
199
|
+
if (!showOpts.message && showOpts.index === undefined) {
|
|
200
|
+
throw new UsageError(
|
|
201
|
+
"Either --message <messageId> or --index <number> is required"
|
|
202
|
+
)
|
|
203
|
+
}
|
|
204
|
+
if (showOpts.message && showOpts.index !== undefined) {
|
|
205
|
+
throw new UsageError(
|
|
206
|
+
"Cannot use both --message and --index. Use one or the other."
|
|
207
|
+
)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Resolve session ID (with prefix matching)
|
|
211
|
+
const { session } = await resolveSessionId(showOpts.session, {
|
|
212
|
+
root: globalOpts.root,
|
|
213
|
+
allowPrefix: true,
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
// Load all messages for the session
|
|
217
|
+
const messages = await loadSessionChatIndex(session.sessionId, globalOpts.root)
|
|
218
|
+
|
|
219
|
+
if (messages.length === 0) {
|
|
220
|
+
throw new NotFoundError(
|
|
221
|
+
`Session "${session.sessionId}" has no messages`,
|
|
222
|
+
"message"
|
|
223
|
+
)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
let message: ChatMessage | undefined
|
|
227
|
+
|
|
228
|
+
if (showOpts.message) {
|
|
229
|
+
// Find by message ID (exact or prefix match)
|
|
230
|
+
const messageId = showOpts.message
|
|
231
|
+
message = messages.find((m) => m.messageId === messageId)
|
|
232
|
+
if (!message) {
|
|
233
|
+
// Try prefix matching
|
|
234
|
+
const prefixMatches = messages.filter((m) =>
|
|
235
|
+
m.messageId.startsWith(messageId)
|
|
236
|
+
)
|
|
237
|
+
if (prefixMatches.length === 1) {
|
|
238
|
+
message = prefixMatches[0]
|
|
239
|
+
} else if (prefixMatches.length > 1) {
|
|
240
|
+
throw new NotFoundError(
|
|
241
|
+
`Ambiguous message ID prefix "${messageId}" matches ${prefixMatches.length} messages: ${prefixMatches.map((m) => m.messageId).join(", ")}`,
|
|
242
|
+
"message"
|
|
243
|
+
)
|
|
244
|
+
} else {
|
|
245
|
+
throw new NotFoundError(
|
|
246
|
+
`Message "${messageId}" not found in session "${session.sessionId}"`,
|
|
247
|
+
"message"
|
|
248
|
+
)
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
} else {
|
|
252
|
+
// Find by index (1-based)
|
|
253
|
+
const index = showOpts.index!
|
|
254
|
+
if (index < 1 || index > messages.length) {
|
|
255
|
+
throw new NotFoundError(
|
|
256
|
+
`Message index ${index} is out of range. Session has ${messages.length} message(s).`,
|
|
257
|
+
"message"
|
|
258
|
+
)
|
|
259
|
+
}
|
|
260
|
+
message = messages[index - 1]
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Hydrate message parts to get full content
|
|
264
|
+
const hydratedMessage = await hydrateChatMessageParts(message, globalOpts.root)
|
|
265
|
+
|
|
266
|
+
// Copy to clipboard if requested
|
|
267
|
+
if (showOpts.clipboard) {
|
|
268
|
+
const content = hydratedMessage.parts
|
|
269
|
+
?.map((p) => p.text)
|
|
270
|
+
.join("\n\n") ?? hydratedMessage.previewText
|
|
271
|
+
try {
|
|
272
|
+
await copyToClipboard(content)
|
|
273
|
+
if (globalOpts.format === "table") {
|
|
274
|
+
console.log("(copied to clipboard)")
|
|
275
|
+
}
|
|
276
|
+
} catch {
|
|
277
|
+
// Clipboard copy failed (e.g., xclip/pbcopy not available)
|
|
278
|
+
// Warn but continue - the user still gets the message output
|
|
279
|
+
if (globalOpts.format === "table") {
|
|
280
|
+
console.error("Warning: Could not copy to clipboard")
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Output the message
|
|
286
|
+
printChatMessageOutput(hydratedMessage, globalOpts.format)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Handle the chat search command.
|
|
291
|
+
*
|
|
292
|
+
* Searches chat content across all sessions (or filtered by project).
|
|
293
|
+
* Returns matching messages with context snippets.
|
|
294
|
+
*/
|
|
295
|
+
async function handleChatSearch(
|
|
296
|
+
globalOpts: GlobalOptions,
|
|
297
|
+
searchOpts: ChatSearchOptions
|
|
298
|
+
): Promise<void> {
|
|
299
|
+
// Load sessions to search
|
|
300
|
+
const sessions = await loadSessionRecords({
|
|
301
|
+
root: globalOpts.root,
|
|
302
|
+
projectId: searchOpts.project,
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
// Search across sessions using the limit from global options
|
|
306
|
+
const results = await searchSessionsChat(
|
|
307
|
+
sessions,
|
|
308
|
+
searchOpts.query,
|
|
309
|
+
globalOpts.root,
|
|
310
|
+
{ maxResults: globalOpts.limit }
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
// Add 1-based index for display
|
|
314
|
+
const indexedResults: IndexedChatSearchResult[] = results.map((result, i) => ({
|
|
315
|
+
...result,
|
|
316
|
+
index: i + 1,
|
|
317
|
+
}))
|
|
318
|
+
|
|
319
|
+
// Output using configured format
|
|
320
|
+
const outputOpts = getOutputOptions(globalOpts)
|
|
321
|
+
printChatSearchOutput(indexedResults, outputOpts)
|
|
322
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Projects CLI subcommands.
|
|
3
|
+
*
|
|
4
|
+
* Provides commands for listing and deleting OpenCode projects.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { Command, type OptionValues } from "commander"
|
|
8
|
+
import { parseGlobalOptions, type GlobalOptions } from "../index"
|
|
9
|
+
import {
|
|
10
|
+
loadProjectRecords,
|
|
11
|
+
filterProjectsByState,
|
|
12
|
+
deleteProjectMetadata,
|
|
13
|
+
type ProjectRecord,
|
|
14
|
+
} from "../../lib/opencode-data"
|
|
15
|
+
import {
|
|
16
|
+
getOutputOptions,
|
|
17
|
+
printProjectsOutput,
|
|
18
|
+
printDryRunOutput,
|
|
19
|
+
createDryRunResult,
|
|
20
|
+
printSuccessOutput,
|
|
21
|
+
} from "../output"
|
|
22
|
+
import { tokenizedSearch } from "../../lib/search"
|
|
23
|
+
import { resolveProjectId } from "../resolvers"
|
|
24
|
+
import { requireConfirmation, withErrorHandling, FileOperationError } from "../errors"
|
|
25
|
+
import { copyToBackupDir, formatBackupResult } from "../backup"
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Collect all options from a command and its ancestors.
|
|
29
|
+
* Commander stores global options on the root program, not on subcommands.
|
|
30
|
+
*/
|
|
31
|
+
function collectOptions(cmd: Command): OptionValues {
|
|
32
|
+
const opts: OptionValues = {}
|
|
33
|
+
let current: Command | null = cmd
|
|
34
|
+
while (current) {
|
|
35
|
+
Object.assign(opts, current.opts())
|
|
36
|
+
current = current.parent
|
|
37
|
+
}
|
|
38
|
+
return opts
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Options specific to the projects list command.
|
|
43
|
+
*/
|
|
44
|
+
export interface ProjectsListOptions {
|
|
45
|
+
/** Only show projects with missing directories */
|
|
46
|
+
missingOnly: boolean
|
|
47
|
+
/** Search query to filter projects */
|
|
48
|
+
search?: string
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Options specific to the projects delete command.
|
|
53
|
+
*/
|
|
54
|
+
export interface ProjectsDeleteOptions {
|
|
55
|
+
/** Project ID to delete */
|
|
56
|
+
id: string
|
|
57
|
+
/** Skip confirmation prompt */
|
|
58
|
+
yes: boolean
|
|
59
|
+
/** Preview changes without deleting */
|
|
60
|
+
dryRun: boolean
|
|
61
|
+
/** Directory to backup files before deletion */
|
|
62
|
+
backupDir?: string
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Register projects subcommands on the given parent command.
|
|
67
|
+
*/
|
|
68
|
+
export function registerProjectsCommands(parent: Command): void {
|
|
69
|
+
const projects = parent
|
|
70
|
+
.command("projects")
|
|
71
|
+
.description("Manage OpenCode projects")
|
|
72
|
+
|
|
73
|
+
projects
|
|
74
|
+
.command("list")
|
|
75
|
+
.description("List projects")
|
|
76
|
+
.option("--missing-only", "Only show projects with missing directories", false)
|
|
77
|
+
.option("-s, --search <query>", "Search query to filter projects")
|
|
78
|
+
.action(function (this: Command) {
|
|
79
|
+
const globalOpts = parseGlobalOptions(collectOptions(this))
|
|
80
|
+
const cmdOpts = this.opts()
|
|
81
|
+
const listOpts: ProjectsListOptions = {
|
|
82
|
+
missingOnly: Boolean(cmdOpts.missingOnly),
|
|
83
|
+
search: cmdOpts.search as string | undefined,
|
|
84
|
+
}
|
|
85
|
+
handleProjectsList(globalOpts, listOpts)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
projects
|
|
89
|
+
.command("delete")
|
|
90
|
+
.description("Delete a project's metadata file")
|
|
91
|
+
.requiredOption("--id <projectId>", "Project ID to delete")
|
|
92
|
+
.option("--yes", "Skip confirmation prompt", false)
|
|
93
|
+
.option("--dry-run", "Preview changes without deleting", false)
|
|
94
|
+
.option("--backup-dir <dir>", "Directory to backup files before deletion")
|
|
95
|
+
.action(async function (this: Command) {
|
|
96
|
+
const allOpts = collectOptions(this)
|
|
97
|
+
const globalOpts = parseGlobalOptions(allOpts)
|
|
98
|
+
const cmdOpts = this.opts()
|
|
99
|
+
const deleteOpts: ProjectsDeleteOptions = {
|
|
100
|
+
id: String(cmdOpts.id),
|
|
101
|
+
yes: Boolean(allOpts.yes ?? cmdOpts.yes),
|
|
102
|
+
dryRun: Boolean(allOpts.dryRun ?? cmdOpts.dryRun),
|
|
103
|
+
backupDir: (allOpts.backupDir ?? cmdOpts.backupDir) as string | undefined,
|
|
104
|
+
}
|
|
105
|
+
await withErrorHandling(handleProjectsDelete, getOutputOptions(globalOpts).format)(
|
|
106
|
+
globalOpts,
|
|
107
|
+
deleteOpts
|
|
108
|
+
)
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Handle the projects list command.
|
|
114
|
+
*/
|
|
115
|
+
async function handleProjectsList(
|
|
116
|
+
globalOpts: GlobalOptions,
|
|
117
|
+
listOpts: ProjectsListOptions
|
|
118
|
+
): Promise<void> {
|
|
119
|
+
// Load project records from the data layer
|
|
120
|
+
let projects = await loadProjectRecords({ root: globalOpts.root })
|
|
121
|
+
|
|
122
|
+
// Apply missing-only filter if requested
|
|
123
|
+
if (listOpts.missingOnly) {
|
|
124
|
+
projects = filterProjectsByState(projects, "missing")
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Apply tokenized search if query provided (matches TUI semantics)
|
|
128
|
+
if (listOpts.search) {
|
|
129
|
+
projects = tokenizedSearch(
|
|
130
|
+
projects,
|
|
131
|
+
listOpts.search,
|
|
132
|
+
(p) => [p.projectId, p.worktree],
|
|
133
|
+
{ limit: globalOpts.limit }
|
|
134
|
+
)
|
|
135
|
+
} else {
|
|
136
|
+
// Apply limit cap even without search (default 200)
|
|
137
|
+
projects = projects.slice(0, globalOpts.limit)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Output the projects using the appropriate formatter
|
|
141
|
+
const outputOpts = getOutputOptions(globalOpts)
|
|
142
|
+
printProjectsOutput(projects, outputOpts)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Handle the projects delete command.
|
|
147
|
+
*
|
|
148
|
+
* This command deletes a project's metadata file from the OpenCode storage.
|
|
149
|
+
* It does NOT delete the actual project directory on disk.
|
|
150
|
+
*
|
|
151
|
+
* Exit codes:
|
|
152
|
+
* - 0: Success (or dry-run completed)
|
|
153
|
+
* - 2: Usage error (--yes not provided for destructive operation)
|
|
154
|
+
* - 3: Project not found
|
|
155
|
+
* - 4: File operation failure (backup or delete failed)
|
|
156
|
+
*/
|
|
157
|
+
async function handleProjectsDelete(
|
|
158
|
+
globalOpts: GlobalOptions,
|
|
159
|
+
deleteOpts: ProjectsDeleteOptions
|
|
160
|
+
): Promise<void> {
|
|
161
|
+
const outputOpts = getOutputOptions(globalOpts)
|
|
162
|
+
|
|
163
|
+
// Resolve project ID to a project record
|
|
164
|
+
const { project } = await resolveProjectId(deleteOpts.id, {
|
|
165
|
+
root: globalOpts.root,
|
|
166
|
+
allowPrefix: true,
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
const pathsToDelete = [project.filePath]
|
|
170
|
+
|
|
171
|
+
// Handle dry-run mode
|
|
172
|
+
if (deleteOpts.dryRun) {
|
|
173
|
+
const dryRunResult = createDryRunResult(pathsToDelete, "delete", "project")
|
|
174
|
+
printDryRunOutput(dryRunResult, outputOpts.format)
|
|
175
|
+
return
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Require confirmation for destructive operation
|
|
179
|
+
requireConfirmation(deleteOpts.yes, "Project deletion")
|
|
180
|
+
|
|
181
|
+
// Backup files if requested
|
|
182
|
+
if (deleteOpts.backupDir) {
|
|
183
|
+
const backupResult = await copyToBackupDir(pathsToDelete, {
|
|
184
|
+
backupDir: deleteOpts.backupDir,
|
|
185
|
+
prefix: "project",
|
|
186
|
+
preserveStructure: true,
|
|
187
|
+
structureRoot: globalOpts.root,
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
if (backupResult.failed.length > 0) {
|
|
191
|
+
throw new FileOperationError(
|
|
192
|
+
`Backup failed for ${backupResult.failed.length} file(s): ${backupResult.failed
|
|
193
|
+
.map((f) => f.path)
|
|
194
|
+
.join(", ")}`,
|
|
195
|
+
"backup"
|
|
196
|
+
)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (!globalOpts.quiet) {
|
|
200
|
+
console.log(formatBackupResult(backupResult))
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Perform the deletion
|
|
205
|
+
const deleteResult = await deleteProjectMetadata([project], { dryRun: false })
|
|
206
|
+
|
|
207
|
+
if (deleteResult.failed.length > 0) {
|
|
208
|
+
throw new FileOperationError(
|
|
209
|
+
`Failed to delete ${deleteResult.failed.length} file(s): ${deleteResult.failed
|
|
210
|
+
.map((f) => `${f.path}: ${f.error}`)
|
|
211
|
+
.join(", ")}`,
|
|
212
|
+
"delete"
|
|
213
|
+
)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Output success
|
|
217
|
+
printSuccessOutput(
|
|
218
|
+
`Deleted project: ${project.projectId}`,
|
|
219
|
+
{ projectId: project.projectId, deleted: deleteResult.removed },
|
|
220
|
+
outputOpts.format
|
|
221
|
+
)
|
|
222
|
+
}
|