@gallopsystems/agent-skills 1.20.1 → 1.22.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/package.json
CHANGED
|
@@ -15,7 +15,9 @@ Invoke it as `node <skill>/bin/linear.mjs <command> [args] [--flags]`. Examples
|
|
|
15
15
|
|
|
16
16
|
### Check 1 — Workspace bootstrap config exists
|
|
17
17
|
|
|
18
|
-
Before running any `linear.mjs` command, verify that the per-user workspace config exists at `~/.config/linctl/workspace.json` (override path with `$LINCTL_WORKSPACE_FILE`). This file holds
|
|
18
|
+
Before running any `linear.mjs` command, verify that the per-user workspace config exists at `~/.config/linctl/workspace.json` (override path with `$LINCTL_WORKSPACE_FILE`). This file holds **every team** in the workspace (each with its own UUID plus its workflow-state and label UUIDs — states differ per team), an optional `defaultTeam`, and the Linear member UUIDs that play the Frontend/PM and Backend roles. Without it, every command that needs the team, members, states, or labels will refuse to run.
|
|
19
|
+
|
|
20
|
+
> **Multi-team workspaces:** `workspace.json` registers all teams, but `states`/`labels` are per-team (each team's `Todo` is a distinct UUID). Which team a command targets is resolved in this order: the **`--team <key|name|uuid>`** flag → the **`LINCTL_DEFAULT_TEAM`** env var (a per-repo default — set it via direnv/`.envrc` or your shell so every command in a repo targets that team) → the **`defaultTeam`** field in `workspace.json`. If none resolve, `--team` is **required** on team-scoped commands; workspace-wide commands (e.g. `list-initiatives`) work without a team. A legacy config (predating per-team support, i.e. with no `defaultTeam` key and top-level `states`/`labels`) still works — it falls back to the first registered team — but re-run `init` to migrate it to the per-team schema.
|
|
19
21
|
|
|
20
22
|
```bash
|
|
21
23
|
[ -f "${LINCTL_WORKSPACE_FILE:-$HOME/.config/linctl/workspace.json}" ] && echo "ok" || echo "missing"
|
|
@@ -27,7 +29,7 @@ Before running any `linear.mjs` command, verify that the per-user workspace conf
|
|
|
27
29
|
node linear.mjs init
|
|
28
30
|
```
|
|
29
31
|
|
|
30
|
-
`init` calls Linear's GraphQL API, lists the workspace's members, and prompts the user to designate (1) the Frontend/PM lead and (2) the Backend lead by number. It
|
|
32
|
+
`init` calls Linear's GraphQL API, lists the workspace's members, and prompts the user to designate (1) the Frontend/PM lead and (2) the Backend lead by number, then (3) an optional default team key (blank = no default, so `--team` is required on each team-scoped call). It registers **all** teams with their per-team states/labels and writes `~/.config/linctl/workspace.json`. The config is read fresh on every invocation — no re-sourcing needed.
|
|
31
33
|
|
|
32
34
|
### Check 2 — Linear MCP server installed and authorized
|
|
33
35
|
|
|
@@ -187,6 +189,7 @@ node linear.mjs help # full command list
|
|
|
187
189
|
The CLI resolves friendly names against `workspace.json`, so you rarely need raw UUIDs:
|
|
188
190
|
|
|
189
191
|
```
|
|
192
|
+
--team ACME | "Acme Corp" (team key or name) (or a UUID)
|
|
190
193
|
--state todo | backlog | "in progress" | "in review" | done | canceled (or a UUID)
|
|
191
194
|
--assignee frontend | backend (or a UUID)
|
|
192
195
|
--labels bug,frontend,feature (comma-separated label names) (or UUIDs)
|
|
@@ -194,13 +197,15 @@ The CLI resolves friendly names against `workspace.json`, so you rarely need raw
|
|
|
194
197
|
--cycle current (the active cycle) (or a UUID)
|
|
195
198
|
```
|
|
196
199
|
|
|
197
|
-
Any value that's already a UUID is passed through untouched. Project and milestone IDs are still UUIDs (pass them with `--project` / `--milestone`).
|
|
200
|
+
`--team` selects which team a team-scoped command runs against, and `--state`/`--labels` then resolve against **that team's** states and labels. When `--team` is omitted it falls back to `$LINCTL_DEFAULT_TEAM` (a per-repo default) and then to `workspace.json`'s `defaultTeam`; if none are set you'll get an error listing the registered team keys. Workspace-wide commands (initiatives) don't need a team. Any value that's already a UUID is passed through untouched. Project and milestone IDs are still UUIDs (pass them with `--project` / `--milestone`).
|
|
198
201
|
|
|
199
202
|
### Creating Issues
|
|
200
203
|
|
|
201
204
|
> **Important:** When assigning an issue to a cycle, always set `--state todo`. Issues default to Backlog, which doesn't work with cycles — they must be in Todo status.
|
|
202
205
|
>
|
|
203
206
|
> **Required placement rule:** Never create an issue without both `--project` and `--milestone`. If the right project does not exist, create it first. If the project exists but the right milestone does not, create the milestone first. Do not leave issues unscoped or unmilestoned.
|
|
207
|
+
>
|
|
208
|
+
> **Confirm decisions with the requester — don't punt them into the issue.** When the person asking you to create the issue is right there in the conversation, ask the open decisions (scope, mechanism, data source, ownership, who/where it should land) *before* writing the issue — e.g. via a structured question prompt — and bake the confirmed answers into the body. Do **not** write an "Open questions" section full of decisions you could have just asked, and do **not** use that manufactured uncertainty as a rationale to leave the issue in Backlog or unassigned. Only genuinely external unknowns (something that needs a meeting, a client, or a spike to resolve) belong as open questions; everything the requester can answer on the spot should already be a confirmed decision with the issue placed and assigned accordingly.
|
|
204
209
|
|
|
205
210
|
```bash
|
|
206
211
|
# --state todo is required when using --cycle
|
|
@@ -73,39 +73,111 @@ function readMaybeFile(v, key) {
|
|
|
73
73
|
// --- Workspace config ------------------------------------------------------
|
|
74
74
|
|
|
75
75
|
/**
|
|
76
|
+
* @typedef {{ id: string, key: string, name: string, states?: Record<string, string>, labels?: Record<string, string> }} TeamEntry
|
|
76
77
|
* @typedef {{
|
|
77
78
|
* teamId: string,
|
|
79
|
+
* teams: TeamEntry[],
|
|
78
80
|
* members: { frontend?: string, backend?: string, frontendName?: string, backendName?: string },
|
|
79
81
|
* states: Record<string, string>,
|
|
80
82
|
* labels: Record<string, string>,
|
|
81
83
|
* }} Config
|
|
82
84
|
*/
|
|
83
85
|
|
|
84
|
-
/**
|
|
85
|
-
|
|
86
|
+
/**
|
|
87
|
+
* Resolve which team a command runs against. Precedence: the `--team` flag
|
|
88
|
+
* (key, name, or UUID), then `$LINCTL_DEFAULT_TEAM` (a per-repo default, set via
|
|
89
|
+
* direnv or the shell), then the config's `defaultTeam`. A config that predates
|
|
90
|
+
* per-team support (no `defaultTeam` key at all) and no env override falls back
|
|
91
|
+
* to the first team for compatibility. Returns null only when there is genuinely
|
|
92
|
+
* no default and no `--team` — the caller defers the error until `cfg.teamId` is
|
|
93
|
+
* actually read.
|
|
94
|
+
* @param {TeamEntry[]} teams
|
|
95
|
+
* @param {string|undefined} ref
|
|
96
|
+
* @param {string|null|undefined} fileDefault
|
|
97
|
+
* @returns {TeamEntry | null}
|
|
98
|
+
*/
|
|
99
|
+
function selectTeam(teams, ref, fileDefault) {
|
|
100
|
+
/** @param {string} val */
|
|
101
|
+
const find = (val) => {
|
|
102
|
+
const v = String(val).toLowerCase();
|
|
103
|
+
return teams.find(
|
|
104
|
+
(t) => t.id === val || t.key?.toLowerCase() === v || t.name?.toLowerCase() === v,
|
|
105
|
+
);
|
|
106
|
+
};
|
|
107
|
+
if (ref) {
|
|
108
|
+
const t = find(ref);
|
|
109
|
+
if (!t) {
|
|
110
|
+
fail(`unknown team "${ref}". Registered: ${teams.map((x) => x.key).join(", ") || "(none)"}`);
|
|
111
|
+
}
|
|
112
|
+
return t ?? null;
|
|
113
|
+
}
|
|
114
|
+
const envDefault = process.env.LINCTL_DEFAULT_TEAM?.trim();
|
|
115
|
+
if (envDefault) {
|
|
116
|
+
const t = find(envDefault);
|
|
117
|
+
if (!t) {
|
|
118
|
+
fail(`LINCTL_DEFAULT_TEAM="${envDefault}" is not among the registered teams.`);
|
|
119
|
+
}
|
|
120
|
+
return t ?? null;
|
|
121
|
+
}
|
|
122
|
+
if (fileDefault) {
|
|
123
|
+
const t = find(fileDefault);
|
|
124
|
+
if (!t) fail(`configured defaultTeam "${fileDefault}" is not among the registered teams.`);
|
|
125
|
+
return t ?? null;
|
|
126
|
+
}
|
|
127
|
+
if (fileDefault === undefined) return teams[0] ?? null; // legacy config: no explicit default
|
|
128
|
+
return null; // defaultTeam === null and no env → no default; --team is required
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** @param {TeamEntry[]} teams */
|
|
132
|
+
function teamSelectionError(teams) {
|
|
133
|
+
const keys = teams.map((t) => t.key).join(", ") || "(none registered)";
|
|
134
|
+
return (
|
|
135
|
+
`no team selected for this command.\n` +
|
|
136
|
+
` Pass --team <key> (one of: ${keys}),\n` +
|
|
137
|
+
` set LINCTL_DEFAULT_TEAM in your shell/repo (per-repo default),\n` +
|
|
138
|
+
` or set "defaultTeam" in ${WORKSPACE_FILE}.`
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** @param {string} [teamRef] @returns {Config | null} */
|
|
143
|
+
function loadConfig(teamRef) {
|
|
86
144
|
if (!existsSync(WORKSPACE_FILE)) return null;
|
|
87
145
|
const raw = JSON.parse(readFileSync(WORKSPACE_FILE, "utf8"));
|
|
146
|
+
/** @type {TeamEntry[]} */
|
|
88
147
|
const teams = raw.teams ?? [];
|
|
89
148
|
const roles = raw.roles ?? {};
|
|
90
|
-
|
|
91
|
-
|
|
149
|
+
const sel = selectTeam(teams, teamRef, "defaultTeam" in raw ? raw.defaultTeam : undefined);
|
|
150
|
+
/** @type {Config} */
|
|
151
|
+
const cfg = {
|
|
152
|
+
// @ts-ignore — teamId is installed as a lazy getter below.
|
|
153
|
+
teams,
|
|
92
154
|
members: {
|
|
93
155
|
frontend: roles.frontend_lead?.id,
|
|
94
156
|
backend: roles.backend_lead?.id,
|
|
95
157
|
frontendName: roles.frontend_lead?.name,
|
|
96
158
|
backendName: roles.backend_lead?.name,
|
|
97
159
|
},
|
|
98
|
-
states: raw.states ?? {},
|
|
99
|
-
labels: raw.labels ?? {},
|
|
160
|
+
states: sel?.states ?? raw.states ?? {},
|
|
161
|
+
labels: sel?.labels ?? raw.labels ?? {},
|
|
100
162
|
};
|
|
163
|
+
// Lazy: workspace-wide commands (e.g. list-initiatives) never read teamId, so
|
|
164
|
+
// they work without --team. Team-scoped commands trigger the error on access.
|
|
165
|
+
Object.defineProperty(cfg, "teamId", {
|
|
166
|
+
enumerable: true,
|
|
167
|
+
get() {
|
|
168
|
+
if (sel?.id) return sel.id;
|
|
169
|
+
return fail(teamSelectionError(teams));
|
|
170
|
+
},
|
|
171
|
+
});
|
|
172
|
+
return cfg;
|
|
101
173
|
}
|
|
102
174
|
|
|
103
|
-
/** @returns {Config} */
|
|
104
|
-
function requireConfig() {
|
|
105
|
-
const cfg = loadConfig();
|
|
106
|
-
if (!cfg
|
|
175
|
+
/** @param {string} [teamRef] @returns {Config} */
|
|
176
|
+
function requireConfig(teamRef) {
|
|
177
|
+
const cfg = loadConfig(teamRef);
|
|
178
|
+
if (!cfg) {
|
|
107
179
|
fail(
|
|
108
|
-
`workspace config not found
|
|
180
|
+
`workspace config not found at ${WORKSPACE_FILE}\n` +
|
|
109
181
|
` Run \`node linear.mjs init\` to generate it.`,
|
|
110
182
|
);
|
|
111
183
|
}
|
|
@@ -970,12 +1042,46 @@ async function cmdApi(args, v, cfg) {
|
|
|
970
1042
|
|
|
971
1043
|
// --- init (interactive workspace bootstrap) --------------------------------
|
|
972
1044
|
|
|
1045
|
+
const WANTED_STATES = ["Backlog", "Todo", "In Progress", "In Review", "Done", "Canceled"];
|
|
1046
|
+
const WANTED_LABELS = ["discovery", "tech-debt", "backend", "frontend", "db", "bug", "feature", "improvement"];
|
|
1047
|
+
|
|
1048
|
+
/** Filter a team's raw state nodes down to the canonical names we track.
|
|
1049
|
+
* @param {Array<{id:string,name:string}>} nodes */
|
|
1050
|
+
function pickStates(nodes) {
|
|
1051
|
+
/** @type {Record<string, string>} */
|
|
1052
|
+
const states = {};
|
|
1053
|
+
const lookup = new Map(nodes.map((s) => [s.name.toLowerCase(), s]));
|
|
1054
|
+
for (const name of WANTED_STATES) {
|
|
1055
|
+
const s = lookup.get(name.toLowerCase());
|
|
1056
|
+
if (s) states[name] = s.id;
|
|
1057
|
+
}
|
|
1058
|
+
return states;
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
/** Filter a team's raw label nodes down to the canonical names we track.
|
|
1062
|
+
* @param {Array<{id:string,name:string}>} nodes */
|
|
1063
|
+
function pickLabels(nodes) {
|
|
1064
|
+
const norm = (s) => s.toLowerCase().replace(/-/g, " ").trim();
|
|
1065
|
+
/** @type {Record<string, string>} */
|
|
1066
|
+
const labels = {};
|
|
1067
|
+
const lookup = new Map(nodes.map((l) => [norm(l.name), l]));
|
|
1068
|
+
for (const name of WANTED_LABELS) {
|
|
1069
|
+
const l = lookup.get(norm(name));
|
|
1070
|
+
if (l) labels[name] = l.id;
|
|
1071
|
+
}
|
|
1072
|
+
return labels;
|
|
1073
|
+
}
|
|
1074
|
+
|
|
973
1075
|
async function cmdInit() {
|
|
974
1076
|
if (!API_KEY) fail("LINEAR_API_KEY is not set. Set it first, then re-run `init`.");
|
|
975
1077
|
|
|
976
1078
|
console.error("Fetching workspace data from Linear...");
|
|
1079
|
+
// Pull each team's own states + labels — they differ per team (e.g. each
|
|
1080
|
+
// team's "Todo" is a distinct workflow-state UUID).
|
|
977
1081
|
const bootstrap = await gql(
|
|
978
|
-
`{ teams(first: 50) { nodes { id key name parent { id }
|
|
1082
|
+
`{ teams(first: 50) { nodes { id key name parent { id }
|
|
1083
|
+
states { nodes { id name type } }
|
|
1084
|
+
labels { nodes { id name } } } }
|
|
979
1085
|
users(first: 250) { nodes { id name email displayName } } }`,
|
|
980
1086
|
);
|
|
981
1087
|
|
|
@@ -987,17 +1093,8 @@ async function cmdInit() {
|
|
|
987
1093
|
process.exit(1);
|
|
988
1094
|
}
|
|
989
1095
|
|
|
990
|
-
//
|
|
991
|
-
|
|
992
|
-
const parents = teams.filter((t) => !t.parent);
|
|
993
|
-
const chosenTeam = parents[0] ?? teams[0];
|
|
994
|
-
|
|
995
|
-
const teamExtra = await gql(
|
|
996
|
-
`query TeamExtra($id: String!) {
|
|
997
|
-
team(id: $id) { states { nodes { id name type } } labels { nodes { id name } } }
|
|
998
|
-
}`,
|
|
999
|
-
{ id: chosenTeam.id },
|
|
1000
|
-
);
|
|
1096
|
+
// Parent (umbrella) teams first so the list reads top-down.
|
|
1097
|
+
const teamsSorted = [...teams].sort((a, b) => (a.parent ? 1 : 0) - (b.parent ? 1 : 0));
|
|
1001
1098
|
|
|
1002
1099
|
// Filter out Linear's integration/bot users.
|
|
1003
1100
|
const users = (bootstrap?.data?.users?.nodes ?? []).filter(
|
|
@@ -1014,6 +1111,10 @@ async function cmdInit() {
|
|
|
1014
1111
|
const rl = createInterface({ input, output });
|
|
1015
1112
|
const frontendIdx = await rl.question("Which member is the Frontend/PM lead? Enter number: ");
|
|
1016
1113
|
const backendIdx = await rl.question("Which member is the Backend lead? Enter number: ");
|
|
1114
|
+
console.error("\nTeams: " + teamsSorted.map((t) => t.key).join(", "));
|
|
1115
|
+
const defaultTeamAns = (
|
|
1116
|
+
await rl.question("Default team key (blank = none; --team required each call): ")
|
|
1117
|
+
).trim();
|
|
1017
1118
|
rl.close();
|
|
1018
1119
|
|
|
1019
1120
|
/** @param {string} idxStr */
|
|
@@ -1029,37 +1130,27 @@ async function cmdInit() {
|
|
|
1029
1130
|
const frontend = pick(frontendIdx);
|
|
1030
1131
|
const backend = pick(backendIdx);
|
|
1031
1132
|
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
for (const name of WANTED_STATES) {
|
|
1042
|
-
const s = stateLookup.get(name.toLowerCase());
|
|
1043
|
-
if (s) states[name] = s.id;
|
|
1044
|
-
}
|
|
1045
|
-
|
|
1046
|
-
const norm = (s) => s.toLowerCase().replace(/-/g, " ").trim();
|
|
1047
|
-
/** @type {Record<string, string>} */
|
|
1048
|
-
const labels = {};
|
|
1049
|
-
const labelLookup = new Map(labelNodes.map((l) => [norm(l.name), l]));
|
|
1050
|
-
for (const name of WANTED_LABELS) {
|
|
1051
|
-
const l = labelLookup.get(norm(name));
|
|
1052
|
-
if (l) labels[name] = l.id;
|
|
1133
|
+
let defaultTeam = null;
|
|
1134
|
+
if (defaultTeamAns) {
|
|
1135
|
+
const match = teamsSorted.find(
|
|
1136
|
+
(t) =>
|
|
1137
|
+
t.key?.toLowerCase() === defaultTeamAns.toLowerCase() ||
|
|
1138
|
+
t.name?.toLowerCase() === defaultTeamAns.toLowerCase(),
|
|
1139
|
+
);
|
|
1140
|
+
if (!match) fail(`"${defaultTeamAns}" is not one of the workspace teams.`);
|
|
1141
|
+
defaultTeam = match.key;
|
|
1053
1142
|
}
|
|
1054
1143
|
|
|
1055
|
-
// Parent teams first so teams[0] is the umbrella team.
|
|
1056
|
-
const teamsSorted = [...teams].sort((a, b) => (a.parent ? 1 : 0) - (b.parent ? 1 : 0));
|
|
1057
|
-
|
|
1058
1144
|
const cfg = {
|
|
1059
|
-
teams: teamsSorted.map((t) => ({
|
|
1145
|
+
teams: teamsSorted.map((t) => ({
|
|
1146
|
+
id: t.id,
|
|
1147
|
+
key: t.key,
|
|
1148
|
+
name: t.name,
|
|
1149
|
+
states: pickStates(t.states?.nodes ?? []),
|
|
1150
|
+
labels: pickLabels(t.labels?.nodes ?? []),
|
|
1151
|
+
})),
|
|
1152
|
+
defaultTeam,
|
|
1060
1153
|
roles: { frontend_lead: frontend, backend_lead: backend },
|
|
1061
|
-
states,
|
|
1062
|
-
labels,
|
|
1063
1154
|
};
|
|
1064
1155
|
|
|
1065
1156
|
mkdirSync(dirname(WORKSPACE_FILE), { recursive: true });
|
|
@@ -1119,6 +1210,13 @@ const USAGE = `Linear CLI — node linear.mjs <command> [args] [--flags]
|
|
|
1119
1210
|
Setup:
|
|
1120
1211
|
init Interactive: fetch workspace, write ~/.config/linctl/workspace.json
|
|
1121
1212
|
|
|
1213
|
+
Team selection:
|
|
1214
|
+
--team <key|name|uuid> Run a team-scoped command against this team
|
|
1215
|
+
(e.g. --team ACME). Precedence: --team >
|
|
1216
|
+
$LINCTL_DEFAULT_TEAM (per-repo default) >
|
|
1217
|
+
workspace.json "defaultTeam". Workspace-wide
|
|
1218
|
+
commands (initiatives) don't need a team.
|
|
1219
|
+
|
|
1122
1220
|
Issues:
|
|
1123
1221
|
create-issue --title T [--description D|--description-file F] [--priority P]
|
|
1124
1222
|
[--state S] [--assignee frontend|backend] [--labels a,b]
|
|
@@ -1187,6 +1285,7 @@ async function main() {
|
|
|
1187
1285
|
priority: { type: "string" },
|
|
1188
1286
|
state: { type: "string" },
|
|
1189
1287
|
assignee: { type: "string" },
|
|
1288
|
+
team: { type: "string" },
|
|
1190
1289
|
labels: { type: "string" },
|
|
1191
1290
|
estimate: { type: "string" },
|
|
1192
1291
|
project: { type: "string" },
|
|
@@ -1221,7 +1320,7 @@ async function main() {
|
|
|
1221
1320
|
const handler = COMMANDS[command];
|
|
1222
1321
|
if (!handler) fail(`unknown command "${command}". Run \`node linear.mjs help\`.`);
|
|
1223
1322
|
|
|
1224
|
-
const cfg = NO_CONFIG.has(command) ? /** @type {Config} */ ({}) : requireConfig();
|
|
1323
|
+
const cfg = NO_CONFIG.has(command) ? /** @type {Config} */ ({}) : requireConfig(values.team);
|
|
1225
1324
|
await handler(args, values, cfg);
|
|
1226
1325
|
}
|
|
1227
1326
|
|