linear-sdk-cli 0.4.0 → 0.5.1
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 +17 -0
- package/COVERAGE.md +8 -3
- package/MIGRATING.md +6 -0
- package/README.md +193 -37
- package/package.json +8 -7
- package/skills/linear-sdk-cli/SKILL.md +11 -0
- package/skills/linear-sdk-cli/references/auth.md +4 -1
- package/skills/linear-sdk-cli/references/comment.md +2 -0
- package/skills/linear-sdk-cli/references/config.md +10 -7
- package/skills/linear-sdk-cli/references/initiative.md +10 -5
- package/skills/linear-sdk-cli/references/issue.md +224 -77
- package/skills/linear-sdk-cli/references/notification.md +3 -3
- package/skills/linear-sdk-cli/references/project.md +9 -4
- package/src/cli.ts +1 -0
- package/src/commands/comment.ts +9 -7
- package/src/commands/initiative.ts +23 -6
- package/src/commands/issue.ts +313 -12
- package/src/commands/meta.ts +85 -13
- package/src/commands/notification.ts +5 -3
- package/src/commands/open.ts +2 -1
- package/src/commands/project.ts +21 -3
- package/src/config.ts +124 -39
- package/src/context.ts +21 -1
- package/src/lib/action.ts +8 -0
- package/src/lib/delegation.ts +240 -0
- package/src/lib/options.ts +17 -3
- package/src/lib/output-shapes.ts +78 -3
- package/src/lib/resolve.ts +53 -39
- package/src/output/lifecycle.ts +10 -0
- package/src/services/initiative.ts +12 -3
- package/src/services/issue.ts +498 -21
- package/src/services/project.ts +14 -3
package/src/config.ts
CHANGED
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Two tiers with different trust boundaries:
|
|
5
5
|
* - Non-secret settings (team, workspace, sort): flag > env > project
|
|
6
|
-
* config >
|
|
7
|
-
*
|
|
6
|
+
* config > selected workspace profile (team only) > user config
|
|
7
|
+
* (`~/.config/linear/config.toml`) > the reference CLI's global config
|
|
8
|
+
* (`~/.config/linear/linear.toml`, read-only).
|
|
8
9
|
* The project config is the first file found walking cwd → filesystem
|
|
9
10
|
* root, checking `linear.toml`, `.linear.toml`, `.config/linear.toml` in
|
|
10
11
|
* each directory — every location schpet/linear-cli 2.5 reads (it checks
|
|
@@ -70,7 +71,15 @@ export interface RawSettings {
|
|
|
70
71
|
* `global` is the reference CLI's `~/.config/linear/linear.toml`, read for
|
|
71
72
|
* non-secret settings only; `keychain` is the OS keyring (API key or human OAuth session).
|
|
72
73
|
*/
|
|
73
|
-
export type ConfigSource =
|
|
74
|
+
export type ConfigSource =
|
|
75
|
+
| "flag"
|
|
76
|
+
| "env"
|
|
77
|
+
| "project"
|
|
78
|
+
| "workspace-profile"
|
|
79
|
+
| "user"
|
|
80
|
+
| "global"
|
|
81
|
+
| "keychain"
|
|
82
|
+
| "none";
|
|
74
83
|
|
|
75
84
|
/** Where a stored workspace credential's secret lives. */
|
|
76
85
|
export type CredentialStorage = "file" | "keychain";
|
|
@@ -81,6 +90,8 @@ export interface SettingOrigin {
|
|
|
81
90
|
path?: string;
|
|
82
91
|
/** Exact spelling found in the file (`issue_sort`, not just canonical `sort`). */
|
|
83
92
|
key?: string;
|
|
93
|
+
/** Workspace slug for a value read from `[workspaces."<slug>"]`. */
|
|
94
|
+
workspace?: string;
|
|
84
95
|
}
|
|
85
96
|
|
|
86
97
|
/** The non-secret settings, each with its provenance. */
|
|
@@ -102,8 +113,12 @@ export interface ResolvedConfig {
|
|
|
102
113
|
* needed (see createClient / `auth token`).
|
|
103
114
|
*/
|
|
104
115
|
apiKeyError?: CliError;
|
|
116
|
+
/** Stored choices when selection is ambiguous; never an implicit default. */
|
|
117
|
+
workspaceChoices?: string[];
|
|
105
118
|
/** Workspace slug whose stored credential was used, if any. */
|
|
106
119
|
credentialWorkspace?: string;
|
|
120
|
+
/** Selected stored profile whose non-secret metadata may participate. */
|
|
121
|
+
workspaceProfile?: string;
|
|
107
122
|
team?: string;
|
|
108
123
|
/** Non-secret display workspace setting (separate from credentialWorkspace). */
|
|
109
124
|
workspace?: string;
|
|
@@ -174,6 +189,8 @@ interface WorkspaceEntry {
|
|
|
174
189
|
apiKey?: string;
|
|
175
190
|
/** This workspace uses the isolated `oauth:<slug>` keyring account. */
|
|
176
191
|
oauth?: boolean;
|
|
192
|
+
/** Non-secret default team for this workspace. */
|
|
193
|
+
team?: string;
|
|
177
194
|
}
|
|
178
195
|
|
|
179
196
|
/** Parsed view of the user config: top-level settings + nested workspace creds. */
|
|
@@ -328,6 +345,7 @@ function readUserConfig(path: string): UserConfig {
|
|
|
328
345
|
workspaces[slug] = {
|
|
329
346
|
apiKey: asString(table.api_key),
|
|
330
347
|
oauth: table.oauth === true,
|
|
348
|
+
team: asString(table.team),
|
|
331
349
|
};
|
|
332
350
|
}
|
|
333
351
|
}
|
|
@@ -450,6 +468,8 @@ export function resolveConfig(inputs: ConfigInputs = {}): ResolvedConfig {
|
|
|
450
468
|
let accessTokenSource: ConfigSource = "none";
|
|
451
469
|
let oauthCredential: OAuthUserCredential | undefined;
|
|
452
470
|
let credentialWorkspace: string | undefined;
|
|
471
|
+
let workspaceProfile: string | undefined;
|
|
472
|
+
let workspaceChoices: string[] | undefined;
|
|
453
473
|
let apiKeyError: CliError | undefined;
|
|
454
474
|
|
|
455
475
|
if (flags.apiKey && flags.accessToken) {
|
|
@@ -522,8 +542,9 @@ export function resolveConfig(inputs: ConfigInputs = {}): ResolvedConfig {
|
|
|
522
542
|
take(slugs[0]!);
|
|
523
543
|
} else if (slugs.length > 1) {
|
|
524
544
|
// Multiple workspaces but no default → ambiguous (deferred).
|
|
545
|
+
workspaceChoices = slugs;
|
|
525
546
|
apiKeyError = new CliError(
|
|
526
|
-
`Multiple workspaces are configured (${slugs.join(", ")}) but none is selected. Pass --workspace <slug
|
|
547
|
+
`Multiple workspaces are configured (${slugs.join(", ")}) but none is selected. Pass --workspace <slug>, run \`linear config set workspace <slug>\` for this project, or explicitly choose \`linear auth default <slug>\`.`,
|
|
527
548
|
"usage",
|
|
528
549
|
);
|
|
529
550
|
}
|
|
@@ -531,8 +552,27 @@ export function resolveConfig(inputs: ConfigInputs = {}): ResolvedConfig {
|
|
|
531
552
|
}
|
|
532
553
|
}
|
|
533
554
|
|
|
555
|
+
// A stored credential binds its own profile. An invocation-scoped credential
|
|
556
|
+
// never inherits the default/project workspace implicitly; it may bind only
|
|
557
|
+
// when this invocation explicitly selected a profile by flag or environment
|
|
558
|
+
// and that profile is actually configured.
|
|
559
|
+
if (credentialWorkspace && user.workspaces[credentialWorkspace]) {
|
|
560
|
+
workspaceProfile = credentialWorkspace;
|
|
561
|
+
} else {
|
|
562
|
+
const invocationCredential =
|
|
563
|
+
apiKeySource === "flag" ||
|
|
564
|
+
apiKeySource === "env" ||
|
|
565
|
+
accessTokenSource === "flag" ||
|
|
566
|
+
accessTokenSource === "env";
|
|
567
|
+
const explicitWorkspace = flags.workspace ?? envSettings.workspace;
|
|
568
|
+
if (invocationCredential && explicitWorkspace && user.workspaces[explicitWorkspace]) {
|
|
569
|
+
workspaceProfile = explicitWorkspace;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
534
573
|
// ----- Non-secret settings (project + global config MAY participate) -----
|
|
535
|
-
//
|
|
574
|
+
// Workspace and sort keep their existing order. Team inserts the selected
|
|
575
|
+
// workspace profile between project and the legacy top-level user fallback.
|
|
536
576
|
const tiers: Array<[ConfigSource, RawSettings, string | undefined, SettingKeys | undefined]> = [
|
|
537
577
|
["flag", flags, undefined, undefined],
|
|
538
578
|
["env", envSettings, undefined, undefined],
|
|
@@ -543,13 +583,30 @@ export function resolveConfig(inputs: ConfigInputs = {}): ResolvedConfig {
|
|
|
543
583
|
const pick = <K extends "team" | "workspace" | "sort">(key: K): string | undefined =>
|
|
544
584
|
tiers.find(([, settings]) => settings[key] !== undefined)?.[1][key];
|
|
545
585
|
|
|
586
|
+
const profileTeam = workspaceProfile ? user.workspaces[workspaceProfile]?.team : undefined;
|
|
587
|
+
const teamTiers: typeof tiers = [
|
|
588
|
+
["flag", flags, undefined, undefined],
|
|
589
|
+
["env", envSettings, undefined, undefined],
|
|
590
|
+
["project", project.settings, projectPath, project.keys],
|
|
591
|
+
[
|
|
592
|
+
"workspace-profile",
|
|
593
|
+
profileTeam ? { team: profileTeam } : {},
|
|
594
|
+
userPath,
|
|
595
|
+
profileTeam ? { team: "team" } : undefined,
|
|
596
|
+
],
|
|
597
|
+
["user", user.settings, userPath, user.settingKeys],
|
|
598
|
+
["global", global.settings, globalPath, global.keys],
|
|
599
|
+
];
|
|
600
|
+
|
|
546
601
|
/** Which tier `pick` took the value from, and which file if it was one. */
|
|
547
602
|
const originOf = (key: "team" | "workspace" | "sort"): SettingOrigin => {
|
|
548
|
-
const
|
|
603
|
+
const candidates = key === "team" ? teamTiers : tiers;
|
|
604
|
+
const hit = candidates.find(([, settings]) => settings[key] !== undefined);
|
|
549
605
|
if (!hit) return { source: "none" };
|
|
550
606
|
const origin: SettingOrigin = { source: hit[0], path: hit[2] };
|
|
551
607
|
const spelling = hit[3]?.[key];
|
|
552
608
|
if (spelling !== undefined) origin.key = spelling;
|
|
609
|
+
if (hit[0] === "workspace-profile") origin.workspace = workspaceProfile;
|
|
553
610
|
return origin;
|
|
554
611
|
};
|
|
555
612
|
|
|
@@ -567,7 +624,9 @@ export function resolveConfig(inputs: ConfigInputs = {}): ResolvedConfig {
|
|
|
567
624
|
oauthCredential,
|
|
568
625
|
apiKeyError,
|
|
569
626
|
credentialWorkspace,
|
|
570
|
-
|
|
627
|
+
workspaceProfile,
|
|
628
|
+
workspaceChoices,
|
|
629
|
+
team: teamTiers.find(([, settings]) => settings.team !== undefined)?.[1].team,
|
|
571
630
|
// The display setting walks every tier. Credential selection above uses
|
|
572
631
|
// the flag, env, and project tiers, then the auth-specific user default.
|
|
573
632
|
workspace: pick("workspace"),
|
|
@@ -823,6 +882,7 @@ interface WorkspaceTable {
|
|
|
823
882
|
api_key?: string;
|
|
824
883
|
keyring?: boolean;
|
|
825
884
|
oauth?: boolean;
|
|
885
|
+
team?: string;
|
|
826
886
|
}
|
|
827
887
|
|
|
828
888
|
function workspacesTable(obj: Record<string, unknown>): Record<string, WorkspaceTable> {
|
|
@@ -946,10 +1006,6 @@ export function writeOAuthCredential(credential: OAuthUserCredential): WriteOAut
|
|
|
946
1006
|
table.keyring = true;
|
|
947
1007
|
table.oauth = true;
|
|
948
1008
|
ws[slug] = table;
|
|
949
|
-
if (!asString(obj.default_workspace)) {
|
|
950
|
-
const reference = readReferenceCredentials(referenceCredentialsPath());
|
|
951
|
-
if (!reference.defaultWorkspace) obj.default_workspace = slug;
|
|
952
|
-
}
|
|
953
1009
|
writeUserObject(path, obj);
|
|
954
1010
|
} catch (err) {
|
|
955
1011
|
try {
|
|
@@ -1075,10 +1131,6 @@ export function adoptKeyringCredential(
|
|
|
1075
1131
|
delete table.api_key;
|
|
1076
1132
|
table.keyring = true;
|
|
1077
1133
|
ws[slug] = table;
|
|
1078
|
-
if (!asString(obj.default_workspace)) {
|
|
1079
|
-
const reference = readReferenceCredentials(referenceCredentialsPath());
|
|
1080
|
-
if (!reference.defaultWorkspace) obj.default_workspace = slug;
|
|
1081
|
-
}
|
|
1082
1134
|
writeUserObject(path, obj);
|
|
1083
1135
|
return { path, storage: "keychain", keyringLabel: found.keyringLabel };
|
|
1084
1136
|
});
|
|
@@ -1091,8 +1143,8 @@ export function adoptKeyringCredential(
|
|
|
1091
1143
|
* no keyring, it is written as `api_key` in the 0600 file — the latter without
|
|
1092
1144
|
* complaint, since a platform is not an error. Either way the OTHER form for
|
|
1093
1145
|
* the same slug is dropped from the file, so a re-login moves the key rather
|
|
1094
|
-
* than leaving a plaintext copy behind that would keep winning.
|
|
1095
|
-
*
|
|
1146
|
+
* than leaving a plaintext copy behind that would keep winning. Global defaults
|
|
1147
|
+
* are only written by setDefaultWorkspace; existing legacy defaults are preserved.
|
|
1096
1148
|
*/
|
|
1097
1149
|
export function writeCredential(
|
|
1098
1150
|
slug: string,
|
|
@@ -1122,12 +1174,6 @@ export function writeCredential(
|
|
|
1122
1174
|
}
|
|
1123
1175
|
systemBackend?.delete(oauthAccount(slug));
|
|
1124
1176
|
ws[slug] = table;
|
|
1125
|
-
if (!asString(obj.default_workspace)) {
|
|
1126
|
-
// Leave the reference CLI's default in charge if it has one; writing ours
|
|
1127
|
-
// over it would silently switch a migrating user's workspace.
|
|
1128
|
-
const reference = readReferenceCredentials(referenceCredentialsPath());
|
|
1129
|
-
if (!reference.defaultWorkspace) obj.default_workspace = slug;
|
|
1130
|
-
}
|
|
1131
1177
|
writeUserObject(path, obj);
|
|
1132
1178
|
return { path, storage, keyringLabel: backend?.label };
|
|
1133
1179
|
});
|
|
@@ -1151,6 +1197,31 @@ export function setDefaultWorkspace(slug: string): string {
|
|
|
1151
1197
|
});
|
|
1152
1198
|
}
|
|
1153
1199
|
|
|
1200
|
+
/** Set non-secret team metadata on one existing workspace profile. */
|
|
1201
|
+
export function setWorkspaceTeam(
|
|
1202
|
+
slug: string,
|
|
1203
|
+
team: string,
|
|
1204
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
1205
|
+
): string {
|
|
1206
|
+
const path = userConfigPath(env);
|
|
1207
|
+
return withUserConfigLock(path, () => {
|
|
1208
|
+
const known = readUserConfig(path).workspaces;
|
|
1209
|
+
if (!known[slug]) {
|
|
1210
|
+
throw new CliError(
|
|
1211
|
+
`Workspace '${sanitize(slug)}' is not configured. Run \`linear auth login --workspace ${sanitize(slug)}\` first.`,
|
|
1212
|
+
"not_found",
|
|
1213
|
+
);
|
|
1214
|
+
}
|
|
1215
|
+
const obj = readUserObject(path);
|
|
1216
|
+
const ws = workspacesTable(obj);
|
|
1217
|
+
const table: WorkspaceTable = { ...(ws[slug] ?? {}) };
|
|
1218
|
+
table.team = team;
|
|
1219
|
+
ws[slug] = table;
|
|
1220
|
+
writeUserObject(path, obj);
|
|
1221
|
+
return path;
|
|
1222
|
+
});
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1154
1225
|
/**
|
|
1155
1226
|
* Drop `slug` from the reference CLI's keyring-format credentials list, if it
|
|
1156
1227
|
* is there, in that file's own layout (`default` first, sorted `workspaces`).
|
|
@@ -1162,7 +1233,7 @@ function removeFromReferenceCredentials(slug: string): boolean {
|
|
|
1162
1233
|
const reference = readReferenceCredentials(path);
|
|
1163
1234
|
if (!reference.workspaces.includes(slug)) return false;
|
|
1164
1235
|
const workspaces = reference.workspaces.filter((s) => s !== slug).sort();
|
|
1165
|
-
const def = reference.defaultWorkspace === slug ?
|
|
1236
|
+
const def = reference.defaultWorkspace === slug ? undefined : reference.defaultWorkspace;
|
|
1166
1237
|
const obj: Record<string, unknown> = {};
|
|
1167
1238
|
if (def) obj.default = def;
|
|
1168
1239
|
obj.workspaces = workspaces;
|
|
@@ -1173,13 +1244,20 @@ function removeFromReferenceCredentials(slug: string): boolean {
|
|
|
1173
1244
|
/**
|
|
1174
1245
|
* Forget a workspace: remove its `[workspaces."<slug>"]` table, its keyring
|
|
1175
1246
|
* entry, and its line in the reference CLI's list — whichever exist. If the
|
|
1176
|
-
* removed slug was the default,
|
|
1177
|
-
* workspace
|
|
1247
|
+
* removed slug was the default, that default is cleared without choosing another
|
|
1248
|
+
* workspace. Returns true if anything was removed.
|
|
1178
1249
|
*/
|
|
1179
|
-
export
|
|
1250
|
+
export interface RemoveCredentialResult {
|
|
1251
|
+
removed: boolean;
|
|
1252
|
+
teamMetadataRemoved: boolean;
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
/** Detailed removal receipt used by `auth logout`. */
|
|
1256
|
+
export function removeCredentialWithMetadata(slug: string): RemoveCredentialResult {
|
|
1180
1257
|
const path = userConfigPath();
|
|
1181
1258
|
return withUserConfigLock(path, () => {
|
|
1182
1259
|
let removed = false;
|
|
1260
|
+
let teamMetadataRemoved = false;
|
|
1183
1261
|
|
|
1184
1262
|
const backend = keyring();
|
|
1185
1263
|
if (backend) {
|
|
@@ -1188,31 +1266,36 @@ export function removeCredential(slug: string): boolean {
|
|
|
1188
1266
|
}
|
|
1189
1267
|
if (removeFromReferenceCredentials(slug)) removed = true;
|
|
1190
1268
|
|
|
1191
|
-
if (!existsSync(path)) return removed;
|
|
1269
|
+
if (!existsSync(path)) return { removed, teamMetadataRemoved };
|
|
1192
1270
|
const obj = readUserObject(path);
|
|
1193
1271
|
const ws = workspacesTable(obj);
|
|
1194
1272
|
const listed = Boolean(ws[slug]);
|
|
1195
1273
|
if (listed) {
|
|
1274
|
+
teamMetadataRemoved = asString(ws[slug]?.team) !== undefined;
|
|
1196
1275
|
delete ws[slug];
|
|
1197
1276
|
removed = true;
|
|
1198
1277
|
}
|
|
1199
1278
|
if (Object.keys(ws).length === 0) delete obj.workspaces;
|
|
1200
1279
|
|
|
1201
1280
|
if (asString(obj.default_workspace) === slug) {
|
|
1202
|
-
|
|
1203
|
-
if (remaining.length > 0) obj.default_workspace = remaining[0]!;
|
|
1204
|
-
else delete obj.default_workspace;
|
|
1281
|
+
delete obj.default_workspace;
|
|
1205
1282
|
writeUserObject(path, obj);
|
|
1206
1283
|
} else if (listed) {
|
|
1207
1284
|
writeUserObject(path, obj);
|
|
1208
1285
|
}
|
|
1209
|
-
return removed;
|
|
1286
|
+
return { removed, teamMetadataRemoved };
|
|
1210
1287
|
});
|
|
1211
1288
|
}
|
|
1212
1289
|
|
|
1290
|
+
/** Backward-compatible boolean removal API. */
|
|
1291
|
+
export function removeCredential(slug: string): boolean {
|
|
1292
|
+
return removeCredentialWithMetadata(slug).removed;
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1213
1295
|
export interface RemoveOAuthCredentialResult {
|
|
1214
1296
|
removed: boolean;
|
|
1215
1297
|
fallbackCredentialType: "api-key" | null;
|
|
1298
|
+
teamMetadataRemoved: boolean;
|
|
1216
1299
|
}
|
|
1217
1300
|
|
|
1218
1301
|
/** Remove only the human OAuth lifecycle, restoring a pre-existing API-key profile if present. */
|
|
@@ -1223,6 +1306,7 @@ export function removeOAuthCredential(slug: string): RemoveOAuthCredentialResult
|
|
|
1223
1306
|
let oauthRaw: string | null = null;
|
|
1224
1307
|
let keyringApiKey = false;
|
|
1225
1308
|
let removed = false;
|
|
1309
|
+
let teamMetadataRemoved = false;
|
|
1226
1310
|
if (backend) {
|
|
1227
1311
|
try {
|
|
1228
1312
|
oauthRaw = backend.get(oauthAccount(slug));
|
|
@@ -1237,7 +1321,11 @@ export function removeOAuthCredential(slug: string): RemoveOAuthCredentialResult
|
|
|
1237
1321
|
}
|
|
1238
1322
|
|
|
1239
1323
|
if (!existsSync(path)) {
|
|
1240
|
-
return {
|
|
1324
|
+
return {
|
|
1325
|
+
removed,
|
|
1326
|
+
fallbackCredentialType: keyringApiKey ? "api-key" : null,
|
|
1327
|
+
teamMetadataRemoved,
|
|
1328
|
+
};
|
|
1241
1329
|
}
|
|
1242
1330
|
try {
|
|
1243
1331
|
const obj = readUserObject(path);
|
|
@@ -1245,7 +1333,6 @@ export function removeOAuthCredential(slug: string): RemoveOAuthCredentialResult
|
|
|
1245
1333
|
const table = ws[slug];
|
|
1246
1334
|
const hadOAuthMarker = table?.oauth === true;
|
|
1247
1335
|
if (hadOAuthMarker) removed = true;
|
|
1248
|
-
const reference = readReferenceCredentials(referenceCredentialsPath());
|
|
1249
1336
|
const apiKeyFallback = Boolean(asString(table?.api_key)) || keyringApiKey;
|
|
1250
1337
|
|
|
1251
1338
|
if (apiKeyFallback) {
|
|
@@ -1255,21 +1342,19 @@ export function removeOAuthCredential(slug: string): RemoveOAuthCredentialResult
|
|
|
1255
1342
|
else fallback.keyring = true;
|
|
1256
1343
|
ws[slug] = fallback;
|
|
1257
1344
|
} else if (table) {
|
|
1345
|
+
teamMetadataRemoved = asString(table.team) !== undefined;
|
|
1258
1346
|
delete ws[slug];
|
|
1259
1347
|
}
|
|
1260
1348
|
if (Object.keys(ws).length === 0) delete obj.workspaces;
|
|
1261
1349
|
|
|
1262
1350
|
if (!apiKeyFallback && asString(obj.default_workspace) === slug) {
|
|
1263
|
-
|
|
1264
|
-
const referenceFallback = reference.workspaces.find((candidate) => candidate !== slug);
|
|
1265
|
-
if (remaining.length > 0) obj.default_workspace = remaining[0]!;
|
|
1266
|
-
else if (referenceFallback) obj.default_workspace = referenceFallback;
|
|
1267
|
-
else delete obj.default_workspace;
|
|
1351
|
+
delete obj.default_workspace;
|
|
1268
1352
|
}
|
|
1269
1353
|
if (hadOAuthMarker || apiKeyFallback) writeUserObject(path, obj);
|
|
1270
1354
|
return {
|
|
1271
1355
|
removed,
|
|
1272
1356
|
fallbackCredentialType: apiKeyFallback ? "api-key" : null,
|
|
1357
|
+
teamMetadataRemoved,
|
|
1273
1358
|
};
|
|
1274
1359
|
} catch (err) {
|
|
1275
1360
|
try {
|
package/src/context.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import type { LinearClient } from "@linear/sdk";
|
|
8
8
|
import { resolveConfig, type ResolvedConfig } from "./config.js";
|
|
9
9
|
import { createClient, setRetryReporter } from "./client.js";
|
|
10
|
+
import { promptSelect } from "./lib/prompt.js";
|
|
10
11
|
import { Output } from "./output/format.js";
|
|
11
12
|
import { isDebugEnabled, shouldUseColor } from "./output/color.js";
|
|
12
13
|
|
|
@@ -46,7 +47,7 @@ export function firstTeam(team: string | string[] | undefined): string | undefin
|
|
|
46
47
|
}
|
|
47
48
|
|
|
48
49
|
export class Context {
|
|
49
|
-
|
|
50
|
+
config: ResolvedConfig;
|
|
50
51
|
readonly output: Output;
|
|
51
52
|
readonly options: GlobalOptions;
|
|
52
53
|
readonly isTTY: boolean;
|
|
@@ -96,6 +97,25 @@ export class Context {
|
|
|
96
97
|
setRetryReporter((line) => this.output.info(line));
|
|
97
98
|
}
|
|
98
99
|
|
|
100
|
+
/** Choose only for this invocation; never persist an interactive fallback. */
|
|
101
|
+
async selectWorkspace(): Promise<void> {
|
|
102
|
+
const choices = this.config.workspaceChoices;
|
|
103
|
+
if (!choices || !this.isTTY) return;
|
|
104
|
+
const workspace = await promptSelect(
|
|
105
|
+
this,
|
|
106
|
+
"Select a workspace for this invocation",
|
|
107
|
+
choices.map((slug) => ({ name: slug, value: slug })),
|
|
108
|
+
);
|
|
109
|
+
this.config = resolveConfig({
|
|
110
|
+
flags: {
|
|
111
|
+
apiKey: this.options.apiKey,
|
|
112
|
+
accessToken: this.options.accessToken,
|
|
113
|
+
team: firstTeam(this.options.team),
|
|
114
|
+
workspace,
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
99
119
|
/** Lazily construct the Linear client (so `--help` never needs a key). */
|
|
100
120
|
get client(): LinearClient {
|
|
101
121
|
if (!this._client) this._client = createClient(this.config);
|
package/src/lib/action.ts
CHANGED
|
@@ -21,6 +21,14 @@ export function action(handler: ActionHandler) {
|
|
|
21
21
|
const localOpts = (args[args.length - 2] ?? {}) as Record<string, any>;
|
|
22
22
|
const positionals = args.slice(0, -2);
|
|
23
23
|
const ctx = new Context(command.optsWithGlobals() as GlobalOptions);
|
|
24
|
+
// Repair and local discovery need no selection. Config/open select within
|
|
25
|
+
// their handlers only when the chosen path needs credentials.
|
|
26
|
+
let group = command;
|
|
27
|
+
while (group.parent?.parent) group = group.parent;
|
|
28
|
+
const offline =
|
|
29
|
+
["config", "open", "commands", "completion"].includes(group.name()) ||
|
|
30
|
+
(group.name() === "auth" && !["whoami", "status", "token"].includes(command.name()));
|
|
31
|
+
if (!offline) await ctx.selectWorkspace();
|
|
24
32
|
await handler(ctx, localOpts, ...positionals);
|
|
25
33
|
};
|
|
26
34
|
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Linear for Agents (Developer Preview) helpers.
|
|
3
|
+
*
|
|
4
|
+
* Delegates are deliberately resolved separately from assignees. A human can
|
|
5
|
+
* own an issue; only an active, assignable app user with access to the issue's
|
|
6
|
+
* team can be delegated the work.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { LinearClient } from "@linear/sdk";
|
|
10
|
+
import { withRetry } from "../client.js";
|
|
11
|
+
import { CliError, ambiguous, normalizeError, notFound } from "./errors.js";
|
|
12
|
+
import { isUuid } from "./resolve.js";
|
|
13
|
+
|
|
14
|
+
const PAGE_SIZE = 250;
|
|
15
|
+
const SCAN_CAP = 2000;
|
|
16
|
+
const MAX_LISTED = 25;
|
|
17
|
+
|
|
18
|
+
export interface DelegateRef {
|
|
19
|
+
id: string;
|
|
20
|
+
displayName: string;
|
|
21
|
+
name: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function delegationMessages(err: unknown): { normalized: CliError; messages: string } {
|
|
25
|
+
const normalized = normalizeError(err);
|
|
26
|
+
return {
|
|
27
|
+
normalized,
|
|
28
|
+
messages: [normalized.message, JSON.stringify(normalized.detail ?? "")].join(" "),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function missingDelegationSchema(messages: string): boolean {
|
|
33
|
+
return (
|
|
34
|
+
/(cannot query field|unknown (argument|field)|is not defined by type).*(delegate|isAssignable|canAccessAnyPublicTeam)/i.test(
|
|
35
|
+
messages,
|
|
36
|
+
) ||
|
|
37
|
+
/(delegateId|delegate|isAssignable|canAccessAnyPublicTeam).*(cannot query field|unknown (argument|field)|is not defined by type)/i.test(
|
|
38
|
+
messages,
|
|
39
|
+
)
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Whether an issue read can safely retry without its one preview-only field. */
|
|
44
|
+
export function isDelegationReadUnavailable(err: unknown): boolean {
|
|
45
|
+
const { normalized, messages } = delegationMessages(err);
|
|
46
|
+
return (
|
|
47
|
+
missingDelegationSchema(messages) ||
|
|
48
|
+
(normalized.code === "feature_not_accessible" && /delegate/i.test(messages))
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface DelegateCandidate extends DelegateRef {
|
|
53
|
+
active: boolean;
|
|
54
|
+
app: boolean;
|
|
55
|
+
isAssignable: boolean;
|
|
56
|
+
canAccessAnyPublicTeam: boolean;
|
|
57
|
+
teams: { nodes: Array<{ id: string }> };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const CANDIDATE_FIELDS = `
|
|
61
|
+
id displayName name active app isAssignable canAccessAnyPublicTeam
|
|
62
|
+
teams(filter: { id: { eq: $teamId } }, first: 1) { nodes { id } }
|
|
63
|
+
`;
|
|
64
|
+
|
|
65
|
+
const CANDIDATES_QUERY = `
|
|
66
|
+
query CliDelegateCandidates($teamId: String!, $after: String) {
|
|
67
|
+
users(first: ${PAGE_SIZE}, after: $after, includeDisabled: true, includeArchived: true) {
|
|
68
|
+
nodes { ${CANDIDATE_FIELDS} }
|
|
69
|
+
pageInfo { hasNextPage endCursor }
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
`;
|
|
73
|
+
|
|
74
|
+
const CANDIDATE_QUERY = `
|
|
75
|
+
query CliDelegateCandidate($id: String!, $teamId: String!) {
|
|
76
|
+
user(id: $id) { ${CANDIDATE_FIELDS} }
|
|
77
|
+
}
|
|
78
|
+
`;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Turn an unavailable Developer Preview schema/feature into a stable boundary.
|
|
82
|
+
* Other validation failures retain their original code and wording.
|
|
83
|
+
*/
|
|
84
|
+
export function delegationFeatureError(err: unknown): CliError {
|
|
85
|
+
const { normalized, messages } = delegationMessages(err);
|
|
86
|
+
const schemaMissing = missingDelegationSchema(messages);
|
|
87
|
+
if (normalized.code !== "feature_not_accessible" && !schemaMissing) return normalized;
|
|
88
|
+
return new CliError(
|
|
89
|
+
"Issue delegation is unavailable in this Linear workspace or API schema (Linear for Agents is in Developer Preview).",
|
|
90
|
+
"feature_not_accessible",
|
|
91
|
+
normalized.detail,
|
|
92
|
+
"Enable an assignable agent integration with access to this team, or retry when the workspace supports issue delegation.",
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function request<T>(client: LinearClient, query: string, variables: object): Promise<T> {
|
|
97
|
+
try {
|
|
98
|
+
const response: any = await withRetry(() =>
|
|
99
|
+
(client as any).client.rawRequest(query, variables),
|
|
100
|
+
);
|
|
101
|
+
return response.data as T;
|
|
102
|
+
} catch (err) {
|
|
103
|
+
throw delegationFeatureError(err);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function allCandidates(client: LinearClient, teamId: string): Promise<DelegateCandidate[]> {
|
|
108
|
+
const candidates: DelegateCandidate[] = [];
|
|
109
|
+
let after: string | undefined;
|
|
110
|
+
for (;;) {
|
|
111
|
+
const data = await request<{
|
|
112
|
+
users: {
|
|
113
|
+
nodes: DelegateCandidate[];
|
|
114
|
+
pageInfo: { hasNextPage: boolean; endCursor: string | null };
|
|
115
|
+
};
|
|
116
|
+
}>(client, CANDIDATES_QUERY, { teamId, after });
|
|
117
|
+
candidates.push(...data.users.nodes);
|
|
118
|
+
if (!data.users.pageInfo.hasNextPage) return candidates;
|
|
119
|
+
if (candidates.length >= SCAN_CAP || !data.users.pageInfo.endCursor) {
|
|
120
|
+
throw new CliError(
|
|
121
|
+
`More than ${SCAN_CAP} workspace users to search; pass the agent user UUID instead.`,
|
|
122
|
+
"usage",
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
after = data.users.pageInfo.endCursor;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function isBaseEligible(candidate: DelegateCandidate): boolean {
|
|
130
|
+
return candidate.active && candidate.app && candidate.isAssignable;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function hasTeamAccess(candidate: DelegateCandidate, privateTeam: boolean): boolean {
|
|
134
|
+
return candidate.teams.nodes.length > 0 || (!privateTeam && candidate.canAccessAnyPublicTeam);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function label(candidate: DelegateCandidate): string {
|
|
138
|
+
const names =
|
|
139
|
+
candidate.displayName === candidate.name
|
|
140
|
+
? candidate.displayName
|
|
141
|
+
: `${candidate.displayName} (${candidate.name})`;
|
|
142
|
+
return `${names} [${candidate.id}]`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function validateCandidate(
|
|
146
|
+
candidate: DelegateCandidate,
|
|
147
|
+
team: { key: string; private: boolean },
|
|
148
|
+
): DelegateRef {
|
|
149
|
+
if (!candidate.active) {
|
|
150
|
+
throw new CliError(
|
|
151
|
+
`'${candidate.displayName}' is inactive and cannot be delegated issues.`,
|
|
152
|
+
"validation",
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
if (!candidate.app) {
|
|
156
|
+
throw new CliError(
|
|
157
|
+
`'${candidate.displayName}' is a human user. Use --assignee for people; --delegate accepts agent app users only.`,
|
|
158
|
+
"validation",
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
if (!candidate.isAssignable) {
|
|
162
|
+
throw new CliError(
|
|
163
|
+
`'${candidate.displayName}' is an app user but is not eligible for issue delegation.`,
|
|
164
|
+
"validation",
|
|
165
|
+
undefined,
|
|
166
|
+
"Reinstall or reconfigure the agent with Linear's app:assignable scope.",
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
if (!hasTeamAccess(candidate, team.private)) {
|
|
170
|
+
throw new CliError(
|
|
171
|
+
`'${candidate.displayName}' cannot access team ${team.key} and cannot be delegated this issue.`,
|
|
172
|
+
"validation",
|
|
173
|
+
undefined,
|
|
174
|
+
"Ask a Linear workspace admin to grant the agent access to this team.",
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
return { id: candidate.id, displayName: candidate.displayName, name: candidate.name };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Resolve an agent by UUID, exact display name, or exact full name. Exact case
|
|
182
|
+
* wins over case-insensitive matching; ambiguity considers eligible app users
|
|
183
|
+
* only and lists safe names plus IDs.
|
|
184
|
+
*/
|
|
185
|
+
export async function resolveDelegate(
|
|
186
|
+
client: LinearClient,
|
|
187
|
+
input: string,
|
|
188
|
+
teamId: string,
|
|
189
|
+
): Promise<DelegateRef> {
|
|
190
|
+
let team: { key: string; private: boolean };
|
|
191
|
+
try {
|
|
192
|
+
const model: any = await withRetry(() => client.team(teamId));
|
|
193
|
+
team = { key: model.key, private: !!model.private };
|
|
194
|
+
} catch (err) {
|
|
195
|
+
throw delegationFeatureError(err);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (isUuid(input)) {
|
|
199
|
+
const data = await request<{ user: DelegateCandidate | null }>(client, CANDIDATE_QUERY, {
|
|
200
|
+
id: input,
|
|
201
|
+
teamId,
|
|
202
|
+
});
|
|
203
|
+
if (!data.user) throw notFound(`No agent user with id '${input}'.`);
|
|
204
|
+
return validateCandidate(data.user, team);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const candidates = await allCandidates(client, teamId);
|
|
208
|
+
const exact = candidates.filter(
|
|
209
|
+
(candidate) => candidate.displayName === input || candidate.name === input,
|
|
210
|
+
);
|
|
211
|
+
const lower = input.toLocaleLowerCase();
|
|
212
|
+
const matching = exact.length
|
|
213
|
+
? exact
|
|
214
|
+
: candidates.filter(
|
|
215
|
+
(candidate) =>
|
|
216
|
+
candidate.displayName.toLocaleLowerCase() === lower ||
|
|
217
|
+
candidate.name.toLocaleLowerCase() === lower,
|
|
218
|
+
);
|
|
219
|
+
const eligible = matching.filter(
|
|
220
|
+
(candidate) => isBaseEligible(candidate) && hasTeamAccess(candidate, team.private),
|
|
221
|
+
);
|
|
222
|
+
if (eligible.length > 1) {
|
|
223
|
+
throw ambiguous(
|
|
224
|
+
`Multiple eligible agents match '${input}': ${eligible.map(label).join(", ")}. Pass the agent user UUID.`,
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
if (eligible.length === 1) return validateCandidate(eligible[0]!, team);
|
|
228
|
+
if (matching.length > 0) return validateCandidate(matching[0]!, team);
|
|
229
|
+
|
|
230
|
+
const available = candidates.filter(
|
|
231
|
+
(candidate) => isBaseEligible(candidate) && hasTeamAccess(candidate, team.private),
|
|
232
|
+
);
|
|
233
|
+
const suffix =
|
|
234
|
+
available.length === 0
|
|
235
|
+
? " No eligible agents can access this team."
|
|
236
|
+
: available.length > MAX_LISTED
|
|
237
|
+
? ` Run 'linear user list --all' to inspect workspace users.`
|
|
238
|
+
: ` Available agents: ${available.map(label).join(", ")}.`;
|
|
239
|
+
throw notFound(`No eligible agent matching '${input}'.${suffix}`);
|
|
240
|
+
}
|