opencode-manager 0.1.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/LICENSE +21 -0
- package/PROJECT-SUMMARY.md +188 -0
- package/bun.lock +217 -0
- package/manage_opencode_projects.py +94 -0
- package/package.json +55 -0
- package/src/bin/opencode-manager.ts +4 -0
- package/src/lib/opencode-data.ts +311 -0
- package/src/opencode-tui.tsx +1046 -0
- package/tsconfig.json +17 -0
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import { constants, promises as fs } from "node:fs"
|
|
2
|
+
import { dirname, join, resolve } from "node:path"
|
|
3
|
+
import { homedir } from "node:os"
|
|
4
|
+
|
|
5
|
+
export type ProjectState = "present" | "missing" | "unknown"
|
|
6
|
+
|
|
7
|
+
export interface ProjectRecord {
|
|
8
|
+
index: number
|
|
9
|
+
bucket: ProjectBucket
|
|
10
|
+
filePath: string
|
|
11
|
+
projectId: string
|
|
12
|
+
worktree: string
|
|
13
|
+
vcs: string | null
|
|
14
|
+
createdAt: Date | null
|
|
15
|
+
state: ProjectState
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface SessionRecord {
|
|
19
|
+
index: number
|
|
20
|
+
filePath: string
|
|
21
|
+
sessionId: string
|
|
22
|
+
projectId: string
|
|
23
|
+
directory: string
|
|
24
|
+
title: string
|
|
25
|
+
version: string
|
|
26
|
+
createdAt: Date | null
|
|
27
|
+
updatedAt: Date | null
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const DEFAULT_ROOT = join(homedir(), ".local", "share", "opencode")
|
|
31
|
+
const PROJECT_BUCKETS = ["project", "sessions"] as const
|
|
32
|
+
type ProjectBucket = (typeof PROJECT_BUCKETS)[number]
|
|
33
|
+
|
|
34
|
+
export interface LoadOptions {
|
|
35
|
+
root?: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface SessionLoadOptions extends LoadOptions {
|
|
39
|
+
projectId?: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface DeleteOptions {
|
|
43
|
+
dryRun?: boolean
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface DeleteResult {
|
|
47
|
+
removed: string[]
|
|
48
|
+
failed: { path: string; error?: string }[]
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const BUCKET_SORT = new Map(PROJECT_BUCKETS.map((bucket, idx) => [bucket, idx]))
|
|
52
|
+
|
|
53
|
+
function expandUserPath(rawPath?: string): string | null {
|
|
54
|
+
if (!rawPath) {
|
|
55
|
+
return null
|
|
56
|
+
}
|
|
57
|
+
if (rawPath === "~") {
|
|
58
|
+
return homedir()
|
|
59
|
+
}
|
|
60
|
+
if (rawPath.startsWith("~/")) {
|
|
61
|
+
return join(homedir(), rawPath.slice(2))
|
|
62
|
+
}
|
|
63
|
+
return resolve(rawPath)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function shortenPath(path: string): string {
|
|
67
|
+
if (!path) {
|
|
68
|
+
return path
|
|
69
|
+
}
|
|
70
|
+
const home = homedir()
|
|
71
|
+
return path.startsWith(home) ? path.replace(home, "~") : path
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function pathExists(path: string): Promise<boolean> {
|
|
75
|
+
try {
|
|
76
|
+
await fs.access(path, constants.F_OK)
|
|
77
|
+
return true
|
|
78
|
+
} catch (error) {
|
|
79
|
+
return false
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function readJsonFile<T>(filePath: string): Promise<T | null> {
|
|
84
|
+
try {
|
|
85
|
+
const raw = await fs.readFile(filePath, "utf8")
|
|
86
|
+
return JSON.parse(raw) as T
|
|
87
|
+
} catch (error) {
|
|
88
|
+
return null
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function msToDate(ms?: number | null): Date | null {
|
|
93
|
+
if (typeof ms !== "number" || Number.isNaN(ms)) {
|
|
94
|
+
return null
|
|
95
|
+
}
|
|
96
|
+
return new Date(ms)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function computeState(worktree: string | null): Promise<ProjectState> {
|
|
100
|
+
if (!worktree) {
|
|
101
|
+
return "unknown"
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
const stat = await fs.stat(worktree)
|
|
105
|
+
return stat.isDirectory() ? "present" : "missing"
|
|
106
|
+
} catch (error) {
|
|
107
|
+
return "missing"
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function compareDates(a: Date | null, b: Date | null): number {
|
|
112
|
+
const aTime = a?.getTime() ?? 0
|
|
113
|
+
const bTime = b?.getTime() ?? 0
|
|
114
|
+
return bTime - aTime
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function withIndex<T extends { index: number }>(records: T[]): T[] {
|
|
118
|
+
return records.map((record, idx) => ({ ...record, index: idx + 1 }))
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function formatDisplayPath(path: string, options?: { fullPath?: boolean }): string {
|
|
122
|
+
if (options?.fullPath || !path) {
|
|
123
|
+
return path
|
|
124
|
+
}
|
|
125
|
+
return shortenPath(path)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export async function loadProjectRecords(options: LoadOptions = {}): Promise<ProjectRecord[]> {
|
|
129
|
+
const root = resolve(options.root ?? DEFAULT_ROOT)
|
|
130
|
+
const records: ProjectRecord[] = []
|
|
131
|
+
|
|
132
|
+
for (const bucket of PROJECT_BUCKETS) {
|
|
133
|
+
const bucketDir = join(root, "storage", bucket)
|
|
134
|
+
if (!(await pathExists(bucketDir))) {
|
|
135
|
+
continue
|
|
136
|
+
}
|
|
137
|
+
const entries = await fs.readdir(bucketDir)
|
|
138
|
+
for (const entry of entries) {
|
|
139
|
+
if (!entry.endsWith(".json")) {
|
|
140
|
+
continue
|
|
141
|
+
}
|
|
142
|
+
const filePath = join(bucketDir, entry)
|
|
143
|
+
const payload = await readJsonFile<any>(filePath)
|
|
144
|
+
if (!payload) {
|
|
145
|
+
continue
|
|
146
|
+
}
|
|
147
|
+
const createdAt = msToDate(payload?.time?.created)
|
|
148
|
+
const worktree = expandUserPath(payload?.worktree ?? undefined)
|
|
149
|
+
const state = await computeState(worktree)
|
|
150
|
+
records.push({
|
|
151
|
+
index: 0,
|
|
152
|
+
bucket,
|
|
153
|
+
filePath,
|
|
154
|
+
projectId: String(payload?.id ?? entry.replace(/\.json$/i, "")),
|
|
155
|
+
worktree: worktree ?? "",
|
|
156
|
+
vcs: typeof payload?.vcs === "string" ? payload.vcs : null,
|
|
157
|
+
createdAt,
|
|
158
|
+
state,
|
|
159
|
+
})
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
records.sort((a, b) => {
|
|
164
|
+
const dateDelta = compareDates(a.createdAt, b.createdAt)
|
|
165
|
+
if (dateDelta !== 0) {
|
|
166
|
+
return dateDelta
|
|
167
|
+
}
|
|
168
|
+
const bucketDelta = (BUCKET_SORT.get(a.bucket) ?? 0) - (BUCKET_SORT.get(b.bucket) ?? 0)
|
|
169
|
+
if (bucketDelta !== 0) {
|
|
170
|
+
return bucketDelta
|
|
171
|
+
}
|
|
172
|
+
return a.projectId.localeCompare(b.projectId)
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
return withIndex(records)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export async function loadSessionRecords(options: SessionLoadOptions = {}): Promise<SessionRecord[]> {
|
|
179
|
+
const root = resolve(options.root ?? DEFAULT_ROOT)
|
|
180
|
+
const sessionRoot = join(root, "storage", "session")
|
|
181
|
+
|
|
182
|
+
if (!(await pathExists(sessionRoot))) {
|
|
183
|
+
return []
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const projectDirs = await fs.readdir(sessionRoot, { withFileTypes: true })
|
|
187
|
+
const sessions: SessionRecord[] = []
|
|
188
|
+
|
|
189
|
+
for (const dirent of projectDirs) {
|
|
190
|
+
if (!dirent.isDirectory()) {
|
|
191
|
+
continue
|
|
192
|
+
}
|
|
193
|
+
const currentProjectId = dirent.name
|
|
194
|
+
if (options.projectId && options.projectId !== currentProjectId) {
|
|
195
|
+
continue
|
|
196
|
+
}
|
|
197
|
+
const projectDir = join(sessionRoot, dirent.name)
|
|
198
|
+
const files = await fs.readdir(projectDir)
|
|
199
|
+
for (const file of files) {
|
|
200
|
+
if (!file.endsWith(".json")) {
|
|
201
|
+
continue
|
|
202
|
+
}
|
|
203
|
+
const filePath = join(projectDir, file)
|
|
204
|
+
const payload = await readJsonFile<any>(filePath)
|
|
205
|
+
if (!payload) {
|
|
206
|
+
continue
|
|
207
|
+
}
|
|
208
|
+
const createdAt = msToDate(payload?.time?.created)
|
|
209
|
+
const updatedAt = msToDate(payload?.time?.updated)
|
|
210
|
+
const directory = expandUserPath(payload?.directory ?? undefined)
|
|
211
|
+
sessions.push({
|
|
212
|
+
index: 0,
|
|
213
|
+
filePath,
|
|
214
|
+
sessionId: String(payload?.id ?? file.replace(/\.json$/i, "")),
|
|
215
|
+
projectId: String(payload?.projectID ?? currentProjectId),
|
|
216
|
+
directory: directory ?? "",
|
|
217
|
+
title: typeof payload?.title === "string" ? payload.title : "",
|
|
218
|
+
version: typeof payload?.version === "string" ? payload.version : "",
|
|
219
|
+
createdAt,
|
|
220
|
+
updatedAt,
|
|
221
|
+
})
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
sessions.sort((a, b) => {
|
|
226
|
+
const updatedDelta = compareDates(a.updatedAt ?? a.createdAt, b.updatedAt ?? b.createdAt)
|
|
227
|
+
if (updatedDelta !== 0) {
|
|
228
|
+
return updatedDelta
|
|
229
|
+
}
|
|
230
|
+
return a.sessionId.localeCompare(b.sessionId)
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
return withIndex(sessions)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export async function deleteProjectMetadata(
|
|
237
|
+
records: ProjectRecord[],
|
|
238
|
+
options: DeleteOptions = {},
|
|
239
|
+
): Promise<DeleteResult> {
|
|
240
|
+
const removed: string[] = []
|
|
241
|
+
const failed: { path: string; error?: string }[] = []
|
|
242
|
+
for (const record of records) {
|
|
243
|
+
if (options.dryRun) {
|
|
244
|
+
removed.push(record.filePath)
|
|
245
|
+
continue
|
|
246
|
+
}
|
|
247
|
+
try {
|
|
248
|
+
await fs.unlink(record.filePath)
|
|
249
|
+
removed.push(record.filePath)
|
|
250
|
+
} catch (error) {
|
|
251
|
+
failed.push({ path: record.filePath, error: error instanceof Error ? error.message : String(error) })
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return { removed, failed }
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export async function deleteSessionMetadata(
|
|
258
|
+
records: SessionRecord[],
|
|
259
|
+
options: DeleteOptions = {},
|
|
260
|
+
): Promise<DeleteResult> {
|
|
261
|
+
const removed: string[] = []
|
|
262
|
+
const failed: { path: string; error?: string }[] = []
|
|
263
|
+
for (const session of records) {
|
|
264
|
+
if (options.dryRun) {
|
|
265
|
+
removed.push(session.filePath)
|
|
266
|
+
continue
|
|
267
|
+
}
|
|
268
|
+
try {
|
|
269
|
+
await fs.unlink(session.filePath)
|
|
270
|
+
removed.push(session.filePath)
|
|
271
|
+
} catch (error) {
|
|
272
|
+
failed.push({ path: session.filePath, error: error instanceof Error ? error.message : String(error) })
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return { removed, failed }
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export function filterProjectsByState(records: ProjectRecord[], state: ProjectState): ProjectRecord[] {
|
|
279
|
+
return records.filter((record) => record.state === state)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export function filterProjectsByIds(records: ProjectRecord[], ids: Set<string>): ProjectRecord[] {
|
|
283
|
+
return records.filter((record) => ids.has(record.projectId))
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export function filterProjectsByIndexes(records: ProjectRecord[], indexes: Set<number>): ProjectRecord[] {
|
|
287
|
+
return records.filter((record) => indexes.has(record.index))
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export function filterSessionsByIndexes(records: SessionRecord[], indexes: Set<number>): SessionRecord[] {
|
|
291
|
+
return records.filter((record) => indexes.has(record.index))
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export function formatDate(date: Date | null): string {
|
|
295
|
+
if (!date) {
|
|
296
|
+
return "?"
|
|
297
|
+
}
|
|
298
|
+
return date.toLocaleString()
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export function describeProject(record: ProjectRecord, options?: { fullPath?: boolean }): string {
|
|
302
|
+
return `${record.bucket}:${record.projectId} (${formatDisplayPath(record.worktree, options)})`
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export function describeSession(record: SessionRecord, options?: { fullPath?: boolean }): string {
|
|
306
|
+
return `${record.sessionId} [${record.projectId}] (${record.title || "no title"})`
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export async function ensureDirectory(path: string): Promise<void> {
|
|
310
|
+
await fs.mkdir(dirname(path), { recursive: true })
|
|
311
|
+
}
|