@retasc/cli 1.14.0 → 1.16.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/CHANGELOG.md CHANGED
@@ -6,6 +6,62 @@ release commits and the issues they reference.
6
6
 
7
7
  Dates are the npm publish date. Each entry names the RTSC issue behind it.
8
8
 
9
+ ## 1.16.0 (2026-08-02)
10
+
11
+ - **RTSC-526** — `retasc import` catches up with the Dash on four things.
12
+
13
+ **It warns before a second import.** Re-importing does not duplicate anything, but it
14
+ does re-sync: status, labels, title and body are replaced with whatever the source says
15
+ now. Import from Jira, spend two weeks moving issues along in Retasc, re-import to pick up
16
+ new tickets, and those two weeks of changes snap back. You now get told, with the date of
17
+ the last import, before the confirmation.
18
+
19
+ **The run shows progress**, as a bar that fills while it works, rather than a silent wait
20
+ that looks like it has hung. The run is server-side, so pressing Ctrl-C out of boredom
21
+ never stopped it anyway.
22
+
23
+ **The column list is grouped** under your own tool's sections (Not started, Active, Done,
24
+ Closed) instead of arriving flat. On a twenty-column board that is the difference between
25
+ scannable and a wall.
26
+
27
+ **Columns that were not mapped are named.** If a column appears between the moment you set
28
+ the mapping and the moment the import runs, its issues land in `todo`. They used to show
29
+ as a number; now they are listed, with what to do about it.
30
+
31
+ ## 1.15.0 (2026-08-02)
32
+
33
+ - **RTSC-524** — new command: `retasc import`. Bring a Linear, Jira, Asana, ClickUp or
34
+ Shortcut project across without opening a browser.
35
+
36
+ This was the last thing the terminal could not do. Everything else — sign in, make or join
37
+ an org, create a project, wire the folder, pull work — already worked end to end, but
38
+ importing meant stopping and finishing in the Dash.
39
+
40
+ ```
41
+ retasc import
42
+ retasc import --source jira --org-id …
43
+ ```
44
+
45
+ It walks you through the source, your credentials, which team or project to take, and then
46
+ **what each of your columns means**. That last part is the point, and it is asked rather
47
+ than guessed: a tool lets its users name their own columns, so the only honest way to know
48
+ what one means is to ask you. Pressing Enter accepts a suggestion derived from the
49
+ column's *type* in your tool, never its name — a column called "Rejected" that is really
50
+ an in-progress lane maps to `doing`, not `canceled`.
51
+
52
+ A column mapped to `review` needs a named reviewer, chosen per column, because two
53
+ "awaiting acceptance" columns can belong to different people. Columns holding work nobody
54
+ has started cannot be mapped to `review` at all, and the CLI says why rather than letting
55
+ the server refuse it later.
56
+
57
+ **Your source token is never an argument.** There is no `--token`, because anything passed
58
+ that way lands in shell history and in `ps` output. Secrets are typed with echo off, or
59
+ taken from `RETASC_IMPORT_<FIELD>` for scripted runs.
60
+
61
+ The run is confirmed before anything is written, and afterwards you are offered the
62
+ imported identity the migration just created for you, through the same prompt
63
+ `retasc join` uses.
64
+
9
65
  ## 1.14.0 (2026-08-02)
10
66
 
11
67
  - **RTSC-523** — setup asks before installing anything on your machine.
