Skip to content

Commit 2d36a11

Browse files
dcramerclaude
andcommitted
feat(cli): Add dex export command for one-way GitHub issue creation
Add a new export command that creates GitHub issues from tasks without saving issue metadata back to the task. This is useful for sharing tasks externally without enabling bidirectional sync. Key features: - Accepts multiple task IDs: dex export taskId1 taskId2 - Supports --dry-run to preview without creating issues - Skips tasks already synced to GitHub with a warning - For subtasks, exports the root task (same as sync behavior) Also extracts findRootTask to shared utils to eliminate duplication between sync.ts and export.ts. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent fe93ae7 commit 2d36a11

6 files changed

Lines changed: 203 additions & 19 deletions

File tree

docs/src/pages/cli.astro

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,26 @@ dex plan feature.md --parent abc123`}
216216
</Terminal>
217217
</div>
218218

219+
<div class="command-card">
220+
<h3>dex export</h3>
221+
<div class="synopsis">dex export &lt;task-id&gt;... [options]</div>
222+
<p>Export tasks to GitHub Issues without enabling sync. Unlike <code>dex sync</code>, this creates issues but does not save the issue metadata back to the task.</p>
223+
<ul>
224+
<li><code>&lt;task-id&gt;...</code> — One or more task IDs to export (required)</li>
225+
<li><code>--dry-run</code> — Preview without creating issues</li>
226+
</ul>
227+
<p>Tasks that are already synced to GitHub are skipped with a warning.</p>
228+
<Terminal title="Terminal">
229+
<Code
230+
code={`dex export abc123 # Export single task
231+
dex export abc123 def456 # Export multiple tasks
232+
dex export abc123 --dry-run # Preview export`}
233+
lang="bash"
234+
theme="vitesse-black"
235+
/>
236+
</Terminal>
237+
</div>
238+
219239
<h2>Diagnostics</h2>
220240

221241
<div class="command-card">

src/cli/export.ts

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import {
2+
CliOptions,
3+
createService,
4+
findRootTask,
5+
formatCliError,
6+
} from "./utils.js";
7+
import { colors } from "./colors.js";
8+
import { getBooleanFlag, parseArgs } from "./args.js";
9+
import {
10+
createGitHubSyncServiceOrThrow,
11+
getGitHubIssueNumber,
12+
GitHubSyncService,
13+
} from "../core/github/index.js";
14+
import { loadConfig } from "../core/config.js";
15+
16+
export async function exportCommand(
17+
args: string[],
18+
options: CliOptions,
19+
): Promise<void> {
20+
const { positional, flags } = parseArgs(
21+
args,
22+
{
23+
"dry-run": { hasValue: false },
24+
help: { short: "h", hasValue: false },
25+
},
26+
"export",
27+
);
28+
29+
if (getBooleanFlag(flags, "help")) {
30+
console.log(`${colors.bold}dex export${colors.reset} - Export tasks to GitHub Issues (one-way, no sync)
31+
32+
${colors.bold}USAGE:${colors.reset}
33+
dex export <task-id>... # Export one or more tasks
34+
dex export --dry-run # Preview without creating issues
35+
36+
${colors.bold}ARGUMENTS:${colors.reset}
37+
<task-id>... One or more task IDs to export (required)
38+
39+
${colors.bold}OPTIONS:${colors.reset}
40+
--dry-run Show what would be exported without making changes
41+
-h, --help Show this help message
42+
43+
${colors.bold}DIFFERENCE FROM SYNC:${colors.reset}
44+
Unlike 'dex sync', export creates GitHub issues without saving
45+
the issue metadata back to the task. This is useful for sharing
46+
tasks externally without enabling bidirectional sync.
47+
48+
${colors.bold}REQUIREMENTS:${colors.reset}
49+
- Git repository with GitHub remote
50+
- GITHUB_TOKEN environment variable
51+
52+
${colors.bold}EXAMPLE:${colors.reset}
53+
dex export abc123 # Export single task
54+
dex export abc123 def456 # Export multiple tasks
55+
dex export abc123 --dry-run # Preview export
56+
`);
57+
return;
58+
}
59+
60+
const taskIds = positional;
61+
const dryRun = getBooleanFlag(flags, "dry-run");
62+
63+
if (taskIds.length === 0) {
64+
console.error(
65+
`${colors.red}Error:${colors.reset} At least one task ID is required`,
66+
);
67+
console.error(`Usage: dex export <task-id>...`);
68+
process.exit(1);
69+
}
70+
71+
const config = loadConfig({ storagePath: options.storage.getIdentifier() });
72+
73+
let syncService: GitHubSyncService;
74+
try {
75+
syncService = createGitHubSyncServiceOrThrow(config.sync?.github);
76+
} catch (err) {
77+
console.error(formatCliError(err));
78+
process.exit(1);
79+
}
80+
81+
const service = createService(options);
82+
const repo = syncService.getRepo();
83+
const store = await options.storage.readAsync();
84+
85+
let exportedCount = 0;
86+
let skippedCount = 0;
87+
88+
for (const taskId of taskIds) {
89+
try {
90+
const task = await service.get(taskId);
91+
if (!task) {
92+
console.error(
93+
`${colors.red}Error:${colors.reset} Task ${taskId} not found`,
94+
);
95+
continue;
96+
}
97+
98+
// Find root task if this is a subtask
99+
const rootTask = await findRootTask(service, task);
100+
101+
// Check if already synced
102+
if (getGitHubIssueNumber(rootTask)) {
103+
console.log(
104+
`${colors.yellow}Skipped${colors.reset} ${colors.bold}${rootTask.id}${colors.reset}: already synced to GitHub`,
105+
);
106+
skippedCount++;
107+
continue;
108+
}
109+
110+
if (dryRun) {
111+
console.log(
112+
`Would export to ${colors.cyan}${repo.owner}/${repo.repo}${colors.reset}:`,
113+
);
114+
console.log(
115+
` [create] ${colors.bold}${rootTask.id}${colors.reset}: ${rootTask.name}`,
116+
);
117+
exportedCount++;
118+
continue;
119+
}
120+
121+
// Export the task (create issue but don't save metadata)
122+
const result = await syncService.syncTask(rootTask, store);
123+
124+
if (result) {
125+
console.log(
126+
`${colors.green}Exported${colors.reset} task ${colors.bold}${rootTask.id}${colors.reset} to ${colors.cyan}${repo.owner}/${repo.repo}${colors.reset}`,
127+
);
128+
console.log(` ${colors.dim}${result.github.issueUrl}${colors.reset}`);
129+
exportedCount++;
130+
}
131+
} catch (err) {
132+
console.error(
133+
`${colors.red}Error${colors.reset} exporting ${taskId}: ${formatCliError(err)}`,
134+
);
135+
}
136+
}
137+
138+
// Summary for multiple tasks
139+
if (taskIds.length > 1) {
140+
const parts = [];
141+
if (exportedCount > 0) {
142+
parts.push(
143+
`${exportedCount} ${dryRun ? "would be exported" : "exported"}`,
144+
);
145+
}
146+
if (skippedCount > 0) {
147+
parts.push(`${skippedCount} skipped`);
148+
}
149+
if (parts.length > 0) {
150+
console.log(`\n${parts.join(", ")}`);
151+
}
152+
}
153+
}

