runline 0.30.0 → 0.31.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.
Files changed (35) hide show
  1. package/dist/index.d.ts +1 -1
  2. package/dist/index.js +1 -1
  3. package/dist/plugins/_shared/authedFetch.js +33 -0
  4. package/dist/plugins/_shared/provider.js +80 -0
  5. package/dist/plugins/_shared/tokenRefresh.js +5 -3
  6. package/dist/plugins/gett/src/index.js +358 -0
  7. package/dist/plugins/gett/src/places.js +140 -0
  8. package/dist/plugins/gett/src/rides.js +144 -0
  9. package/dist/plugins/gett/src/session.js +242 -0
  10. package/dist/plugins/gett/src/shared.js +180 -0
  11. package/dist/plugins/linear/src/attachments.js +2 -2
  12. package/dist/plugins/linear/src/cycles.js +6 -6
  13. package/dist/plugins/linear/src/index.js +2 -2
  14. package/dist/plugins/linear/src/initiatives.js +6 -6
  15. package/dist/plugins/linear/src/issues.js +9 -9
  16. package/dist/plugins/linear/src/labels.js +6 -6
  17. package/dist/plugins/linear/src/organization.js +2 -2
  18. package/dist/plugins/linear/src/projects.js +12 -12
  19. package/dist/plugins/linear/src/shared.js +148 -20
  20. package/dist/plugins/linear/src/states.js +3 -3
  21. package/dist/plugins/linear/src/teams.js +6 -5
  22. package/dist/plugins/linear/src/users.js +3 -3
  23. package/dist/plugins/linear/src/views.js +10 -8
  24. package/dist/plugins/linear/src/webhooks.js +5 -5
  25. package/dist/plugins/openai/src/index.js +28 -9
  26. package/dist/plugins/typesafe/src/guide.js +172 -0
  27. package/dist/plugins/typesafe/src/index.js +169 -0
  28. package/dist/plugins/typesafe/src/questions.js +180 -0
  29. package/dist/plugins/typesafe/src/shared.js +166 -0
  30. package/dist/plugins/wolt/src/account.js +427 -0
  31. package/dist/plugins/wolt/src/catalog.js +166 -0
  32. package/dist/plugins/wolt/src/index.js +526 -0
  33. package/dist/plugins/wolt/src/orders.js +349 -0
  34. package/dist/plugins/wolt/src/shared.js +218 -0
  35. package/package.json +1 -1
@@ -1,5 +1,5 @@
1
1
  import * as t from "typebox";
