@enrichlayer/el-linear 1.2.0 → 1.4.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/README.md CHANGED
@@ -17,6 +17,8 @@ first-class command.
17
17
 
18
18
  ## Install
19
19
 
20
+ Published on npm as [`@enrichlayer/el-linear`](https://www.npmjs.com/package/@enrichlayer/el-linear).
21
+
20
22
  ```bash
21
23
  pnpm add -g @enrichlayer/el-linear
22
24
  # or
@@ -76,11 +78,50 @@ The API token is resolved in this order:
76
78
 
77
79
  1. `--api-token <token>` flag.
78
80
  2. `LINEAR_API_TOKEN` environment variable.
79
- 3. `~/.config/el-linear/token` file (recommended for human use).
80
- 4. `~/.linear_api_token` file (legacy, still honored).
81
+ 3. **Active profile's** `~/.config/el-linear/profiles/<name>/token` file (see *Profiles* below).
82
+ 4. `~/.config/el-linear/token` file (legacy single-profile, recommended for human use when only one workspace is needed).
83
+ 5. `~/.linear_api_token` file (legacy, still honored).
81
84
 
82
85
  el-linear never logs the token.
83
86
 
87
+ ## Profiles
88
+
89
+ Use **profiles** to switch between multiple Linear workspaces (e.g.
90
+ day-job and side-project) with separate tokens + configs.
91
+
92
+ ```bash
93
+ # Create a profile + run the init wizard scoped to it.
94
+ # After this finishes, <name> becomes the active profile.
95
+ el-linear profile add forage
96
+
97
+ # Switch the default at any time:
98
+ el-linear profile use day-job
99
+ el-linear profile current # → day-job
100
+ el-linear profile list # all profiles + which is active
101
+
102
+ # One-off override for a single command:
103
+ el-linear --profile forage issues list
104
+ EL_LINEAR_PROFILE=forage el-linear teams list
105
+
106
+ # Remove a profile (token + config gone; confirms first):
107
+ el-linear profile remove old-profile
108
+ ```
109
+
110
+ Each profile lives at `~/.config/el-linear/profiles/<name>/` and owns:
111
+
112
+ - `token` — its Linear API token (mode 0600)
113
+ - `config.json` — its full el-linear config (defaultTeam, terms, etc.)
114
+
115
+ The active profile is selected by, in priority:
116
+
117
+ 1. `--profile <name>` flag (per-invocation)
118
+ 2. `EL_LINEAR_PROFILE` env var
119
+ 3. `~/.config/el-linear/active-profile` (one-line marker, written by `profile use`)
120
+ 4. Legacy single-file layout (`~/.config/el-linear/{token,config.json}`)
121
+
122
+ The legacy fallback means **existing single-profile users see no
123
+ behavior change** — profiles are purely opt-in.
124
+
84
125
  ## Configuration
85
126
 
86
127
  el-linear reads `~/.config/el-linear/config.json` on startup. All keys are
@@ -174,12 +215,43 @@ el-linear <command> --help # detailed help for one command
174
215
  | Releases | `releases {list, read, create, pipelines}` |
175
216
  | Files | `embeds {upload, download}`, `attachments {list, create, delete}` |
176
217
  | Search | `search <query>` (semantic, cross-resource) |
218
+ | Refs | `refs wrap` (rewrite issue identifiers in arbitrary text as links) |
177
219
  | Escape hatch | `graphql [query]` (with `--introspect`) |
178
220
  | Config | `config show`, `users list`, `teams list`, `templates list` |
179
221
 
180
222
  All `list` subcommands support `-l, --limit <n>`. All commands accept the
181
223
  top-level filters: `--raw`, `--jq <expr>`, `--fields <list>`.
182
224
 
225
+ ## Wrapping Linear references in arbitrary text
226
+
227
+ `el-linear refs wrap` takes plain text on stdin (or via `--file`) and rewrites
228
+ every recognized Linear issue identifier (e.g. `DEV-123`, `LIN-1`) as a real
229
+ link. By default it validates each candidate against the workspace — strings
230
+ that match the `[A-Z]+-\d+` shape but aren't real issues (e.g. `ISO-1424`)
231
+ are left untouched.
232
+
233
+ ```bash
234
+ # stdin → stdout, markdown output (default)
235
+ echo "see DEV-100 and ISO-1424" | el-linear refs wrap
236
+ # → see [DEV-100](https://linear.app/acme/issue/DEV-100/) and ISO-1424
237
+
238
+ # read from a file
239
+ el-linear refs wrap --file notes.md > notes.linked.md
240
+
241
+ # Slack mrkdwn output: <url|label>
242
+ el-linear refs wrap --target slack < release-notes.md
243
+
244
+ # offline regex-only fallback — wraps every match, no API calls,
245
+ # may produce broken links for IDs that don't exist in the workspace
246
+ el-linear refs wrap --no-validate < notes.md
247
+ ```
248
+
249
+ Wrapping is **idempotent** — running it again on already-wrapped output is a
250
+ no-op. Refs are also skipped inside fenced code blocks, inline backticks,
251
+ existing markdown or Slack links, angle-bracket autolinks, and bare URLs, so
252
+ it's safe to pipe documents that already contain a mix of formatted links
253
+ and bare identifiers.
254
+
183
255
  ## Use with Claude Code
184
256
 
185
257
  el-linear ships a Claude Code skill at `claude-skills/linear-operations/SKILL.md`.
@@ -16,3 +16,10 @@
16
16
  */
17
17
  import type { Command } from "commander";
18
18
  export declare function setupInitCommands(program: Command): void;
19
+ /**
20
+ * Full wizard: walk through all four steps in sequence. Each step writes its
21
+ * own slice of the config and is restartable on its own.
22
+ */
23
+ export declare function runFullWizard(options?: {
24
+ force?: boolean;
25
+ }): Promise<void>;
@@ -129,7 +129,11 @@ export function setupInitCommands(program) {
129
129
  * Full wizard: walk through all four steps in sequence. Each step writes its
130
130
  * own slice of the config and is restartable on its own.
131
131
  */
132
- async function runFullWizard(options) {
132
+ export async function runFullWizard(options = {}) {
133
+ const opts = { force: options.force ?? false };
134
+ await runFullWizardImpl(opts);
135
+ }
136
+ async function runFullWizardImpl(options) {
133
137
  // biome-ignore lint/suspicious/noConsole: wizard
134
138
  console.log("Welcome to el-linear. This wizard will set up your config.");
135
139
  // biome-ignore lint/suspicious/noConsole: wizard
@@ -7,9 +7,27 @@
7
7
  */
8
8
  import { randomBytes } from "node:crypto";
9
9
  import fs from "node:fs/promises";
10
- import { ALIASES_PROGRESS_PATH, CONFIG_DIR, CONFIG_PATH, TOKEN_PATH, } from "../../config/paths.js";
10
+ import path from "node:path";
11
+ import { ALIASES_PROGRESS_PATH, CONFIG_DIR, CONFIG_PATH, resolveActiveProfile, TOKEN_PATH, } from "../../config/paths.js";
11
12
  // Re-export for tests and call sites that already pulled the paths from here.
12
13
  export { ALIASES_PROGRESS_PATH, CONFIG_PATH, TOKEN_PATH };
14
+ /**
15
+ * Profile-aware paths for the active wizard run. The wizard always
16
+ * writes to (and reads from) the active profile — switched via
17
+ * `EL_LINEAR_PROFILE`, `--profile`, or the on-disk `active-profile`
18
+ * marker. When no profile is selected, paths fall through to the
19
+ * legacy single-file layout (CONFIG_PATH / TOKEN_PATH).
20
+ */
21
+ function activePaths() {
22
+ const active = resolveActiveProfile();
23
+ return {
24
+ // Profile dir is always the directory of configPath (whether
25
+ // that's the legacy CONFIG_DIR or a per-profile subdirectory).
26
+ configDir: path.dirname(active.configPath),
27
+ configPath: active.configPath,
28
+ tokenPath: active.tokenPath,
29
+ };
30
+ }
13
31
  /**
14
32
  * Atomic file write: write to a sibling tmp file then rename. Survives SIGINT,
15
33
  * OOM, and laptop suspend mid-write — the original file is either untouched
@@ -38,11 +56,18 @@ async function atomicWrite(targetPath, data, mode = 0o644) {
38
56
  }
39
57
  }
40
58
  export async function ensureConfigDir() {
59
+ // Always make sure the legacy CONFIG_DIR exists (it's where the
60
+ // `active-profile` marker + `profiles/` tree live), then make the
61
+ // active profile's directory if it differs.
41
62
  await fs.mkdir(CONFIG_DIR, { recursive: true, mode: 0o700 });
63
+ const dir = activePaths().configDir;
64
+ if (dir !== CONFIG_DIR) {
65
+ await fs.mkdir(dir, { recursive: true, mode: 0o700 });
66
+ }
42
67
  }
43
68
  export async function readConfig() {
44
69
  try {
45
- const raw = await fs.readFile(CONFIG_PATH, "utf8");
70
+ const raw = await fs.readFile(activePaths().configPath, "utf8");
46
71
  return JSON.parse(raw);
47
72
  }
48
73
  catch (err) {
@@ -56,11 +81,11 @@ export async function writeConfig(config) {
56
81
  await ensureConfigDir();
57
82
  // Stable key order so byte-identical config produces byte-identical output.
58
83
  const sorted = sortKeys(config);
59
- await atomicWrite(CONFIG_PATH, `${JSON.stringify(sorted, null, 2)}\n`, 0o644);
84
+ await atomicWrite(activePaths().configPath, `${JSON.stringify(sorted, null, 2)}\n`, 0o644);
60
85
  }
61
86
  export async function readToken() {
62
87
  try {
63
- const raw = await fs.readFile(TOKEN_PATH, "utf8");
88
+ const raw = await fs.readFile(activePaths().tokenPath, "utf8");
64
89
  return raw.trim() || null;
65
90
  }
66
91
  catch (err) {
@@ -82,7 +107,7 @@ export async function readToken() {
82
107
  */
83
108
  export async function writeToken(token) {
84
109
  await ensureConfigDir();
85
- await atomicWrite(TOKEN_PATH, `${token.trim()}\n`, 0o600);
110
+ await atomicWrite(activePaths().tokenPath, `${token.trim()}\n`, 0o600);
86
111
  }
87
112
  /**
88
113
  * Build a new object containing only the keys whose values are not `undefined`.
@@ -0,0 +1,46 @@
1
+ /**
2
+ * `el-linear profile` — manage named profiles.
3
+ *
4
+ * A profile is a named directory under `~/.config/el-linear/profiles/`
5
+ * that holds its own `token` + `config.json`. Profiles let one user
6
+ * keep multiple Linear workspaces (e.g. day-job + side-project) on the
7
+ * same machine without juggling tokens.
8
+ *
9
+ * Subcommands:
10
+ *
11
+ * el-linear profile list — show all profiles + which is active
12
+ * el-linear profile current — print the active profile name (or `<default>`)
13
+ * el-linear profile use <name> — make <name> the default profile
14
+ * el-linear profile add <name> — create a profile + run init for it
15
+ * el-linear profile remove <name> — delete the profile dir (with confirmation)
16
+ *
17
+ * The `--profile <name>` flag (top-level, see main.ts) overrides the
18
+ * active profile for one invocation only.
19
+ *
20
+ * Backward-compat: when no profile is configured, every read still
21
+ * falls back to the legacy single-file paths (CONFIG_PATH / TOKEN_PATH).
22
+ */
23
+ import type { Command } from "commander";
24
+ export declare function setupProfileCommands(program: Command): void;
25
+ export interface ProfileListEntry {
26
+ name: string;
27
+ active: boolean;
28
+ hasToken: boolean;
29
+ hasConfig: boolean;
30
+ configPath: string;
31
+ tokenPath: string;
32
+ }
33
+ export interface ProfileListReport {
34
+ activeName: string | null;
35
+ defaultPaths: {
36
+ configPath: string;
37
+ tokenPath: string;
38
+ };
39
+ hasLegacyToken: boolean;
40
+ hasLegacyConfig: boolean;
41
+ profiles: ProfileListEntry[];
42
+ }
43
+ export declare function runProfileList(): Promise<ProfileListReport>;
44
+ export declare function runProfileUse(name: string): Promise<void>;
45
+ export declare function runProfileAdd(name: string): Promise<void>;
46
+ export declare function runProfileRemove(name: string, force: boolean): Promise<void>;
@@ -0,0 +1,185 @@
1
+ /**
2
+ * `el-linear profile` — manage named profiles.
3
+ *
4
+ * A profile is a named directory under `~/.config/el-linear/profiles/`
5
+ * that holds its own `token` + `config.json`. Profiles let one user
6
+ * keep multiple Linear workspaces (e.g. day-job + side-project) on the
7
+ * same machine without juggling tokens.
8
+ *
9
+ * Subcommands:
10
+ *
11
+ * el-linear profile list — show all profiles + which is active
12
+ * el-linear profile current — print the active profile name (or `<default>`)
13
+ * el-linear profile use <name> — make <name> the default profile
14
+ * el-linear profile add <name> — create a profile + run init for it
15
+ * el-linear profile remove <name> — delete the profile dir (with confirmation)
16
+ *
17
+ * The `--profile <name>` flag (top-level, see main.ts) overrides the
18
+ * active profile for one invocation only.
19
+ *
20
+ * Backward-compat: when no profile is configured, every read still
21
+ * falls back to the legacy single-file paths (CONFIG_PATH / TOKEN_PATH).
22
+ */
23
+ import { promises as fsp } from "node:fs";
24
+ import path from "node:path";
25
+ import { confirm } from "@inquirer/prompts";
26
+ import { ACTIVE_PROFILE_FILE, CONFIG_DIR, CONFIG_PATH, PROFILES_DIR, profilePaths, resolveActiveProfile, setActiveProfileForSession, TOKEN_PATH, } from "../config/paths.js";
27
+ import { outputSuccess, outputWarning } from "../utils/output.js";
28
+ import { runFullWizard } from "./init/index.js";
29
+ export function setupProfileCommands(program) {
30
+ const profile = program
31
+ .command("profile")
32
+ .description("Manage el-linear profiles (named workspaces with separate tokens + configs).");
33
+ profile.action(() => profile.help());
34
+ profile
35
+ .command("list")
36
+ .description("List configured profiles + which one is active.")
37
+ .action(async () => {
38
+ const data = await runProfileList();
39
+ outputSuccess({ data });
40
+ });
41
+ profile
42
+ .command("current")
43
+ .description("Print the active profile name (or `<default>` for the legacy single-profile setup).")
44
+ .action(() => {
45
+ const active = resolveActiveProfile();
46
+ outputSuccess({
47
+ data: {
48
+ name: active.name ?? "<default>",
49
+ configPath: active.configPath,
50
+ tokenPath: active.tokenPath,
51
+ },
52
+ });
53
+ });
54
+ profile
55
+ .command("use <name>")
56
+ .description("Make <name> the active profile (writes ~/.config/el-linear/active-profile).")
57
+ .action(async (name) => {
58
+ await runProfileUse(name);
59
+ outputSuccess({ data: { name, activeProfileFile: ACTIVE_PROFILE_FILE } });
60
+ });
61
+ profile
62
+ .command("add <name>")
63
+ .description("Create a new profile named <name> + run the init wizard scoped to it. After this finishes, <name> becomes the active profile.")
64
+ .action(async (name) => {
65
+ await runProfileAdd(name);
66
+ });
67
+ profile
68
+ .command("remove <name>")
69
+ .alias("rm")
70
+ .description("Delete the profile directory + its token (with confirmation).")
71
+ .option("--force", "skip the confirmation prompt")
72
+ .action(async (name, opts) => {
73
+ await runProfileRemove(name, opts.force === true);
74
+ });
75
+ }
76
+ export async function runProfileList() {
77
+ const active = resolveActiveProfile();
78
+ const profiles = [];
79
+ let entries = [];
80
+ try {
81
+ entries = (await fsp.readdir(PROFILES_DIR, { withFileTypes: true }))
82
+ .filter((d) => d.isDirectory())
83
+ .map((d) => d.name)
84
+ .sort();
85
+ }
86
+ catch (err) {
87
+ if (err.code !== "ENOENT")
88
+ throw err;
89
+ }
90
+ for (const name of entries) {
91
+ const paths = profilePaths(name);
92
+ profiles.push({
93
+ name,
94
+ active: active.name === name,
95
+ hasToken: await pathExists(paths.tokenPath),
96
+ hasConfig: await pathExists(paths.configPath),
97
+ configPath: paths.configPath,
98
+ tokenPath: paths.tokenPath,
99
+ });
100
+ }
101
+ return {
102
+ activeName: active.name,
103
+ defaultPaths: { configPath: CONFIG_PATH, tokenPath: TOKEN_PATH },
104
+ hasLegacyToken: await pathExists(TOKEN_PATH),
105
+ hasLegacyConfig: await pathExists(CONFIG_PATH),
106
+ profiles,
107
+ };
108
+ }
109
+ export async function runProfileUse(name) {
110
+ const trimmed = name.trim();
111
+ if (!trimmed)
112
+ throw new Error("Profile name must be non-empty.");
113
+ if (!isSafeName(trimmed)) {
114
+ throw new Error(`Profile name "${trimmed}" must contain only [a-z0-9_-]. Pick a different name.`);
115
+ }
116
+ await fsp.mkdir(CONFIG_DIR, { recursive: true, mode: 0o700 });
117
+ await fsp.writeFile(ACTIVE_PROFILE_FILE, `${trimmed}\n`, { mode: 0o644 });
118
+ }
119
+ export async function runProfileAdd(name) {
120
+ const trimmed = name.trim();
121
+ if (!trimmed)
122
+ throw new Error("Profile name must be non-empty.");
123
+ if (!isSafeName(trimmed)) {
124
+ throw new Error(`Profile name "${trimmed}" must contain only [a-z0-9_-]. Pick a different name.`);
125
+ }
126
+ const dir = path.dirname(profilePaths(trimmed).configPath);
127
+ await fsp.mkdir(dir, { recursive: true, mode: 0o700 });
128
+ // Activate the profile for the rest of THIS process so the wizard's
129
+ // readConfig/writeConfig/readToken/writeToken IO targets the new
130
+ // profile's directory (not the legacy single-file path).
131
+ setActiveProfileForSession(trimmed);
132
+ // Persist activation: write `active-profile` so subsequent invocations
133
+ // stay on the new profile until `profile use <other>` switches away.
134
+ await runProfileUse(trimmed);
135
+ outputWarning(`Created profile "${trimmed}" at ${dir}. Running init wizard scoped to this profile…`);
136
+ await runFullWizard();
137
+ }
138
+ export async function runProfileRemove(name, force) {
139
+ const trimmed = name.trim();
140
+ if (!trimmed || !isSafeName(trimmed)) {
141
+ throw new Error(`Invalid profile name "${name}".`);
142
+ }
143
+ const paths = profilePaths(trimmed);
144
+ const dir = path.dirname(paths.configPath);
145
+ if (!(await pathExists(dir))) {
146
+ throw new Error(`Profile "${trimmed}" not found at ${dir}.`);
147
+ }
148
+ if (!force) {
149
+ const confirmed = await confirm({
150
+ message: `Delete profile "${trimmed}" (${dir})? Token + config will be lost.`,
151
+ default: false,
152
+ });
153
+ if (!confirmed) {
154
+ outputWarning("Aborted.");
155
+ return;
156
+ }
157
+ }
158
+ await fsp.rm(dir, { recursive: true, force: true });
159
+ // If the just-removed profile was the active one, clear the marker
160
+ // so subsequent invocations fall back to the default paths.
161
+ const active = resolveActiveProfile();
162
+ if (active.name === trimmed && (await pathExists(ACTIVE_PROFILE_FILE))) {
163
+ await fsp.rm(ACTIVE_PROFILE_FILE, { force: true });
164
+ }
165
+ outputSuccess({ data: { removed: trimmed, dir } });
166
+ }
167
+ // ---- Helpers ------------------------------------------------------------
168
+ async function pathExists(p) {
169
+ try {
170
+ await fsp.access(p);
171
+ return true;
172
+ }
173
+ catch {
174
+ return false;
175
+ }
176
+ }
177
+ /**
178
+ * Profile names land in filesystem paths AND get written to a
179
+ * single-line marker. Restrict to a conservative charset so we can't
180
+ * accidentally pick up `..` traversal, command-line metacharacters, or
181
+ * Unicode lookalikes that would confuse `profile use`.
182
+ */
183
+ function isSafeName(name) {
184
+ return /^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/.test(name);
185
+ }
@@ -0,0 +1,18 @@
1
+ import type { Command } from "commander";
2
+ import { type WrapTarget } from "../utils/issue-reference-wrapper.js";
3
+ interface WrapDeps {
4
+ resolveValidIdentifiers: (ids: readonly string[]) => Promise<Set<string>>;
5
+ resolveUrlKey: () => Promise<string>;
6
+ }
7
+ interface WrapInput {
8
+ text: string;
9
+ target: WrapTarget;
10
+ validate: boolean;
11
+ }
12
+ /**
13
+ * Pure-ish core of `refs wrap` — split out so tests can drive it directly with
14
+ * a stubbed `WrapDeps`, no commander parsing or stdin/fs IO.
15
+ */
16
+ export declare function wrapRefsCore(input: WrapInput, deps: WrapDeps): Promise<string>;
17
+ export declare function setupRefsCommands(program: Command): void;
18
+ export {};
@@ -0,0 +1,95 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { createGraphQLService } from "../utils/graphql-service.js";
3
+ import { extractIssueReferences } from "../utils/issue-reference-extractor.js";
4
+ import { wrapIssueReferencesAsLinks, } from "../utils/issue-reference-wrapper.js";
5
+ import { createLinearService } from "../utils/linear-service.js";
6
+ import { handleAsyncCommand } from "../utils/output.js";
7
+ import { validateReferences } from "../utils/validate-references.js";
8
+ import { getWorkspaceUrlKey } from "../utils/workspace-url.js";
9
+ const VALID_TARGETS = new Set(["markdown", "slack"]);
10
+ function isWrapTarget(value) {
11
+ return VALID_TARGETS.has(value);
12
+ }
13
+ /**
14
+ * Read all input bytes from a Readable stream as a UTF-8 string. Used to slurp
15
+ * stdin when the user pipes content (`el-linear refs wrap < input.md`).
16
+ */
17
+ async function readAllStdin() {
18
+ if (process.stdin.isTTY) {
19
+ throw new Error("No input provided. Pipe text to stdin or pass --file <path>.");
20
+ }
21
+ const chunks = [];
22
+ for await (const chunk of process.stdin) {
23
+ chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
24
+ }
25
+ return Buffer.concat(chunks).toString("utf8");
26
+ }
27
+ /**
28
+ * Pure-ish core of `refs wrap` — split out so tests can drive it directly with
29
+ * a stubbed `WrapDeps`, no commander parsing or stdin/fs IO.
30
+ */
31
+ export async function wrapRefsCore(input, deps) {
32
+ const refs = extractIssueReferences(input.text);
33
+ if (refs.length === 0) {
34
+ return input.text;
35
+ }
36
+ const candidateIds = refs.map((r) => r.identifier);
37
+ let validIds;
38
+ if (input.validate) {
39
+ validIds = await deps.resolveValidIdentifiers(candidateIds);
40
+ }
41
+ else {
42
+ // --no-validate: trust every regex match, skip the API.
43
+ validIds = new Set(candidateIds);
44
+ }
45
+ if (validIds.size === 0) {
46
+ return input.text;
47
+ }
48
+ const urlKey = await deps.resolveUrlKey();
49
+ return wrapIssueReferencesAsLinks(input.text, validIds, urlKey, input.target);
50
+ }
51
+ async function handleWrap(options, command) {
52
+ const target = options.target ?? "markdown";
53
+ if (!isWrapTarget(target)) {
54
+ throw new Error(`Invalid --target "${target}". Expected one of: ${[...VALID_TARGETS].join(", ")}`);
55
+ }
56
+ // `validate` is `true` by default and `false` when `--no-validate` is passed.
57
+ const validate = options.validate !== false;
58
+ const text = typeof options.file === "string" && options.file.length > 0
59
+ ? readFileSync(options.file, "utf8")
60
+ : await readAllStdin();
61
+ const rootOpts = command.parent.parent.opts();
62
+ const deps = {
63
+ async resolveValidIdentifiers(ids) {
64
+ const linearService = createLinearService(rootOpts);
65
+ const map = await validateReferences(ids, linearService);
66
+ return new Set(map.keys());
67
+ },
68
+ async resolveUrlKey() {
69
+ const graphQLService = createGraphQLService(rootOpts);
70
+ return getWorkspaceUrlKey(graphQLService);
71
+ },
72
+ };
73
+ if (!validate) {
74
+ // stderr advisory only — keeps stdout a clean text stream for piping.
75
+ process.stderr.write("el-linear refs wrap: --no-validate set; emitting links for every regex match without checking the workspace.\n");
76
+ }
77
+ const wrapped = await wrapRefsCore({ text, target, validate }, deps);
78
+ process.stdout.write(wrapped);
79
+ }
80
+ export function setupRefsCommands(program) {
81
+ const refs = program
82
+ .command("refs")
83
+ .description("Operations on Linear issue references found in arbitrary text.");
84
+ refs.action(() => refs.help());
85
+ refs
86
+ .command("wrap")
87
+ .description("Wrap recognized Linear issue identifiers in input text as links. " +
88
+ "Reads from stdin (or --file) and writes to stdout. By default, " +
89
+ "each candidate identifier is validated against the workspace; " +
90
+ "unresolvable ones are left as plain text.")
91
+ .option("--file <path>", "read input from a file instead of stdin")
92
+ .option("--target <target>", "output format: markdown (default) or slack", "markdown")
93
+ .option("--no-validate", "skip workspace validation; wrap every regex match. Faster, but may produce broken links for IDs that don't exist.")
94
+ .action(handleAsyncCommand(handleWrap));
95
+ }
@@ -40,4 +40,6 @@ export interface ElLinearConfig {
40
40
  */
41
41
  workspaceUrlKey?: string;
42
42
  }
43
+ /** Test seam — resets the cache between test cases. */
44
+ export declare function _resetConfigCacheForTests(): void;
43
45
  export declare function loadConfig(): ElLinearConfig;
@@ -1,6 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import { outputWarning } from "../utils/output.js";
3
- import { CONFIG_PATH } from "./paths.js";
3
+ import { CONFIG_PATH, resolveActiveProfile } from "./paths.js";
4
4
  const DEFAULT_CONFIG = {
5
5
  defaultTeam: "",
6
6
  defaultLabels: [],
@@ -23,13 +23,25 @@ const DEFAULT_CONFIG = {
23
23
  terms: [],
24
24
  };
25
25
  let cachedConfig;
26
+ /** Test seam — resets the cache between test cases. */
27
+ export function _resetConfigCacheForTests() {
28
+ cachedConfig = undefined;
29
+ }
26
30
  export function loadConfig() {
27
31
  if (cachedConfig) {
28
32
  return cachedConfig;
29
33
  }
30
- if (fs.existsSync(CONFIG_PATH)) {
34
+ // Profile-aware: read from <CONFIG_DIR>/profiles/<name>/config.json
35
+ // when a profile is active, falling back to the legacy single-file
36
+ // path so existing setups keep working without migration.
37
+ const active = resolveActiveProfile();
38
+ const candidates = [active.configPath];
39
+ if (active.configPath !== CONFIG_PATH)
40
+ candidates.push(CONFIG_PATH);
41
+ const sourcePath = candidates.find((p) => fs.existsSync(p));
42
+ if (sourcePath) {
31
43
  try {
32
- const userConfig = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8"));
44
+ const userConfig = JSON.parse(fs.readFileSync(sourcePath, "utf8"));
33
45
  // Migration: the legacy `brand: { name, reject }` config is auto-promoted to
34
46
  // a single entry in `terms[]`. We warn (not throw) so existing users get a
35
47
  // grace period to update their config.
@@ -52,12 +64,15 @@ export function loadConfig() {
52
64
  cachedConfig = deepMerge(DEFAULT_CONFIG, userConfig);
53
65
  }
54
66
  catch {
55
- outputWarning(`Failed to parse ${CONFIG_PATH}, using empty defaults`);
67
+ outputWarning(`Failed to parse ${sourcePath}, using empty defaults`);
56
68
  cachedConfig = DEFAULT_CONFIG;
57
69
  }
58
70
  }
59
71
  else {
60
- outputWarning(`No config found at ${CONFIG_PATH}. Run with --init to create one.`);
72
+ const profileNote = active.name
73
+ ? ` (active profile: \`${active.name}\` — expected at ${active.configPath})`
74
+ : "";
75
+ outputWarning(`No config found at ${active.configPath}${profileNote}. Run \`el-linear init\` (or \`el-linear profile add ${active.name ?? "<name>"}\`) to create one.`);
61
76
  cachedConfig = DEFAULT_CONFIG;
62
77
  }
63
78
  return cachedConfig;
@@ -18,3 +18,30 @@ export declare const LEGACY_LINCTL_CONFIG_PATH: string;
18
18
  export declare const LEGACY_LINCTL_TOKEN_PATH: string;
19
19
  /** Even older fallback from before the `~/.config/...` move (one release). */
20
20
  export declare const LEGACY_TOKEN_PATH: string;
21
+ export declare const PROFILES_DIR: string;
22
+ export declare const ACTIVE_PROFILE_FILE: string;
23
+ export interface ProfilePaths {
24
+ /** Profile name; null when using the legacy single-file layout. */
25
+ name: string | null;
26
+ configPath: string;
27
+ tokenPath: string;
28
+ }
29
+ export interface ProfileFsOps {
30
+ readFileSync: (p: string) => string;
31
+ existsSync: (p: string) => boolean;
32
+ }
33
+ /**
34
+ * Set the active profile for the duration of this process. Pass `null`
35
+ * to clear the override and fall back to env / on-disk markers.
36
+ */
37
+ export declare function setActiveProfileForSession(name: string | null): void;
38
+ /** Read-only accessor for the per-session override (test seam). */
39
+ export declare function getSessionProfileOverride(): string | null;
40
+ /**
41
+ * Resolve the active profile name + on-disk paths. Returns the legacy
42
+ * single-file layout when no profile is selected, so existing setups
43
+ * keep working without migration.
44
+ */
45
+ export declare function resolveActiveProfile(env?: NodeJS.ProcessEnv, fsImpl?: ProfileFsOps): ProfilePaths;
46
+ /** Build profile-relative paths for a named profile. Pure. */
47
+ export declare function profilePaths(name: string): ProfilePaths;
@@ -3,6 +3,7 @@
3
3
  * config loader, the auth module, and the init wizard all agree on where to
4
4
  * read and write.
5
5
  */
6
+ import fs from "node:fs";
6
7
  import os from "node:os";
7
8
  import path from "node:path";
8
9
  export const CONFIG_DIR = path.join(os.homedir(), ".config", "el-linear");
@@ -20,3 +21,79 @@ export const LEGACY_LINCTL_CONFIG_PATH = path.join(LEGACY_LINCTL_CONFIG_DIR, "co
20
21
  export const LEGACY_LINCTL_TOKEN_PATH = path.join(LEGACY_LINCTL_CONFIG_DIR, "token");
21
22
  /** Even older fallback from before the `~/.config/...` move (one release). */
22
23
  export const LEGACY_TOKEN_PATH = path.join(os.homedir(), ".linear_api_token");
24
+ // ---- Profiles --------------------------------------------------------
25
+ //
26
+ // el-linear supports named profiles for switching between Linear
27
+ // workspaces (org A vs org B vs scratch token). Each profile owns its
28
+ // own config + token. The single-profile layout (CONFIG_PATH +
29
+ // TOKEN_PATH above) keeps working unchanged — existing users see no
30
+ // behavior change. A user opts in to multi-profile by either:
31
+ // - calling `el-linear profile add <name>`, OR
32
+ // - hand-creating <CONFIG_DIR>/profiles/<name>/{config.json,token}
33
+ //
34
+ // Resolution order (highest priority first):
35
+ // 1. Per-call --profile <name> flag (set via setActiveProfileForSession)
36
+ // 2. EL_LINEAR_PROFILE env var
37
+ // 3. <CONFIG_DIR>/active-profile (single-line text file)
38
+ // 4. Legacy single-file paths (CONFIG_PATH / TOKEN_PATH) — default,
39
+ // keeps backward compatibility.
40
+ export const PROFILES_DIR = path.join(CONFIG_DIR, "profiles");
41
+ export const ACTIVE_PROFILE_FILE = path.join(CONFIG_DIR, "active-profile");
42
+ const DEFAULT_FS_OPS = {
43
+ readFileSync: (p) => fs.readFileSync(p, "utf8"),
44
+ existsSync: (p) => fs.existsSync(p),
45
+ };
46
+ /**
47
+ * Per-process override for the active profile name. Set by main.ts when
48
+ * `--profile <name>` is passed (highest priority).
49
+ */
50
+ let sessionProfileOverride = null;
51
+ /**
52
+ * Set the active profile for the duration of this process. Pass `null`
53
+ * to clear the override and fall back to env / on-disk markers.
54
+ */
55
+ export function setActiveProfileForSession(name) {
56
+ sessionProfileOverride = name && name.trim().length > 0 ? name.trim() : null;
57
+ }
58
+ /** Read-only accessor for the per-session override (test seam). */
59
+ export function getSessionProfileOverride() {
60
+ return sessionProfileOverride;
61
+ }
62
+ /**
63
+ * Resolve the active profile name + on-disk paths. Returns the legacy
64
+ * single-file layout when no profile is selected, so existing setups
65
+ * keep working without migration.
66
+ */
67
+ export function resolveActiveProfile(env = process.env, fsImpl = DEFAULT_FS_OPS) {
68
+ const explicit = sessionProfileOverride ??
69
+ (env.EL_LINEAR_PROFILE?.trim() || null) ??
70
+ readActiveProfileMarker(fsImpl);
71
+ if (explicit) {
72
+ return profilePaths(explicit);
73
+ }
74
+ return {
75
+ name: null,
76
+ configPath: CONFIG_PATH,
77
+ tokenPath: TOKEN_PATH,
78
+ };
79
+ }
80
+ /** Build profile-relative paths for a named profile. Pure. */
81
+ export function profilePaths(name) {
82
+ const dir = path.join(PROFILES_DIR, name);
83
+ return {
84
+ name,
85
+ configPath: path.join(dir, "config.json"),
86
+ tokenPath: path.join(dir, "token"),
87
+ };
88
+ }
89
+ function readActiveProfileMarker(fsImpl) {
90
+ if (!fsImpl.existsSync(ACTIVE_PROFILE_FILE))
91
+ return null;
92
+ try {
93
+ const value = fsImpl.readFileSync(ACTIVE_PROFILE_FILE).toString().trim();
94
+ return value.length > 0 ? value : null;
95
+ }
96
+ catch {
97
+ return null;
98
+ }
99
+ }
package/dist/main.js CHANGED
@@ -13,22 +13,26 @@ import { setupInitCommands } from "./commands/init/index.js";
13
13
  import { setupIssueIdCommand } from "./commands/issue-id.js";
14
14
  import { setupIssuesCommands } from "./commands/issues.js";
15
15
  import { setupLabelsCommands } from "./commands/labels.js";
16
+ import { setupProfileCommands } from "./commands/profile.js";
16
17
  import { setupProjectMilestonesCommands } from "./commands/project-milestones.js";
17
18
  import { setupProjectsCommands } from "./commands/projects.js";
18
19
  import { setupReadShortcut } from "./commands/read-shortcut.js";
20
+ import { setupRefsCommands } from "./commands/refs.js";
19
21
  import { setupReleasesCommands } from "./commands/releases.js";
20
22
  import { setupSearchCommands } from "./commands/search.js";
21
23
  import { setupTeamsCommands } from "./commands/teams.js";
22
24
  import { setupTemplatesCommands } from "./commands/templates.js";
23
25
  import { setupUsersCommands } from "./commands/users.js";
26
+ import { setActiveProfileForSession } from "./config/paths.js";
24
27
  import { setFieldsFilter, setJqFilter, setRawMode } from "./utils/output.js";
25
28
  import { outputUsageInfo } from "./utils/usage.js";
26
29
  import { splitList } from "./utils/validators.js";
27
30
  program
28
31
  .name("el-linear")
29
32
  .description("A pragmatic CLI for Linear.app — deterministic resolution, structured validation, GraphQL escape hatch.")
30
- .version("1.2.0")
33
+ .version("1.3.0")
31
34
  .option("--api-token <token>", "Linear API token")
35
+ .option("--profile <name>", "named profile (under ~/.config/el-linear/profiles/<name>/) for this invocation. Overrides EL_LINEAR_PROFILE env + the on-disk active-profile marker.")
32
36
  .option("--json", "output as JSON (default, accepted for compatibility)")
33
37
  .option("--raw", "strip { data, meta } wrapper from list output — emit the array directly")
34
38
  .option("--jq <filter>", "apply a jq filter to the JSON output")
@@ -44,6 +48,13 @@ program.hook("preAction", (_thisCommand, actionCommand) => {
44
48
  if (rootOpts.fields) {
45
49
  setFieldsFilter(splitList(rootOpts.fields));
46
50
  }
51
+ // `--profile <name>` is highest-priority. preAction runs BEFORE the
52
+ // command body, which is BEFORE getApiToken / loadConfig fire — so
53
+ // setting the override here means the rest of the run picks up the
54
+ // right profile's token + config.
55
+ if (rootOpts.profile) {
56
+ setActiveProfileForSession(rootOpts.profile);
57
+ }
47
58
  });
48
59
  program.action(() => {
49
60
  program.help();
@@ -68,6 +79,8 @@ setupGdocCommands(program);
68
79
  setupGraphQLCommands(program);
69
80
  setupConfigCommands(program);
70
81
  setupInitCommands(program);
82
+ setupProfileCommands(program);
83
+ setupRefsCommands(program);
71
84
  setupReadShortcut(program);
72
85
  program
73
86
  .command("usage")
@@ -1,5 +1,5 @@
1
1
  import fs from "node:fs";
2
- import { LEGACY_LINCTL_TOKEN_PATH, LEGACY_TOKEN_PATH, TOKEN_PATH, } from "../config/paths.js";
2
+ import { LEGACY_LINCTL_TOKEN_PATH, LEGACY_TOKEN_PATH, resolveActiveProfile, TOKEN_PATH, } from "../config/paths.js";
3
3
  export function getApiToken(options) {
4
4
  if (options.apiToken) {
5
5
  return options.apiToken;
@@ -7,7 +7,18 @@ export function getApiToken(options) {
7
7
  if (process.env.LINEAR_API_TOKEN) {
8
8
  return process.env.LINEAR_API_TOKEN;
9
9
  }
10
- if (fs.existsSync(TOKEN_PATH)) {
10
+ // Profile-aware: read from <CONFIG_DIR>/profiles/<name>/token when a
11
+ // profile is active, falling back to the legacy single-file path
12
+ // (TOKEN_PATH) for the no-profile case.
13
+ const active = resolveActiveProfile();
14
+ if (fs.existsSync(active.tokenPath)) {
15
+ return fs.readFileSync(active.tokenPath, "utf8").trim();
16
+ }
17
+ // Even when a profile is selected, fall through to the legacy token
18
+ // path so an operator who only has the single-file layout doesn't
19
+ // suddenly fail. The active-profile name is informational only when
20
+ // no profile-scoped token exists yet.
21
+ if (active.tokenPath !== TOKEN_PATH && fs.existsSync(TOKEN_PATH)) {
11
22
  return fs.readFileSync(TOKEN_PATH, "utf8").trim();
12
23
  }
13
24
  // Fallback to the linctl-era token (the CLI was briefly published as
@@ -19,5 +30,8 @@ export function getApiToken(options) {
19
30
  if (fs.existsSync(LEGACY_TOKEN_PATH)) {
20
31
  return fs.readFileSync(LEGACY_TOKEN_PATH, "utf8").trim();
21
32
  }
22
- throw new Error("No API token found. Use --api-token, LINEAR_API_TOKEN env var, ~/.config/el-linear/token, or ~/.linear_api_token file");
33
+ const profileNote = active.name
34
+ ? ` (active profile: \`${active.name}\` — expected token at ${active.tokenPath})`
35
+ : "";
36
+ throw new Error(`No API token found${profileNote}. Use --api-token, LINEAR_API_TOKEN env var, ~/.config/el-linear/token, or ~/.linear_api_token file. To switch profiles, use \`el-linear profile use <name>\` or \`--profile <name>\`.`);
23
37
  }
@@ -1,12 +1,26 @@
1
+ /**
2
+ * Output emitter target. `markdown` produces `[ID](url)`; `slack` produces
3
+ * Slack mrkdwn `<url|ID>`. New targets (e.g. `html`) can be added to the
4
+ * discriminated map without touching callers.
5
+ */
6
+ export type WrapTarget = "markdown" | "slack";
1
7
  /**
2
8
  * Rewrite `text`, wrapping each occurrence of a known-valid Linear issue identifier
3
- * as a markdown link. Skips any occurrence inside protected ranges (code blocks,
4
- * inline code, existing markdown links, angle-bracket autolinks).
9
+ * as a link in the chosen `target` syntax. Skips any occurrence inside protected
10
+ * ranges (code blocks, inline code, existing markdown links, Slack links,
11
+ * angle-bracket autolinks, bare URLs).
5
12
  *
6
13
  * Only IDs in `validIdentifiers` are wrapped — IDs that don't resolve in the
7
14
  * workspace are left as plain text (handles false positives like ISO codes).
8
15
  *
9
- * If `text` already contains `[ID](url)` for a given ID, that occurrence is left
10
- * alone (it's inside a protected range), so this function is idempotent.
16
+ * If `text` already contains a wrapped `[ID](url)` or `<url|ID>` for a given ID,
17
+ * that occurrence is left alone (it's inside a protected range), so this
18
+ * function is idempotent for the matching target. Running `markdown` then
19
+ * `slack` (or vice versa) will not re-wrap previously-wrapped IDs because the
20
+ * existing link's content stays inside a protected range.
21
+ *
22
+ * The 3-arg signature is preserved as a positional `target` defaulting to
23
+ * `"markdown"` for backward compatibility with existing callers
24
+ * (`issues create/update`, `comments create/update`).
11
25
  */
12
- export declare function wrapIssueReferencesAsLinks(text: string, validIdentifiers: Set<string>, workspaceUrlKey: string): string;
26
+ export declare function wrapIssueReferencesAsLinks(text: string, validIdentifiers: Set<string>, workspaceUrlKey: string, target?: WrapTarget): string;
@@ -9,6 +9,9 @@ const FENCED_CODE_BLOCK_REGEX = /(?:^|\n)([ \t]*)(?:```|~~~)[^\n]*\n[\s\S]*?\n\1
9
9
  const INLINE_CODE_REGEX = /`[^`\n]+?`/g;
10
10
  // Existing markdown links: [text](url). We protect both the text and the url.
11
11
  const MARKDOWN_LINK_REGEX = /\[([^\]]*)\]\(([^)]*)\)/g;
12
+ // Existing Slack mrkdwn links: <url|text>. We protect both halves so identifiers
13
+ // inside an already-wrapped Slack link aren't double-wrapped.
14
+ const SLACK_LINK_REGEX = /<https?:\/\/[^|>\s]+\|[^>]*>/g;
12
15
  // Angle-bracket autolinks: <https://...>
13
16
  const ANGLE_AUTOLINK_REGEX = /<[^>\s]+>/g;
14
17
  // Bare URLs in prose. We protect these so identifiers inside paths
@@ -30,6 +33,11 @@ function findProtectedRanges(text) {
30
33
  FENCED_CODE_BLOCK_REGEX,
31
34
  INLINE_CODE_REGEX,
32
35
  MARKDOWN_LINK_REGEX,
36
+ // Slack links must be protected before generic angle-bracket autolinks,
37
+ // otherwise the autolink regex would still match `<https://…|label>` because
38
+ // it doesn't include the pipe boundary. matchAll resets per regex so order
39
+ // in this list is irrelevant for correctness — both ranges still cover the span.
40
+ SLACK_LINK_REGEX,
33
41
  ANGLE_AUTOLINK_REGEX,
34
42
  BARE_URL_REGEX,
35
43
  ]) {
@@ -51,22 +59,41 @@ function isProtected(pos, ranges) {
51
59
  function buildIssueUrl(identifier, workspaceUrlKey) {
52
60
  return `https://linear.app/${workspaceUrlKey}/issue/${identifier}/`;
53
61
  }
62
+ /**
63
+ * Per-target emitters. Each takes the resolved url and human identifier and
64
+ * returns the wrapped link string. Add a new target by adding a key here.
65
+ */
66
+ const EMITTERS = {
67
+ markdown: (id, url) => `[${id}](${url})`,
68
+ // Slack mrkdwn link syntax: <url|label>. Reference:
69
+ // https://api.slack.com/reference/surfaces/formatting#linking-urls
70
+ slack: (id, url) => `<${url}|${id}>`,
71
+ };
54
72
  /**
55
73
  * Rewrite `text`, wrapping each occurrence of a known-valid Linear issue identifier
56
- * as a markdown link. Skips any occurrence inside protected ranges (code blocks,
57
- * inline code, existing markdown links, angle-bracket autolinks).
74
+ * as a link in the chosen `target` syntax. Skips any occurrence inside protected
75
+ * ranges (code blocks, inline code, existing markdown links, Slack links,
76
+ * angle-bracket autolinks, bare URLs).
58
77
  *
59
78
  * Only IDs in `validIdentifiers` are wrapped — IDs that don't resolve in the
60
79
  * workspace are left as plain text (handles false positives like ISO codes).
61
80
  *
62
- * If `text` already contains `[ID](url)` for a given ID, that occurrence is left
63
- * alone (it's inside a protected range), so this function is idempotent.
81
+ * If `text` already contains a wrapped `[ID](url)` or `<url|ID>` for a given ID,
82
+ * that occurrence is left alone (it's inside a protected range), so this
83
+ * function is idempotent for the matching target. Running `markdown` then
84
+ * `slack` (or vice versa) will not re-wrap previously-wrapped IDs because the
85
+ * existing link's content stays inside a protected range.
86
+ *
87
+ * The 3-arg signature is preserved as a positional `target` defaulting to
88
+ * `"markdown"` for backward compatibility with existing callers
89
+ * (`issues create/update`, `comments create/update`).
64
90
  */
65
- export function wrapIssueReferencesAsLinks(text, validIdentifiers, workspaceUrlKey) {
91
+ export function wrapIssueReferencesAsLinks(text, validIdentifiers, workspaceUrlKey, target = "markdown") {
66
92
  if (!text || validIdentifiers.size === 0) {
67
93
  return text;
68
94
  }
69
95
  const ranges = findProtectedRanges(text);
96
+ const emit = EMITTERS[target];
70
97
  // Walk through matches and build the result string in pieces.
71
98
  // We can't use String.replace with a callback because we need positional protection checks.
72
99
  let result = "";
@@ -83,7 +110,7 @@ export function wrapIssueReferencesAsLinks(text, validIdentifiers, workspaceUrlK
83
110
  }
84
111
  // Append everything up to this match unchanged
85
112
  result += text.slice(cursor, start);
86
- result += `[${id}](${buildIssueUrl(id, workspaceUrlKey)})`;
113
+ result += emit(id, buildIssueUrl(id, workspaceUrlKey));
87
114
  cursor = end;
88
115
  }
89
116
  result += text.slice(cursor);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@enrichlayer/el-linear",
3
- "version": "1.2.0",
3
+ "version": "1.4.0",
4
4
  "description": "A pragmatic CLI for Linear.app — deterministic team/label/member resolution, structured issue validation, configurable term enforcement, and a GraphQL escape hatch.",
5
5
  "main": "dist/main.js",
6
6
  "types": "dist/main.d.ts",
@@ -52,11 +52,11 @@
52
52
  "picocolors": "^1.1.1"
53
53
  },
54
54
  "devDependencies": {
55
- "@biomejs/biome": "^2.4.0",
55
+ "@biomejs/biome": "^2.4.14",
56
56
  "@types/node": "^25.0.0",
57
57
  "graphql": "^16.0.0",
58
- "tsx": "^4.20.5",
59
- "typescript": "^5.0.0",
58
+ "tsx": "^4.21.0",
59
+ "typescript": "^6.0.3",
60
60
  "vitest": "^4.0.18"
61
61
  },
62
62
  "scripts": {