@profoundlogic/coderflow-cli 0.13.10 → 0.13.11-dev.20260809021314.g778dadcc

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/README.md CHANGED
@@ -90,6 +90,52 @@ visibility.
90
90
  When supplying a container ID directly, use the full ID or an unambiguous
91
91
  prefix of at least 12 hexadecimal characters.
92
92
 
93
+ ## Exporting and Importing Tasks
94
+
95
+ Export a task as a portable ZIP package:
96
+
97
+ ```bash
98
+ coder export <task-id>
99
+ coder export <task-id> --output ./task-backup.zip
100
+ ```
101
+
102
+ The package contains persisted task output and activity history, task metadata,
103
+ container logs, and a `/workspace` snapshot when the retained container is
104
+ available. Runtime-only server credential configuration is not included.
105
+
106
+ Import an export package or a plain local workspace directory:
107
+
108
+ ```bash
109
+ # Restore the task in the state it was exported in (default)
110
+ coder import ./task-backup.zip
111
+
112
+ # Package a directory and restore it into an environment
113
+ coder import ./my-workspace --environment=myproject
114
+
115
+ # Store the package as a reusable objective, or launch immediately
116
+ coder import ./task-backup.zip --mode=objective
117
+ coder import ./task-backup.zip --mode=running
118
+ ```
119
+
120
+ Import modes are `preserve` (the default), `objective`, `staged`, `running`,
121
+ and `completed`. `preserve` reproduces the exported state: a finished task
122
+ imports as a finished task, an objective as an objective, and anything else —
123
+ including a plain workspace directory, which carries no state — as a staged
124
+ task. Use `--environment`, `--name`, `--agent`, or `--instructions` to override
125
+ package metadata. In the Web UI, **Import** creates an objective from the
126
+ Objectives view and preserves the exported state everywhere else.
127
+
128
+ A restored task keeps its transcript, its follow-up history, and its changed
129
+ files: the changed-files list is recomputed against the destination's own
130
+ repositories once the workspace is restored, so the Changed Files tab and the
131
+ approve dialog are populated as soon as the import finishes.
132
+
133
+ Continuing an imported conversation replays it rather than resuming it. An
134
+ agent session belongs to the container that created it, and the destination
135
+ container has never run the agent, so the first message after an import hands
136
+ the agent the imported transcript as context — the same mechanism a forked task
137
+ uses.
138
+
93
139
  ## Configuration and Profiles
94
140
 
95
141
  Manage connection settings:
package/coder.js CHANGED
@@ -20,6 +20,8 @@ import { attachToContainer } from './lib/commands/attach.js';
20
20
  import { handleConfig } from './lib/commands/config.js';
21
21
  import { handleProfile } from './lib/commands/profile.js';
22
22
  import { runTest } from './lib/commands/test.js';
23
+ import { exportTask } from './lib/commands/export.js';
24
+ import { importTask } from './lib/commands/import.js';
23
25
  import { setCliProfileOverride } from './lib/config.js';
24
26
  import { readFileSync } from 'fs';
25
27
  import { fileURLToPath } from 'url';
