@retasc/cli 1.14.0 → 1.15.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 +34 -0
- package/dist/api.js +12 -0
- package/dist/commands/import.js +226 -0
- package/dist/index.js +19 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,40 @@ 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.15.0 (2026-08-02)
|
|
10
|
+
|
|
11
|
+
- **RTSC-524** — new command: `retasc import`. Bring a Linear, Jira, Asana, ClickUp or
|
|
12
|
+
Shortcut project across without opening a browser.
|
|
13
|
+
|
|
14
|
+
This was the last thing the terminal could not do. Everything else — sign in, make or join
|
|
15
|
+
an org, create a project, wire the folder, pull work — already worked end to end, but
|
|
16
|
+
importing meant stopping and finishing in the Dash.
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
retasc import
|
|
20
|
+
retasc import --source jira --org-id …
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
It walks you through the source, your credentials, which team or project to take, and then
|
|
24
|
+
**what each of your columns means**. That last part is the point, and it is asked rather
|
|
25
|
+
than guessed: a tool lets its users name their own columns, so the only honest way to know
|
|
26
|
+
what one means is to ask you. Pressing Enter accepts a suggestion derived from the
|
|
27
|
+
column's *type* in your tool, never its name — a column called "Rejected" that is really
|
|
28
|
+
an in-progress lane maps to `doing`, not `canceled`.
|
|
29
|
+
|
|
30
|
+
A column mapped to `review` needs a named reviewer, chosen per column, because two
|
|
31
|
+
"awaiting acceptance" columns can belong to different people. Columns holding work nobody
|
|
32
|
+
has started cannot be mapped to `review` at all, and the CLI says why rather than letting
|
|
33
|
+
the server refuse it later.
|
|
34
|
+
|
|
35
|
+
**Your source token is never an argument.** There is no `--token`, because anything passed
|
|
36
|
+
that way lands in shell history and in `ps` output. Secrets are typed with echo off, or
|
|
37
|
+
taken from `RETASC_IMPORT_<FIELD>` for scripted runs.
|
|
38
|
+
|
|
39
|
+
The run is confirmed before anything is written, and afterwards you are offered the
|
|
40
|
+
imported identity the migration just created for you, through the same prompt
|
|
41
|
+
`retasc join` uses.
|
|
42
|
+
|
|
9
43
|
## 1.14.0 (2026-08-02)
|
|
10
44
|
|
|
11
45
|
- **RTSC-523** — setup asks before installing anything on your machine.
|
package/dist/api.js
CHANGED
|
@@ -28,6 +28,13 @@ 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"),
|
|
31
38
|
claimableGhosts: makeFunctionReference("ghosts:claimableGhosts"),
|
|
32
39
|
claimGhost: makeFunctionReference("ghosts:claimGhost"),
|
|
33
40
|
dismissGhostPrompt: makeFunctionReference("ghosts:dismissGhostPrompt"),
|
|
@@ -159,6 +166,11 @@ export const api = {
|
|
|
159
166
|
billingStatus: (args) => withAuth(() => client().query(fns.billingStatus, args)),
|
|
160
167
|
chargeHistory: (args) => withAuth(() => client().query(fns.chargeHistory, args)),
|
|
161
168
|
orgPayments: (args) => withAuth(() => client().action(fns.orgPayments, args)),
|
|
169
|
+
listImportSources: () => withAuth(() => client().query(fns.listImportSources, {})),
|
|
170
|
+
listImportTargets: (args) => withAuth(() => client().action(fns.listImportTargets, args)),
|
|
171
|
+
listImportStatuses: (args) => withAuth(() => client().action(fns.listImportStatuses, args)),
|
|
172
|
+
listReviewCandidates: (args) => withAuth(() => client().query(fns.listReviewCandidates, args)),
|
|
173
|
+
runImport: (args) => withAuth(() => client().action(fns.runImport, args)),
|
|
162
174
|
claimableGhosts: (args) => withAuth(() => client().query(fns.claimableGhosts, args)),
|
|
163
175
|
claimGhost: (args) => withAuth(() => client().mutation(fns.claimGhost, args)),
|
|
164
176
|
dismissGhostPrompt: (args) => withAuth(() => client().mutation(fns.dismissGhostPrompt, args)),
|
|
@@ -0,0 +1,226 @@
|
|
|
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
|
+
* May this column be mapped to `review`?
|
|
11
|
+
*
|
|
12
|
+
* Mirrors `reviewAllowedForGroup` in `convex/lib/importAdapter.ts`, and the reasoning is
|
|
13
|
+
* the server's: `review` means the work is FINISHED and waiting for someone to accept it,
|
|
14
|
+
* and it is non-terminal, so it keeps blocking dependents. Routing a not-yet-started column
|
|
15
|
+
* there manufactures review issues with nothing to review, each jamming whatever depends on
|
|
16
|
+
* it. Offering an option the server will reject is worse than not offering it.
|
|
17
|
+
*/
|
|
18
|
+
export function reviewAllowed(group) {
|
|
19
|
+
return group === "active" || group === "done";
|
|
20
|
+
}
|
|
21
|
+
/** Read a secret without echoing it, so a token never lands in scrollback. */
|
|
22
|
+
async function askSecret(question) {
|
|
23
|
+
if (!stdin.isTTY)
|
|
24
|
+
return await ask(question);
|
|
25
|
+
const rl = createInterface({ input: stdin, output: stdout, terminal: true });
|
|
26
|
+
// readline has no built-in masking, so suppress the echo ourselves and restore it in
|
|
27
|
+
// `finally` — a thrown error mid-prompt must not leave the terminal silent.
|
|
28
|
+
const out = stdout;
|
|
29
|
+
const real = out.write.bind(out);
|
|
30
|
+
try {
|
|
31
|
+
const answer = rl.question(question);
|
|
32
|
+
out.write = (chunk) => (chunk.includes("\n") ? real(chunk) : true);
|
|
33
|
+
const value = await answer;
|
|
34
|
+
return value.trim();
|
|
35
|
+
}
|
|
36
|
+
finally {
|
|
37
|
+
out.write = real;
|
|
38
|
+
rl.close();
|
|
39
|
+
real("\n");
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Collect the source's credentials.
|
|
44
|
+
*
|
|
45
|
+
* NEVER from an argument. A token passed as `--token …` lands in shell history, in `ps`
|
|
46
|
+
* output, and in any transcript of the session. Secret fields are read with echo off; an
|
|
47
|
+
* env var (`RETASC_IMPORT_<KEY>`) covers the scripted case without either problem.
|
|
48
|
+
*/
|
|
49
|
+
async function collectAuth(src) {
|
|
50
|
+
const auth = {};
|
|
51
|
+
for (const f of src.authFields) {
|
|
52
|
+
const envKey = `RETASC_IMPORT_${f.key.toUpperCase()}`;
|
|
53
|
+
const fromEnv = process.env[envKey];
|
|
54
|
+
if (fromEnv) {
|
|
55
|
+
console.log(` ${clean(f.label)}: taken from ${envKey}`);
|
|
56
|
+
auth[f.key] = fromEnv;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (!isInteractive()) {
|
|
60
|
+
cliError("MISSING_AUTH", `${clean(src.label)} needs ${clean(f.label)}, and there is no terminal to ask on.`, `Set ${envKey} and run again.`);
|
|
61
|
+
}
|
|
62
|
+
if (f.hint)
|
|
63
|
+
console.log(` ${clean(f.hint)}`);
|
|
64
|
+
const value = f.secret
|
|
65
|
+
? await askSecret(` ${clean(f.label)} (hidden): `)
|
|
66
|
+
: await ask(` ${clean(f.label)}: `);
|
|
67
|
+
if (!value)
|
|
68
|
+
cliError("MISSING_AUTH", `${clean(f.label)} is required.`);
|
|
69
|
+
auth[f.key] = value;
|
|
70
|
+
}
|
|
71
|
+
return auth;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Walk every column and record what it means. The heart of the command.
|
|
75
|
+
*
|
|
76
|
+
* Each row arrives with a `suggested` value derived server-side from the source's own
|
|
77
|
+
* STRUCTURAL type, never from the column's name, so pressing Enter through the list is a
|
|
78
|
+
* defensible mapping rather than a guess. This is a review-and-correct surface, exactly as
|
|
79
|
+
* the Dash panel is.
|
|
80
|
+
*/
|
|
81
|
+
export async function mapStatuses(statuses, reviewers, askFn) {
|
|
82
|
+
const statusMap = {};
|
|
83
|
+
const reviewerByStatus = {};
|
|
84
|
+
console.log(`\nWhat does each column mean? ${statuses.length} to confirm.\n` +
|
|
85
|
+
" Enter keeps the suggestion. It comes from the column's type in your tool, not its name.");
|
|
86
|
+
// The source's own order, so the list reads the way it does in the tool they know.
|
|
87
|
+
for (const s of [...statuses].sort((a, b) => a.order - b.order)) {
|
|
88
|
+
const allowed = MAPPED.filter((m) => m !== "review" || (reviewAllowed(s.group) && reviewers.length > 0));
|
|
89
|
+
const suggested = allowed.includes(s.suggested)
|
|
90
|
+
? s.suggested
|
|
91
|
+
: "todo";
|
|
92
|
+
for (let attempt = 0;; attempt++) {
|
|
93
|
+
const answer = (await askFn(`\n ${clean(s.name)} [${suggested}] (${allowed.join(" / ")}): `)).trim().toLowerCase();
|
|
94
|
+
const choice = answer === "" ? suggested : answer;
|
|
95
|
+
if (allowed.includes(choice)) {
|
|
96
|
+
statusMap[s.id] = choice;
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
if (choice === "review" && !reviewAllowed(s.group)) {
|
|
100
|
+
// Say WHY, rather than repeating the list. The server would reject it anyway.
|
|
101
|
+
console.log(" Not for this column: review means finished and awaiting acceptance,");
|
|
102
|
+
console.log(" and this one holds work nobody has started.");
|
|
103
|
+
}
|
|
104
|
+
else if (choice === "review") {
|
|
105
|
+
console.log(" No one in this org can be a reviewer yet, so review isn't available.");
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
console.log(` Pick one of: ${allowed.join(", ")}`);
|
|
109
|
+
}
|
|
110
|
+
if (attempt >= 2)
|
|
111
|
+
throw new Error("no valid choice — aborting");
|
|
112
|
+
}
|
|
113
|
+
// A review column needs a named reviewer, per column rather than per run: two
|
|
114
|
+
// "awaiting acceptance" columns can legitimately belong to different people.
|
|
115
|
+
if (statusMap[s.id] === "review") {
|
|
116
|
+
const who = await pickExisting(` Who reviews "${clean(s.name)}"?`, reviewers, (m) => clean(m.name), askFn);
|
|
117
|
+
reviewerByStatus[s.id] = who.id;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return { statusMap, reviewerByStatus };
|
|
121
|
+
}
|
|
122
|
+
export async function importAction(opts) {
|
|
123
|
+
if (!isInteractive() && !opts.yes) {
|
|
124
|
+
cliError("NEEDS_TERMINAL", "Importing asks what each of your columns means, so it needs a terminal.", "Run it interactively, or use the Dash.");
|
|
125
|
+
}
|
|
126
|
+
// --- which org ------------------------------------------------------------
|
|
127
|
+
const me = (await api.me());
|
|
128
|
+
const orgs = me.orgs ?? [];
|
|
129
|
+
let orgId = opts.orgId;
|
|
130
|
+
if (!orgId) {
|
|
131
|
+
if (orgs.length === 0)
|
|
132
|
+
cliError("NO_ORG", "You're not a member of any org yet.");
|
|
133
|
+
else if (orgs.length === 1)
|
|
134
|
+
orgId = orgs[0].id;
|
|
135
|
+
else {
|
|
136
|
+
const chosen = await pickExisting("Import into which org", orgs, (o) => `${clean(o.name)}${o.slug ? ` (${clean(o.slug)})` : ""}`);
|
|
137
|
+
orgId = chosen.id;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
const orgLabel = clean(orgs.find((o) => o.id === orgId)?.name ?? "this org");
|
|
141
|
+
// --- which tracker --------------------------------------------------------
|
|
142
|
+
const sources = (await api.listImportSources());
|
|
143
|
+
if (!sources.length)
|
|
144
|
+
cliError("NO_SOURCES", "No import sources are available.");
|
|
145
|
+
const src = opts.source
|
|
146
|
+
? sources.find((s) => s.source === opts.source) ??
|
|
147
|
+
cliError("UNKNOWN_SOURCE", `There's no import source called "${clean(opts.source)}".`, `Try one of: ${sources.map((s) => s.source).join(", ")}`)
|
|
148
|
+
: await pickExisting("Bring work across from", sources, (s) => clean(s.label));
|
|
149
|
+
// --- credentials ----------------------------------------------------------
|
|
150
|
+
console.log(`\nConnect to ${clean(src.label)}:`);
|
|
151
|
+
const auth = await collectAuth(src);
|
|
152
|
+
// --- which team/project/workspace -----------------------------------------
|
|
153
|
+
const { targets } = (await api.listImportTargets({ orgId: orgId, source: src.source, auth }));
|
|
154
|
+
if (!targets.length) {
|
|
155
|
+
cliError("NO_TARGETS", `That ${clean(src.label)} account has no ${clean(src.targetNoun)} we can import.`, "Check the credentials belong to the right account.");
|
|
156
|
+
}
|
|
157
|
+
const target = targets.length === 1
|
|
158
|
+
? targets[0]
|
|
159
|
+
: await pickExisting(`Which ${clean(src.targetNoun)}`, targets, (t) => `${clean(t.name)} (${clean(t.key)})`);
|
|
160
|
+
// --- what each column means ------------------------------------------------
|
|
161
|
+
let statusMap;
|
|
162
|
+
let reviewerByStatus;
|
|
163
|
+
if (src.supportsStatusMapping) {
|
|
164
|
+
const { statuses } = (await api.listImportStatuses({
|
|
165
|
+
orgId: orgId,
|
|
166
|
+
source: src.source,
|
|
167
|
+
auth,
|
|
168
|
+
targetRef: target.id,
|
|
169
|
+
}));
|
|
170
|
+
if (statuses.length) {
|
|
171
|
+
const reviewers = (await api.listReviewCandidates({ orgId: orgId }));
|
|
172
|
+
const mapped = await mapStatuses(statuses, reviewers, ask);
|
|
173
|
+
statusMap = mapped.statusMap;
|
|
174
|
+
reviewerByStatus = Object.keys(mapped.reviewerByStatus).length
|
|
175
|
+
? mapped.reviewerByStatus
|
|
176
|
+
: undefined;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
// --- confirm ---------------------------------------------------------------
|
|
180
|
+
// Named in full before anything is written. An import creates a project's worth of
|
|
181
|
+
// issues and mints a placeholder per source author, and there is no per-project delete —
|
|
182
|
+
// undoing a mistake means an org-level cleanup.
|
|
183
|
+
console.log(`\nAbout to import ${clean(target.name)} from ${clean(src.label)} into org ${orgLabel},\n` +
|
|
184
|
+
`as a new project. Issues, comments and their authors come across.`);
|
|
185
|
+
if (!opts.yes && !(await confirm("This can't be undone. Go ahead?"))) {
|
|
186
|
+
console.log("Nothing imported.");
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
// --- run -------------------------------------------------------------------
|
|
190
|
+
console.log("\nImporting. This can take a few minutes on a big project…");
|
|
191
|
+
let res;
|
|
192
|
+
try {
|
|
193
|
+
res = (await api.runImport({
|
|
194
|
+
orgId: orgId,
|
|
195
|
+
source: src.source,
|
|
196
|
+
auth,
|
|
197
|
+
target: { id: target.id, key: target.key, name: target.name },
|
|
198
|
+
statusMap,
|
|
199
|
+
reviewerByStatus,
|
|
200
|
+
}));
|
|
201
|
+
}
|
|
202
|
+
catch (e) {
|
|
203
|
+
// The run is server-side, so a dead connection here does NOT mean a dead import.
|
|
204
|
+
// Saying "failed" would be a guess, and the wrong one sends someone re-importing on
|
|
205
|
+
// top of a run that is still writing.
|
|
206
|
+
const { code, message, hint } = formatError(e);
|
|
207
|
+
console.error(`\n✗ ${code ? `${code}: ` : ""}${message}`);
|
|
208
|
+
if (hint)
|
|
209
|
+
console.error(` → ${hint}`);
|
|
210
|
+
console.error(" If this was a connection problem the import may still be running.");
|
|
211
|
+
console.error(" Check the Dash before running it again.");
|
|
212
|
+
throw e;
|
|
213
|
+
}
|
|
214
|
+
console.log("\n✓ Imported.");
|
|
215
|
+
for (const [k, v] of Object.entries(res.summary ?? {})) {
|
|
216
|
+
if (typeof v === "number" || typeof v === "string")
|
|
217
|
+
console.log(` ${k.padEnd(18)}${clean(v)}`);
|
|
218
|
+
}
|
|
219
|
+
// The import just minted a placeholder for whoever authored that work, and the person
|
|
220
|
+
// who ran it is very often one of them. `identityLoop` is the same prompt `join` uses and
|
|
221
|
+
// is per-source since RTSC-507, so offering it here costs one round trip and saves them
|
|
222
|
+
// finding it later.
|
|
223
|
+
console.log("");
|
|
224
|
+
const { identityLoop } = await import("./join.js");
|
|
225
|
+
await identityLoop(orgId, {}, orgLabel);
|
|
226
|
+
}
|
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.
|
|
3
|
+
"version": "1.15.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": {
|