linmux 0.1.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 (118) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +240 -0
  3. package/bin/run.js +4 -0
  4. package/dist/commands/comment/create.js +94 -0
  5. package/dist/commands/comment/delete.js +74 -0
  6. package/dist/commands/comment/list.js +84 -0
  7. package/dist/commands/comment/update.js +80 -0
  8. package/dist/commands/cycle/current.js +78 -0
  9. package/dist/commands/cycle/list.js +84 -0
  10. package/dist/commands/cycle/move.js +91 -0
  11. package/dist/commands/describe.js +65 -0
  12. package/dist/commands/graphql/index.js +92 -0
  13. package/dist/commands/install-skill.js +54 -0
  14. package/dist/commands/issue/archive.js +75 -0
  15. package/dist/commands/issue/create.js +115 -0
  16. package/dist/commands/issue/get.js +84 -0
  17. package/dist/commands/issue/list.js +93 -0
  18. package/dist/commands/issue/purge.js +81 -0
  19. package/dist/commands/issue/search.js +109 -0
  20. package/dist/commands/issue/transition.js +91 -0
  21. package/dist/commands/issue/trash.js +75 -0
  22. package/dist/commands/issue/update.js +126 -0
  23. package/dist/commands/label/create.js +91 -0
  24. package/dist/commands/label/list.js +76 -0
  25. package/dist/commands/list-tools.js +47 -0
  26. package/dist/commands/me.js +71 -0
  27. package/dist/commands/project/create.js +101 -0
  28. package/dist/commands/project/get.js +83 -0
  29. package/dist/commands/project/list.js +75 -0
  30. package/dist/commands/project/update-status.js +99 -0
  31. package/dist/commands/project/update.js +99 -0
  32. package/dist/commands/raw/batch.js +85 -0
  33. package/dist/commands/raw/index.js +72 -0
  34. package/dist/commands/schema.js +69 -0
  35. package/dist/commands/state/list.js +77 -0
  36. package/dist/commands/team/get.js +73 -0
  37. package/dist/commands/team/list.js +73 -0
  38. package/dist/commands/whoami.js +71 -0
  39. package/dist/commands/workspace/add.js +97 -0
  40. package/dist/commands/workspace/list.js +47 -0
  41. package/dist/commands/workspace/remove.js +63 -0
  42. package/dist/commands/workspace/replace-token.js +89 -0
  43. package/dist/commands/workspace/use.js +54 -0
  44. package/dist/core/client/factory.js +28 -0
  45. package/dist/core/client/index.js +2 -0
  46. package/dist/core/config/index.js +4 -0
  47. package/dist/core/config/paths.js +30 -0
  48. package/dist/core/config/schema.js +36 -0
  49. package/dist/core/config/store.js +149 -0
  50. package/dist/core/errors/error.js +142 -0
  51. package/dist/core/errors/exit-codes.js +70 -0
  52. package/dist/core/output/envelope.js +53 -0
  53. package/dist/core/output/format.js +42 -0
  54. package/dist/core/output/index.js +3 -0
  55. package/dist/core/pagination/flags.js +29 -0
  56. package/dist/core/pagination/index.js +2 -0
  57. package/dist/core/projection/presets.js +116 -0
  58. package/dist/core/projection/project.js +282 -0
  59. package/dist/core/redact/redact.js +45 -0
  60. package/dist/core/resolvers/cycle.js +60 -0
  61. package/dist/core/resolvers/index.js +7 -0
  62. package/dist/core/resolvers/label.js +54 -0
  63. package/dist/core/resolvers/project-status.js +42 -0
  64. package/dist/core/resolvers/project.js +43 -0
  65. package/dist/core/resolvers/state.js +46 -0
  66. package/dist/core/resolvers/team.js +50 -0
  67. package/dist/core/transport/fetch-interceptor.js +109 -0
  68. package/dist/core/transport/index.js +3 -0
  69. package/dist/core/transport/rate-limit.js +167 -0
  70. package/dist/core/workspace/resolver.js +70 -0
  71. package/dist/core/workspace/write-guard.js +43 -0
  72. package/dist/generated/graphql.js +89428 -0
  73. package/dist/generated/operations.js +3013 -0
  74. package/dist/lib/comment-create-runtime.js +96 -0
  75. package/dist/lib/comment-delete-runtime.js +46 -0
  76. package/dist/lib/comment-list-runtime.js +182 -0
  77. package/dist/lib/comment-update-runtime.js +93 -0
  78. package/dist/lib/cycle-current-runtime.js +90 -0
  79. package/dist/lib/cycle-list-runtime.js +151 -0
  80. package/dist/lib/cycle-move-runtime.js +142 -0
  81. package/dist/lib/describe-runtime.js +180 -0
  82. package/dist/lib/filter-heuristics.js +59 -0
  83. package/dist/lib/graphql-runtime.js +202 -0
  84. package/dist/lib/include-fragments.js +73 -0
  85. package/dist/lib/install-skill-runtime.js +228 -0
  86. package/dist/lib/introspection-registry.js +488 -0
  87. package/dist/lib/issue-archive-runtime.js +89 -0
  88. package/dist/lib/issue-create-runtime.js +175 -0
  89. package/dist/lib/issue-get-runtime.js +153 -0
  90. package/dist/lib/issue-list-runtime.js +164 -0
  91. package/dist/lib/issue-purge-runtime.js +89 -0
  92. package/dist/lib/issue-search-runtime.js +114 -0
  93. package/dist/lib/issue-transition-runtime.js +131 -0
  94. package/dist/lib/issue-trash-runtime.js +84 -0
  95. package/dist/lib/issue-update-runtime.js +164 -0
  96. package/dist/lib/label-create-runtime.js +113 -0
  97. package/dist/lib/label-list-runtime.js +97 -0
  98. package/dist/lib/levenshtein.js +42 -0
  99. package/dist/lib/list-tools-runtime.js +38 -0
  100. package/dist/lib/me-runtime.js +55 -0
  101. package/dist/lib/project-create-runtime.js +103 -0
  102. package/dist/lib/project-get-runtime.js +134 -0
  103. package/dist/lib/project-list-runtime.js +84 -0
  104. package/dist/lib/project-update-runtime.js +110 -0
  105. package/dist/lib/project-update-status-runtime.js +91 -0
  106. package/dist/lib/raw-batch-runtime.js +229 -0
  107. package/dist/lib/raw-runtime.js +171 -0
  108. package/dist/lib/schema-loader.js +41 -0
  109. package/dist/lib/schema-runtime.js +65 -0
  110. package/dist/lib/state-list-runtime.js +93 -0
  111. package/dist/lib/team-get-runtime.js +55 -0
  112. package/dist/lib/team-list-runtime.js +52 -0
  113. package/dist/lib/workspace-runtime.js +112 -0
  114. package/dist/operations/_registry.zod.js +5337 -0
  115. package/oclif.manifest.json +3631 -0
  116. package/package.json +99 -0
  117. package/schema.graphql +30772 -0
  118. package/skills/linmux/SKILL.md +186 -0