2
- import { bindGetAction, bindListAction, gql, INITIATIVE_FIELDS, key, requireUnscoped, } from "./shared.js";
2
+ import { bindGetAction, bindListAction, gql, INITIATIVE_FIELDS, key, requireUnscoped, withScopedNote, } from "./shared.js";
3
3
  export function registerInitiativeActions(rl) {
4
4
  const listAction = bindListAction(rl);
5
5
  const getAction = bindGetAction(rl);
@@ -7,7 +7,7 @@ export function registerInitiativeActions(rl) {
7
7
  getAction("initiative.get", "Get an initiative by ID or slug.", "initiative", INITIATIVE_FIELDS);
8
8
  rl.registerAction("initiative.create", {
9
9
  access: "write",
10
- description: "Create an initiative. Status: Planned | Active | Completed.",
10
+ description: withScopedNote("Create an initiative. Status: Planned | Active | Completed."),
11
11
  inputSchema: t.Object({
12
12
  name: t.String({ description: "The name of the initiative" }),
13
13
  description: t.Optional(t.String({ description: "The description of the initiative" })),
@@ -41,7 +41,7 @@ export function registerInitiativeActions(rl) {
41
41
  });
42
42
  rl.registerAction("initiative.update", {
43
43
  access: "write",
44
- description: "Update an initiative.",
44
+ description: withScopedNote("Update an initiative."),
45
45
  inputSchema: t.Object({
46
46
  id: t.String({
47
47
  description: "The identifier of the initiative to update",
@@ -79,7 +79,7 @@ export function registerInitiativeActions(rl) {
79
79
  });
80
80
  rl.registerAction("initiative.delete", {
81
81
  access: "write",
82
- description: "Trash an initiative.",
82
+ description: withScopedNote("Trash an initiative."),
83
83
  inputSchema: t.Object({
84
84
  id: t.String({
85
85
  description: "The identifier of the initiative to delete",
@@ -93,7 +93,7 @@ export function registerInitiativeActions(rl) {
93
93
  });
94
94
  rl.registerAction("initiative.addProject", {
95
95
  access: "write",
96
- description: "Associate a project with an initiative. Use this action for project-to-initiative linking; project.update does not accept initiativeId. Verify with initiative.get or the returned initiative.projects list.",
96
+ description: withScopedNote("Associate a project with an initiative. Use this action for project-to-initiative linking; project.update does not accept initiativeId. Verify with initiative.get or the returned initiative.projects list."),
97
97
  inputSchema: t.Object({
98
98
  initiativeId: t.String({
99
99
  description: "The identifier of the initiative",
@@ -114,7 +114,7 @@ export function registerInitiativeActions(rl) {
114
114
  });
115
115
  rl.registerAction("initiative.removeProject", {
116
116
  access: "write",
117
- description: "Remove a project from an initiative. Pass the link id returned by initiative.addProject, then verify with initiative.get.",
117
+ description: withScopedNote("Remove a project from an initiative. Pass the link id returned by initiative.addProject, then verify with initiative.get."),
118
118
  inputSchema: t.Object({
119
119
  id: t.String({
120
120
  description: "The identifier of the initiativeToProject to delete",
@@ -68,14 +68,14 @@ export function registerIssueActions(rl) {
68
68
  }),
69
69
  async execute(input, ctx) {
70
70
  const fields = { ...input };
71
- fields.labelIds = ensureScopeLabelsOnCreateOrReplace(ctx, fields.labelIds);
71
+ fields.labelIds = await ensureScopeLabelsOnCreateOrReplace(ctx, fields.labelIds);
72
72
  const data = await gql(key(ctx), `mutation($input: IssueCreateInput!) { issueCreate(input: $input) { success issue { ${ISSUE_FIELDS} } } }`, { input: fields });
73
73
  return data.issueCreate?.issue;
74
74
  },
75
75
  });
76
76
  rl.registerAction("issue.get", {
77
77
  access: "read",
78
- description: "Get an issue by ID or identifier (e.g. 'THE-154')",
78
+ description: "Get an issue by ID or identifier (e.g. 'THE-154').",
79
79
  inputSchema: t.Object({
80
80
  issueId: t.String({
81
81
  description: "Required parameter name. Pass either the Linear issue UUID or issue identifier, e.g. 'THE-154'.",
@@ -84,14 +84,14 @@ export function registerIssueActions(rl) {
84
84
  async execute(input, ctx) {
85
85
  const data = await gql(key(ctx), `query($id: String!) { issue(id: $id) { ${ISSUE_FIELDS} } }`, { id: input.issueId });
86
86
  const issue = data.issue;
87
- if (!issueHasScope(ctx, issue))
87
+ if (!(await issueHasScope(ctx, issue)))
88
88
  throw new Error("Linear issue is not available to this scoped connection");
89
89
  return issue;
90
90
  },
91
91
  });
92
92
  rl.registerAction("issue.list", {
93
93
  access: "read",
94
- description: "List issues. Pass `filter` for state/label/project/etc. Default hides archived.",
94
+ description: "List issues. teamId and assigneeId are convenience filters; use `filter` for anything else - assignee { assignee: { isMe: { eq: true } } }, cycle { cycle: { id: { eq: 'cycle-id' } } }, open work { state: { type: { nin: ['completed','canceled'] } } }. Default hides archived.",
95
95
  inputSchema: t.Object({
96
96
  ...LIST_INPUT_SCHEMA,
97
97
  teamId: t.Optional(t.String({ description: "Convenience: filter by team" })),
@@ -105,7 +105,7 @@ export function registerIssueActions(rl) {
105
105
  merged.team = { id: { eq: opts.teamId } };
106
106
  if (opts.assigneeId)
107
107
  merged.assignee = { id: { eq: opts.assigneeId } };
108
- const filter = mergeIssueScopeFilter(ctx, Object.keys(merged).length > 0 ? merged : undefined);
108
+ const filter = await mergeIssueScopeFilter(ctx, Object.keys(merged).length > 0 ? merged : undefined);
109
109
  const { argsDecl, argsCall, vars } = buildConnArgs({ ...opts, filter }, "IssueFilter");
110
110
  const data = await gql(key(ctx), `query${argsDecl} { issues${argsCall} { nodes { ${ISSUE_LITE} } pageInfo { hasNextPage endCursor } } }`, vars);
111
111
  const conn = data.issues;
@@ -169,9 +169,9 @@ export function registerIssueActions(rl) {
169
169
  const { issueId, ...fields } = input;
170
170
  await assertIssueInScope(ctx, String(issueId));
171
171
  if (fields.removedLabelIds)
172
- forbidScopeLabelRemoval(ctx, fields.removedLabelIds);
172
+ await forbidScopeLabelRemoval(ctx, fields.removedLabelIds);
173
173
  if (fields.labelIds)
174
- fields.labelIds = ensureScopeLabelsOnCreateOrReplace(ctx, fields.labelIds);
174
+ fields.labelIds = await ensureScopeLabelsOnCreateOrReplace(ctx, fields.labelIds);
175
175
  const data = await gql(key(ctx), `mutation($id: String!, $input: IssueUpdateInput!) { issueUpdate(id: $id, input: $input) { success issue { ${ISSUE_FIELDS} } } }`, { id: issueId, input: fields });
176
176
  return data.issueUpdate?.issue;
177
177
  },
@@ -246,7 +246,7 @@ export function registerIssueActions(rl) {
246
246
  }`, {
247
247
  term: opts.term,
248
248
  first: opts.limit ?? 50,
249
- filter: mergeIssueScopeFilter(ctx, opts.filter) ?? null,
249
+ filter: (await mergeIssueScopeFilter(ctx, opts.filter)) ?? null,
250
250
  includeComments: opts.includeComments ?? null,
251
251
  includeArchived: opts.includeArchived ?? null,
252
252
  teamId: opts.teamId ?? null,
@@ -281,7 +281,7 @@ export function registerIssueActions(rl) {
281
281
  async execute(input, ctx) {
282
282
  const { issueId, labelId } = input;
283
283
  await assertIssueInScope(ctx, issueId);
284
- forbidScopeLabelRemoval(ctx, labelId);
284
+ await forbidScopeLabelRemoval(ctx, labelId);
285
285
  const data = await gql(key(ctx), `mutation($id: String!, $labelId: String!) { issueRemoveLabel(id: $id, labelId: $labelId) { success } }`, { id: issueId, labelId });
286
286
  return data.issueRemoveLabel;
287
287
  },
@@ -1,5 +1,5 @@
1
1
  import * as t from "typebox";
2
- import { bindGetAction, bindListAction, gql, key, LABEL_FIELDS, requireUnscoped, } from "./shared.js";
2
+ import { bindGetAction, bindListAction, gql, key, LABEL_FIELDS, requireUnscoped, withScopedNote, } from "./shared.js";
3
3
  export function registerLabelActions(rl) {
4
4
  const listAction = bindListAction(rl);
5
5
  const getAction = bindGetAction(rl);
@@ -7,7 +7,7 @@ export function registerLabelActions(rl) {
7
7
  getAction("label.get", "Get a label by ID.", "issueLabel", LABEL_FIELDS);
8
8
  rl.registerAction("label.create", {
9
9
  access: "write",
10
- description: "Create a label. Omit teamId for a workspace-level label.",
10
+ description: withScopedNote("Create a label. Omit teamId for a workspace-level label."),
11
11
  inputSchema: t.Object({
12
12
  name: t.String({ description: "The name of the label" }),
13
13
  teamId: t.Optional(t.String({
@@ -38,7 +38,7 @@ export function registerLabelActions(rl) {
38
38
  });
39
39
  rl.registerAction("label.update", {
40
40
  access: "write",
41
- description: "Update a label.",
41
+ description: withScopedNote("Update a label."),
42
42
  inputSchema: t.Object({
43
43
  id: t.String({ description: "The identifier of the label to update" }),
44
44
  name: t.Optional(t.String({ description: "The name of the label" })),
@@ -62,7 +62,7 @@ export function registerLabelActions(rl) {
62
62
  });
63
63
  rl.registerAction("label.delete", {
64
64
  access: "write",
65
- description: "Delete a label.",
65
+ description: withScopedNote("Delete a label."),
66
66
  inputSchema: t.Object({
67
67
  id: t.String({ description: "The identifier of the label to delete" }),
68
68
  }),
@@ -74,7 +74,7 @@ export function registerLabelActions(rl) {
74
74
  });
75
75
  rl.registerAction("label.retire", {
76
76
  access: "write",
77
- description: "Retire a label. Retired labels remain visible but cannot be applied to new issues.",
77
+ description: withScopedNote("Retire a label. Retired labels remain visible but cannot be applied to new issues."),
78
78
  inputSchema: t.Object({
79
79
  id: t.String({ description: "The identifier of the label to retire" }),
80
80
  }),
@@ -86,7 +86,7 @@ export function registerLabelActions(rl) {
86
86
  });
87
87
  rl.registerAction("label.restore", {
88
88
  access: "write",
89
- description: "Restore a previously retired label.",
89
+ description: withScopedNote("Restore a previously retired label."),
90
90
  inputSchema: t.Object({
91
91
  id: t.String({ description: "The identifier of the label to restore" }),
92
92
  }),
@@ -1,9 +1,9 @@
1
1
  import * as t from "typebox";
2
- import { gql, key, ORG_FIELDS, requireUnscoped } from "./shared.js";
2
+ import { gql, key, ORG_FIELDS, requireUnscoped, withScopedNote, } from "./shared.js";
3
3
  export function registerOrganizationActions(rl) {
4
4
  rl.registerAction("org.get", {
5
5
  access: "read",
6
- description: "Get the authenticated workspace.",
6
+ description: withScopedNote("Get the authenticated workspace."),
7
7
  inputSchema: t.Object({}),
8
8
  async execute(_input, ctx) {
9
9
  requireUnscoped(ctx, "org.get");
@@ -1,5 +1,5 @@
1
1
  import * as t from "typebox";
2
- import { bindGetAction, bindListAction, gql, key, MILESTONE_FIELDS, PROJECT_FIELDS, PROJECT_UPDATE_FIELDS, requireUnscoped, } from "./shared.js";
2
+ import { bindGetAction, bindListAction, gql, key, MILESTONE_FIELDS, PROJECT_FIELDS, PROJECT_UPDATE_FIELDS, requireUnscoped, withScopedNote, } from "./shared.js";
3
3
  export function registerProjectActions(rl) {
4
4
  const listAction = bindListAction(rl);
5
5
  const getAction = bindGetAction(rl);
@@ -7,7 +7,7 @@ export function registerProjectActions(rl) {
7
7
  getAction("project.get", "Get a project by ID or slug.", "project", PROJECT_FIELDS);
8
8
  rl.registerAction("project.create", {
9
9
  access: "write",
10
- description: "Create a project. teamIds is required.",
10
+ description: withScopedNote("Create a project. teamIds is required."),
11
11
  inputSchema: t.Object({
12
12
  name: t.String({ description: "The name of the project" }),
13
13
  teamIds: t.Array(t.Unknown(), {
@@ -68,7 +68,7 @@ export function registerProjectActions(rl) {
68
68
  });
69
69
  rl.registerAction("project.update", {
70
70
  access: "write",
71
- description: "Update a project.",
71
+ description: withScopedNote("Update a project."),
72
72
  inputSchema: t.Object({
73
73
  id: t.String({
74
74
  description: "The identifier of the project to update (UUID or slug)",
@@ -126,7 +126,7 @@ export function registerProjectActions(rl) {
126
126
  });
127
127
  rl.registerAction("project.delete", {
128
128
  access: "write",
129
- description: "Trash (soft-delete) a project. Restorable via project.unarchive.",
129
+ description: withScopedNote("Trash (soft-delete) a project. Restorable via project.unarchive."),
130
130
  inputSchema: t.Object({
131
131
  id: t.String({ description: "The identifier of the project to delete" }),
132
132
  }),
@@ -138,7 +138,7 @@ export function registerProjectActions(rl) {
138
138
  });
139
139
  rl.registerAction("project.unarchive", {
140
140
  access: "write",
141
- description: "Restore a previously trashed or archived project.",
141
+ description: withScopedNote("Restore a previously trashed or archived project."),
142
142
  inputSchema: t.Object({
143
143
  id: t.String({
144
144
  description: "The identifier of the project to restore (UUID or slug)",
@@ -152,7 +152,7 @@ export function registerProjectActions(rl) {
152
152
  });
153
153
  rl.registerAction("project.search", {
154
154
  access: "read",
155
- description: "Search projects by text. Rate-limited to 30 req/min.",
155
+ description: withScopedNote("Search projects by text. Rate-limited to 30 req/min."),
156
156
  inputSchema: t.Object({
157
157
  term: t.String({ description: "Search string to look for" }),
158
158
  limit: t.Optional(t.Number({
@@ -185,7 +185,7 @@ export function registerProjectActions(rl) {
185
185
  getAction("milestone.get", "Get a project milestone by ID.", "projectMilestone", MILESTONE_FIELDS);
186
186
  rl.registerAction("milestone.create", {
187
187
  access: "write",
188
- description: "Create a project milestone.",
188
+ description: withScopedNote("Create a project milestone."),
189
189
  inputSchema: t.Object({
190
190
  projectId: t.String({
191
191
  description: "Related project for the project milestone",
@@ -213,7 +213,7 @@ export function registerProjectActions(rl) {
213
213
  });
214
214
  rl.registerAction("milestone.update", {
215
215
  access: "write",
216
- description: "Update a project milestone.",
216
+ description: withScopedNote("Update a project milestone."),
217
217
  inputSchema: t.Object({
218
218
  id: t.String({
219
219
  description: "The identifier of the project milestone to update",
@@ -242,7 +242,7 @@ export function registerProjectActions(rl) {
242
242
  });
243
243
  rl.registerAction("milestone.delete", {
244
244
  access: "write",
245
- description: "Delete a project milestone.",
245
+ description: withScopedNote("Delete a project milestone."),
246
246
  inputSchema: t.Object({
247
247
  id: t.String({
248
248
  description: "The identifier of the project milestone to delete",
@@ -258,7 +258,7 @@ export function registerProjectActions(rl) {
258
258
  listAction("projectUpdate.list", "List project updates.", "projectUpdates", "ProjectUpdateFilter", PROJECT_UPDATE_FIELDS);
259
259
  rl.registerAction("projectUpdate.create", {
260
260
  access: "write",
261
- description: "Post a status update on a project.",
261
+ description: withScopedNote("Post a status update on a project."),
262
262
  inputSchema: t.Object({
263
263
  projectId: t.String({
264
264
  description: "The project to associate the project update with",
@@ -285,7 +285,7 @@ export function registerProjectActions(rl) {
285
285
  });
286
286
  rl.registerAction("projectUpdate.update", {
287
287
  access: "write",
288
- description: "Update a project status update.",
288
+ description: withScopedNote("Update a project status update."),
289
289
  inputSchema: t.Object({
290
290
  id: t.String({
291
291
  description: "The identifier of the project update to update",
@@ -310,7 +310,7 @@ export function registerProjectActions(rl) {
310
310
  });
311
311
  rl.registerAction("projectUpdate.archive", {
312
312
  access: "write",
313
- description: "Archive a project status update.",
313
+ description: withScopedNote("Archive a project status update."),
314
314
  inputSchema: t.Object({
315
315
  id: t.String({
316
316
  description: "The identifier of the project update to archive",
@@ -1,10 +1,12 @@
1
+ import { createHash } from "node:crypto";
1
2
  import * as t from "typebox";
3
+ import { authedFetch } from "../../_shared/authedFetch.js";
2
4
  const GQL_URL = "https://api.linear.app/graphql";
3
5
  export async function gql(apiKey, query, variables) {
4
6
  const body = { query };
5
7
  if (variables)
6
8
  body.variables = variables;
7
- const res = await fetch(GQL_URL, {
9
+ const res = await authedFetch(GQL_URL, {
8
10
  method: "POST",
9
11
  headers: { Authorization: apiKey, "Content-Type": "application/json" },
10
12
  body: JSON.stringify(body),
@@ -19,7 +21,11 @@ export async function gql(apiKey, query, variables) {
19
21
  export function key(ctx) {
20
22
  return ctx.connection.config.apiKey;
21
23
  }
22
- export function scopeLabelIds(ctx) {
24
+ /**
25
+ * The configured scope values, verbatim. Each is either a label UUID or a
26
+ * label name — `resolveScopeLabelIds` turns the latter into the former.
27
+ */
28
+ export function scopeLabelValues(ctx) {
23
29
  const raw = ctx.connection.config.scopeLabelIds;
24
30
  if (Array.isArray(raw))
25
31
  return raw
@@ -33,11 +39,115 @@ export function scopeLabelIds(ctx) {
33
39
  .map((s) => s.trim())
34
40
  .filter(Boolean);
35
41
  }
42
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
43
+ /**
44
+ * The workspace's labels, per API key. Linear rejects a non-UUID in
45
+ * `labels: { id: { in: [...] } }` with "each value in in must be a UUID",
46
+ * so a human-written scope value like `requester:yosi` breaks every issue
47
+ * query (SHFT-1644) unless it is resolved to an id first. The directory is
48
+ * fetched once per key and re-read on a miss, so a label created later
49
+ * still resolves.
50
+ *
51
+ * Keyed by digest, so a long-lived Map never holds the API key itself.
52
+ */
53
+ const labelDirectories = new Map();
54
+ const directoryKey = (apiKey) => createHash("sha256").update(apiKey).digest("hex");
55
+ async function fetchLabelDirectory(apiKey) {
56
+ const byName = new Map();
57
+ let after = null;
58
+ for (;;) {
59
+ const data = await gql(apiKey, `query($after: String) { issueLabels(first: 250, after: $after) { nodes { id name team { key } } pageInfo { hasNextPage endCursor } } }`, { after });
60
+ const conn = data.issueLabels;
61
+ for (const node of conn?.nodes ?? []) {
62
+ const name = String(node.name);
63
+ const team = node.team;
64
+ const entry = {
65
+ id: String(node.id),
66
+ name,
67
+ teamKey: typeof team?.key === "string" ? team.key : null,
68
+ };
69
+ const lookup = name.toLowerCase();
70
+ const existing = byName.get(lookup);
71
+ if (existing)
72
+ existing.push(entry);
73
+ else
74
+ byName.set(lookup, [entry]);
75
+ }
76
+ const pageInfo = conn?.pageInfo;
77
+ if (!pageInfo?.hasNextPage)
78
+ break;
79
+ const next = String(pageInfo.endCursor);
80
+ // A cursor that does not advance would page forever; stop instead.
81
+ if (next === after)
82
+ break;
83
+ after = next;
84
+ }
85
+ return byName;
86
+ }
87
+ function labelDirectory(apiKey, { refresh = false } = {}) {
88
+ const cacheKey = directoryKey(apiKey);
89
+ const cached = refresh ? undefined : labelDirectories.get(cacheKey);
90
+ if (cached)
91
+ return cached;
92
+ const pending = fetchLabelDirectory(apiKey);
93
+ labelDirectories.set(cacheKey, pending);
94
+ // A failed lookup must not be cached as the answer for this key.
95
+ pending.catch(() => {
96
+ if (labelDirectories.get(cacheKey) === pending)
97
+ labelDirectories.delete(cacheKey);
98
+ });
99
+ return pending;
100
+ }
101
+ function unknownLabelError(value) {
102
+ return new Error(`Linear scope label "${value}" is neither a label UUID nor the name of a label in this workspace. ` +
103
+ `Set scopeLabelIds (LINEAR_SCOPE_LABEL_IDS) to label UUIDs or exact label names, or unset it for full access.`);
104
+ }
105
+ /**
106
+ * Label names are not unique in Linear: every team may own a `bug` alongside
107
+ * a workspace one, and a case-insensitive match widens that further. Picking
108
+ * whichever paginated last would scope the connection to a label nobody
109
+ * chose — and a scope that silently binds to the wrong label shows the agent
110
+ * issues it was never meant to read. An ambiguous name is refused instead,
111
+ * with the candidates to choose between.
112
+ */
113
+ function ambiguousLabelError(value, matches) {
114
+ const candidates = matches
115
+ .map((m) => `${m.teamKey ? `team ${m.teamKey}` : "workspace"} "${m.name}" → ${m.id}`)
116
+ .join("; ");
117
+ return new Error(`Linear scope label "${value}" is ambiguous: ${matches.length} labels share that name (${candidates}). ` +
118
+ `Set scopeLabelIds (LINEAR_SCOPE_LABEL_IDS) to the UUID of the one you mean.`);
119
+ }
120
+ /** Scope label UUIDs, resolving any configured label *names* to their ids. */
121
+ export async function resolveScopeLabelIds(ctx) {
122
+ const values = scopeLabelValues(ctx);
123
+ if (values.length === 0)
124
+ return [];
125
+ if (values.every((v) => UUID_RE.test(v)))
126
+ return values;
127
+ const apiKey = key(ctx);
128
+ const missing = (directory) => values.some((v) => !UUID_RE.test(v) && !directory.has(v.toLowerCase()));
129
+ let directory = await labelDirectory(apiKey);
130
+ // A label created after the directory was cached would otherwise stay
131
+ // unresolvable for the lifetime of the process: re-read once before
132
+ // declaring a name unknown.
133
+ if (missing(directory))
134
+ directory = await labelDirectory(apiKey, { refresh: true });
135
+ return values.map((value) => {
136
+ if (UUID_RE.test(value))
137
+ return value;
138
+ const matches = directory.get(value.toLowerCase()) ?? [];
139
+ if (matches.length === 0)
140
+ throw unknownLabelError(value);
141
+ if (matches.length > 1)
142
+ throw ambiguousLabelError(value, matches);
143
+ return matches[0].id;
144
+ });
145
+ }
36
146
  export function isScoped(ctx) {
37
- return scopeLabelIds(ctx).length > 0;
147
+ return scopeLabelValues(ctx).length > 0;
38
148
  }
39
- export function mergeIssueScopeFilter(ctx, filter) {
40
- const ids = scopeLabelIds(ctx);
149
+ export async function mergeIssueScopeFilter(ctx, filter) {
150
+ const ids = await resolveScopeLabelIds(ctx);
41
151
  if (ids.length === 0)
42
152
  return filter;
43
153
  const scopeFilter = { labels: { id: { in: ids } } };
@@ -45,8 +155,8 @@ export function mergeIssueScopeFilter(ctx, filter) {
45
155
  return scopeFilter;
46
156
  return { and: [filter, scopeFilter] };
47
157
  }
48
- export function issueHasScope(ctx, issue) {
49
- const ids = new Set(scopeLabelIds(ctx));
158
+ export async function issueHasScope(ctx, issue) {
159
+ const ids = new Set(await resolveScopeLabelIds(ctx));
50
160
  if (ids.size === 0)
51
161
  return true;
52
162
  const labels = issue?.labels?.nodes;
@@ -61,7 +171,7 @@ export async function assertIssueInScope(ctx, issueId) {
61
171
  if (!isScoped(ctx))
62
172
  return null;
63
173
  const issue = await getIssueForScope(ctx, issueId);
64
- if (!issue || !issueHasScope(ctx, issue))
174
+ if (!issue || !(await issueHasScope(ctx, issue)))
65
175
  throw new Error("Linear issue is not available to this scoped connection");
66
176
  return issue;
67
177
  }
@@ -70,7 +180,7 @@ export async function assertCommentInScope(ctx, commentId) {
70
180
  return;
71
181
  const data = await gql(key(ctx), `query($id: String!) { comment(id: $id) { id issue { id identifier labels { nodes { id name } } } } }`, { id: commentId });
72
182
  const issue = data.comment?.issue;
73
- if (!issue || !issueHasScope(ctx, issue))
183
+ if (!issue || !(await issueHasScope(ctx, issue)))
74
184
  throw new Error("Linear comment is not available to this scoped connection");
75
185
  }
76
186
  export async function assertAttachmentInScope(ctx, attachmentId) {
@@ -78,11 +188,11 @@ export async function assertAttachmentInScope(ctx, attachmentId) {
78
188
  return;
79
189
  const data = await gql(key(ctx), `query($id: String!) { attachment(id: $id) { id issue { id identifier labels { nodes { id name } } } } }`, { id: attachmentId });
80
190
  const issue = data.attachment?.issue;
81
- if (!issue || !issueHasScope(ctx, issue))
191
+ if (!issue || !(await issueHasScope(ctx, issue)))
82
192
  throw new Error("Linear attachment is not available to this scoped connection");
83
193
  }
84
- export function forbidScopeLabelRemoval(ctx, labelIds) {
85
- const scoped = new Set(scopeLabelIds(ctx));
194
+ export async function forbidScopeLabelRemoval(ctx, labelIds) {
195
+ const scoped = new Set(await resolveScopeLabelIds(ctx));
86
196
  if (scoped.size === 0)
87
197
  return;
88
198
  const ids = Array.isArray(labelIds)
@@ -92,8 +202,8 @@ export function forbidScopeLabelRemoval(ctx, labelIds) {
92
202
  throw new Error("Cannot remove a required Linear scope label");
93
203
  }
94
204
  }
95
- export function ensureScopeLabelsOnCreateOrReplace(ctx, labelIds) {
96
- const scoped = scopeLabelIds(ctx);
205
+ export async function ensureScopeLabelsOnCreateOrReplace(ctx, labelIds) {
206
+ const scoped = await resolveScopeLabelIds(ctx);
97
207
  if (scoped.length === 0)
98
208
  return labelIds;
99
209
  const ids = new Set(Array.isArray(labelIds) ? labelIds.map(String) : []);
@@ -101,6 +211,16 @@ export function ensureScopeLabelsOnCreateOrReplace(ctx, labelIds) {
101
211
  ids.add(id);
102
212
  return [...ids];
103
213
  }
214
+ /**
215
+ * Appended to the description of every action a scoped connection cannot
216
+ * use. Descriptions are static, but they are also all an agent sees when it
217
+ * picks an action (`actions.find`, the Vex catalog): without this, a scoped
218
+ * agent discovers the action, calls it, and only then learns it is blocked.
219
+ */
220
+ export const SCOPED_UNAVAILABLE_NOTE = "Unavailable on scoped connections.";
221
+ export function withScopedNote(description) {
222
+ return `${description} ${SCOPED_UNAVAILABLE_NOTE}`;
223
+ }
104
224
  export function requireUnscoped(ctx, action) {
105
225
  if (isScoped(ctx)) {
106
226
  throw new Error(`${action} is not available to scoped Linear connections`);
@@ -195,13 +315,19 @@ export function bindListAction(rl) {
195
315
  export function bindGetAction(rl) {
196
316
  return (...args) => registerGetAction(rl, ...args);
197
317
  }
318
+ /**
319
+ * Root fields a scoped connection may not read.
320
+ *
321
+ * The scope restricts *issue content*, so workspace metadata an agent needs
322
+ * to route and report on its own issues — cycles, users, teams, states and
323
+ * labels — stays readable. Fields that would leak other people's content or
324
+ * secrets are blocked, and a blocked field's description says so.
325
+ */
198
326
  const SCOPED_BLOCKED_ROOT_FIELDS = new Set([
199
327
  "attachments",
200
328
  "comments",
201
329
  "customView",
202
330
  "customViews",
203
- "cycle",
204
- "cycles",
205
331
  "initiative",
206
332
  "initiatives",
207
333
  "project",
@@ -209,8 +335,6 @@ const SCOPED_BLOCKED_ROOT_FIELDS = new Set([
209
335
  "projectMilestone",
210
336
  "projectMilestones",
211
337
  "projectUpdates",
212
- "user",
213
- "users",
214
338
  "webhook",
215
339
  "webhooks",
216
340
  ]);
@@ -221,7 +345,9 @@ function requireRootFieldAvailable(ctx, action, rootField) {
221
345
  export function registerListAction(rl, name, description, rootField, filterTypeName, selection) {
222
346
  rl.registerAction(name, {
223
347
  access: "read",
224
- description,
348
+ description: SCOPED_BLOCKED_ROOT_FIELDS.has(rootField)
349
+ ? withScopedNote(description)
350
+ : description,
225
351
  inputSchema: t.Object(LIST_INPUT_SCHEMA),
226
352
  async execute(input, ctx) {
227
353
  requireRootFieldAvailable(ctx, name, rootField);
@@ -236,7 +362,9 @@ export function registerListAction(rl, name, description, rootField, filterTypeN
236
362
  export function registerGetAction(rl, name, description, rootField, selection) {
237
363
  rl.registerAction(name, {
238
364
  access: "read",
239
- description,
365
+ description: SCOPED_BLOCKED_ROOT_FIELDS.has(rootField)
366
+ ? withScopedNote(description)
367
+ : description,
240
368
  inputSchema: t.Object({
241
369
  id: t.String({ description: "Identifier or slug" }),
242
370
  }),
@@ -1,5 +1,5 @@
1
1
  import * as t from "typebox";
2
- import { bindGetAction, bindListAction, gql, key, requireUnscoped, STATE_FIELDS, } from "./shared.js";
2
+ import { bindGetAction, bindListAction, gql, key, requireUnscoped, STATE_FIELDS, withScopedNote, } from "./shared.js";
3
3
  export function registerStateActions(rl) {
4
4
  const listAction = bindListAction(rl);
5
5
  const getAction = bindGetAction(rl);
@@ -7,7 +7,7 @@ export function registerStateActions(rl) {
7
7
  getAction("state.get", "Get a workflow state by ID.", "workflowState", STATE_FIELDS);
8
8
  rl.registerAction("state.create", {
9
9
  access: "write",
10
- description: "Create a workflow state in a team.",
10
+ description: withScopedNote("Create a workflow state in a team."),
11
11
  inputSchema: t.Object({
12
12
  teamId: t.String({ description: "The team associated with the state" }),
13
13
  name: t.String({ description: "The name of the state" }),
@@ -32,7 +32,7 @@ export function registerStateActions(rl) {
32
32
  });
33
33
  rl.registerAction("state.update", {
34
34
  access: "write",
35
- description: "Update a workflow state. Type cannot be changed after creation.",
35
+ description: withScopedNote("Update a workflow state. Type cannot be changed after creation."),
36
36
  inputSchema: t.Object({
37
37
  id: t.String({ description: "The identifier of the state to update" }),
38
38
  name: t.Optional(t.String({ description: "The name of the state" })),
@@ -1,5 +1,5 @@
1
1
  import * as t from "typebox";
2
- import { bindGetAction, bindListAction, gql, key, requireUnscoped, TEAM_FIELDS, USER_FIELDS, } from "./shared.js";
2
+ import { bindGetAction, bindListAction, gql, key, requireUnscoped, TEAM_FIELDS, USER_FIELDS, withScopedNote, } from "./shared.js";
3
3
  export function registerTeamActions(rl) {
4
4
  const listAction = bindListAction(rl);
5
5
  const getAction = bindGetAction(rl);
@@ -7,7 +7,7 @@ export function registerTeamActions(rl) {
7
7
  getAction("team.get", "Get a team by ID or key.", "team", TEAM_FIELDS);
8
8
  rl.registerAction("team.create", {
9
9
  access: "write",
10
- description: "Create a team. Most settings have sensible defaults.",
10
+ description: withScopedNote("Create a team. Most settings have sensible defaults."),
11
11
  inputSchema: t.Object({
12
12
  name: t.String({ description: "The name of the team" }),
13
13
  key: t.Optional(t.String({
@@ -51,7 +51,7 @@ export function registerTeamActions(rl) {
51
51
  });
52
52
  rl.registerAction("team.update", {
53
53
  access: "write",
54
- description: "Update a team. Requires team owner or workspace admin permissions.",
54
+ description: withScopedNote("Update a team. Requires team owner or workspace admin permissions."),
55
55
  inputSchema: t.Object({
56
56
  id: t.String({ description: "The identifier of the team to update" }),
57
57
  name: t.Optional(t.String({ description: "The name of the team" })),
@@ -86,13 +86,14 @@ export function registerTeamActions(rl) {
86
86
  });
87
87
  rl.registerAction("team.members", {
88
88
  access: "read",
89
- description: "List members of a team.",
89
+ description: "List members of a team. Same people surface as user.list, narrowed to one team.",
90
90
  inputSchema: t.Object({
91
91
  teamId: t.String({ description: "The identifier of the team" }),
92
92
  limit: t.Optional(t.Number({ description: "Max members to return (default 50)" })),
93
93
  }),
94
94
  async execute(input, ctx) {
95
- requireUnscoped(ctx, "team.members");
95
+ // Team membership is workspace metadata, like user.list: readable
96
+ // under a scoped connection (SHFT-1644).
96
97
  const { teamId, limit } = input;
97
98
  const data = await gql(key(ctx), `query($id: String!, $first: Int) {
98
99
  team(id: $id) { members(first: $first) { nodes { ${USER_FIELDS} } } }
@@ -1,5 +1,5 @@
1
1
  import * as t from "typebox";
2
- import { bindGetAction, bindListAction, gql, key, requireUnscoped, USER_FIELDS, } from "./shared.js";
2
+ import { bindGetAction, bindListAction, gql, key, requireUnscoped, USER_FIELDS, withScopedNote, } from "./shared.js";
3
3
  export function registerUserActions(rl) {
4
4
  const listAction = bindListAction(rl);
5
5
  const getAction = bindGetAction(rl);
@@ -7,7 +7,7 @@ export function registerUserActions(rl) {
7
7
  getAction("user.get", "Get a user by ID. Use 'me' to reference the authenticated user.", "user", USER_FIELDS);
8
8
  rl.registerAction("user.me", {
9
9
  access: "read",
10
- description: "Get the authenticated user.",
10
+ description: "Get the authenticated user. Use this to resolve 'me' before filtering issues by assignee.",
11
11
  inputSchema: t.Object({}),
12
12
  async execute(_input, ctx) {
13
13
  const data = await gql(key(ctx), `query { viewer { ${USER_FIELDS} } }`);
@@ -16,7 +16,7 @@ export function registerUserActions(rl) {
16
16
  });
17
17
  rl.registerAction("user.update", {
18
18
  access: "write",
19
- description: "Update a user. Use id='me' to update the authenticated user.",
19
+ description: withScopedNote("Update a user. Use id='me' to update the authenticated user."),
20
20
  inputSchema: t.Object({
21
21
  id: t.String({
22
22
  description: "The identifier of the user to update. Use 'me' to reference the currently authenticated user",