@@ -167,6 +169,14 @@ async function main() {
167
169
  await runTest(args.slice(1));
168
170
  break;
169
171
 
172
+ case 'export':
173
+ await exportTask(args.slice(1));
174
+ break;
175
+
176
+ case 'import':
177
+ await importTask(args.slice(1));
178
+ break;
179
+
170
180
  case 'start':
171
181
  await startInteractive(args.slice(1));
172
182
  break;
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Command: coder export - Download a portable task package.
3
+ */
4
+
5
+ import { createWriteStream, promises as fs } from 'node:fs';
6
+ import path from 'node:path';
7
+ import { randomUUID } from 'node:crypto';
8
+ import { Readable } from 'node:stream';
9
+ import { pipeline } from 'node:stream/promises';
10
+ import { getApiKey, getServerUrl } from '../config.js';
11
+ import { buildHttpError } from '../http-client.js';
12
+
13
+ const WORKSPACE_MODES = ['delta', 'full', 'none'];
14
+
15
+ const USAGE = 'Usage: coder export <task-id> [--output <file.zip>] [--workspace delta|full|none]';
16
+
17
+ export function parseExportArgs(args) {
18
+ let taskId = null;
19
+ let output = null;
20
+ let workspace = null;
21
+
22
+ for (let index = 0; index < args.length; index += 1) {
23
+ const arg = args[index];
24
+ if (arg === '--output' || arg === '-o') {
25
+ output = args[++index];
26
+ } else if (arg.startsWith('--output=')) {
27
+ output = arg.slice('--output='.length);
28
+ } else if (arg === '--workspace') {
29
+ workspace = args[++index];
30
+ } else if (arg.startsWith('--workspace=')) {
31
+ workspace = arg.slice('--workspace='.length);
32
+ } else if (!arg.startsWith('-') && !taskId) {
33
+ taskId = arg;
34
+ } else {
35
+ throw new Error(`Unexpected export argument: ${arg}`);
36
+ }
37
+ }
38
+
39
+ if (!taskId) throw new Error(`Task ID required. ${USAGE}`);
40
+ if (workspace != null && !WORKSPACE_MODES.includes(workspace)) {
41
+ throw new Error(`--workspace must be ${WORKSPACE_MODES.join(', ')}`);
42
+ }
43
+ return { taskId, output, workspace };
44
+ }
45
+
46
+ /**
47
+ * Describe what the server's capture actually carried.
48
+ *
49
+ * The archive body is a zip, so the capture summary travels in headers. An
50
+ * export that came back without a workspace says why, because "the delta was
51
+ * over the size cap" and "the task changed nothing" are not the same news.
52
+ */
53
+ export function describeExportWorkspace(headers) {
54
+ const mode = headers.get('x-coderflow-workspace-mode') || 'none';
55
+ const reasonHeader = headers.get('x-coderflow-workspace-reason');
56
+ let reason = null;
57
+ if (reasonHeader) {
58
+ try {
59
+ reason = decodeURIComponent(reasonHeader);
60
+ } catch {
61
+ reason = reasonHeader;
62
+ }
63
+ }
64
+
65
+ if (mode === 'none') {
66
+ return reason ? `Workspace: not included (${reason})` : 'Workspace: not included';
67
+ }
68
+ if (mode !== 'delta') {
69
+ return 'Workspace: full capture';
70
+ }
71
+
72
+ const files = Number(headers.get('x-coderflow-workspace-files')) || 0;
73
+ const bytes = Number(headers.get('x-coderflow-workspace-bytes')) || 0;
74
+ const deleted = Number(headers.get('x-coderflow-workspace-deleted')) || 0;
75
+ const parts = [`${files} changed file${files === 1 ? '' : 's'} (${bytes} bytes)`];
76
+ if (deleted > 0) parts.push(`${deleted} deleted path${deleted === 1 ? '' : 's'}`);
77
+ return `Workspace: delta - ${parts.join(', ')}`;
78
+ }
79
+
80
+ function getResponseFilename(response, taskId) {
81
+ const disposition = response.headers.get('content-disposition') || '';
82
+ const match = disposition.match(/filename="([^"]+)"/i);
83
+ return match?.[1] || `coderflow-task-${taskId}.zip`;
84
+ }
85
+
86
+ export async function writeExportStream(source, destination) {
87
+ const directory = path.dirname(destination);
88
+ const temporaryPath = path.join(
89
+ directory,
90
+ `.coderflow-export-${process.pid}-${randomUUID()}.tmp`
91
+ );
92
+
93
+ try {
94
+ await pipeline(source, createWriteStream(temporaryPath, { flags: 'wx' }));
95
+ // Publishing with a hard link is atomic and fails with EEXIST instead of
96
+ // replacing a destination another process (or the user) already owns.
97
+ await fs.link(temporaryPath, destination);
98
+ } finally {
99
+ await fs.rm(temporaryPath, { force: true }).catch(() => {});
100
+ }
101
+ }
102
+
103
+ export async function exportTask(args = []) {
104
+ const { taskId, output, workspace } = parseExportArgs(args);
105
+ const serverUrl = await getServerUrl();
106
+ const apiKey = await getApiKey();
107
+ const query = workspace ? `?workspace=${encodeURIComponent(workspace)}` : '';
108
+ const response = await fetch(`${serverUrl}/tasks/${encodeURIComponent(taskId)}/export${query}`, {
109
+ headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : {}
110
+ });
111
+
112
+ if (!response.ok) {
113
+ const contentType = response.headers.get('content-type') || '';
114
+ const body = contentType.includes('application/json')
115
+ ? await response.json()
116
+ : await response.text();
117
+ throw buildHttpError(response.status, body);
118
+ }
119
+
120
+ const destination = path.resolve(output || getResponseFilename(response, taskId));
121
+ await fs.mkdir(path.dirname(destination), { recursive: true });
122
+ await writeExportStream(Readable.fromWeb(response.body), destination);
123
+ console.log(`Task ${taskId} exported to ${destination}`);
124
+ console.log(describeExportWorkspace(response.headers));
125
+ return destination;
126
+ }
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Command: coder import - Import a task export or local workspace directory.
3
+ */
4
+
5
+ import { createWriteStream, openAsBlob, promises as fs } from 'node:fs';
6
+ import os from 'node:os';
7
+ import path from 'node:path';
8
+ import { ZipArchive } from 'archiver';
9
+ import { getApiKey, getServerUrl } from '../config.js';
10
+ import { buildHttpError } from '../http-client.js';
11
+
12
+ function takeOption(args, index, name) {
13
+ const arg = args[index];
14
+ if (arg === `--${name}`) {
15
+ if (!args[index + 1]) throw new Error(`--${name} requires a value`);
16
+ return { value: args[index + 1], consumed: 2 };
17
+ }
18
+ if (arg.startsWith(`--${name}=`)) {
19
+ return { value: arg.slice(name.length + 3), consumed: 1 };
20
+ }
21
+ return null;
22
+ }
23
+
24
+ // `preserve` is not a state of its own: it asks the server to land the task in
25
+ // whatever state it was exported from, which is what makes a round trip a round
26
+ // trip. The explicit modes force a state instead.
27
+ export const IMPORT_MODES = ['preserve', 'objective', 'staged', 'running', 'completed'];
28
+
29
+ export function parseImportArgs(args) {
30
+ let source = null;
31
+ const options = { mode: 'preserve' };
32
+ const names = ['mode', 'environment', 'name', 'agent', 'instructions'];
33
+
34
+ for (let index = 0; index < args.length;) {
35
+ const arg = args[index];
36
+ // A delta records the base each repository came from. By default a
37
+ // differing base is reported and the import proceeds; this asks the server
38
+ // to refuse instead.
39
+ if (arg === '--require-matching-base') {
40
+ options.requireMatchingBase = 'true';
41
+ index += 1;
42
+ continue;
43
+ }
44
+ let matched = false;
45
+ for (const name of names) {
46
+ const parsed = takeOption(args, index, name);
47
+ if (!parsed) continue;
48
+ options[name] = parsed.value;
49
+ index += parsed.consumed;
50
+ matched = true;
51
+ break;
52
+ }
53
+ if (matched) continue;
54
+ if (!arg.startsWith('-') && !source) {
55
+ source = arg;
56
+ index += 1;
57
+ continue;
58
+ }
59
+ throw new Error(`Unexpected import argument: ${arg}`);
60
+ }
61
+
62
+ if (!source) throw new Error('Path required. Usage: coder import <directory-or-zip> [options]');
63
+ if (!IMPORT_MODES.includes(options.mode)) {
64
+ throw new Error(`--mode must be one of ${IMPORT_MODES.join(', ')}`);
65
+ }
66
+ return { source, options };
67
+ }
68
+
69
+ /**
70
+ * Say which state the import landed in, and — for a preserved import — that it
71
+ * was the package that decided.
72
+ */
73
+ export function describeImportState(data = {}) {
74
+ const state = data.mode === 'objective' ? 'objective' : `${data.status} task`;
75
+ const preserved = data.requestedMode === 'preserve' && data.importedStatus
76
+ ? ` (preserved from the exported ${data.importedStatus} task)`
77
+ : '';
78
+ return `Imported as ${state}${preserved}`;
79
+ }
80
+
81
+ /**
82
+ * An imported conversation is replayed, not resumed: the destination container
83
+ * has never run this agent, so there is no session to attach to. Say so, or a
84
+ * user reading "completed" will expect `--continue` semantics they will not get.
85
+ */
86
+ export function describeImportConversation(data = {}) {
87
+ if (data.conversationMode === 'history_replay') {
88
+ return 'Conversation: the imported transcript replays into a fresh agent session on your next message.';
89
+ }
90
+ if (data.conversationMode === 'instructions_replay') {
91
+ return 'Conversation: no transcript travelled with this package, so the next message replays the '
92
+ + 'imported instructions and follow-up history into a fresh agent session.';
93
+ }
94
+ return null;
95
+ }
96
+
97
+ /**
98
+ * Say what the import will do to the destination workspace: merge a delta into
99
+ * it, or replace it outright.
100
+ */
101
+ export function describeImportWorkspace(data = {}) {
102
+ if (!data.workspaceIncluded) {
103
+ // "not included" alone reads as a task that changed nothing. If the export
104
+ // said why it carried no workspace, say it here too.
105
+ return data.workspaceUnavailableReason
106
+ ? `Workspace: not included (${data.workspaceUnavailableReason})`
107
+ : 'Workspace: not included';
108
+ }
109
+ if (data.workspaceMode !== 'delta' || !data.workspaceDelta) {
110
+ return 'Workspace: full archive (replaces the initialized workspace)';
111
+ }
112
+
113
+ const { fileCount = 0, byteCount = 0, deletedPathCount = 0, repositories = [] } = data.workspaceDelta;
114
+ const parts = [`${fileCount} changed file${fileCount === 1 ? '' : 's'} (${byteCount} bytes)`];
115
+ if (deletedPathCount > 0) parts.push(`${deletedPathCount} deleted path${deletedPathCount === 1 ? '' : 's'}`);
116
+ if (repositories.length > 0) parts.push(`repos: ${repositories.join(', ')}`);
117
+ return `Workspace: delta merged into the initialized workspace - ${parts.join(', ')}`;
118
+ }
119
+
120
+ /**
121
+ * The base check cannot run before the server answers: it compares against
122
+ * repositories that only exist once the destination container has finished
123
+ * cloning them, which happens after the import is accepted. Say so, rather than
124
+ * letting a printed success imply the check already passed.
125
+ */
126
+ export function describeBaseCheck(data = {}) {
127
+ if (!data.requireMatchingBase) return null;
128
+ if (!data.workspaceIncluded || data.workspaceMode !== 'delta') {
129
+ return 'Base match: requested, but this package carries no delta to check.';
130
+ }
131
+ return 'Base match: required. The check runs once the destination repositories are cloned; '
132
+ + `if the base differs the import stops and task ${data.taskId} is marked failed.`;
133
+ }
134
+
135
+ async function zipDirectory(sourceDir) {
136
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'coderflow-cli-import-'));
137
+ const zipPath = path.join(tempDir, 'workspace.zip');
138
+ await new Promise((resolve, reject) => {
139
+ const output = createWriteStream(zipPath, { flags: 'wx' });
140
+ const archive = new ZipArchive({ zlib: { level: 6 } });
141
+ output.on('close', resolve);
142
+ output.on('error', reject);
143
+ archive.on('error', reject);
144
+ archive.pipe(output);
145
+ archive.directory(sourceDir, false);
146
+ archive.finalize().catch(reject);
147
+ });
148
+ return { zipPath, tempDir };
149
+ }
150
+
151
+ export async function importTask(args = []) {
152
+ const { source, options } = parseImportArgs(args);
153
+ const sourcePath = path.resolve(source);
154
+ const sourceStats = await fs.stat(sourcePath).catch(() => null);
155
+ if (!sourceStats) throw new Error(`Import path does not exist: ${sourcePath}`);
156
+
157
+ let archivePath = sourcePath;
158
+ let tempDir = null;
159
+ if (sourceStats.isDirectory()) {
160
+ console.log(`Packaging workspace directory ${sourcePath}...`);
161
+ ({ zipPath: archivePath, tempDir } = await zipDirectory(sourcePath));
162
+ } else if (path.extname(sourcePath).toLowerCase() !== '.zip') {
163
+ throw new Error('Import path must be a directory or .zip file');
164
+ }
165
+
166
+ try {
167
+ const form = new FormData();
168
+ form.append('archive', await openAsBlob(archivePath, { type: 'application/zip' }), path.basename(archivePath));
169
+ for (const [key, value] of Object.entries(options)) {
170
+ if (value != null && value !== '') form.append(key, value);
171
+ }
172
+
173
+ const serverUrl = await getServerUrl();
174
+ const apiKey = await getApiKey();
175
+ const response = await fetch(`${serverUrl}/tasks/import`, {
176
+ method: 'POST',
177
+ headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : {},
178
+ body: form
179
+ });
180
+ const contentType = response.headers.get('content-type') || '';
181
+ const data = contentType.includes('application/json')
182
+ ? await response.json()
183
+ : await response.text();
184
+ if (!response.ok) throw buildHttpError(response.status, data);
185
+
186
+ console.log(`Task import created ${data.status} ${data.taskId}`);
187
+ if (data.containerId) console.log(`Container: ${data.containerId}`);
188
+ if (data.queuePosition) console.log(`Queue position: ${data.queuePosition}`);
189
+ console.log(`Environment: ${data.environment}`);
190
+ console.log(describeImportState(data));
191
+ console.log(describeImportWorkspace(data));
192
+ const conversation = describeImportConversation(data);
193
+ if (conversation) console.log(conversation);
194
+ const baseCheck = describeBaseCheck(data);
195
+ if (baseCheck) console.log(baseCheck);
196
+ return data;
197
+ } finally {
198
+ if (tempDir) await fs.rm(tempDir, { recursive: true, force: true });
199
+ }
200
+ }
package/lib/help.js CHANGED
@@ -182,6 +182,73 @@ Examples:
182
182
  coder logs 1759542727986-3uoyf48lr --tail=100