@@ -0,0 +1,63 @@
1
+ import { LinearAgentError } from "../../core/errors/error.js";
2
+ import { BASE_FLAGS, runCommand } from "../../lib/workspace-runtime.js";
3
+ import { updateConfig } from "../../core/config/store.js";
4
+ import "../../core/config/index.js";
5
+ import { Args, Command } from "@oclif/core";
6
+ //#region src/commands/workspace/remove.ts
7
+ async function runWorkspaceRemove(args) {
8
+ const runArgs = {
9
+ commandPath: "workspace remove",
10
+ pretty: args.pretty,
11
+ handler: async (_retryOpts) => {
12
+ let nextActive = null;
13
+ updateConfig((current) => {
14
+ if (!Object.hasOwn(current.workspaces, args.name)) throw LinearAgentError.workspace.notFound(args.name);
15
+ const remaining = {};
16
+ for (const [k, v] of Object.entries(current.workspaces)) if (k !== args.name) remaining[k] = v;
17
+ if (current.active === args.name) nextActive = Object.keys(remaining).sort()[0] ?? null;
18
+ else nextActive = current.active;
19
+ return {
20
+ active: nextActive,
21
+ workspaces: remaining
22
+ };
23
+ });
24
+ return {
25
+ data: {
26
+ removed: args.name,
27
+ active: nextActive
28
+ },
29
+ meta: {}
30
+ };
31
+ }
32
+ };
33
+ if (args.noMeta !== void 0) runArgs.noMeta = args.noMeta;
34
+ if (args.quiet !== void 0) runArgs.quiet = args.quiet;
35
+ if (args.retry !== void 0) runArgs.retry = args.retry;
36
+ return runCommand(runArgs);
37
+ }
38
+ var WorkspaceRemove = class WorkspaceRemove extends Command {
39
+ static description = "Remove a workspace from local config";
40
+ static enableJsonFlag = true;
41
+ static args = { name: Args.string({
42
+ required: true,
43
+ description: "Name of the workspace to remove"
44
+ }) };
45
+ static flags = { ...BASE_FLAGS };
46
+ async run() {
47
+ const { args, flags } = await this.parse(WorkspaceRemove);
48
+ const callArgs = {
49
+ name: args.name,
50
+ pretty: flags.pretty
51
+ };
52
+ if (flags.quiet !== void 0) callArgs.quiet = flags.quiet;
53
+ if (flags.noMeta !== void 0) callArgs.noMeta = flags.noMeta;
54
+ if (flags.retry !== void 0) callArgs.retry = flags.retry;
55
+ const out = await runWorkspaceRemove(callArgs);
56
+ if (!flags.json) process.stdout.write(out.stdout);
57
+ if (out.stderr) process.stderr.write(out.stderr);
58
+ if (out.exitCode !== 0) process.exitCode = out.exitCode;
59
+ return JSON.parse(out.stdout);
60
+ }
61
+ };
62
+ //#endregion
63
+ export { WorkspaceRemove as default, runWorkspaceRemove };
@@ -0,0 +1,89 @@
1
+ import { LinearAgentError } from "../../core/errors/error.js";
2
+ import { withFetchInterception } from "../../core/transport/fetch-interceptor.js";
3
+ import { withRateLimitRetry } from "../../core/transport/rate-limit.js";
4
+ import "../../core/transport/index.js";
5
+ import { BASE_FLAGS, runCommand } from "../../lib/workspace-runtime.js";
6
+ import { createLinearClient } from "../../core/client/factory.js";
7
+ import "../../core/client/index.js";
8
+ import { loadConfig, saveConfig } from "../../core/config/store.js";
9
+ import "../../core/config/index.js";
10
+ import { Args, Command, Flags } from "@oclif/core";
11
+ //#region src/commands/workspace/replace-token.ts
12
+ async function runWorkspaceReplaceToken(args) {
13
+ const runArgs = {
14
+ commandPath: "workspace replace-token",
15
+ pretty: args.pretty,
16
+ handler: async (retryOpts) => {
17
+ const config = loadConfig();
18
+ const entry = config.workspaces[args.name];
19
+ if (!entry) throw LinearAgentError.workspace.notFound(args.name);
20
+ const expectedOrgId = entry.organizationId;
21
+ const client = createLinearClient({
22
+ name: null,
23
+ token: args.token,
24
+ organizationId: null,
25
+ source: "api-key-env"
26
+ });
27
+ const effectiveRetryOpts = args.retryOptsOverride ?? retryOpts;
28
+ const actualOrgId = await withFetchInterception(async () => {
29
+ const viewer = await withRateLimitRetry(() => client.viewer, effectiveRetryOpts);
30
+ return (await withRateLimitRetry(() => viewer.organization, effectiveRetryOpts)).id;
31
+ });
32
+ if (actualOrgId !== expectedOrgId) throw LinearAgentError.workspace.tokenMismatch(expectedOrgId, actualOrgId);
33
+ saveConfig({
34
+ ...config,
35
+ workspaces: {
36
+ ...config.workspaces,
37
+ [args.name]: {
38
+ ...entry,
39
+ token: args.token
40
+ }
41
+ }
42
+ });
43
+ return {
44
+ data: {
45
+ name: args.name,
46
+ organizationId: expectedOrgId
47
+ },
48
+ meta: { workspace: args.name }
49
+ };
50
+ }
51
+ };
52
+ if (args.noMeta !== void 0) runArgs.noMeta = args.noMeta;
53
+ if (args.quiet !== void 0) runArgs.quiet = args.quiet;
54
+ if (args.retry !== void 0) runArgs.retry = args.retry;
55
+ return runCommand(runArgs);
56
+ }
57
+ var WorkspaceReplaceToken = class WorkspaceReplaceToken extends Command {
58
+ static description = "Rotate the API token for a registered workspace";
59
+ static enableJsonFlag = true;
60
+ static args = { name: Args.string({
61
+ required: true,
62
+ description: "Name of the workspace to rotate"
63
+ }) };
64
+ static flags = {
65
+ token: Flags.string({
66
+ required: true,
67
+ description: "New Linear personal API key (lin_api_*)"
68
+ }),
69
+ ...BASE_FLAGS
70
+ };
71
+ async run() {
72
+ const { args, flags } = await this.parse(WorkspaceReplaceToken);
73
+ const callArgs = {
74
+ name: args.name,
75
+ token: flags.token,
76
+ pretty: flags.pretty
77
+ };
78
+ if (flags.quiet !== void 0) callArgs.quiet = flags.quiet;
79
+ if (flags.noMeta !== void 0) callArgs.noMeta = flags.noMeta;
80
+ if (flags.retry !== void 0) callArgs.retry = flags.retry;
81
+ const out = await runWorkspaceReplaceToken(callArgs);
82
+ if (!flags.json) process.stdout.write(out.stdout);
83
+ if (out.stderr) process.stderr.write(out.stderr);
84
+ if (out.exitCode !== 0) process.exitCode = out.exitCode;
85
+ return JSON.parse(out.stdout);
86
+ }
87
+ };
88
+ //#endregion
89
+ export { WorkspaceReplaceToken as default, runWorkspaceReplaceToken };
@@ -0,0 +1,54 @@
1
+ import { LinearAgentError } from "../../core/errors/error.js";
2
+ import { BASE_FLAGS, runCommand } from "../../lib/workspace-runtime.js";
3
+ import { updateConfig } from "../../core/config/store.js";
4
+ import "../../core/config/index.js";
5
+ import { Args, Command } from "@oclif/core";
6
+ //#region src/commands/workspace/use.ts
7
+ async function runWorkspaceUse(args) {
8
+ const runArgs = {
9
+ commandPath: "workspace use",
10
+ pretty: args.pretty,
11
+ handler: async (_retryOpts) => {
12
+ return {
13
+ data: { active: updateConfig((current) => {
14
+ if (!Object.hasOwn(current.workspaces, args.name)) throw LinearAgentError.workspace.notFound(args.name);
15
+ return {
16
+ ...current,
17
+ active: args.name
18
+ };
19
+ }).active },
20
+ meta: { workspace: args.name }
21
+ };
22
+ }
23
+ };
24
+ if (args.noMeta !== void 0) runArgs.noMeta = args.noMeta;
25
+ if (args.quiet !== void 0) runArgs.quiet = args.quiet;
26
+ if (args.retry !== void 0) runArgs.retry = args.retry;
27
+ return runCommand(runArgs);
28
+ }
29
+ var WorkspaceUse = class WorkspaceUse extends Command {
30
+ static description = "Set the active default workspace";
31
+ static enableJsonFlag = true;
32
+ static args = { name: Args.string({
33
+ required: true,
34
+ description: "Name of the workspace to make active"
35
+ }) };
36
+ static flags = { ...BASE_FLAGS };
37
+ async run() {
38
+ const { args, flags } = await this.parse(WorkspaceUse);
39
+ const callArgs = {
40
+ name: args.name,
41
+ pretty: flags.pretty
42
+ };
43
+ if (flags.quiet !== void 0) callArgs.quiet = flags.quiet;
44
+ if (flags.noMeta !== void 0) callArgs.noMeta = flags.noMeta;
45
+ if (flags.retry !== void 0) callArgs.retry = flags.retry;
46
+ const out = await runWorkspaceUse(callArgs);
47
+ if (!flags.json) process.stdout.write(out.stdout);
48
+ if (out.stderr) process.stderr.write(out.stderr);
49
+ if (out.exitCode !== 0) process.exitCode = out.exitCode;
50
+ return JSON.parse(out.stdout);
51
+ }
52
+ };
53
+ //#endregion
54
+ export { WorkspaceUse as default, runWorkspaceUse };
@@ -0,0 +1,28 @@
1
+ import { LinearClient } from "@linear/sdk";
2
+ //#region src/core/client/factory.ts
3
+ /**
4
+ * KRN-05: a fresh `LinearClient` per CLI invocation.
5
+ *
6
+ * This factory is deliberately uncached. Every call constructs a new
7
+ * `LinearClient` from the resolved token. The token-pinning bug class
8
+ * (PITFALLS § Pitfall 2 — "stale token reused after workspace switch") is
9
+ * impossible by construction.
10
+ *
11
+ * Why no caching:
12
+ * - Each CLI invocation is a fresh process. There is no "warm" client to
13
+ * reuse across invocations.
14
+ * - Within a single invocation, workspace is resolved exactly once at
15
+ * startup; we never switch mid-process.
16
+ * - Even if we DID cache, the cache key would have to include the resolved
17
+ * token — at which point the cache buys us nothing on a single-workspace
18
+ * invocation and is actively dangerous if a future refactor tries to
19
+ * share clients across workspaces.
20
+ *
21
+ * The SDK handles Linear's no-`Bearer ` Authorization header convention
22
+ * internally; the caller passes `apiKey` and never touches the header.
23
+ */
24
+ function createLinearClient(resolved) {
25
+ return new LinearClient({ apiKey: resolved.token });
26
+ }
27
+ //#endregion
28
+ export { createLinearClient };
@@ -0,0 +1,2 @@
1
+ import "./factory.js";
2
+ export {};
@@ -0,0 +1,4 @@
1
+ import "./paths.js";
2
+ import "./schema.js";
3
+ import "./store.js";
4
+ export {};
@@ -0,0 +1,30 @@
1
+ import { join } from "node:path";
2
+ import { homedir } from "node:os";
3
+ //#region src/core/config/paths.ts
4
+ /**
5
+ * Resolve the directory the config file lives in.
6
+ *
7
+ * Order:
8
+ * 1. `$XDG_CONFIG_HOME/linear-agent` if the env var is set and non-empty
9
+ * 2. `~/.config/linear-agent` otherwise
10
+ *
11
+ * NOTE: the on-disk directory name is intentionally kept as `linear-agent`
12
+ * (the CLI's former name) so existing registered workspaces survive the
13
+ * rename to `linmux` without a migration. Do not change this string without a
14
+ * read-side fallback that copies the legacy config — see CHANGELOG 0.1.0.
15
+ *
16
+ * Cross-platform note: this honors XDG on Linux/macOS (and on Windows when
17
+ * a shell sets the env explicitly). Native Windows path defaults
18
+ * (`%APPDATA%\linear-agent`) are out of scope for v1 — see PROJECT.md
19
+ * runtime parity matrix; the file mode story doesn't apply on NTFS anyway.
20
+ */
21
+ function configDir() {
22
+ const xdg = process.env.XDG_CONFIG_HOME;
23
+ return join(xdg && xdg.length > 0 ? xdg : join(homedir(), ".config"), "linear-agent");
24
+ }
25
+ /** Full path to the JSON config file (`<configDir>/config.json`). */
26
+ function configPath() {
27
+ return join(configDir(), "config.json");
28
+ }
29
+ //#endregion
30
+ export { configDir, configPath };
@@ -0,0 +1,36 @@
1
+ import { z } from "zod";
2
+ //#region src/core/config/schema.ts
3
+ /**
4
+ * Schema for one entry in the workspaces map.
5
+ *
6
+ * `token` holds a Linear Personal API key. The redactor scrubs the token
7
+ * value out of any rendered envelope; the schema treats it as an
8
+ * opaque non-empty string so we never reveal/decode it client-side.
9
+ *
10
+ * `organizationId` is captured at `workspace add` time via the SDK's
11
+ * `viewer.organization.id` query (PLAN-04 wires this in). The schema
12
+ * reserves the column here.
13
+ */
14
+ const WorkspaceEntrySchema = z.object({
15
+ name: z.string().min(1),
16
+ token: z.string().min(1),
17
+ organizationId: z.string().min(1),
18
+ createdAt: z.string().datetime(),
19
+ lastUsedAt: z.string().datetime().optional()
20
+ });
21
+ /**
22
+ * Top-level config shape: `{ active, workspaces }`.
23
+ *
24
+ * Invariant: when `active` is non-null, it MUST exist as a key in
25
+ * `workspaces` (refine below). This catches hand-edited configs that
26
+ * dangle the active pointer at a removed workspace.
27
+ */
28
+ const ConfigSchema = z.object({
29
+ active: z.string().min(1).nullable(),
30
+ workspaces: z.record(z.string(), WorkspaceEntrySchema)
31
+ }).refine((c) => c.active === null || Object.hasOwn(c.workspaces, c.active), {
32
+ message: "active references an unregistered workspace",
33
+ path: ["active"]
34
+ });
35
+ //#endregion
36
+ export { ConfigSchema, WorkspaceEntrySchema };
@@ -0,0 +1,149 @@
1
+ import { LinearAgentError } from "../errors/error.js";
2
+ import { configPath } from "./paths.js";
3
+ import { ConfigSchema } from "./schema.js";
4
+ import { chmodSync, closeSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeSync } from "node:fs";
5
+ import { dirname } from "node:path";
6
+ import { randomBytes } from "node:crypto";
7
+ //#region src/core/config/store.ts
8
+ /**
9
+ * ConfigStore — atomic, 0600-aware reader/writer for the workspace registry.
10
+ *
11
+ * Design rationale (PITFALLS § Pitfalls 3,4 + CONTEXT § Config Storage):
12
+ *
13
+ * We do NOT use `conf@^14` for this file. `conf` is excellent for general
14
+ * user-config storage but does not enforce a strict 0600 mode on read,
15
+ * which is precisely the property we need for token-bearing files. Wrapping
16
+ * `conf` to add the mode check would split logic across two libraries; a
17
+ * ~80 LOC bespoke writer is simpler to audit and easier to keep correct.
18
+ *
19
+ * The store enforces three contract guarantees:
20
+ * 1. The file mode on disk is exactly 0600 after every write.
21
+ * 2. Reading a file whose mode is broader than 0600 fails closed with
22
+ * `CONFIG_PERMISSIONS_TOO_BROAD` (exit 11) BEFORE returning data.
23
+ * 3. Writes are atomic from a reader's perspective: a sibling temp file
24
+ * (created with mode 0600 + fsync'd) is `rename`d into place. Readers
25
+ * never see a partial file.
26
+ *
27
+ * Cross-platform note: POSIX modes don't apply on NTFS, so the
28
+ * permission-check is a no-op on Windows. v1 documents this as a known
29
+ * limitation; multi-user Windows boxes are outside the v1 threat model.
30
+ */
31
+ const REQUIRED_DIR_MODE = 448;
32
+ const IS_WINDOWS = process.platform === "win32";
33
+ /**
34
+ * Read the config file. Returns the empty config (`{ active: null, workspaces: {} }`)
35
+ * if the file is missing — the very first `workspace add` writes it.
36
+ *
37
+ * Throws:
38
+ * - `CONFIG_PERMISSIONS_TOO_BROAD` (exit 11) if the file mode is broader
39
+ * than 0600 on a POSIX platform. Recovery: `chmod 600 <path>`.
40
+ * - `VALIDATION_FAILED` (exit 12) on malformed JSON or schema mismatch.
41
+ */
42
+ function loadConfig(opts = {}) {
43
+ const target = opts.path ?? configPath();
44
+ let stat;
45
+ try {
46
+ stat = statSync(target);
47
+ } catch (e) {
48
+ if (isErrnoException(e) && e.code === "ENOENT") return cloneEmpty();
49
+ throw LinearAgentError.generic(`failed to stat config file: ${e?.message ?? String(e)}`);
50
+ }
51
+ if (!IS_WINDOWS) {
52
+ const fileMode = stat.mode & 511;
53
+ if (fileMode !== 384) throw LinearAgentError.auth.configPermissionsTooBroad(target, octal(fileMode));
54
+ }
55
+ let raw;
56
+ try {
57
+ raw = readFileSync(target, "utf8");
58
+ } catch (e) {
59
+ throw LinearAgentError.generic(`failed to read config file: ${e?.message ?? String(e)}`);
60
+ }
61
+ let parsedJson;
62
+ try {
63
+ parsedJson = JSON.parse(raw);
64
+ } catch (e) {
65
+ throw LinearAgentError.validation.failed("config file is not valid JSON", {
66
+ stage: "json-parse",
67
+ path: target,
68
+ parseError: e?.message ?? String(e)
69
+ });
70
+ }
71
+ const result = ConfigSchema.safeParse(parsedJson);
72
+ if (!result.success) throw LinearAgentError.validation.failed("config file failed schema validation", {
73
+ stage: "schema",
74
+ path: target,
75
+ issues: result.error.issues
76
+ });
77
+ return result.data;
78
+ }
79
+ /**
80
+ * Write the config to disk atomically with mode 0600.
81
+ *
82
+ * The write protocol:
83
+ * 1. Ensure parent directory exists with mode 0700 (recursive).
84
+ * 2. Open a sibling temp file with `O_CREAT | O_WRONLY` and mode 0600.
85
+ * 3. Write the serialized config + newline; `fsync`; `close`.
86
+ * 4. `chmod 0600` defensively (umask may have masked the open() mode).
87
+ * 5. `rename` the temp file over the target — atomic on POSIX.
88
+ * 6. On any error mid-flight, attempt to unlink the temp file.
89
+ */
90
+ function saveConfig(config, opts = {}) {
91
+ const validated = ConfigSchema.parse(config);
92
+ const target = opts.path ?? configPath();
93
+ const parent = dirname(target);
94
+ try {
95
+ mkdirSync(parent, {
96
+ recursive: true,
97
+ mode: REQUIRED_DIR_MODE
98
+ });
99
+ } catch (e) {
100
+ throw LinearAgentError.generic(`failed to create config directory: ${e?.message ?? String(e)}`);
101
+ }
102
+ const tempPath = `${target}.tmp.${process.pid}.${randomBytes(4).toString("hex")}`;
103
+ let fd;
104
+ try {
105
+ fd = openSync(tempPath, "wx", 384);
106
+ const payload = `${JSON.stringify(validated, null, 2)}\n`;
107
+ writeSync(fd, payload);
108
+ fsyncSync(fd);
109
+ closeSync(fd);
110
+ fd = void 0;
111
+ if (!IS_WINDOWS) chmodSync(tempPath, 384);
112
+ renameSync(tempPath, target);
113
+ } catch (e) {
114
+ if (fd !== void 0) try {
115
+ closeSync(fd);
116
+ } catch {}
117
+ try {
118
+ unlinkSync(tempPath);
119
+ } catch {}
120
+ if (e instanceof LinearAgentError) throw e;
121
+ throw LinearAgentError.generic(`failed to write config file: ${e?.message ?? String(e)}`);
122
+ }
123
+ }
124
+ /**
125
+ * Convenience: load → mutate → save in one call. Used by
126
+ * `workspace add/use/remove/replace-token` in PLAN-04.
127
+ *
128
+ * Returns the saved config (post-mutator) so callers can inspect the result
129
+ * without an extra `loadConfig` round-trip.
130
+ */
131
+ function updateConfig(mutator, opts = {}) {
132
+ const next = mutator(loadConfig({ path: opts.path }));
133
+ saveConfig(next, opts);
134
+ return next;
135
+ }
136
+ function cloneEmpty() {
137
+ return {
138
+ active: null,
139
+ workspaces: {}
140
+ };
141
+ }
142
+ function octal(mode) {
143
+ return `0${mode.toString(8).padStart(3, "0")}`;
144
+ }
145
+ function isErrnoException(e) {
146
+ return e instanceof Error && typeof e.code === "string";
147
+ }
148
+ //#endregion
149
+ export { loadConfig, saveConfig, updateConfig };
@@ -0,0 +1,142 @@
1
+ //#region src/core/errors/error.ts
2
+ /**
3
+ * Substring patterns that must never appear in a LinearAgentError's
4
+ * `message` field. The redactor (`src/core/redact/redact.ts`) is the
5
+ * primary scrubber — this constructor-level guard is defense in depth
6
+ * for callers that hand-craft messages.
7
+ *
8
+ * Linear PATs are formatted `lin_api_<base64-ish>` (and `lin_oauth_<...>`
9
+ * for OAuth tokens). We forbid the literal prefixes anywhere in the
10
+ * message string, not just at the start, because `Error("Authorization:
11
+ * lin_api_...")` is the exact pattern callers tend to construct.
12
+ */
13
+ const FORBIDDEN_TOKEN_PREFIXES = ["lin_api_", "lin_oauth_"];
14
+ /** Codes whose default `transient` value is `true`. */
15
+ const TRANSIENT_BY_DEFAULT = new Set(["RATELIMITED", "NETWORK_ERROR"]);
16
+ /**
17
+ * The single error class for the kernel. One base class with the code
18
+ * enum, no per-code subclasses (over-engineering — see CONTEXT § specifics).
19
+ *
20
+ * Construction-time guarantees:
21
+ * - `code` is a member of the frozen taxonomy (TS-enforced).
22
+ * - `message` does NOT contain a literal `lin_api_` or `lin_oauth_`
23
+ * substring. This is defense in depth on top of the redactor — if a
24
+ * caller hand-crafts a message that bakes in a token, the constructor
25
+ * throws a *plain* `Error` (not a `LinearAgentError`) so the failure
26
+ * surfaces as a developer bug rather than a user-facing error.
27
+ * - `transient` defaults to `true` for `RATELIMITED` and `NETWORK_ERROR`,
28
+ * `false` for everything else. Callers may override.
29
+ */
30
+ var LinearAgentError = class LinearAgentError extends Error {
31
+ name = "LinearAgentError";
32
+ code;
33
+ transient;
34
+ retryAfterMs;
35
+ details;
36
+ constructor(init) {
37
+ super(init.message);
38
+ for (const prefix of FORBIDDEN_TOKEN_PREFIXES) if (init.message.includes(prefix)) throw new Error(`token-shaped substring forbidden in error message (matched ${prefix}*); use the redactor or strip the token before constructing the error`);
39
+ this.code = init.code;
40
+ this.transient = init.transient ?? TRANSIENT_BY_DEFAULT.has(init.code);
41
+ if (init.retryAfterMs !== void 0) this.retryAfterMs = init.retryAfterMs;
42
+ if (init.details !== void 0) this.details = init.details;
43
+ }
44
+ static workspace = {
45
+ notResolved: (detail) => new LinearAgentError({
46
+ code: "WORKSPACE_NOT_RESOLVED",
47
+ message: detail ?? "no workspace could be resolved for this invocation"
48
+ }),
49
+ notFound: (name) => new LinearAgentError({
50
+ code: "WORKSPACE_NOT_FOUND",
51
+ message: `workspace not found: ${name}`,
52
+ details: { workspace: name }
53
+ }),
54
+ requiredForWrite: () => new LinearAgentError({
55
+ code: "WORKSPACE_REQUIRED_FOR_WRITE",
56
+ message: "write commands require an explicit --workspace flag, LINEAR_WORKSPACE env, or --allow-active-workspace-write opt-in"
57
+ }),
58
+ tokenMismatch: (expected, got) => new LinearAgentError({
59
+ code: "WORKSPACE_TOKEN_MISMATCH",
60
+ message: "token does not match the organizationId stored for this workspace",
61
+ details: {
62
+ expectedOrganizationId: expected,
63
+ gotOrganizationId: got
64
+ }
65
+ }),
66
+ alreadyExists: (name) => new LinearAgentError({
67
+ code: "WORKSPACE_ALREADY_EXISTS",
68
+ message: `workspace already registered: ${name} (use replace-token to rotate)`,
69
+ details: { workspace: name }
70
+ })
71
+ };
72
+ static auth = {
73
+ invalid: (detail) => new LinearAgentError({
74
+ code: "AUTH_INVALID",
75
+ message: detail ?? "authentication token is invalid or expired"
76
+ }),
77
+ configPermissionsTooBroad: (path, mode) => new LinearAgentError({
78
+ code: "CONFIG_PERMISSIONS_TOO_BROAD",
79
+ message: `config file mode is broader than 0600 (${mode}); run \`chmod 600 ${path}\``,
80
+ details: {
81
+ path,
82
+ mode
83
+ }
84
+ }),
85
+ configNotFound: (path) => new LinearAgentError({
86
+ code: "CONFIG_NOT_FOUND",
87
+ message: `config file not found: ${path}`,
88
+ details: { path }
89
+ })
90
+ };
91
+ static validation = {
92
+ failed: (detail, details) => new LinearAgentError({
93
+ code: "VALIDATION_FAILED",
94
+ message: detail,
95
+ ...details !== void 0 ? { details } : {}
96
+ }),
97
+ invalidField: (field, allowed) => new LinearAgentError({
98
+ code: "INVALID_FIELD",
99
+ message: `unknown field: ${field}`,
100
+ details: allowed !== void 0 ? {
101
+ field,
102
+ allowed: [...allowed]
103
+ } : { field }
104
+ })
105
+ };
106
+ static linear = { apiError: (init) => new LinearAgentError({
107
+ code: "LINEAR_API_ERROR",
108
+ message: init.message,
109
+ ...init.details !== void 0 ? { details: init.details } : {}
110
+ }) };
111
+ static rateLimited(retryAfterMs, details) {
112
+ return new LinearAgentError({
113
+ code: "RATELIMITED",
114
+ message: "Linear rate limit exceeded",
115
+ transient: true,
116
+ retryAfterMs,
117
+ ...details !== void 0 ? { details } : {}
118
+ });
119
+ }
120
+ static network(detail, details) {
121
+ return new LinearAgentError({
122
+ code: "NETWORK_ERROR",
123
+ message: detail,
124
+ transient: true,
125
+ ...details !== void 0 ? { details } : {}
126
+ });
127
+ }
128
+ static usage(detail) {
129
+ return new LinearAgentError({
130
+ code: "USAGE_ERROR",
131
+ message: detail
132
+ });
133
+ }
134
+ static generic(detail) {
135
+ return new LinearAgentError({
136
+ code: "GENERIC_ERROR",
137
+ message: detail
138
+ });
139
+ }
140
+ };
141
+ //#endregion
142
+ export { LinearAgentError };
@@ -0,0 +1,70 @@
1
+ //#region src/core/errors/exit-codes.ts
2
+ /**
3
+ * Canonical numeric exit codes per CONTEXT.md § Exit Code Taxonomy.
4
+ *
5
+ * Stays below 64 to avoid POSIX 125+ shell-reserved codes (clig.dev,
6
+ * Wikipedia § Exit status). Agent runtimes can build retry logic on these
7
+ * because every code is documented and stable.
8
+ */
9
+ const EXIT_CODES = {
10
+ SUCCESS: 0,
11
+ GENERIC: 1,
12
+ USAGE: 2,
13
+ WORKSPACE: 10,
14
+ AUTH: 11,
15
+ VALIDATION: 12,
16
+ LINEAR_API: 13,
17
+ RATELIMITED: 14,
18
+ NETWORK: 15
19
+ };
20
+ /**
21
+ * Map an `ErrorCode` to its canonical numeric exit code.
22
+ *
23
+ * Implemented as an exhaustive switch with a `_exhaustive: never` guard in
24
+ * the default branch — adding a new code to `ERROR_CODES` without a
25
+ * corresponding case will fail `tsc --noEmit`. This is exactly the
26
+ * "hard-to-skip" property this kernel needs (T-01-03 in the threat model).
27
+ */
28
+ function exitCodeFor(code) {
29
+ switch (code) {
30
+ case "WORKSPACE_NOT_RESOLVED":
31
+ case "WORKSPACE_NOT_FOUND":
32
+ case "WORKSPACE_REQUIRED_FOR_WRITE":
33
+ case "WORKSPACE_TOKEN_MISMATCH":
34
+ case "WORKSPACE_ALREADY_EXISTS": return EXIT_CODES.WORKSPACE;
35
+ case "AUTH_INVALID":
36
+ case "CONFIG_PERMISSIONS_TOO_BROAD":
37
+ case "CONFIG_NOT_FOUND": return EXIT_CODES.AUTH;
38
+ case "VALIDATION_FAILED":
39
+ case "INVALID_FIELD": return EXIT_CODES.VALIDATION;
40
+ case "LINEAR_API_ERROR": return EXIT_CODES.LINEAR_API;
41
+ case "RATELIMITED": return EXIT_CODES.RATELIMITED;
42
+ case "NETWORK_ERROR": return EXIT_CODES.NETWORK;
43
+ case "USAGE_ERROR":
44
+ case "VALIDATION_NO_FIELDS":
45
+ case "WORKFLOW_TEAM_REQUIRED":
46
+ case "CONFIRMATION_REQUIRED":
47
+ case "RAW_OPERATION_NOT_FOUND":
48
+ case "RAW_MUTATION_REQUIRES_FLAG":
49
+ case "OPERATION_SUBSCRIPTIONS_UNSUPPORTED":
50
+ case "GRAPHQL_QUERY_FILE_NOT_FOUND":
51
+ case "BATCH_REQUIRES_YES":
52
+ case "INVALID_INCLUDE":
53
+ case "DESCRIBE_COMMAND_NOT_FOUND": return EXIT_CODES.USAGE;
54
+ case "RAW_VARS_INVALID":
55
+ case "GRAPHQL_VALIDATION_FAILED":
56
+ case "BATCH_PLAN_INVALID": return EXIT_CODES.VALIDATION;
57
+ case "WORKFLOW_STATE_NOT_FOUND":
58
+ case "ISSUE_NOT_FOUND":
59
+ case "LABEL_NOT_FOUND":
60
+ case "TEAM_NOT_FOUND":
61
+ case "PROJECT_NOT_FOUND":
62
+ case "CYCLE_NOT_FOUND": return EXIT_CODES.LINEAR_API;
63
+ case "GENERIC_ERROR":
64
+ case "INSTALL_SKILL_BUNDLE_NOT_FOUND":
65
+ case "INSTALL_SKILL_WRITE_FAILED": return EXIT_CODES.GENERIC;
66
+ default: return code;
67
+ }
68
+ }
69
+ //#endregion
70
+ export { exitCodeFor };