package/dist/api.js CHANGED
@@ -28,6 +28,18 @@ const fns = {
28
28
  // Imported-identity claim (RTSC-433/473), wired for `join` by RTSC-492 and shared with
29
29
  // RTSC-477's standalone `retasc identity`. Member-gated already — nothing was widened
30
30
  // server-side for the CLI, and nothing should be.
31
+ // RTSC-524 — the import surface. Owner-gated on the server, authenticated by the same
32
+ // user session every other management call uses.
33
+ listImportSources: makeFunctionReference("import:listImportSources"),
34
+ listImportTargets: makeFunctionReference("import:listImportTargets"),
35
+ listImportStatuses: makeFunctionReference("import:listImportStatuses"),
36
+ listReviewCandidates: makeFunctionReference("import:listReviewCandidates"),
37
+ runImport: makeFunctionReference("import:runImport"),
38
+ // RTSC-526: `latestImport` is LIVE progress and is swept after 24h; `importHistory` is
39
+ // permanent and per source, so it is the only thing that can answer "has this org
40
+ // imported from X before?" once the progress row is gone.
41
+ latestImport: makeFunctionReference("import:latestImport"),
42
+ importHistory: makeFunctionReference("import:importHistory"),
31
43
  claimableGhosts: makeFunctionReference("ghosts:claimableGhosts"),
32
44
  claimGhost: makeFunctionReference("ghosts:claimGhost"),
33
45
  dismissGhostPrompt: makeFunctionReference("ghosts:dismissGhostPrompt"),
@@ -159,6 +171,13 @@ export const api = {
159
171
  billingStatus: (args) => withAuth(() => client().query(fns.billingStatus, args)),
160
172
  chargeHistory: (args) => withAuth(() => client().query(fns.chargeHistory, args)),
161
173
  orgPayments: (args) => withAuth(() => client().action(fns.orgPayments, args)),
174
+ listImportSources: () => withAuth(() => client().query(fns.listImportSources, {})),
175
+ listImportTargets: (args) => withAuth(() => client().action(fns.listImportTargets, args)),
176
+ listImportStatuses: (args) => withAuth(() => client().action(fns.listImportStatuses, args)),
177
+ listReviewCandidates: (args) => withAuth(() => client().query(fns.listReviewCandidates, args)),
178
+ runImport: (args) => withAuth(() => client().action(fns.runImport, args)),
179
+ latestImport: (args) => withAuth(() => client().query(fns.latestImport, args)),
180
+ importHistory: (args) => withAuth(() => client().query(fns.importHistory, args)),
162
181
  claimableGhosts: (args) => withAuth(() => client().query(fns.claimableGhosts, args)),
163
182
  claimGhost: (args) => withAuth(() => client().mutation(fns.claimGhost, args)),
164
183
  dismissGhostPrompt: (args) => withAuth(() => client().mutation(fns.dismissGhostPrompt, args)),
@@ -0,0 +1,369 @@
1
+ import { stdin, stdout } from "node:process";
2
+ import { createInterface } from "node:readline/promises";
3
+ import { api, cliError, formatError } from "../api.js";
4
+ import { clean } from "../lib/text.js";
5
+ import { ask, confirm, isInteractive } from "../lib/prompt.js";
6
+ import { pickExisting } from "./bind.js";
7
+ /** The five destinations a column can be mapped to. Mirrors `MappedStatus`. */
8
+ const MAPPED = ["todo", "doing", "review", "done", "canceled"];
9
+ /**
10
+ * The source's own section names, so the list reads as the shape the human already knows
11
+ * from their tool. Mirrors `GROUP_LABEL` in `dash/src/lib/statusMapping.ts` (RTSC-436).
12
+ */
13
+ const GROUP_LABEL = {
14
+ not_started: "Not started",
15
+ active: "Active",
16
+ done: "Done",
17
+ closed: "Closed",
18
+ };
19
+ /** Section order, matching the Dash. */
20
+ const GROUP_ORDER = ["not_started", "active", "done", "closed"];
21
+ /**
22
+ * What to say before a SECOND import into the same org (RTSC-526).
23
+ *
24
+ * Not a formality. `convex/importWrite.ts` upserts by `sourceIssueId` and patches the match,
25
+ * so a re-import does not duplicate — the identifier and number survive even a rename — but
26
+ * it DOES replace status, labels, title and body with whatever the source says now. Two
27
+ * weeks of moving issues along in Retasc snap back to the source's version.
28
+ *
29
+ * Read from `importHistory`, NOT `latestImport`. The latter is live progress and is swept
30
+ * after 24h, so gating on it means the warning stops firing for exactly the person most at
31
+ * risk: the one who imported last week and has been working in Retasc since.
32
+ *
33
+ * Null when this source has never been imported, so a first run says nothing.
34
+ */
35
+ export function reimportWarning(history, source, label) {
36
+ const prior = history.find((h) => h.source === source);
37
+ if (!prior)
38
+ return null;
39
+ const when = prior.lastImportedAt
40
+ ? ` (last on ${new Date(prior.lastImportedAt).toISOString().slice(0, 10)})`
41
+ : "";
42
+ return (`\n! You've imported from ${clean(label)} into this org before${when}.\n` +
43
+ ` Re-importing re-syncs those issues, so any edits you made in Retasc to them\n` +
44
+ ` (status, labels, and so on) will be replaced by ${clean(label)}'s version.`);
45
+ }
46
+ /**
47
+ * The summary after a run, with the one field that needs words rather than a number.
48
+ *
49
+ * `unmappedStatuses` holds columns that appeared BETWEEN the mapping being set and the run;
50
+ * their issues landed in `todo`. As a count it is a silent slice of work in the wrong place,
51
+ * which is the whole reason the Dash names them.
52
+ */
53
+ export function summaryLines(summary, label) {
54
+ const out = [];
55
+ const unmapped = [];
56
+ for (const [k, v] of Object.entries(summary ?? {})) {
57
+ if (k === "unmappedStatuses") {
58
+ if (Array.isArray(v))
59
+ unmapped.push(...v.map(String));
60
+ continue;
61
+ }
62
+ if (typeof v === "number" || typeof v === "string")
63
+ out.push(` ${k.padEnd(20)}${clean(v)}`);
64
+ }
65
+ if (unmapped.length) {
66
+ out.push("", `! These ${clean(label)} statuses weren't mapped, so their issues landed in todo:`, ` ${unmapped.map((u) => clean(u)).join(", ")}`, " They appeared after you set the mapping. Re-import to place them.");
67
+ }
68
+ return out;
69
+ }
70
+ // --- the progress bar --------------------------------------------------------
71
+ /** Lime, in 256-colour. Bright enough to read on both a light and a dark terminal. */
72
+ const LIME = "\x1b[38;5;154m";
73
+ const DIM = "\x1b[38;5;240m";
74
+ const RESET = "\x1b[0m";
75
+ /**
76
+ * One line of progress bar.
77
+ *
78
+ * Pure, so the shape can be tested without a terminal. Colour is opt-out via `color`, which
79
+ * the caller ties to a TTY and to `NO_COLOR` — a bar full of escape sequences in a piped log
80
+ * is worse than no bar.
81
+ */
82
+ export function progressBar(done, total, width = 24, color = true) {
83
+ const known = typeof total === "number" && total > 0;
84
+ // Clamp: a server that reports more done than total must not print a bar wider than the
85
+ // terminal, or a negative one.
86
+ const frac = known ? Math.max(0, Math.min(1, done / total)) : 0;
87
+ const filled = Math.round(frac * width);
88
+ const bar = known
89
+ ? `${color ? LIME : ""}${"█".repeat(filled)}${color ? DIM : ""}${"░".repeat(width - filled)}${color ? RESET : ""}`
90
+ : `${color ? DIM : ""}${"░".repeat(width)}${color ? RESET : ""}`;
91
+ const count = known ? `${Math.round(frac * 100)}% ${done}/${total}` : `${done} so far`;
92
+ return ` ${bar} ${count}`;
93
+ }
94
+ /**
95
+ * May this column be mapped to `review`?
96
+ *
97
+ * Mirrors `reviewAllowedForGroup` in `convex/lib/importAdapter.ts`, and the reasoning is
98
+ * the server's: `review` means the work is FINISHED and waiting for someone to accept it,
99
+ * and it is non-terminal, so it keeps blocking dependents. Routing a not-yet-started column
100
+ * there manufactures review issues with nothing to review, each jamming whatever depends on
101
+ * it. Offering an option the server will reject is worse than not offering it.
102
+ */
103
+ export function reviewAllowed(group) {
104
+ return group === "active" || group === "done";
105
+ }
106
+ /** Read a secret without echoing it, so a token never lands in scrollback. */
107
+ async function askSecret(question) {
108
+ if (!stdin.isTTY)
109
+ return await ask(question);
110
+ const rl = createInterface({ input: stdin, output: stdout, terminal: true });
111
+ // readline has no built-in masking, so suppress the echo ourselves and restore it in
112
+ // `finally` — a thrown error mid-prompt must not leave the terminal silent.
113
+ const out = stdout;
114
+ const real = out.write.bind(out);
115
+ try {
116
+ const answer = rl.question(question);
117
+ out.write = (chunk) => (chunk.includes("\n") ? real(chunk) : true);
118
+ const value = await answer;
119
+ return value.trim();
120
+ }
121
+ finally {
122
+ out.write = real;
123
+ rl.close();
124
+ real("\n");
125
+ }
126
+ }
127
+ /**
128
+ * Collect the source's credentials.
129
+ *
130
+ * NEVER from an argument. A token passed as `--token …` lands in shell history, in `ps`
131
+ * output, and in any transcript of the session. Secret fields are read with echo off; an
132
+ * env var (`RETASC_IMPORT_<KEY>`) covers the scripted case without either problem.
133
+ */
134
+ async function collectAuth(src) {
135
+ const auth = {};
136
+ for (const f of src.authFields) {
137
+ const envKey = `RETASC_IMPORT_${f.key.toUpperCase()}`;
138
+ const fromEnv = process.env[envKey];
139
+ if (fromEnv) {
140
+ console.log(` ${clean(f.label)}: taken from ${envKey}`);
141
+ auth[f.key] = fromEnv;
142
+ continue;
143
+ }
144
+ if (!isInteractive()) {
145
+ cliError("MISSING_AUTH", `${clean(src.label)} needs ${clean(f.label)}, and there is no terminal to ask on.`, `Set ${envKey} and run again.`);
146
+ }
147
+ if (f.hint)
148
+ console.log(` ${clean(f.hint)}`);
149
+ const value = f.secret
150
+ ? await askSecret(` ${clean(f.label)} (hidden): `)
151
+ : await ask(` ${clean(f.label)}: `);
152
+ if (!value)
153
+ cliError("MISSING_AUTH", `${clean(f.label)} is required.`);
154
+ auth[f.key] = value;
155
+ }
156
+ return auth;
157
+ }
158
+ /**
159
+ * Walk every column and record what it means. The heart of the command.
160
+ *
161
+ * Each row arrives with a `suggested` value derived server-side from the source's own
162
+ * STRUCTURAL type, never from the column's name, so pressing Enter through the list is a
163
+ * defensible mapping rather than a guess. This is a review-and-correct surface, exactly as
164
+ * the Dash panel is.
165
+ */
166
+ export async function mapStatuses(statuses, reviewers, askFn) {
167
+ const statusMap = {};
168
+ const reviewerByStatus = {};
169
+ console.log(`\nWhat does each column mean? ${statuses.length} to confirm.\n` +
170
+ " Enter keeps the suggestion. It comes from the column's type in your tool, not its name.");
171
+ // Grouped under the source's own section names, in the source's own order within each
172
+ // (RTSC-436/526). On a twenty-column Jira board a flat list is a wall; the sections are
173
+ // the shape the human already has in front of them in their tool.
174
+ const ordered = GROUP_ORDER.flatMap((g) => {
175
+ const rows = statuses.filter((s) => s.group === g).sort((a, b) => a.order - b.order);
176
+ return rows.map((s, i) => ({ s, heading: i === 0 ? GROUP_LABEL[g] : null }));
177
+ });
178
+ for (const { s, heading } of ordered) {
179
+ if (heading)
180
+ console.log(`\n ${heading}`);
181
+ const allowed = MAPPED.filter((m) => m !== "review" || (reviewAllowed(s.group) && reviewers.length > 0));
182
+ const suggested = allowed.includes(s.suggested)
183
+ ? s.suggested
184
+ : "todo";
185
+ for (let attempt = 0;; attempt++) {
186
+ const answer = (await askFn(`\n ${clean(s.name)} [${suggested}] (${allowed.join(" / ")}): `)).trim().toLowerCase();
187
+ const choice = answer === "" ? suggested : answer;
188
+ if (allowed.includes(choice)) {
189
+ statusMap[s.id] = choice;
190
+ break;
191
+ }
192
+ if (choice === "review" && !reviewAllowed(s.group)) {
193
+ // Say WHY, rather than repeating the list. The server would reject it anyway.
194
+ console.log(" Not for this column: review means finished and awaiting acceptance,");
195
+ console.log(" and this one holds work nobody has started.");
196
+ }
197
+ else if (choice === "review") {
198
+ console.log(" No one in this org can be a reviewer yet, so review isn't available.");
199
+ }
200
+ else {
201
+ console.log(` Pick one of: ${allowed.join(", ")}`);
202
+ }
203
+ if (attempt >= 2)
204
+ throw new Error("no valid choice — aborting");
205
+ }
206
+ // A review column needs a named reviewer, per column rather than per run: two
207
+ // "awaiting acceptance" columns can legitimately belong to different people.
208
+ if (statusMap[s.id] === "review") {
209
+ const who = await pickExisting(` Who reviews "${clean(s.name)}"?`, reviewers, (m) => clean(m.name), askFn);
210
+ reviewerByStatus[s.id] = who.id;
211
+ }
212
+ }
213
+ return { statusMap, reviewerByStatus };
214
+ }
215
+ /**
216
+ * Poll `latestImport` and redraw one line until told to stop. Returns the stopper.
217
+ *
218
+ * One LINE, rewritten in place, rather than a scrolling log: the interesting number is the
219
+ * current one. Silent without a TTY — a progress bar in a piped log is noise, and the
220
+ * escape codes would end up in whatever reads it.
221
+ *
222
+ * Every failure here is swallowed. This is decoration on top of a run that is happening
223
+ * server-side regardless; a hiccup in the progress query must never be what surfaces as an
224
+ * import failure.
225
+ */
226
+ function followProgress(orgId) {
227
+ const tty = Boolean(stdout.isTTY) && !process.env.NO_COLOR;
228
+ if (!stdout.isTTY)
229
+ return () => { };
230
+ let stopped = false;
231
+ const tick = async () => {
232
+ while (!stopped) {
233
+ try {
234
+ const p = (await api.latestImport({ orgId }));
235
+ if (!stopped && p && p.status === "running") {
236
+ stdout.write(`\r\x1b[2K${progressBar(p.issuesDone ?? 0, p.issuesTotal ?? null, 24, tty)}`);
237
+ }
238
+ }
239
+ catch {
240
+ /* progress is decoration; never let it speak for the run */
241
+ }
242
+ await new Promise((r) => setTimeout(r, 1200));
243
+ }
244
+ };
245
+ void tick();
246
+ return () => {
247
+ stopped = true;
248
+ // Clear the line so the summary does not land on top of a half-drawn bar.
249
+ stdout.write("\r\x1b[2K");
250
+ };
251
+ }
252
+ export async function importAction(opts) {
253
+ if (!isInteractive() && !opts.yes) {
254
+ cliError("NEEDS_TERMINAL", "Importing asks what each of your columns means, so it needs a terminal.", "Run it interactively, or use the Dash.");
255
+ }
256
+ // --- which org ------------------------------------------------------------
257
+ const me = (await api.me());
258
+ const orgs = me.orgs ?? [];
259
+ let orgId = opts.orgId;
260
+ if (!orgId) {
261
+ if (orgs.length === 0)
262
+ cliError("NO_ORG", "You're not a member of any org yet.");
263
+ else if (orgs.length === 1)
264
+ orgId = orgs[0].id;
265
+ else {
266
+ const chosen = await pickExisting("Import into which org", orgs, (o) => `${clean(o.name)}${o.slug ? ` (${clean(o.slug)})` : ""}`);
267
+ orgId = chosen.id;
268
+ }
269
+ }
270
+ const orgLabel = clean(orgs.find((o) => o.id === orgId)?.name ?? "this org");
271
+ // --- which tracker --------------------------------------------------------
272
+ const sources = (await api.listImportSources());
273
+ if (!sources.length)
274
+ cliError("NO_SOURCES", "No import sources are available.");
275
+ const src = opts.source
276
+ ? sources.find((s) => s.source === opts.source) ??
277
+ cliError("UNKNOWN_SOURCE", `There's no import source called "${clean(opts.source)}".`, `Try one of: ${sources.map((s) => s.source).join(", ")}`)
278
+ : await pickExisting("Bring work across from", sources, (s) => clean(s.label));
279
+ // --- credentials ----------------------------------------------------------
280
+ console.log(`\nConnect to ${clean(src.label)}:`);
281
+ const auth = await collectAuth(src);
282
+ // --- which team/project/workspace -----------------------------------------
283
+ const { targets } = (await api.listImportTargets({ orgId: orgId, source: src.source, auth }));
284
+ if (!targets.length) {
285
+ cliError("NO_TARGETS", `That ${clean(src.label)} account has no ${clean(src.targetNoun)} we can import.`, "Check the credentials belong to the right account.");
286
+ }
287
+ const target = targets.length === 1
288
+ ? targets[0]
289
+ : await pickExisting(`Which ${clean(src.targetNoun)}`, targets, (t) => `${clean(t.name)} (${clean(t.key)})`);
290
+ // --- what each column means ------------------------------------------------
291
+ let statusMap;
292
+ let reviewerByStatus;
293
+ if (src.supportsStatusMapping) {
294
+ const { statuses } = (await api.listImportStatuses({
295
+ orgId: orgId,
296
+ source: src.source,
297
+ auth,
298
+ targetRef: target.id,
299
+ }));
300
+ if (statuses.length) {
301
+ const reviewers = (await api.listReviewCandidates({ orgId: orgId }));
302
+ const mapped = await mapStatuses(statuses, reviewers, ask);
303
+ statusMap = mapped.statusMap;
304
+ reviewerByStatus = Object.keys(mapped.reviewerByStatus).length
305
+ ? mapped.reviewerByStatus
306
+ : undefined;
307
+ }
308
+ }
309
+ // --- confirm ---------------------------------------------------------------
310
+ // Named in full before anything is written. An import creates a project's worth of
311
+ // issues and mints a placeholder per source author, and there is no per-project delete —
312
+ // undoing a mistake means an org-level cleanup.
313
+ console.log(`\nAbout to import ${clean(target.name)} from ${clean(src.label)} into org ${orgLabel},\n` +
314
+ `as a new project. Issues, comments and their authors come across.`);
315
+ // RTSC-526 — the second run is the dangerous one, and the terminal is where a command
316
+ // gets re-run casually. Read from `importHistory` (permanent) rather than `latestImport`
317
+ // (live progress, swept after 24h), so this still fires for someone who imported last
318
+ // week and has been working in Retasc since — the person with the most to lose.
319
+ const history = (await api.importHistory({ orgId: orgId }));
320
+ const warning = reimportWarning(history, src.source, src.label);
321
+ if (warning)
322
+ console.log(warning);
323
+ if (!opts.yes && !(await confirm("This can't be undone. Go ahead?"))) {
324
+ console.log("Nothing imported.");
325
+ return;
326
+ }
327
+ // --- run -------------------------------------------------------------------
328
+ console.log("\nImporting…\n");
329
+ // RTSC-526 — show it moving. The run is server-side and can take minutes on a big
330
+ // project; a silent wait is exactly when someone decides it has hung and presses Ctrl-C,
331
+ // which is the worst moment to do it because the server keeps going regardless.
332
+ const stop = followProgress(orgId);
333
+ let res;
334
+ try {
335
+ res = (await api.runImport({
336
+ orgId: orgId,
337
+ source: src.source,
338
+ auth,
339
+ target: { id: target.id, key: target.key, name: target.name },
340
+ statusMap,
341
+ reviewerByStatus,
342
+ }));
343
+ }
344
+ catch (e) {
345
+ stop();
346
+ // The run is server-side, so a dead connection here does NOT mean a dead import.
347
+ // Saying "failed" would be a guess, and the wrong one sends someone re-importing on
348
+ // top of a run that is still writing.
349
+ const { code, message, hint } = formatError(e);
350
+ console.error(`\n✗ ${code ? `${code}: ` : ""}${message}`);
351
+ if (hint)
352
+ console.error(` → ${hint}`);
353
+ console.error(" If this was a connection problem the import may still be running.");
354
+ console.error(" Check the Dash before running it again.");
355
+ throw e;
356
+ }
357
+ stop();
358
+ console.log("\n✓ Imported.");
359
+ for (const line of summaryLines(res.summary, src.label)) {
360
+ console.log(line);
361
+ }
362
+ // The import just minted a placeholder for whoever authored that work, and the person
363
+ // who ran it is very often one of them. `identityLoop` is the same prompt `join` uses and
364
+ // is per-source since RTSC-507, so offering it here costs one round trip and saves them
365
+ // finding it later.
366
+ console.log("");
367
+ const { identityLoop } = await import("./join.js");
368
+ await identityLoop(orgId, {}, orgLabel);
369
+ }
package/dist/index.js CHANGED
@@ -8,6 +8,7 @@ import { claimAction } from "./commands/claim.js";
8
8
  import { bindAction } from "./commands/bind.js";
9
9
  import { joinAction } from "./commands/join.js";
10
10
  import { identityAction } from "./commands/identity.js";
11
+ import { importAction } from "./commands/import.js";
11
12
  import { doctorAction } from "./commands/doctor.js";
12
13
  import { billingAction } from "./commands/billing.js";
13
14
  import { isNetworkError, readLocalBinding, resolveBinding } from "./lib/binding.js";
@@ -429,6 +430,24 @@ program
429
430
  requireLogin();
430
431
  await identityAction({ orgId: opts.orgId }).catch(fail);
431
432
  });
