Compare commits

...

8 Commits

Author SHA1 Message Date
Kit Langton
1986a6e817 refactor(cli): convert session subcommands to effectCmd (#25483) 2026-05-02 18:15:28 -04:00
opencode-agent[bot]
dfe1325fca chore: generate 2026-05-02 22:02:14 +00:00
Kit Langton
c1686c6ddc refactor(cli): convert stats command to effectCmd (#25474) 2026-05-02 18:01:06 -04:00
Kit Langton
79b6ce5db4 refactor(cli): convert import command to effectCmd (#25467) 2026-05-02 21:56:32 +00:00
Kit Langton
0c816eb4b1 refactor(cli): convert plugin command to effectCmd (#25473) 2026-05-02 17:55:13 -04:00
Kit Langton
e318e173d8 refactor(cli): convert export command to effectCmd (#25471) 2026-05-02 17:45:41 -04:00
opencode-agent[bot]
b314781a1a chore: generate 2026-05-02 21:02:46 +00:00
Kit Langton
8396d6b016 refactor(cli): convert pr command to effectCmd (#25465) 2026-05-02 17:01:46 -04:00
7 changed files with 391 additions and 408 deletions

View File

@@ -1,13 +1,13 @@
import type { Argv } from "yargs"
import { Session } from "@/session/session"
import { MessageV2 } from "../../session/message-v2"
import { SessionID } from "../../session/schema"
import { cmd } from "./cmd"
import { bootstrap } from "../bootstrap"
import { effectCmd, fail } from "../effect-cmd"
import { UI } from "../ui"
import * as prompts from "@clack/prompts"
import { EOL } from "os"
import { AppRuntime } from "@/effect/app-runtime"
import { Effect } from "effect"
import { InstanceRef } from "@/effect/instance-ref"
import { InstanceStore } from "@/project/instance-store"
function redact(kind: string, id: string, value: string) {
return value.trim() ? `[redacted:${kind}:${id}]` : value
@@ -220,11 +220,11 @@ function sanitize(data: { info: Session.Info; messages: MessageV2.WithParts[] })
}
}
export const ExportCommand = cmd({
export const ExportCommand = effectCmd({
command: "export [sessionID]",
describe: "export session data as JSON",
builder: (yargs: Argv) => {
return yargs
builder: (yargs) =>
yargs
.positional("sessionID", {
describe: "session id to export",
type: "string",
@@ -232,72 +232,65 @@ export const ExportCommand = cmd({
.option("sanitize", {
describe: "redact sensitive transcript and file data",
type: "boolean",
})
},
handler: async (args) => {
await bootstrap(process.cwd(), async () => {
let sessionID = args.sessionID ? SessionID.make(args.sessionID) : undefined
process.stderr.write(`Exporting session: ${sessionID ?? "latest"}\n`)
if (!sessionID) {
UI.empty()
prompts.intro("Export session", {
output: process.stderr,
})
const sessions = await AppRuntime.runPromise(Session.Service.use((svc) => svc.list()))
if (sessions.length === 0) {
prompts.log.error("No sessions found", {
output: process.stderr,
})
prompts.outro("Done", {
output: process.stderr,
})
return
}
sessions.sort((a, b) => b.time.updated - a.time.updated)
const selectedSession = await prompts.autocomplete({
message: "Select session to export",
maxItems: 10,
options: sessions.map((session) => ({
label: session.title,
value: session.id,
hint: `${new Date(session.time.updated).toLocaleString()}${session.id.slice(-8)}`,
})),
output: process.stderr,
})
if (prompts.isCancel(selectedSession)) {
throw new UI.CancelledError()
}
sessionID = selectedSession
prompts.outro("Exporting session...", {
output: process.stderr,
})
}
try {
const sessionInfo = await AppRuntime.runPromise(Session.Service.use((svc) => svc.get(sessionID!)))
const messages = await AppRuntime.runPromise(
Session.Service.use((svc) => svc.messages({ sessionID: sessionInfo.id })),
)
const exportData = {
info: sessionInfo,
messages,
}
process.stdout.write(JSON.stringify(args.sanitize ? sanitize(exportData) : exportData, null, 2))
process.stdout.write(EOL)
} catch {
UI.error(`Session not found: ${sessionID!}`)
process.exit(1)
}
})
},
}),
handler: Effect.fn("Cli.export")(function* (args) {
const ctx = yield* InstanceRef
if (!ctx) return
const store = yield* InstanceStore.Service
return yield* run(args).pipe(Effect.ensuring(store.dispose(ctx)))
}),
})
const run = Effect.fn("Cli.export.body")(function* (args: { sessionID?: string; sanitize?: boolean }) {
const svc = yield* Session.Service
let sessionID = args.sessionID ? SessionID.make(args.sessionID) : undefined
process.stderr.write(`Exporting session: ${sessionID ?? "latest"}\n`)
if (!sessionID) {
UI.empty()
prompts.intro("Export session", { output: process.stderr })
const sessions = yield* svc.list()
if (sessions.length === 0) {
prompts.log.error("No sessions found", { output: process.stderr })
prompts.outro("Done", { output: process.stderr })
return
}
sessions.sort((a, b) => b.time.updated - a.time.updated)
const selectedSession = yield* Effect.promise(() =>
prompts.autocomplete({
message: "Select session to export",
maxItems: 10,
options: sessions.map((session) => ({
label: session.title,
value: session.id,
hint: `${new Date(session.time.updated).toLocaleString()}${session.id.slice(-8)}`,
})),
output: process.stderr,
}),
)
if (prompts.isCancel(selectedSession)) {
return yield* Effect.die(new UI.CancelledError())
}
sessionID = selectedSession
prompts.outro("Exporting session...", { output: process.stderr })
}
// Match legacy try/catch — catches both typed failures and defects
// (Session.Service.get throws NotFoundError as a defect, not a typed E).
return yield* Effect.gen(function* () {
const sessionInfo = yield* svc.get(sessionID!)
const messages = yield* svc.messages({ sessionID: sessionInfo.id })
const exportData = { info: sessionInfo, messages }
process.stdout.write(JSON.stringify(args.sanitize ? sanitize(exportData) : exportData, null, 2))
process.stdout.write(EOL)
}).pipe(Effect.catchCause(() => fail(`Session not found: ${sessionID!}`)))
})

View File

@@ -1,17 +1,15 @@
import type { Argv } from "yargs"
import type { Session as SDKSession, Message, Part } from "@opencode-ai/sdk/v2"
import { Session } from "@/session/session"
import { MessageV2 } from "../../session/message-v2"
import { cmd } from "./cmd"
import { bootstrap } from "../bootstrap"
import { CliError, effectCmd } from "../effect-cmd"
import { Database } from "@/storage/db"
import { SessionTable, MessageTable, PartTable } from "../../session/session.sql"
import { Instance } from "../../project/instance"
import { InstanceRef } from "@/effect/instance-ref"
import { InstanceStore } from "@/project/instance-store"
import { ShareNext } from "@/share/share-next"
import { EOL } from "os"
import { Filesystem } from "@/util/filesystem"
import { AppRuntime } from "@/effect/app-runtime"
import { Schema } from "effect"
import { Effect, Schema } from "effect"
const decodeMessageInfo = Schema.decodeUnknownSync(MessageV2.Info)
const decodePart = Schema.decodeUnknownSync(MessageV2.Part)
@@ -78,135 +76,147 @@ export function transformShareData(shareData: ShareData[]): {
}
}
export const ImportCommand = cmd({
type ExportData = { info: SDKSession; messages: Array<{ info: Message; parts: Part[] }> }
export const ImportCommand = effectCmd({
command: "import <file>",
describe: "import session data from JSON file or URL",
builder: (yargs: Argv) => {
return yargs.positional("file", {
builder: (yargs) =>
yargs.positional("file", {
describe: "path to JSON file or share URL",
type: "string",
demandOption: true,
}),
handler: Effect.fn("Cli.import")(function* (args) {
// effectCmd always provides InstanceRef via InstanceStore.Service.provide; this is an invariant.
const ctx = yield* InstanceRef
if (!ctx) return yield* Effect.die("InstanceRef not provided")
const store = yield* InstanceStore.Service
// Ensure store.dispose runs disposers and emits server.instance.disposed
// on every exit path: success, early return, typed failure, defect, interrupt.
return yield* runImport(args.file, ctx.project.id).pipe(Effect.ensuring(store.dispose(ctx)))
}),
})
const runImport = Effect.fn("Cli.import.body")(function* (file: string, projectID: string) {
const share = yield* ShareNext.Service
let exportData: ExportData | undefined
const isUrl = file.startsWith("http://") || file.startsWith("https://")
if (isUrl) {
const slug = parseShareUrl(file)
if (!slug) {
const baseUrl = yield* Effect.orDie(share.url())
process.stdout.write(`Invalid URL format. Expected: ${baseUrl}/share/<slug>`)
process.stdout.write(EOL)
return
}
const baseUrl = new URL(file).origin
const req = yield* Effect.orDie(share.request())
const headers = shouldAttachShareAuthHeaders(file, req.baseUrl) ? req.headers : {}
const tryFetch = (url: string) =>
Effect.tryPromise({
try: () => fetch(url, { headers }),
catch: (e) =>
new CliError({
message: `Failed to fetch share data: ${e instanceof Error ? e.message : String(e)}`,
}),
})
const dataPath = req.api.data(slug)
let response = yield* tryFetch(`${baseUrl}${dataPath}`)
if (!response.ok && dataPath !== `/api/share/${slug}/data`) {
response = yield* tryFetch(`${baseUrl}/api/share/${slug}/data`)
}
if (!response.ok) {
process.stdout.write(`Failed to fetch share data: ${response.statusText}`)
process.stdout.write(EOL)
return
}
const shareData = yield* Effect.tryPromise({
try: () => response.json() as Promise<ShareData[]>,
catch: () => new CliError({ message: "Share data was not valid JSON" }),
})
},
handler: async (args) => {
await bootstrap(process.cwd(), async () => {
let exportData:
| {
info: SDKSession
messages: Array<{
info: Message
parts: Part[]
}>
}
| undefined
const transformed = transformShareData(shareData)
const isUrl = args.file.startsWith("http://") || args.file.startsWith("https://")
if (!transformed) {
process.stdout.write(`Share not found or empty: ${slug}`)
process.stdout.write(EOL)
return
}
if (isUrl) {
const slug = parseShareUrl(args.file)
if (!slug) {
const baseUrl = await AppRuntime.runPromise(ShareNext.Service.use((svc) => svc.url()))
process.stdout.write(`Invalid URL format. Expected: ${baseUrl}/share/<slug>`)
process.stdout.write(EOL)
return
}
exportData = transformed
} else {
exportData = yield* Effect.promise(() =>
Filesystem.readJson<NonNullable<typeof exportData>>(file).catch(() => undefined),
)
if (!exportData) {
process.stdout.write(`File not found: ${file}`)
process.stdout.write(EOL)
return
}
}
const parsed = new URL(args.file)
const baseUrl = parsed.origin
const req = await AppRuntime.runPromise(ShareNext.Service.use((svc) => svc.request()))
const headers = shouldAttachShareAuthHeaders(args.file, req.baseUrl) ? req.headers : {}
if (!exportData) {
process.stdout.write(`Failed to read session data`)
process.stdout.write(EOL)
return
}
const dataPath = req.api.data(slug)
let response = await fetch(`${baseUrl}${dataPath}`, {
headers,
const info = Schema.decodeUnknownSync(Session.Info)({
...exportData.info,
projectID,
}) as Session.Info
const row = Session.toRow(info)
Database.use((db) =>
db
.insert(SessionTable)
.values(row)
.onConflictDoUpdate({ target: SessionTable.id, set: { project_id: row.project_id } })
.run(),
)
for (const msg of exportData.messages) {
const msgInfo = decodeMessageInfo(msg.info) as MessageV2.Info
const { id, sessionID: _, ...msgData } = msgInfo
Database.use((db) =>
db
.insert(MessageTable)
.values({
id,
session_id: row.id,
time_created: msgInfo.time?.created ?? Date.now(),
data: msgData,
})
.onConflictDoNothing()
.run(),
)
if (!response.ok && dataPath !== `/api/share/${slug}/data`) {
response = await fetch(`${baseUrl}/api/share/${slug}/data`, {
headers,
})
}
if (!response.ok) {
process.stdout.write(`Failed to fetch share data: ${response.statusText}`)
process.stdout.write(EOL)
return
}
const shareData: ShareData[] = await response.json()
const transformed = transformShareData(shareData)
if (!transformed) {
process.stdout.write(`Share not found or empty: ${slug}`)
process.stdout.write(EOL)
return
}
exportData = transformed
} else {
exportData = await Filesystem.readJson<NonNullable<typeof exportData>>(args.file).catch(() => undefined)
if (!exportData) {
process.stdout.write(`File not found: ${args.file}`)
process.stdout.write(EOL)
return
}
}
if (!exportData) {
process.stdout.write(`Failed to read session data`)
process.stdout.write(EOL)
return
}
const info = Schema.decodeUnknownSync(Session.Info)({
...exportData.info,
projectID: Instance.project.id,
}) as Session.Info
const row = Session.toRow(info)
for (const part of msg.parts) {
const partInfo = decodePart(part) as MessageV2.Part
const { id: partId, sessionID: _s, messageID, ...partData } = partInfo
Database.use((db) =>
db
.insert(SessionTable)
.values(row)
.onConflictDoUpdate({ target: SessionTable.id, set: { project_id: row.project_id } })
.insert(PartTable)
.values({
id: partId,
message_id: messageID,
session_id: row.id,
data: partData,
})
.onConflictDoNothing()
.run(),
)
}
}
for (const msg of exportData.messages) {
const msgInfo = decodeMessageInfo(msg.info) as MessageV2.Info
const { id, sessionID: _, ...msgData } = msgInfo
Database.use((db) =>
db
.insert(MessageTable)
.values({
id,
session_id: row.id,
time_created: msgInfo.time?.created ?? Date.now(),
data: msgData,
})
.onConflictDoNothing()
.run(),
)
for (const part of msg.parts) {
const partInfo = decodePart(part) as MessageV2.Part
const { id: partId, sessionID: _s, messageID, ...partData } = partInfo
Database.use((db) =>
db
.insert(PartTable)
.values({
id: partId,
message_id: messageID,
session_id: row.id,
data: partData,
})
.onConflictDoNothing()
.run(),
)
}
}
process.stdout.write(`Imported session: ${exportData.info.id}`)
process.stdout.write(EOL)
})
},
process.stdout.write(`Imported session: ${exportData.info.id}`)
process.stdout.write(EOL)
})

View File

@@ -1,16 +1,16 @@
import { intro, log, outro, spinner } from "@clack/prompts"
import type { Argv } from "yargs"
import { Effect } from "effect"
import { ConfigPaths } from "@/config/paths"
import { Global } from "@opencode-ai/core/global"
import { installPlugin, patchPluginConfig, readPluginManifest } from "../../plugin/install"
import { resolvePluginTarget } from "../../plugin/shared"
import { Instance } from "../../project/instance"
import { errorMessage } from "../../util/error"
import { Filesystem } from "@/util/filesystem"
import { Process } from "@/util/process"
import { UI } from "../ui"
import { cmd } from "./cmd"
import { effectCmd } from "../effect-cmd"
import { InstanceRef } from "@/effect/instance-ref"
type Spin = {
start: (msg: string) => void
@@ -175,12 +175,12 @@ export function createPlugTask(input: PlugInput, dep: PlugDeps = defaultPlugDeps
}
}
export const PluginCommand = cmd({
export const PluginCommand = effectCmd({
command: "plugin <module>",
aliases: ["plug"],
describe: "install plugin and update config",
builder: (yargs: Argv) => {
return yargs
builder: (yargs) =>
yargs
.positional("module", {
type: "string",
describe: "npm module name",
@@ -196,9 +196,8 @@ export const PluginCommand = cmd({
type: "boolean",
default: false,
describe: "replace existing plugin version",
})
},
handler: async (args) => {
}),
handler: Effect.fn("Cli.plug")(function* (args) {
const mod = String(args.module ?? "").trim()
if (!mod) {
UI.error("module is required")
@@ -214,20 +213,18 @@ export const PluginCommand = cmd({
global: Boolean(args.global),
force: Boolean(args.force),
})
let ok = true
await Instance.provide({
directory: process.cwd(),
fn: async () => {
ok = await run({
vcs: Instance.project.vcs,
worktree: Instance.worktree,
directory: Instance.directory,
})
},
})
const ctx = yield* InstanceRef
if (!ctx) return
const ok = yield* Effect.promise(() =>
run({
vcs: ctx.project.vcs,
worktree: ctx.worktree,
directory: ctx.directory,
}),
)
outro("Done")
if (!ok) process.exitCode = 1
},
}),
})

View File

@@ -1,11 +1,11 @@
import { Effect } from "effect"
import { UI } from "../ui"
import { cmd } from "./cmd"
import { AppRuntime } from "@/effect/app-runtime"
import { effectCmd, fail } from "../effect-cmd"
import { Git } from "@/git"
import { Instance } from "@/project/instance"
import { InstanceRef } from "@/effect/instance-ref"
import { Process } from "@/util/process"
export const PrCommand = cmd({
export const PrCommand = effectCmd({
command: "pr <number>",
describe: "fetch and checkout a GitHub PR branch, then run opencode",
builder: (yargs) =>
@@ -14,125 +14,102 @@ export const PrCommand = cmd({
describe: "PR number to checkout",
demandOption: true,
}),
async handler(args) {
await Instance.provide({
directory: process.cwd(),
async fn() {
const project = Instance.project
if (project.vcs !== "git") {
UI.error("Could not find git repository. Please run this command from a git repository.")
process.exit(1)
handler: Effect.fn("Cli.pr")(function* (args) {
const ctx = yield* InstanceRef
if (!ctx) return yield* fail("Could not load instance context")
if (ctx.project.vcs !== "git") {
return yield* fail("Could not find git repository. Please run this command from a git repository.")
}
const git = yield* Git.Service
const worktree = ctx.worktree
const prNumber = args.number
const localBranchName = `pr/${prNumber}`
UI.println(`Fetching and checking out PR #${prNumber}...`)
const checkout = yield* Effect.promise(() =>
Process.run(["gh", "pr", "checkout", `${prNumber}`, "--branch", localBranchName, "--force"], { nothrow: true }),
)
if (checkout.code !== 0) {
return yield* fail(`Failed to checkout PR #${prNumber}. Make sure you have gh CLI installed and authenticated.`)
}
const prInfoResult = yield* Effect.promise(() =>
Process.text(
[
"gh",
"pr",
"view",
`${prNumber}`,
"--json",
"headRepository,headRepositoryOwner,isCrossRepository,headRefName,body",
],
{ nothrow: true },
),
)
let sessionId: string | undefined
if (prInfoResult.code === 0 && prInfoResult.text.trim()) {
const prInfo = JSON.parse(prInfoResult.text)
if (prInfo?.isCrossRepository && prInfo.headRepository && prInfo.headRepositoryOwner) {
const forkOwner = prInfo.headRepositoryOwner.login
const forkName = prInfo.headRepository.name
const remoteName = forkOwner
const remotes = (yield* git.run(["remote"], { cwd: worktree })).text().trim()
if (!remotes.split("\n").includes(remoteName)) {
yield* git.run(["remote", "add", remoteName, `https://github.com/${forkOwner}/${forkName}.git`], {
cwd: worktree,
})
UI.println(`Added fork remote: ${remoteName}`)
}
const prNumber = args.number
const localBranchName = `pr/${prNumber}`
UI.println(`Fetching and checking out PR #${prNumber}...`)
yield* git.run(["branch", `--set-upstream-to=${remoteName}/${prInfo.headRefName}`, localBranchName], {
cwd: worktree,
})
}
// Use gh pr checkout with custom branch name
const result = await Process.run(
["gh", "pr", "checkout", `${prNumber}`, "--branch", localBranchName, "--force"],
{
nothrow: true,
},
)
if (prInfo?.body) {
const sessionMatch = prInfo.body.match(/https:\/\/opncd\.ai\/s\/([a-zA-Z0-9_-]+)/)
if (sessionMatch) {
const sessionUrl = sessionMatch[0]
UI.println(`Found opencode session: ${sessionUrl}`)
UI.println(`Importing session...`)
if (result.code !== 0) {
UI.error(`Failed to checkout PR #${prNumber}. Make sure you have gh CLI installed and authenticated.`)
process.exit(1)
}
// Fetch PR info for fork handling and session link detection
const prInfoResult = await Process.text(
[
"gh",
"pr",
"view",
`${prNumber}`,
"--json",
"headRepository,headRepositoryOwner,isCrossRepository,headRefName,body",
],
{ nothrow: true },
)
let sessionId: string | undefined
if (prInfoResult.code === 0) {
const prInfoText = prInfoResult.text
if (prInfoText.trim()) {
const prInfo = JSON.parse(prInfoText)
// Handle fork PRs
if (prInfo && prInfo.isCrossRepository && prInfo.headRepository && prInfo.headRepositoryOwner) {
const forkOwner = prInfo.headRepositoryOwner.login
const forkName = prInfo.headRepository.name
const remoteName = forkOwner
// Check if remote already exists
const remotes = await AppRuntime.runPromise(
Git.Service.use((git) => git.run(["remote"], { cwd: Instance.worktree })),
).then((x) => x.text().trim())
if (!remotes.split("\n").includes(remoteName)) {
await AppRuntime.runPromise(
Git.Service.use((git) =>
git.run(["remote", "add", remoteName, `https://github.com/${forkOwner}/${forkName}.git`], {
cwd: Instance.worktree,
}),
),
)
UI.println(`Added fork remote: ${remoteName}`)
}
// Set upstream to the fork so pushes go there
const headRefName = prInfo.headRefName
await AppRuntime.runPromise(
Git.Service.use((git) =>
git.run(["branch", `--set-upstream-to=${remoteName}/${headRefName}`, localBranchName], {
cwd: Instance.worktree,
}),
),
)
}
// Check for opencode session link in PR body
if (prInfo && prInfo.body) {
const sessionMatch = prInfo.body.match(/https:\/\/opncd\.ai\/s\/([a-zA-Z0-9_-]+)/)
if (sessionMatch) {
const sessionUrl = sessionMatch[0]
UI.println(`Found opencode session: ${sessionUrl}`)
UI.println(`Importing session...`)
const importResult = await Process.text(["opencode", "import", sessionUrl], {
nothrow: true,
})
if (importResult.code === 0) {
const importOutput = importResult.text.trim()
// Extract session ID from the output (format: "Imported session: <session-id>")
const sessionIdMatch = importOutput.match(/Imported session: ([a-zA-Z0-9_-]+)/)
if (sessionIdMatch) {
sessionId = sessionIdMatch[1]
UI.println(`Session imported: ${sessionId}`)
}
}
}
const importResult = yield* Effect.promise(() =>
Process.text(["opencode", "import", sessionUrl], { nothrow: true }),
)
if (importResult.code === 0) {
const sessionIdMatch = importResult.text.trim().match(/Imported session: ([a-zA-Z0-9_-]+)/)
if (sessionIdMatch) {
sessionId = sessionIdMatch[1]
UI.println(`Session imported: ${sessionId}`)
}
}
}
}
}
UI.println(`Successfully checked out PR #${prNumber} as branch '${localBranchName}'`)
UI.println()
UI.println("Starting opencode...")
UI.println()
UI.println(`Successfully checked out PR #${prNumber} as branch '${localBranchName}'`)
UI.println()
UI.println("Starting opencode...")
UI.println()
const opencodeArgs = sessionId ? ["-s", sessionId] : []
const opencodeProcess = Process.spawn(["opencode", ...opencodeArgs], {
const opencodeArgs = sessionId ? ["-s", sessionId] : []
const code = yield* Effect.promise(
() =>
Process.spawn(["opencode", ...opencodeArgs], {
stdin: "inherit",
stdout: "inherit",
stderr: "inherit",
cwd: process.cwd(),
})
const code = await opencodeProcess.exited
if (code !== 0) throw new Error(`opencode exited with code ${code}`)
},
})
},
}).exited,
)
// Match legacy throw semantics — propagate as a defect so the top-level
// index.ts catch handles it identically (exit 1, "Unexpected error" banner).
if (code !== 0) return yield* Effect.die(new Error(`opencode exited with code ${code}`))
}),
})

View File

@@ -1,8 +1,9 @@
import type { Argv } from "yargs"
import { Effect } from "effect"
import { cmd } from "./cmd"
import { effectCmd, fail } from "../effect-cmd"
import { Session } from "@/session/session"
import { SessionID } from "../../session/schema"
import { bootstrap } from "../bootstrap"
import { UI } from "../ui"
import { Locale } from "@/util/locale"
import { Flag } from "@opencode-ai/core/flag/flag"
@@ -11,7 +12,8 @@ import { Process } from "@/util/process"
import { EOL } from "os"
import path from "path"
import { which } from "../../util/which"
import { AppRuntime } from "@/effect/app-runtime"
import { InstanceRef } from "@/effect/instance-ref"
import { InstanceStore } from "@/project/instance-store"
function pagerCmd(): string[] {
const lessOptions = ["-R", "-S"]
@@ -47,36 +49,35 @@ export const SessionCommand = cmd({
async handler() {},
})
export const SessionDeleteCommand = cmd({
export const SessionDeleteCommand = effectCmd({
command: "delete <sessionID>",
describe: "delete a session",
builder: (yargs: Argv) => {
return yargs.positional("sessionID", {
builder: (yargs) =>
yargs.positional("sessionID", {
describe: "session ID to delete",
type: "string",
demandOption: true,
})
},
handler: async (args) => {
await bootstrap(process.cwd(), async () => {
}),
handler: Effect.fn("Cli.session.delete")(function* (args) {
const ctx = yield* InstanceRef
if (!ctx) return
const store = yield* InstanceStore.Service
return yield* Effect.gen(function* () {
const svc = yield* Session.Service
const sessionID = SessionID.make(args.sessionID)
try {
await AppRuntime.runPromise(Session.Service.use((svc) => svc.get(sessionID)))
} catch {
UI.error(`Session not found: ${args.sessionID}`)
process.exit(1)
}
await AppRuntime.runPromise(Session.Service.use((svc) => svc.remove(sessionID)))
// Match legacy try/catch — Session.get surfaces NotFoundError as a defect.
yield* svc.get(sessionID).pipe(Effect.catchCause(() => fail(`Session not found: ${args.sessionID}`)))
yield* svc.remove(sessionID)
UI.println(UI.Style.TEXT_SUCCESS_BOLD + `Session ${args.sessionID} deleted` + UI.Style.TEXT_NORMAL)
})
},
}).pipe(Effect.ensuring(store.dispose(ctx)))
}),
})
export const SessionListCommand = cmd({
export const SessionListCommand = effectCmd({
command: "list",
describe: "list sessions",
builder: (yargs: Argv) => {
return yargs
builder: (yargs) =>
yargs
.option("max-count", {
alias: "n",
describe: "limit to N most recent sessions",
@@ -87,47 +88,42 @@ export const SessionListCommand = cmd({
type: "string",
choices: ["table", "json"],
default: "table",
})
},
handler: async (args) => {
await bootstrap(process.cwd(), async () => {
const sessions = await AppRuntime.runPromise(
Session.Service.use((svc) => svc.list({ roots: true, limit: args.maxCount })),
)
}),
handler: Effect.fn("Cli.session.list")(function* (args) {
const ctx = yield* InstanceRef
if (!ctx) return
const store = yield* InstanceStore.Service
return yield* Effect.gen(function* () {
const sessions = yield* Session.Service.use((svc) => svc.list({ roots: true, limit: args.maxCount }))
if (sessions.length === 0) {
return
}
if (sessions.length === 0) return
let output: string
if (args.format === "json") {
output = formatSessionJSON(sessions)
} else {
output = formatSessionTable(sessions)
}
const output = args.format === "json" ? formatSessionJSON(sessions) : formatSessionTable(sessions)
const shouldPaginate = process.stdout.isTTY && !args.maxCount && args.format === "table"
if (shouldPaginate) {
const proc = Process.spawn(pagerCmd(), {
stdin: "pipe",
stdout: "inherit",
stderr: "inherit",
yield* Effect.promise(async () => {
const proc = Process.spawn(pagerCmd(), {
stdin: "pipe",
stdout: "inherit",
stderr: "inherit",
})
if (!proc.stdin) {
console.log(output)
return
}
proc.stdin.write(output)
proc.stdin.end()
await proc.exited
})
if (!proc.stdin) {
console.log(output)
return
}
proc.stdin.write(output)
proc.stdin.end()
await proc.exited
} else {
console.log(output)
}
})
},
}).pipe(Effect.ensuring(store.dispose(ctx)))
}),
})
function formatSessionTable(sessions: Session.Info[]): string {

View File

@@ -1,11 +1,11 @@
import type { Argv } from "yargs"
import { cmd } from "./cmd"
import { Effect } from "effect"
import { effectCmd } from "../effect-cmd"
import { Session } from "@/session/session"
import { bootstrap } from "../bootstrap"
import { Database } from "@/storage/db"
import { SessionTable } from "../../session/session.sql"
import { Project } from "@/project/project"
import { Instance } from "../../project/instance"
import { InstanceRef } from "@/effect/instance-ref"
import { InstanceStore } from "@/project/instance-store"
import { AppRuntime } from "@/effect/app-runtime"
interface SessionStats {
@@ -47,11 +47,11 @@ interface SessionStats {
medianTokensPerSession: number
}
export const StatsCommand = cmd({
export const StatsCommand = effectCmd({
command: "stats",
describe: "show token usage and cost statistics",
builder: (yargs: Argv) => {
return yargs
builder: (yargs) =>
yargs
.option("days", {
describe: "show stats for the last N days (default: all time)",
type: "number",
@@ -66,34 +66,42 @@ export const StatsCommand = cmd({
.option("project", {
describe: "filter by project (default: all projects, empty string: current project)",
type: "string",
})
},
handler: async (args) => {
await bootstrap(process.cwd(), async () => {
const stats = await aggregateSessionStats(args.days, args.project)
let modelLimit: number | undefined
if (args.models === true) {
modelLimit = Infinity
} else if (typeof args.models === "number") {
modelLimit = args.models
}
displayStats(stats, args.tools, modelLimit)
})
},
}),
handler: Effect.fn("Cli.stats")(function* (args) {
const ctx = yield* InstanceRef
if (!ctx) return
const store = yield* InstanceStore.Service
return yield* run(args, ctx.project).pipe(Effect.ensuring(store.dispose(ctx)))
}),
})
async function getCurrentProject(): Promise<Project.Info> {
return Instance.project
}
const run = (
args: { days?: number; tools?: number; models?: unknown; project?: string },
currentProject: Project.Info,
) =>
Effect.promise(async () => {
const stats = await aggregateSessionStats(args.days, args.project, currentProject)
let modelLimit: number | undefined
if (args.models === true) {
modelLimit = Infinity
} else if (typeof args.models === "number") {
modelLimit = args.models
}
displayStats(stats, args.tools, modelLimit)
})
async function getAllSessions(): Promise<Session.Info[]> {
const rows = Database.use((db) => db.select().from(SessionTable).all())
return rows.map((row) => Session.fromRow(row))
}
export async function aggregateSessionStats(days?: number, projectFilter?: string): Promise<SessionStats> {
export async function aggregateSessionStats(
days?: number,
projectFilter?: string,
currentProject?: Project.Info,
): Promise<SessionStats> {
const sessions = await getAllSessions()
const MS_IN_DAY = 24 * 60 * 60 * 1000
@@ -117,7 +125,7 @@ export async function aggregateSessionStats(days?: number, projectFilter?: strin
if (projectFilter !== undefined) {
if (projectFilter === "") {
const currentProject = await getCurrentProject()
if (!currentProject) throw new Error("currentProject required when projectFilter is empty string")
filteredSessions = filteredSessions.filter((session) => session.projectID === currentProject.id)
} else {
filteredSessions = filteredSessions.filter((session) => session.projectID === projectFilter)

View File

@@ -31,6 +31,7 @@ export const fail = (message: string, exitCode = 1) => Effect.fail(new CliError(
*/
export const effectCmd = <Args, A>(opts: {
command: string | readonly string[]
aliases?: string | readonly string[]
describe: string | false
builder?: (yargs: Argv) => Argv<Args>
/** Defaults to process.cwd(). Override for commands that take a directory positional. */
@@ -39,6 +40,7 @@ export const effectCmd = <Args, A>(opts: {
}) =>
cmd<{}, Args>({
command: opts.command,
aliases: opts.aliases,
describe: opts.describe,
builder: opts.builder as never,
async handler(rawArgs) {