Skip to content

Commit 61efcf9

Browse files
feat(ocm-cli): add session move with local history export and TUI plugin (#304)
* loop: ocm-session-move completed after 7 iterations * feat(ocm-cli): add session move with local history export and TUI plugin * docs(ocm-cli): update plugin config to tui-only entry and clarify session history source * fix(ocm-cli): replace raw postinstall output with TUI toast notification
1 parent 5432326 commit 61efcf9

19 files changed

Lines changed: 907 additions & 158 deletions

docs/ocm-cli.md

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -29,29 +29,25 @@ The CLI is published as `@opencode-manager/ocm-cli`. There are two install paths
2929

3030
### Option A — install via OpenCode's plugin loader (recommended)
3131

32-
Add the package to your OpenCode config and OpenCode will fetch it on next start. The package's plugin entry self-installs a `~/.local/bin/ocm` symlink, so the `ocm` binary becomes available on your PATH automatically.
32+
Add the TUI plugin entry to your OpenCode config and OpenCode will fetch the package on next start. The package `postinstall` script self-installs a `~/.local/bin/ocm` symlink for local plugin installs, so the `ocm` binary becomes available on your PATH automatically.
3333

3434
```jsonc
3535
// ~/.config/opencode/opencode.json
3636
{
3737
"$schema": "https://opencode.ai/config.json",
38-
"plugin": ["@opencode-manager/ocm-cli"]
38+
"plugin": ["@opencode-manager/ocm-cli/tui"]
3939
}
4040
```
4141

42-
The next time OpenCode starts it will run `bun install` for the plugin and import it once. You'll see:
42+
The next time OpenCode starts it will run `bun install` for the plugin. The installer stays quiet so it does not break the TUI layout; after the plugin loads, OpenCode shows a one-time toast confirming where `ocm` was linked.
4343

44-
```
45-
ocm-cli: installed `ocm` at /Users/you/.local/bin/ocm
46-
```
47-
48-
If `~/.local/bin` is not on your PATH, the message will tell you. Add this to your shell rc:
44+
If `~/.local/bin` is not on your PATH, add this to your shell rc:
4945

5046
```bash
5147
export PATH="$HOME/.local/bin:$PATH"
5248
```
5349

54-
The plugin itself is a no-op — it does not register tools, commands, or hooks. Its only side effect is the bin symlink, so all real work happens through the `ocm` CLI.
50+
The `./tui` entry registers `/ocm-move`, a TUI command that keeps the local session and copies the active session to the Manager after pushing the current repo state. Run it from inside a local OpenCode session after `ocm login` and after the repo exists on the Manager (`ocm push --create` if needed).
5551

5652
### Option B — global package manager install
5753

@@ -129,6 +125,10 @@ The child takes over the terminal (`stdio: inherit`); closing the TUI exits `ocm
129125

130126
`ocm pull` uses a fast git bundle + working-tree patch by default to sync the matching Manager repo over `$PWD`. Pass `--full` to use the legacy tarball mirror. If the fast path fails, `ocm` prompts before reverting to the tarball mirror (and proceeds automatically when there is no TTY to prompt).
131127

128+
### TUI `/ocm-move`
129+
130+
When the TUI plugin entry is installed, `/ocm-move` is available in local OpenCode sessions. It checks that the matching Manager repo has not diverged, pushes the local git state with the fast bundle + working-tree patch path, reads the active session history from the local OpenCode SQLite event database, rewrites local repo directories to the Manager repo directory, and replays the session through `/api/opencode-proxy/sync/replay`. The local session is retained.
131+
132132
- `--force` skips the dirty-working-tree check on `pull` and the safety bail on `push`.
133133
- `--create` (on `push`) creates a new Manager repo when no `origin` match is found.
134134
- `--yes` skips the interactive create confirmation.

ocm-cli/README.md

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -61,18 +61,26 @@ tarball mirror. If the fast path fails, `ocm` prompts before reverting to the
6161
tarball mirror (and proceeds automatically when there is no TTY to prompt). It
6262
refuses to overwrite uncommitted local changes unless `--force` is passed.
6363

64-
## OpenCode plugin
64+
## OpenCode TUI plugin
6565

66-
The package default export is an OpenCode plugin entrypoint. Importing the
67-
plugin performs a best-effort local `ocm` symlink install and then returns an
68-
empty plugin object.
66+
The package exposes an OpenCode TUI plugin at
67+
`@opencode-manager/ocm-cli/tui`. It registers `/ocm-move`, which keeps the
68+
local session and copies the active session to the Manager after pushing the
69+
current repo state. Use it from inside an OpenCode session after `ocm login`
70+
and after the repo already exists on the Manager (`ocm push --create` if
71+
needed).
6972

70-
```ts
71-
import ocm from '@opencode-manager/ocm-cli'
73+
Enable it in `tui.json`:
7274

73-
export default [ocm]
75+
```jsonc
76+
{
77+
"plugin": ["@opencode-manager/ocm-cli/tui"]
78+
}
7479
```
7580

81+
The `ocm` binary is installed via the package `postinstall` (or `bin` field on
82+
global installs); the plugin surface is TUI-only.
83+
7684
## Requirements
7785

7886
- `opencode` available on `PATH`

ocm-cli/bin/ocm.ts

Lines changed: 21 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { createProgressReporter } from '../src/progress.js'
99
import { getBranchName, getOriginUrl } from '../src/local-repo.js'
1010
import { resolveOpenCodeProjectId } from '@opencode-manager/shared/project-id'
1111
import { resolveTarget } from '../src/resolve-target.js'
12+
import { type ManagerRepo, fetchRepos, toRemoteRepoSummaries } from '../src/manager-repos.js'
1213
import packageJson from '../package.json' with { type: 'json' }
1314

1415
const VERSION = packageJson.version
@@ -30,16 +31,6 @@ Usage:
3031
ocm --help Show this help
3132
`
3233

33-
interface ManagerRepo {
34-
repoId: number
35-
name: string
36-
branch: string | null
37-
cloneStatus: string
38-
directory: string
39-
projectId?: string | null
40-
extra: { repoId: number; localPath: string; fullPath: string }
41-
}
42-
4334
function die(msg: string, code = 1): never {
4435
process.stderr.write(`ocm: ${msg}\n`)
4536
process.exit(code)
@@ -124,18 +115,24 @@ function requireToken(state: OcmState): string {
124115
return token
125116
}
126117

127-
async function fetchRepos(managerUrl: string, token: string): Promise<ManagerRepo[]> {
128-
const res = await fetch(`${managerUrl}/api/internal/opencode-workspaces`, {
129-
headers: { Authorization: `Bearer ${token}` },
130-
})
131-
if (!res.ok) {
132-
throw new Error(`manager responded ${res.status} ${res.statusText}`)
118+
async function warmUpInstance(managerUrl: string, token: string, directory: string): Promise<void> {
119+
const url = `${managerUrl}/api/opencode-proxy/session?directory=${encodeURIComponent(directory)}`
120+
for (let attempt = 1; attempt <= 3; attempt++) {
121+
try {
122+
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } })
123+
if (res.ok) {
124+
await res.text()
125+
return
126+
}
127+
} catch {
128+
/* retry */
129+
}
130+
await new Promise((resolve) => setTimeout(resolve, attempt * 500))
133131
}
134-
const data = (await res.json()) as { workspaces: ManagerRepo[] }
135-
return data.workspaces
136132
}
137133

138-
function attach(managerUrl: string, token: string, repo: ManagerRepo): never {
134+
async function attach(managerUrl: string, token: string, repo: ManagerRepo): Promise<never> {
135+
await warmUpInstance(managerUrl, token, repo.directory)
139136
const proxyUrl = `${managerUrl}/api/opencode-proxy`
140137
const args = [
141138
'attach',
@@ -271,7 +268,7 @@ async function cmdUse(args: string[]): Promise<void> {
271268
lastRepoBranch: repo.branch,
272269
})
273270

274-
attach(state.managerUrl, token, repo)
271+
await attach(state.managerUrl, token, repo)
275272
}
276273

277274
async function cmdDefault(): Promise<void> {
@@ -304,13 +301,13 @@ async function cmdDefault(): Promise<void> {
304301
lastRepoDir: repo.directory,
305302
lastRepoBranch: repo.branch,
306303
})
307-
attach(state.managerUrl, token, toManagerRepo(repo))
304+
await attach(state.managerUrl, token, toManagerRepo(repo))
308305
return
309306
}
310307
case 'last': {
311308
const repo = result.repo
312309
info(`attaching to ${repo.name} (last used)`)
313-
attach(state.managerUrl, token, toManagerRepo(repo))
310+
await attach(state.managerUrl, token, toManagerRepo(repo))
314311
return
315312
}
316313
case 'cwd-ambiguous': {
@@ -364,12 +361,7 @@ export async function cmdPush(args: string[]): Promise<void> {
364361
const api = new ManagerApi(state.managerUrl, token)
365362
const repos = await fetchRepos(state.managerUrl, token)
366363

367-
const remotes: RemoteRepoSummary[] = repos.map((r) => ({
368-
repoId: r.repoId,
369-
name: r.name,
370-
projectId: r.projectId ?? null,
371-
branch: r.branch,
372-
}))
364+
const remotes: RemoteRepoSummary[] = toRemoteRepoSummaries(repos)
373365

374366
const plan = await prepareMirror(process.cwd(), remotes)
375367

@@ -447,12 +439,7 @@ async function cmdPull(args: string[]): Promise<void> {
447439
const api = new ManagerApi(state.managerUrl, token)
448440
const repos = await fetchRepos(state.managerUrl, token)
449441

450-
const remotes: RemoteRepoSummary[] = repos.map((r) => ({
451-
repoId: r.repoId,
452-
name: r.name,
453-
projectId: r.projectId ?? null,
454-
branch: r.branch,
455-
}))
442+
const remotes: RemoteRepoSummary[] = toRemoteRepoSummaries(repos)
456443

457444
const plan = await prepareMirror(process.cwd(), remotes)
458445

ocm-cli/package.json

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@opencode-manager/ocm-cli",
3-
"version": "0.2.0",
3+
"version": "0.2.1",
44
"description": "OpenCode Manager CLI: attach a local OpenCode TUI to a Manager-hosted repo.",
55
"license": "MIT",
66
"repository": {
@@ -9,10 +9,12 @@
99
"directory": "ocm-cli"
1010
},
1111
"type": "module",
12-
"main": "./dist/plugin.js",
12+
"oc-plugin": [
13+
"tui"
14+
],
1315
"exports": {
14-
".": {
15-
"import": "./dist/plugin.js"
16+
"./tui": {
17+
"import": "./dist/tui.js"
1618
}
1719
},
1820
"bin": {
@@ -32,10 +34,10 @@
3234
"test:watch": "vitest",
3335
"prepublishOnly": "bun scripts/build.ts"
3436
},
35-
"dependencies": {},
3637
"devDependencies": {
3738
"@eslint/js": "^9.36.0",
3839
"@opencode-manager/shared": "workspace:*",
40+
"@types/bun": "^1.3.14",
3941
"@types/node": "^22.0.0",
4042
"eslint": "^9.39.1",
4143
"typescript": "^5.5.0",

ocm-cli/scripts/build.ts

Lines changed: 16 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -6,37 +6,27 @@ const dist = join(root, 'dist')
66

77
rmSync(dist, { recursive: true, force: true })
88

9-
console.log('Bundling ocm CLI...')
10-
const cliResult = await Bun.build({
11-
entrypoints: [join(root, 'bin', 'ocm.ts')],
12-
outdir: dist,
13-
target: 'node',
14-
format: 'esm',
15-
naming: { entry: 'ocm.js' },
16-
})
17-
18-
if (!cliResult.success) {
19-
for (const log of cliResult.logs) {
9+
async function bundleEntry(label: string, entrypoint: string, outputName: string): Promise<void> {
10+
console.log(`Bundling ${label}...`)
11+
const result = await Bun.build({
12+
entrypoints: [join(root, entrypoint)],
13+
outdir: dist,
14+
target: 'node',
15+
format: 'esm',
16+
external: ['bun:sqlite'],
17+
naming: { entry: outputName },
18+
})
19+
20+
if (result.success) return
21+
22+
for (const log of result.logs) {
2023
console.error(log)
2124
}
2225
process.exit(1)
2326
}
2427

25-
console.log('Bundling opencode plugin entry...')
26-
const pluginResult = await Bun.build({
27-
entrypoints: [join(root, 'src', 'plugin.ts')],
28-
outdir: dist,
29-
target: 'node',
30-
format: 'esm',
31-
naming: { entry: 'plugin.js' },
32-
})
33-
34-
if (!pluginResult.success) {
35-
for (const log of pluginResult.logs) {
36-
console.error(log)
37-
}
38-
process.exit(1)
39-
}
28+
await bundleEntry('ocm CLI', join('bin', 'ocm.ts'), 'ocm.js')
29+
await bundleEntry('TUI plugin entry', join('src', 'tui-plugin.ts'), 'tui.js')
4030

4131
const ocmJsPath = join(dist, 'ocm.js')
4232
const ocmJsContent = readFileSync(ocmJsPath, 'utf-8')

ocm-cli/scripts/postinstall.mjs

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
#!/usr/bin/env node
2-
import { existsSync, mkdirSync, symlinkSync, unlinkSync, lstatSync, readlinkSync, chmodSync } from 'node:fs'
2+
import { existsSync, mkdirSync, symlinkSync, unlinkSync, lstatSync, readlinkSync, chmodSync, writeFileSync } from 'node:fs'
33
import { homedir } from 'node:os'
44
import { join, resolve, dirname } from 'node:path'
55
import { fileURLToPath } from 'node:url'
@@ -26,16 +26,24 @@ if (process.env.npm_config_global === 'true' || process.env.npm_config_global ==
2626
}
2727

2828
const binDir = join(homedir(), '.local', 'bin')
29+
const link = join(binDir, 'ocm')
30+
const noticeDir = join(homedir(), '.config', 'opencode-manager')
31+
const noticeFile = join(noticeDir, 'install-notice.json')
32+
33+
function writeInstallNotice(pathMissing) {
34+
try {
35+
mkdirSync(noticeDir, { recursive: true })
36+
writeFileSync(noticeFile, JSON.stringify({ link, binDir, pathMissing }, null, 2), { mode: 0o600 })
37+
} catch {
38+
}
39+
}
2940

3041
try {
3142
mkdirSync(binDir, { recursive: true })
32-
} catch (err) {
33-
process.stderr.write(`ocm-cli: cannot create ${binDir}: ${err.message}\n`)
43+
} catch {
3444
process.exit(0)
3545
}
3646

37-
const link = join(binDir, 'ocm')
38-
3947
try {
4048
const stat = lstatSync(link)
4149
if (stat.isSymbolicLink()) {
@@ -44,7 +52,6 @@ try {
4452
}
4553
unlinkSync(link)
4654
} else {
47-
process.stderr.write(`ocm-cli: ${link} exists and is not a symlink; leaving alone\n`)
4855
process.exit(0)
4956
}
5057
} catch {
@@ -53,15 +60,9 @@ try {
5360

5461
try {
5562
symlinkSync(target, link)
56-
} catch (err) {
57-
process.stderr.write(`ocm-cli: failed to symlink ${link}: ${err.message}\n`)
63+
} catch {
5864
process.exit(0)
5965
}
6066

61-
process.stdout.write(`ocm installed at ${link}\n`)
62-
6367
const path = process.env.PATH ?? ''
64-
if (!path.split(':').includes(binDir)) {
65-
process.stdout.write(`note: ${binDir} is not on your PATH. Add to your shell rc:\n`)
66-
process.stdout.write(` export PATH="${binDir}:$PATH"\n`)
67-
}
68+
writeInstallNotice(!path.split(':').includes(binDir))

0 commit comments

Comments
 (0)