183
183
  `,
184
184
 
185
+ export: `
186
+ Usage: coder export <task-id> [options]
187
+
188
+ Download a portable task package containing task output, logs, metadata, and
189
+ the workspace changes the task made.
190
+
191
+ By default the package carries a workspace *delta*: only the files the task
192
+ added or modified, plus the list of paths it deleted. Importing it merges that
193
+ into the destination environment's own initialized workspace, so dependencies
194
+ and untouched sources are neither shipped nor overwritten.
195
+
196
+ Arguments:
197
+ task-id Task ID to export (required)
198
+
199
+ Options:
200
+ --output, -o <file> Destination zip path (defaults to the server filename)
201
+ --workspace=<mode> delta (default), full, or none.
202
+ full archives the entire /workspace and replaces the
203
+ destination workspace on import; none omits it.
204
+
205
+ Examples:
206
+ coder export 1759542727986-3uoyf48lr
207
+ coder export 1759542727986-3uoyf48lr --output ./task-backup.zip
208
+ coder export 1759542727986-3uoyf48lr --workspace=full
209
+ `,
210
+
211
+ import: `
212
+ Usage: coder import <directory-or-zip> [options]
213
+
214
+ Recreate a task from a CoderFlow export package, or create one from a plain
215
+ workspace directory. Plain directories are packaged before upload.
216
+
217
+ By default the import reproduces the state the task was exported from: a
218
+ finished task comes back finished, with its transcript and its changed files,
219
+ ready to be continued. Its container has never run the agent, so the first
220
+ message replays the imported conversation into a fresh session rather than
221
+ resuming one.
222
+
223
+ Arguments:
224
+ directory-or-zip Export zip or local workspace directory (required)
225
+
226
+ Options:
227
+ --mode=<mode> preserve (default), objective, staged, running, or
228
+ completed. preserve reproduces the exported state;
229
+ the others force one. A plain workspace directory has
230
+ no exported state, so preserve stages it.
231
+ --environment=<name> Override/select the destination environment
232
+ --name=<name> Override the imported task name
233
+ --agent=<agent> Override the imported/default agent
234
+ --instructions=<text> Override instructions used when the task runs
235
+ --require-matching-base
236
+ Refuse a workspace delta whose repositories were
237
+ exported from a different base commit, remote, or
238
+ branch. Without it the mismatch is reported and the
239
+ delta is applied anyway. The check needs the
240
+ destination's repositories, so it runs after the
241
+ import is accepted: a refusal shows up as a failed
242
+ task, not as a failed command.
243
+
244
+ Examples:
245
+ coder import ./task-backup.zip
246
+ coder import ./workspace --environment=hello --mode=staged
247
+ coder import ./task-backup.zip --mode=objective
248
+ coder import ./task-backup.zip --mode=completed
249
+ coder import ./task-backup.zip --mode=running --agent=codex
250
+ `,
251
+
185
252
  list: `