433
+ // RTSC-524 — bring another tracker across without leaving the terminal. The status
434
+ // mapping is ASKED, never guessed: a source lets its users name their own columns, so the
435
+ // only honest way to know what one means is the human running the import (the reasoning is
436
+ // recorded in convex/lib/importAdapter.ts, which deleted the name-based heuristics after a
437
+ // `Rejected` column imported live work as `canceled`, out of dispatch, silently).
438
+ program
439
+ .command("import")
440
+ .description("Bring a Linear/Jira/Asana/ClickUp/Shortcut project across into a new Retasc project.")
441
+ .option("--org-id <id>", "Which org to import into (defaults to your only one).")
442
+ .option("--source <source>", "Skip the source picker: linear | jira | asana | clickup | shortcut")
443
+ // No `--token`: a credential passed as an argument lands in shell history and in `ps`.
444
+ // Secrets are read with echo off, or from RETASC_IMPORT_<FIELD>.
445
+ .option("-y, --yes", "Skip the final confirmation (the column mapping is still asked)")
446
+ .allowExcessArguments(false)
447
+ .action(async (opts) => {
448
+ requireLogin();
449
+ await importAction({ orgId: opts.orgId, source: opts.source, yes: opts.yes }).catch(fail);
450
+ });
432
451
  // --- mcp wiring ------------------------------------------------------------
433
452
  const mcp = program.command("mcp").description("Wire the Retasc MCP server into your agent.");
434
453
  mcp
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@retasc/cli",
3
- "version": "1.14.0",
3
+ "version": "1.16.0",
4
4
  "description": "Retasc CLI \u2014 the issue tracker AI agents pull work from. Sign in with GitHub or Google, create projects, mint agent API keys, and wire your agent to the Retasc MCP server in one command.",
5
5
  "type": "module",
6
6
  "bin": {