@jam-mcp/server 1.0.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 (123) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +65 -0
  3. package/dist/adapters/cache/noop-cache.d.ts +7 -0
  4. package/dist/adapters/cache/noop-cache.js +12 -0
  5. package/dist/adapters/credentials/composite.d.ts +30 -0
  6. package/dist/adapters/credentials/composite.js +79 -0
  7. package/dist/adapters/credentials/process-env.d.ts +13 -0
  8. package/dist/adapters/credentials/process-env.js +17 -0
  9. package/dist/adapters/credentials/secret-store.d.ts +69 -0
  10. package/dist/adapters/credentials/secret-store.js +259 -0
  11. package/dist/adapters/credentials/windows-user-env.d.ts +20 -0
  12. package/dist/adapters/credentials/windows-user-env.js +51 -0
  13. package/dist/adapters/jira-cloud/adf-to-text.d.ts +12 -0
  14. package/dist/adapters/jira-cloud/adf-to-text.js +151 -0
  15. package/dist/adapters/jira-cloud/jira-client.d.ts +32 -0
  16. package/dist/adapters/jira-cloud/jira-client.js +137 -0
  17. package/dist/adapters/jira-cloud/jira-read.adapter.d.ts +13 -0
  18. package/dist/adapters/jira-cloud/jira-read.adapter.js +97 -0
  19. package/dist/adapters/jira-cloud/jira-write.adapter.d.ts +12 -0
  20. package/dist/adapters/jira-cloud/jira-write.adapter.js +17 -0
  21. package/dist/adapters/jira-cloud/mapper.d.ts +34 -0
  22. package/dist/adapters/jira-cloud/mapper.js +143 -0
  23. package/dist/adapters/telemetry/console-telemetry.d.ts +13 -0
  24. package/dist/adapters/telemetry/console-telemetry.js +31 -0
  25. package/dist/application/get-full-issue-context.d.ts +18 -0
  26. package/dist/application/get-full-issue-context.js +120 -0
  27. package/dist/application/get-issue-context.d.ts +19 -0
  28. package/dist/application/get-issue-context.js +79 -0
  29. package/dist/application/search-issues.d.ts +22 -0
  30. package/dist/application/search-issues.js +93 -0
  31. package/dist/bootstrap/boot-health-gate.d.ts +25 -0
  32. package/dist/bootstrap/boot-health-gate.js +137 -0
  33. package/dist/bootstrap/bootstrap-orchestrator.d.ts +15 -0
  34. package/dist/bootstrap/bootstrap-orchestrator.js +14 -0
  35. package/dist/bootstrap/host-mcp.d.ts +54 -0
  36. package/dist/bootstrap/host-mcp.js +98 -0
  37. package/dist/bootstrap/jira-projects.d.ts +16 -0
  38. package/dist/bootstrap/jira-projects.js +24 -0
  39. package/dist/bootstrap/mcp-config-merger.d.ts +58 -0
  40. package/dist/bootstrap/mcp-config-merger.js +119 -0
  41. package/dist/bootstrap/migration-target.d.ts +60 -0
  42. package/dist/bootstrap/migration-target.js +80 -0
  43. package/dist/bootstrap/project-bindings.d.ts +48 -0
  44. package/dist/bootstrap/project-bindings.js +112 -0
  45. package/dist/bootstrap/project-config-bootstrapper.d.ts +37 -0
  46. package/dist/bootstrap/project-config-bootstrapper.js +69 -0
  47. package/dist/bootstrap/project-config-resolver.d.ts +53 -0
  48. package/dist/bootstrap/project-config-resolver.js +59 -0
  49. package/dist/bootstrap/project-root-resolver.d.ts +22 -0
  50. package/dist/bootstrap/project-root-resolver.js +43 -0
  51. package/dist/bootstrap/setup-apply.d.ts +31 -0
  52. package/dist/bootstrap/setup-apply.js +61 -0
  53. package/dist/bootstrap/setup-plan.d.ts +102 -0
  54. package/dist/bootstrap/setup-plan.js +224 -0
  55. package/dist/bootstrap/setup-state.d.ts +73 -0
  56. package/dist/bootstrap/setup-state.js +89 -0
  57. package/dist/bootstrap/workspace-identity.d.ts +52 -0
  58. package/dist/bootstrap/workspace-identity.js +114 -0
  59. package/dist/cli/agent-api.d.ts +63 -0
  60. package/dist/cli/agent-api.js +149 -0
  61. package/dist/cli/auth.d.ts +39 -0
  62. package/dist/cli/auth.js +201 -0
  63. package/dist/cli/doctor.d.ts +7 -0
  64. package/dist/cli/doctor.js +31 -0
  65. package/dist/cli/runtime.d.ts +16 -0
  66. package/dist/cli/runtime.js +97 -0
  67. package/dist/cli/serve.d.ts +7 -0
  68. package/dist/cli/serve.js +23 -0
  69. package/dist/cli/setup-wizard.d.ts +37 -0
  70. package/dist/cli/setup-wizard.js +338 -0
  71. package/dist/cli/setup.d.ts +26 -0
  72. package/dist/cli/setup.js +214 -0
  73. package/dist/cli/ui.d.ts +118 -0
  74. package/dist/cli/ui.js +338 -0
  75. package/dist/cli-entry.d.ts +7 -0
  76. package/dist/cli-entry.js +124 -0
  77. package/dist/config/load-config.d.ts +18 -0
  78. package/dist/config/load-config.js +48 -0
  79. package/dist/config/schema.d.ts +36 -0
  80. package/dist/config/schema.js +74 -0
  81. package/dist/deps.d.ts +50 -0
  82. package/dist/deps.js +39 -0
  83. package/dist/domain/completeness.d.ts +63 -0
  84. package/dist/domain/completeness.js +20 -0
  85. package/dist/domain/context.d.ts +38 -0
  86. package/dist/domain/context.js +1 -0
  87. package/dist/domain/errors.d.ts +27 -0
  88. package/dist/domain/errors.js +49 -0
  89. package/dist/domain/issue.d.ts +35 -0
  90. package/dist/domain/issue.js +1 -0
  91. package/dist/index.d.ts +2 -0
  92. package/dist/index.js +17 -0
  93. package/dist/mcp/create-server.d.ts +9 -0
  94. package/dist/mcp/create-server.js +32 -0
  95. package/dist/mcp/tool-result.d.ts +10 -0
  96. package/dist/mcp/tool-result.js +33 -0
  97. package/dist/mcp/tools/jira-context.tool.d.ts +3 -0
  98. package/dist/mcp/tools/jira-context.tool.js +25 -0
  99. package/dist/mcp/tools/jira-full.tool.d.ts +3 -0
  100. package/dist/mcp/tools/jira-full.tool.js +27 -0
  101. package/dist/mcp/tools/jira-search.tool.d.ts +3 -0
  102. package/dist/mcp/tools/jira-search.tool.js +31 -0
  103. package/dist/policy/completeness-policy.d.ts +19 -0
  104. package/dist/policy/completeness-policy.js +34 -0
  105. package/dist/policy/consistency-policy.d.ts +13 -0
  106. package/dist/policy/consistency-policy.js +6 -0
  107. package/dist/policy/field-policy.d.ts +11 -0
  108. package/dist/policy/field-policy.js +26 -0
  109. package/dist/policy/output-budget-policy.d.ts +23 -0
  110. package/dist/policy/output-budget-policy.js +96 -0
  111. package/dist/policy/pagination-policy.d.ts +15 -0
  112. package/dist/policy/pagination-policy.js +13 -0
  113. package/dist/ports/cache.port.d.ts +5 -0
  114. package/dist/ports/cache.port.js +1 -0
  115. package/dist/ports/credentials.port.d.ts +27 -0
  116. package/dist/ports/credentials.port.js +1 -0
  117. package/dist/ports/jira-read.port.d.ts +73 -0
  118. package/dist/ports/jira-read.port.js +1 -0
  119. package/dist/ports/jira-write.port.d.ts +12 -0
  120. package/dist/ports/jira-write.port.js +1 -0
  121. package/dist/ports/telemetry.port.d.ts +13 -0
  122. package/dist/ports/telemetry.port.js +1 -0
  123. package/package.json +69 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 colosair
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,65 @@
1
+ # @jam-mcp/server
2
+
3
+ JAM (Jira Agent MCP) itself: the MCP server, the setup core, and the `jam` CLI.
4
+
5
+ JAM is an agent-facing Jira **read** layer. It takes over the decisions an agent
6
+ should not be making — which fields to request, when to paginate, when to read
7
+ the comment thread, what to do when a result is too big — so everyday reads stay
8
+ cheap and important judgements still get full context.
9
+
10
+ ## The three tools
11
+
12
+ The external contract is exactly three tools. Adding or renaming one is a
13
+ breaking change.
14
+
15
+ | Tool | Use it for |
16
+ |---|---|
17
+ | `jira_search` | listing, discovery, "what's open", picking candidates |
18
+ | `jira_context` | readiness, blockers, dependencies, priority |
19
+ | `jira_full` | agreement, contract, approval, closure |
20
+
21
+ A `jira_search` result is never complete issue context — nothing about
22
+ agreement, approval, or done-ness follows from it.
23
+
24
+ ## Evidence boundary
25
+
26
+ Every result carries a `meta` block, and nothing is ever truncated silently.
27
+
28
+ `meta.complete` means **JAM finished the Jira retrieval with no known loss**. It
29
+ is not a statement about the project: not readiness, not "unblocked", not "the
30
+ whole story". What JAM did not look at is named in the same block —
31
+ `evidenceScope` and `limitations` call out the repository, external sources, and
32
+ dependencies that live outside Jira. `provenance` and `source` say where the
33
+ records came from.
34
+
35
+ So an empty comment thread on a complete read is a complete read *of Jira*, not
36
+ proof that nothing was agreed. If the issue points at an external canonical
37
+ source, that source is what settles the question.
38
+
39
+ ## Running it
40
+
41
+ Normally you don't run this package directly — your coding agent launches
42
+ [`@jam-mcp/launcher`](https://www.npmjs.com/package/@jam-mcp/launcher), which
43
+ resolves which JAM build this machine runs and dispatches here. Going through
44
+ the launcher is what keeps a committed `.mcp.json` free of machine-specific
45
+ paths.
46
+
47
+ The `jam` CLI is the same binary:
48
+
49
+ ```text
50
+ jam serve Run the MCP server over stdio
51
+ jam doctor Diagnose config, credentials and Jira connectivity
52
+ jam setup Wire up JAM and verify (personal by default; --shared for the team)
53
+ jam auth login Store Jira credentials in this user's OS secret store
54
+ jam runtime Show or change which JAM build this machine runs
55
+ ```
56
+
57
+ Credentials come from the process environment or this user's OS secret store —
58
+ never from a repository file — and never appear in logs, telemetry, or tool
59
+ results. `stdout` is reserved for the MCP protocol and for JSON output;
60
+ diagnostics go to `stderr`.
61
+
62
+ ## More
63
+
64
+ - [Repository README](https://github.com/colosair/jam#readme)
65
+ - [JAM design of record](https://github.com/colosair/jam/blob/main/docs/architecture/jira-agent-mcp-design.md)
@@ -0,0 +1,7 @@
1
+ import type { CachePort } from "../../ports/cache.port.js";
2
+ /** First release ships without caching. Jira stays the single source of truth. */
3
+ export declare class NoopCache implements CachePort {
4
+ get<T>(): Promise<T | undefined>;
5
+ set(): Promise<void>;
6
+ invalidate(): Promise<void>;
7
+ }
@@ -0,0 +1,12 @@
1
+ /** First release ships without caching. Jira stays the single source of truth. */
2
+ export class NoopCache {
3
+ async get() {
4
+ return undefined;
5
+ }
6
+ async set() {
7
+ // intentionally empty
8
+ }
9
+ async invalidate() {
10
+ // intentionally empty
11
+ }
12
+ }
@@ -0,0 +1,30 @@
1
+ import type { CredentialDescription, CredentialPort, CredentialSource, JiraCredentials } from "../../ports/credentials.port.js";
2
+ import { type CredentialValueSource } from "./process-env.js";
3
+ type NamedSource = {
4
+ name: Exclude<CredentialSource, "mixed" | "none">;
5
+ source: CredentialValueSource;
6
+ };
7
+ /**
8
+ * Merges credential values field-by-field across sources, in priority order:
9
+ * the current process's own environment, then this user's OS secret store,
10
+ * then the Windows User environment.
11
+ *
12
+ * process.env stays first so a per-session override still behaves as expected,
13
+ * and so CI keeps working unchanged. The secret store comes next because it is
14
+ * the only source an editor launched from a Dock or Start menu can reach - such
15
+ * a process never sourced a shell profile. The Windows User environment stays
16
+ * last: a team member who only ever `setx`'d their token still boots without a
17
+ * fresh terminal.
18
+ *
19
+ * Merging is per field, so a single exported variable can shadow one field of a
20
+ * stored credential and leave the rest - `describe()` reports that as "mixed".
21
+ */
22
+ export declare class CompositeCredentialProvider implements CredentialPort {
23
+ private readonly sources;
24
+ private cached?;
25
+ constructor(sources?: NamedSource[]);
26
+ private resolve;
27
+ load(): JiraCredentials;
28
+ describe(): CredentialDescription;
29
+ }
30
+ export {};
@@ -0,0 +1,79 @@
1
+ import { JamError } from "../../domain/errors.js";
2
+ import { CREDENTIAL_ENV_KEYS, ProcessEnvCredentialSource, } from "./process-env.js";
3
+ import { SecretStoreCredentialSource, resolveSecretStore } from "./secret-store.js";
4
+ import { WindowsUserEnvCredentialSource } from "./windows-user-env.js";
5
+ const FIELD_BY_KEY = {
6
+ JIRA_BASE_URL: "baseUrl",
7
+ JIRA_EMAIL: "email",
8
+ JIRA_API_TOKEN: "apiToken",
9
+ };
10
+ /**
11
+ * Merges credential values field-by-field across sources, in priority order:
12
+ * the current process's own environment, then this user's OS secret store,
13
+ * then the Windows User environment.
14
+ *
15
+ * process.env stays first so a per-session override still behaves as expected,
16
+ * and so CI keeps working unchanged. The secret store comes next because it is
17
+ * the only source an editor launched from a Dock or Start menu can reach - such
18
+ * a process never sourced a shell profile. The Windows User environment stays
19
+ * last: a team member who only ever `setx`'d their token still boots without a
20
+ * fresh terminal.
21
+ *
22
+ * Merging is per field, so a single exported variable can shadow one field of a
23
+ * stored credential and leave the rest - `describe()` reports that as "mixed".
24
+ */
25
+ export class CompositeCredentialProvider {
26
+ sources;
27
+ cached;
28
+ constructor(sources = [
29
+ { name: "process", source: new ProcessEnvCredentialSource() },
30
+ { name: "secret-store", source: new SecretStoreCredentialSource(resolveSecretStore()) },
31
+ { name: "user-env", source: new WindowsUserEnvCredentialSource() },
32
+ ]) {
33
+ this.sources = sources;
34
+ }
35
+ resolve() {
36
+ if (this.cached)
37
+ return this.cached;
38
+ const values = {};
39
+ const sourceByKey = {};
40
+ for (const { name, source } of this.sources) {
41
+ const read = source.read();
42
+ for (const key of CREDENTIAL_ENV_KEYS) {
43
+ if (values[key] === undefined && read[key]) {
44
+ values[key] = read[key];
45
+ sourceByKey[key] = name;
46
+ }
47
+ }
48
+ }
49
+ this.cached = { values, sourceByKey };
50
+ return this.cached;
51
+ }
52
+ load() {
53
+ const { values } = this.resolve();
54
+ const missing = CREDENTIAL_ENV_KEYS.filter((k) => !values[k]);
55
+ if (missing.length > 0) {
56
+ throw new JamError("CONFIG_INVALID", `Missing Jira credentials: ${missing.join(", ")} (checked the process environment, this user's OS secret store, then the Windows User environment). Run \`jam auth login\`.`, { missing });
57
+ }
58
+ return {
59
+ baseUrl: values.JIRA_BASE_URL.replace(/\/+$/, ""),
60
+ email: values.JIRA_EMAIL,
61
+ apiToken: values.JIRA_API_TOKEN,
62
+ };
63
+ }
64
+ describe() {
65
+ const { values, sourceByKey } = this.resolve();
66
+ const used = new Set(Object.values(sourceByKey));
67
+ let source = "none";
68
+ if (used.size === 1)
69
+ source = [...used][0];
70
+ else if (used.size > 1)
71
+ source = "mixed";
72
+ const description = { hasToken: Boolean(values.JIRA_API_TOKEN), source };
73
+ if (values.JIRA_BASE_URL)
74
+ description.baseUrl = values.JIRA_BASE_URL;
75
+ if (values.JIRA_EMAIL)
76
+ description.email = values.JIRA_EMAIL;
77
+ return description;
78
+ }
79
+ }
@@ -0,0 +1,13 @@
1
+ export declare const CREDENTIAL_ENV_KEYS: readonly ["JIRA_BASE_URL", "JIRA_EMAIL", "JIRA_API_TOKEN"];
2
+ export type CredentialEnvKey = (typeof CREDENTIAL_ENV_KEYS)[number];
3
+ export type RawCredentialValues = Partial<Record<CredentialEnvKey, string>>;
4
+ /** A named source of raw credential values, keyed by env var name. */
5
+ export interface CredentialValueSource {
6
+ read(): RawCredentialValues;
7
+ }
8
+ /** Reads the current process's own environment. Always tried first. */
9
+ export declare class ProcessEnvCredentialSource implements CredentialValueSource {
10
+ private readonly env;
11
+ constructor(env?: NodeJS.ProcessEnv);
12
+ read(): RawCredentialValues;
13
+ }
@@ -0,0 +1,17 @@
1
+ export const CREDENTIAL_ENV_KEYS = ["JIRA_BASE_URL", "JIRA_EMAIL", "JIRA_API_TOKEN"];
2
+ /** Reads the current process's own environment. Always tried first. */
3
+ export class ProcessEnvCredentialSource {
4
+ env;
5
+ constructor(env = process.env) {
6
+ this.env = env;
7
+ }
8
+ read() {
9
+ const out = {};
10
+ for (const key of CREDENTIAL_ENV_KEYS) {
11
+ const value = this.env[key]?.trim();
12
+ if (value)
13
+ out[key] = value;
14
+ }
15
+ return out;
16
+ }
17
+ }
@@ -0,0 +1,69 @@
1
+ import { type CredentialValueSource, type RawCredentialValues } from "./process-env.js";
2
+ /**
3
+ * Credentials held by the operating system for this user, rather than by a
4
+ * shell profile.
5
+ *
6
+ * This is what makes JAM work in an editor launched from a Dock or Start menu.
7
+ * Such an editor never sourced a shell profile, so the MCP child it spawns
8
+ * inherits no `JIRA_*` at all - but it does run as the user, so it can read
9
+ * what the OS is holding for that user.
10
+ *
11
+ * The stored value never appears in a log, an error, or a tool result. It is
12
+ * read into memory, handed to the credential chain, and nothing else.
13
+ */
14
+ export type StoredCredentials = {
15
+ baseUrl: string;
16
+ email: string;
17
+ apiToken: string;
18
+ };
19
+ export type RunResult = {
20
+ status: number | null;
21
+ stdout: string;
22
+ stderr: string;
23
+ error?: NodeJS.ErrnoException;
24
+ };
25
+ /** Injected by tests so the suite never touches a real keychain. */
26
+ export type RunFn = (command: string, args: string[], input?: string) => RunResult;
27
+ export interface SecretStore {
28
+ /** Shown by `jam auth login`. Names the mechanism, never a value. */
29
+ readonly label: string;
30
+ /** Missing or unreadable resolves to undefined - reading never throws. */
31
+ read(): StoredCredentials | undefined;
32
+ /** Throws on failure: the user just asked for this and must not be told it worked. */
33
+ write(values: StoredCredentials): void;
34
+ /** Succeeds when there was nothing to remove. */
35
+ clear(): void;
36
+ }
37
+ /** Raised when the platform's backend is absent, as opposed to empty. */
38
+ export declare class SecretStoreUnavailableError extends Error {
39
+ readonly remedy: string;
40
+ constructor(message: string, remedy: string);
41
+ }
42
+ export declare function secretStoreDisabled(env?: NodeJS.ProcessEnv): boolean;
43
+ /**
44
+ * The store for this system, or undefined when there is none that works.
45
+ *
46
+ * Being on Linux is not the same as having a secret store: a headless server or
47
+ * a container routinely has no libsecret and no session keyring. So the backend
48
+ * is probed, not assumed from `process.platform`.
49
+ */
50
+ export declare function resolveSecretStore(run?: RunFn, env?: NodeJS.ProcessEnv): SecretStore | undefined;
51
+ /**
52
+ * The credential chain's view of the store.
53
+ *
54
+ * Fail-soft by design, like the Windows registry source: a machine with no
55
+ * store, or an empty one, is a normal state that must not turn every command
56
+ * into an error. `jam auth login` is where a failure gets reported, because
57
+ * there the user asked for something specific.
58
+ */
59
+ export declare class SecretStoreCredentialSource implements CredentialValueSource {
60
+ private readonly store;
61
+ /**
62
+ * The store is required, with no default that would resolve a real one.
63
+ * A parameter default cannot tell `undefined` meaning "this system has no
64
+ * store" from `undefined` meaning "not supplied" - and getting that wrong
65
+ * silently reaches the user's real keychain, including from a test.
66
+ */
67
+ constructor(store: SecretStore | undefined);
68
+ read(): RawCredentialValues;
69
+ }
@@ -0,0 +1,259 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
3
+ import { homedir, userInfo } from "node:os";
4
+ import { join } from "node:path";
5
+ import { CREDENTIAL_ENV_KEYS, } from "./process-env.js";
6
+ /** Raised when the platform's backend is absent, as opposed to empty. */
7
+ export class SecretStoreUnavailableError extends Error {
8
+ remedy;
9
+ constructor(message, remedy) {
10
+ super(message);
11
+ this.name = "SecretStoreUnavailableError";
12
+ this.remedy = remedy;
13
+ }
14
+ }
15
+ /** Derived from constants, never from user input, except the account name. */
16
+ const SERVICE = "jam-mcp";
17
+ /**
18
+ * Escape hatch for isolated test sandboxes.
19
+ *
20
+ * An OS secret store is per-user, not per-HOME, so a sandbox that repoints HOME
21
+ * still reads the developer's real keychain - the same hole the Windows
22
+ * registry source has always had. This is not a user-facing feature: it exists
23
+ * so `npm run smoke` can be offline and deterministic, and `jam auth login`
24
+ * reports it distinctly rather than claiming no store exists.
25
+ */
26
+ const DISABLE_ENV = "JAM_DISABLE_SECRET_STORE";
27
+ export function secretStoreDisabled(env = process.env) {
28
+ return Boolean(env[DISABLE_ENV]);
29
+ }
30
+ function account() {
31
+ return userInfo().username;
32
+ }
33
+ function defaultRun(command, args, input) {
34
+ const result = spawnSync(command, args, {
35
+ encoding: "utf8",
36
+ ...(input === undefined ? {} : { input }),
37
+ // No shell: arguments are passed as an array, so nothing is re-parsed.
38
+ windowsHide: true,
39
+ });
40
+ return {
41
+ status: result.status,
42
+ stdout: result.stdout ?? "",
43
+ stderr: result.stderr ?? "",
44
+ ...(result.error ? { error: result.error } : {}),
45
+ };
46
+ }
47
+ /** Whether a command exists and runs at all. ENOENT is the signal we want. */
48
+ function canRun(run, command, args) {
49
+ return run(command, args).error?.code !== "ENOENT";
50
+ }
51
+ function parse(raw) {
52
+ try {
53
+ const parsed = JSON.parse(raw);
54
+ if (!parsed?.baseUrl || !parsed.email || !parsed.apiToken)
55
+ return undefined;
56
+ return { baseUrl: parsed.baseUrl, email: parsed.email, apiToken: parsed.apiToken };
57
+ }
58
+ catch {
59
+ // A corrupt entry is treated as absent. Reading is one step of credential
60
+ // resolution and must not turn into a crash on an unrelated command.
61
+ return undefined;
62
+ }
63
+ }
64
+ /**
65
+ * macOS login Keychain.
66
+ *
67
+ * The item is created and read by the same binary (`/usr/bin/security`), which
68
+ * is what keeps the OS from prompting for approval on every read.
69
+ */
70
+ function macosStore(run) {
71
+ const base = ["-s", SERVICE, "-a", account()];
72
+ return {
73
+ label: "macOS Keychain",
74
+ read() {
75
+ const res = run("security", ["find-generic-password", ...base, "-w"]);
76
+ if (res.error || res.status !== 0)
77
+ return undefined;
78
+ return parse(res.stdout.trim());
79
+ },
80
+ write(values) {
81
+ // `-U` updates in place rather than failing on an existing item.
82
+ // The secret rides in argv here because `security` reads its -w prompt
83
+ // from the controlling terminal, not stdin, and we already collected the
84
+ // token through our own masked prompt - prompting again would be worse.
85
+ // Visible only to this user, for the lifetime of one short-lived child.
86
+ const res = run("security", [
87
+ "add-generic-password",
88
+ ...base,
89
+ "-U",
90
+ "-w",
91
+ JSON.stringify(values),
92
+ ]);
93
+ if (res.error?.code === "ENOENT")
94
+ throw unavailable("security");
95
+ if (res.status !== 0)
96
+ throw new Error(`Keychain write failed: ${res.stderr.trim()}`);
97
+ },
98
+ clear() {
99
+ run("security", ["delete-generic-password", ...base]);
100
+ },
101
+ };
102
+ }
103
+ /** Linux libsecret, via the `secret-tool` CLI. */
104
+ function linuxStore(run) {
105
+ const attrs = ["service", SERVICE, "account", account()];
106
+ return {
107
+ label: "libsecret (secret-tool)",
108
+ read() {
109
+ const res = run("secret-tool", ["lookup", ...attrs]);
110
+ if (res.error || res.status !== 0)
111
+ return undefined;
112
+ return parse(res.stdout.trim());
113
+ },
114
+ write(values) {
115
+ // secret-tool reads the secret from stdin, so it never reaches argv.
116
+ const res = run("secret-tool", ["store", "--label", "JAM (Jira Agent MCP)", ...attrs], JSON.stringify(values));
117
+ if (res.error?.code === "ENOENT")
118
+ throw unavailable("secret-tool");
119
+ if (res.status !== 0)
120
+ throw new Error(`secret-tool store failed: ${res.stderr.trim()}`);
121
+ },
122
+ clear() {
123
+ run("secret-tool", ["clear", ...attrs]);
124
+ },
125
+ };
126
+ }
127
+ /**
128
+ * Windows: a file encrypted to the current user account with DPAPI.
129
+ *
130
+ * Credential Manager is not usable here - `cmdkey` can store a credential but
131
+ * cannot read one back, and reading it needs either a P/Invoke or a PowerShell
132
+ * module that is not installed by default.
133
+ *
134
+ * The confidentiality boundary is DPAPI's current-user binding. The 0o600 mode
135
+ * on the file is best-effort hardening on top, not the thing protecting it -
136
+ * Node's mode argument does not carry POSIX semantics on Windows.
137
+ *
138
+ * Kept separate from ~/.jam/config.yaml, which declares itself hand-editable
139
+ * and free of credentials.
140
+ */
141
+ function windowsStore(run) {
142
+ const dir = join(homedir(), ".jam");
143
+ const path = join(dir, "credentials.dpapi");
144
+ // The path reaches PowerShell as an argument, never interpolated into the
145
+ // script text; the secret reaches it on stdin.
146
+ const decrypt = [
147
+ "-NoProfile",
148
+ "-NonInteractive",
149
+ "-Command",
150
+ "$p=$args[0]; if(!(Test-Path $p)){exit 1};" +
151
+ "$s=Get-Content $p -Raw | ConvertTo-SecureString;" +
152
+ "[Runtime.InteropServices.Marshal]::PtrToStringAuto(" +
153
+ "[Runtime.InteropServices.Marshal]::SecureStringToBSTR($s))",
154
+ "-args",
155
+ ];
156
+ const encrypt = [
157
+ "-NoProfile",
158
+ "-NonInteractive",
159
+ "-Command",
160
+ "$in=[Console]::In.ReadToEnd();" +
161
+ "$in | ConvertTo-SecureString -AsPlainText -Force |" +
162
+ " ConvertFrom-SecureString | Set-Content $args[0] -NoNewline",
163
+ "-args",
164
+ ];
165
+ return {
166
+ label: "Windows DPAPI (user-encrypted file)",
167
+ read() {
168
+ if (!existsSync(path))
169
+ return undefined;
170
+ const res = run("powershell", [...decrypt, path]);
171
+ if (res.error || res.status !== 0)
172
+ return undefined;
173
+ return parse(res.stdout.trim());
174
+ },
175
+ write(values) {
176
+ mkdirSync(dir, { recursive: true });
177
+ const res = run("powershell", [...encrypt, path], JSON.stringify(values));
178
+ if (res.error?.code === "ENOENT")
179
+ throw unavailable("powershell");
180
+ if (res.status !== 0)
181
+ throw new Error(`DPAPI write failed: ${res.stderr.trim()}`);
182
+ try {
183
+ // Best-effort only; DPAPI is what actually protects the contents.
184
+ writeFileSync(path, "", { flag: "r+", mode: 0o600 });
185
+ }
186
+ catch {
187
+ /* ignore - the encryption, not the mode, is the boundary */
188
+ }
189
+ },
190
+ clear() {
191
+ rmSync(path, { force: true });
192
+ },
193
+ };
194
+ }
195
+ function unavailable(command) {
196
+ return new SecretStoreUnavailableError(`No usable secret store was found on this system (${command} is not available).`, command === "secret-tool"
197
+ ? "Install secret-tool (libsecret), or set JIRA_BASE_URL, JIRA_EMAIL and JIRA_API_TOKEN instead."
198
+ : "Set JIRA_BASE_URL, JIRA_EMAIL and JIRA_API_TOKEN instead.");
199
+ }
200
+ /**
201
+ * The store for this system, or undefined when there is none that works.
202
+ *
203
+ * Being on Linux is not the same as having a secret store: a headless server or
204
+ * a container routinely has no libsecret and no session keyring. So the backend
205
+ * is probed, not assumed from `process.platform`.
206
+ */
207
+ export function resolveSecretStore(run = defaultRun, env = process.env) {
208
+ if (secretStoreDisabled(env))
209
+ return undefined;
210
+ if (process.platform === "darwin") {
211
+ return canRun(run, "security", ["help"]) ? macosStore(run) : undefined;
212
+ }
213
+ if (process.platform === "win32") {
214
+ return canRun(run, "powershell", ["-NoProfile", "-Command", "$null"])
215
+ ? windowsStore(run)
216
+ : undefined;
217
+ }
218
+ if (process.platform === "linux") {
219
+ return canRun(run, "secret-tool", ["--version"]) ? linuxStore(run) : undefined;
220
+ }
221
+ return undefined;
222
+ }
223
+ /**
224
+ * The credential chain's view of the store.
225
+ *
226
+ * Fail-soft by design, like the Windows registry source: a machine with no
227
+ * store, or an empty one, is a normal state that must not turn every command
228
+ * into an error. `jam auth login` is where a failure gets reported, because
229
+ * there the user asked for something specific.
230
+ */
231
+ export class SecretStoreCredentialSource {
232
+ store;
233
+ /**
234
+ * The store is required, with no default that would resolve a real one.
235
+ * A parameter default cannot tell `undefined` meaning "this system has no
236
+ * store" from `undefined` meaning "not supplied" - and getting that wrong
237
+ * silently reaches the user's real keychain, including from a test.
238
+ */
239
+ constructor(store) {
240
+ this.store = store;
241
+ }
242
+ read() {
243
+ const stored = this.store?.read();
244
+ if (!stored)
245
+ return {};
246
+ const values = {};
247
+ const byKey = {
248
+ JIRA_BASE_URL: stored.baseUrl,
249
+ JIRA_EMAIL: stored.email,
250
+ JIRA_API_TOKEN: stored.apiToken,
251
+ };
252
+ for (const key of CREDENTIAL_ENV_KEYS) {
253
+ const value = byKey[key]?.trim();
254
+ if (value)
255
+ values[key] = value;
256
+ }
257
+ return values;
258
+ }
259
+ }
@@ -0,0 +1,20 @@
1
+ import { type CredentialValueSource, type RawCredentialValues } from "./process-env.js";
2
+ export type RegQueryFn = (name: string) => string | undefined;
3
+ /**
4
+ * Reads Jira credentials from the Windows *User* environment (HKCU\Environment)
5
+ * rather than the current process's environment.
6
+ *
7
+ * This exists because a shell that had `setx JIRA_API_TOKEN ...` run in it does
8
+ * NOT see the new value until a new process is spawned - but every terminal
9
+ * spawned afterwards, including the one Claude Code's MCP child inherits from,
10
+ * does. Falling back to the registry closes that gap without asking the user
11
+ * to open a fresh terminal.
12
+ *
13
+ * A value read here is never logged, thrown in an error, or written back
14
+ * anywhere - `read()` only returns it to the caller for in-memory use.
15
+ */
16
+ export declare class WindowsUserEnvCredentialSource implements CredentialValueSource {
17
+ private readonly queryFn;
18
+ constructor(queryFn?: RegQueryFn);
19
+ read(): RawCredentialValues;
20
+ }
@@ -0,0 +1,51 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { CREDENTIAL_ENV_KEYS } from "./process-env.js";
3
+ /**
4
+ * Reads Jira credentials from the Windows *User* environment (HKCU\Environment)
5
+ * rather than the current process's environment.
6
+ *
7
+ * This exists because a shell that had `setx JIRA_API_TOKEN ...` run in it does
8
+ * NOT see the new value until a new process is spawned - but every terminal
9
+ * spawned afterwards, including the one Claude Code's MCP child inherits from,
10
+ * does. Falling back to the registry closes that gap without asking the user
11
+ * to open a fresh terminal.
12
+ *
13
+ * A value read here is never logged, thrown in an error, or written back
14
+ * anywhere - `read()` only returns it to the caller for in-memory use.
15
+ */
16
+ export class WindowsUserEnvCredentialSource {
17
+ queryFn;
18
+ constructor(queryFn = defaultRegQuery) {
19
+ this.queryFn = queryFn;
20
+ }
21
+ read() {
22
+ if (process.platform !== "win32")
23
+ return {};
24
+ const out = {};
25
+ for (const key of CREDENTIAL_ENV_KEYS) {
26
+ const value = this.queryFn(key)?.trim();
27
+ if (value)
28
+ out[key] = value;
29
+ }
30
+ return out;
31
+ }
32
+ }
33
+ const VALUE_LINE = /^\s*\S+\s+REG_(?:SZ|EXPAND_SZ)\s+(.*)$/;
34
+ // Built from a char code rather than a literal backslash escape - a lone
35
+ // backslash before a letter isn't a recognized JS escape and silently
36
+ // disappears, which would quietly turn this into an invalid registry path.
37
+ const HKCU_ENVIRONMENT_KEY = ["HKCU", "Environment"].join(String.fromCharCode(92));
38
+ function defaultRegQuery(name) {
39
+ const res = spawnSync("reg.exe", ["query", HKCU_ENVIRONMENT_KEY, "/v", name], {
40
+ encoding: "utf8",
41
+ windowsHide: true,
42
+ });
43
+ if (res.status !== 0 || !res.stdout)
44
+ return undefined;
45
+ for (const line of res.stdout.split(/\r?\n/)) {
46
+ const match = VALUE_LINE.exec(line);
47
+ if (match)
48
+ return match[1];
49
+ }
50
+ return undefined;
51
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Atlassian Document Format -> plain text.
3
+ *
4
+ * ADF raw JSON is mostly structural tokens the agent has to pay for and then
5
+ * ignore. Normalizing to text is the single biggest payload win in JAM, so
6
+ * nothing downstream of the adapter ever sees an ADF node.
7
+ *
8
+ * Unknown node types are surfaced as `[unsupported: <type>]` rather than
9
+ * dropped - a silently missing paragraph is exactly the failure mode the
10
+ * completeness rules exist to prevent.
11
+ */
12
+ export declare function adfToText(doc: unknown): string;