186
253
  Usage: coder list [options]
187
254
 
@@ -327,6 +394,8 @@ Commands:
327
394
  coder apply [task-id] Apply patches from completed task to local repos
328
395
  coder discard [--env=<environment>] [--yes] Discard applied changes from repos
329
396
  coder attach [container-or-task-id] [options] Connect to a running container
397
+ coder export <task-id> [--workspace=delta|full] Export task contents and logs
398
+ coder import <directory-or-zip> [options] Import an objective or task container
330
399
 
331
400
  Setup:
332
401
  coder login [--sso] Authenticate with server
@@ -389,6 +458,8 @@ Task Management:
389
458
  coder logs <task-id> [--tail=N] Show task logs
390
459
  coder list [--status=...] [--environment=...] List tasks with optional filters
391
460
  coder reject <task-id> [--cleanup] Reject task results
461
+ coder export <task-id> [--workspace=delta|full] Export task contents and logs
462
+ coder import <directory-or-zip> [options] Restore an objective or task
392
463
 
393
464
  Interactive Sessions:
394
465
  coder start <environment> [options] Start interactive session with AI agent
@@ -445,6 +516,8 @@ Task Management (Web UI alternative):
445
516
  coder status 1759542727986-3uoyf48lr # Check task status
446
517
  coder results 1759542727986-3uoyf48lr # Get task results
447
518
  coder list --status=completed # List completed tasks
519
+ coder export 1759542727986-3uoyf48lr # Download a portable task package
520
+ coder import ./task-export.zip # Restore it in the state it was exported in
448
521
 
449
522
  Interactive Sessions (Web UI alternative):
450
523
  coder start hello # Start AI agent in environment
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@profoundlogic/coderflow-cli",
3
- "version": "0.13.10",
3
+ "version": "0.13.11-dev.20260809021314.g778dadcc",
4
4
  "description": "AI Coder CLI - Command-line interface for managing AI coding tasks",
5
5
  "main": "coder.js",
6
6
  "type": "module",
@@ -28,8 +28,9 @@
28
28
  "license": "SEE LICENSE IN LICENSE.txt",
29
29
  "homepage": "https://coderflow.ai",
30
30
  "dependencies": {
31
- "ws": "^8.21.1",
32
31
  "@inquirer/prompts": "^8.5.2",
33
- "open": "^11.0.0"
32
+ "archiver": "^8.0.0",
33
+ "open": "^11.0.0",
34
+ "ws": "^8.21.1"
34
35
  }
35
36
  }