src/cli/help.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ ${colors.bold}COMMANDS:${colors.reset}
3838
archive --older-than 60d Archive tasks completed >60 days ago
3939
archive --completed Archive ALL completed tasks
4040
plan <file> Create task from plan markdown file
41+
sync [id] Push tasks to GitHub Issues
42+
import <ref> Import GitHub Issue as task
43+
export <id>... Export tasks to GitHub (no sync back)
4144
completion <shell> Generate shell completion script
4245
4346
${colors.bold}GLOBAL OPTIONS:${colors.reset}

src/cli/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { planCommand } from "./plan.js";
1515
import { completionCommand } from "./completion.js";
1616
import { syncCommand } from "./sync.js";
1717
import { importCommand } from "./import.js";
18+
import { exportCommand } from "./export.js";
1819
import { doctorCommand } from "./doctor.js";
1920
import { statusCommand } from "./status.js";
2021
import { configCommand } from "./config.js";
@@ -92,6 +93,8 @@ export async function runCli(
9293
return await syncCommand(args.slice(1), options);
9394
case "import":
9495
return await importCommand(args.slice(1), options);
96+
case "export":
97+
return await exportCommand(args.slice(1), options);
9598
case "doctor":
9699
return await doctorCommand(args.slice(1), options);
97100
case "archive":

src/cli/sync.ts

Lines changed: 6 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
import { CliOptions, createService, formatCliError } from "./utils.js";
1+
import {
2+
CliOptions,
3+
createService,
4+
findRootTask,
5+
formatCliError,
6+
} from "./utils.js";
27
import { colors } from "./colors.js";
38
import { getBooleanFlag, parseArgs } from "./args.js";
49
import { truncateText } from "./formatting.js";
@@ -11,7 +16,6 @@ import {
1116
} from "../core/github/index.js";
1217
import { loadConfig } from "../core/config.js";
1318
import { updateSyncState } from "../core/sync-state.js";
14-
import { Task } from "../types.js";
1519

1620
export async function syncCommand(
1721
args: string[],
@@ -253,20 +257,3 @@ async function saveGithubMetadata(
253257
metadata,
254258
});
255259
}
256-
257-
/**
258-
* Find the root task (no parent) for a given task.
259-
*/
260-
async function findRootTask(
261-
service: ReturnType<typeof createService>,
262-
task: Task,
263-
): Promise<Task> {
264-
if (!task.parent_id) {
265-
return task;
266-
}
267-
const parent = await service.get(task.parent_id);
268-
if (!parent) {
269-
return task; // Orphaned subtask, treat as root
270-
}
271-
return findRootTask(service, parent);
272-
}

src/cli/utils.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,3 +74,21 @@ export function promptConfirm(question: string): Promise<boolean> {
7474
});
7575
});
7676
}
77+
78+
/**
79+
* Find the root task (no parent) for a given task.
80+
* Traverses up the parent chain until reaching a task with no parent_id.
81+
*/
82+
export async function findRootTask(
83+
service: TaskService,
84+
task: Task,
85+
): Promise<Task> {
86+
if (!task.parent_id) {
87+
return task;
88+
}
89+
const parent = await service.get(task.parent_id);
90+
if (!parent) {
91+
return task; // Orphaned subtask, treat as root
92+
}
93+
return findRootTask(service, parent);
94+
}

0 commit comments

Comments
 (0)