@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.
- package/LICENSE +21 -0
- package/README.md +65 -0
- package/dist/adapters/cache/noop-cache.d.ts +7 -0
- package/dist/adapters/cache/noop-cache.js +12 -0
- package/dist/adapters/credentials/composite.d.ts +30 -0
- package/dist/adapters/credentials/composite.js +79 -0
- package/dist/adapters/credentials/process-env.d.ts +13 -0
- package/dist/adapters/credentials/process-env.js +17 -0
- package/dist/adapters/credentials/secret-store.d.ts +69 -0
- package/dist/adapters/credentials/secret-store.js +259 -0
- package/dist/adapters/credentials/windows-user-env.d.ts +20 -0
- package/dist/adapters/credentials/windows-user-env.js +51 -0
- package/dist/adapters/jira-cloud/adf-to-text.d.ts +12 -0
- package/dist/adapters/jira-cloud/adf-to-text.js +151 -0
- package/dist/adapters/jira-cloud/jira-client.d.ts +32 -0
- package/dist/adapters/jira-cloud/jira-client.js +137 -0
- package/dist/adapters/jira-cloud/jira-read.adapter.d.ts +13 -0
- package/dist/adapters/jira-cloud/jira-read.adapter.js +97 -0
- package/dist/adapters/jira-cloud/jira-write.adapter.d.ts +12 -0
- package/dist/adapters/jira-cloud/jira-write.adapter.js +17 -0
- package/dist/adapters/jira-cloud/mapper.d.ts +34 -0
- package/dist/adapters/jira-cloud/mapper.js +143 -0
- package/dist/adapters/telemetry/console-telemetry.d.ts +13 -0
- package/dist/adapters/telemetry/console-telemetry.js +31 -0
- package/dist/application/get-full-issue-context.d.ts +18 -0
- package/dist/application/get-full-issue-context.js +120 -0
- package/dist/application/get-issue-context.d.ts +19 -0
- package/dist/application/get-issue-context.js +79 -0
- package/dist/application/search-issues.d.ts +22 -0
- package/dist/application/search-issues.js +93 -0
- package/dist/bootstrap/boot-health-gate.d.ts +25 -0
- package/dist/bootstrap/boot-health-gate.js +137 -0
- package/dist/bootstrap/bootstrap-orchestrator.d.ts +15 -0
- package/dist/bootstrap/bootstrap-orchestrator.js +14 -0
- package/dist/bootstrap/host-mcp.d.ts +54 -0
- package/dist/bootstrap/host-mcp.js +98 -0
- package/dist/bootstrap/jira-projects.d.ts +16 -0
- package/dist/bootstrap/jira-projects.js +24 -0
- package/dist/bootstrap/mcp-config-merger.d.ts +58 -0
- package/dist/bootstrap/mcp-config-merger.js +119 -0
- package/dist/bootstrap/migration-target.d.ts +60 -0
- package/dist/bootstrap/migration-target.js +80 -0
- package/dist/bootstrap/project-bindings.d.ts +48 -0
- package/dist/bootstrap/project-bindings.js +112 -0
- package/dist/bootstrap/project-config-bootstrapper.d.ts +37 -0
- package/dist/bootstrap/project-config-bootstrapper.js +69 -0
- package/dist/bootstrap/project-config-resolver.d.ts +53 -0
- package/dist/bootstrap/project-config-resolver.js +59 -0
- package/dist/bootstrap/project-root-resolver.d.ts +22 -0
- package/dist/bootstrap/project-root-resolver.js +43 -0
- package/dist/bootstrap/setup-apply.d.ts +31 -0
- package/dist/bootstrap/setup-apply.js +61 -0
- package/dist/bootstrap/setup-plan.d.ts +102 -0
- package/dist/bootstrap/setup-plan.js +224 -0
- package/dist/bootstrap/setup-state.d.ts +73 -0
- package/dist/bootstrap/setup-state.js +89 -0
- package/dist/bootstrap/workspace-identity.d.ts +52 -0
- package/dist/bootstrap/workspace-identity.js +114 -0
- package/dist/cli/agent-api.d.ts +63 -0
- package/dist/cli/agent-api.js +149 -0
- package/dist/cli/auth.d.ts +39 -0
- package/dist/cli/auth.js +201 -0
- package/dist/cli/doctor.d.ts +7 -0
- package/dist/cli/doctor.js +31 -0
- package/dist/cli/runtime.d.ts +16 -0
- package/dist/cli/runtime.js +97 -0
- package/dist/cli/serve.d.ts +7 -0
- package/dist/cli/serve.js +23 -0
- package/dist/cli/setup-wizard.d.ts +37 -0
- package/dist/cli/setup-wizard.js +338 -0
- package/dist/cli/setup.d.ts +26 -0
- package/dist/cli/setup.js +214 -0
- package/dist/cli/ui.d.ts +118 -0
- package/dist/cli/ui.js +338 -0
- package/dist/cli-entry.d.ts +7 -0
- package/dist/cli-entry.js +124 -0
- package/dist/config/load-config.d.ts +18 -0
- package/dist/config/load-config.js +48 -0
- package/dist/config/schema.d.ts +36 -0
- package/dist/config/schema.js +74 -0
- package/dist/deps.d.ts +50 -0
- package/dist/deps.js +39 -0
- package/dist/domain/completeness.d.ts +63 -0
- package/dist/domain/completeness.js +20 -0
- package/dist/domain/context.d.ts +38 -0
- package/dist/domain/context.js +1 -0
- package/dist/domain/errors.d.ts +27 -0
- package/dist/domain/errors.js +49 -0
- package/dist/domain/issue.d.ts +35 -0
- package/dist/domain/issue.js +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +17 -0
- package/dist/mcp/create-server.d.ts +9 -0
- package/dist/mcp/create-server.js +32 -0
- package/dist/mcp/tool-result.d.ts +10 -0
- package/dist/mcp/tool-result.js +33 -0
- package/dist/mcp/tools/jira-context.tool.d.ts +3 -0
- package/dist/mcp/tools/jira-context.tool.js +25 -0
- package/dist/mcp/tools/jira-full.tool.d.ts +3 -0
- package/dist/mcp/tools/jira-full.tool.js +27 -0
- package/dist/mcp/tools/jira-search.tool.d.ts +3 -0
- package/dist/mcp/tools/jira-search.tool.js +31 -0
- package/dist/policy/completeness-policy.d.ts +19 -0
- package/dist/policy/completeness-policy.js +34 -0
- package/dist/policy/consistency-policy.d.ts +13 -0
- package/dist/policy/consistency-policy.js +6 -0
- package/dist/policy/field-policy.d.ts +11 -0
- package/dist/policy/field-policy.js +26 -0
- package/dist/policy/output-budget-policy.d.ts +23 -0
- package/dist/policy/output-budget-policy.js +96 -0
- package/dist/policy/pagination-policy.d.ts +15 -0
- package/dist/policy/pagination-policy.js +13 -0
- package/dist/ports/cache.port.d.ts +5 -0
- package/dist/ports/cache.port.js +1 -0
- package/dist/ports/credentials.port.d.ts +27 -0
- package/dist/ports/credentials.port.js +1 -0
- package/dist/ports/jira-read.port.d.ts +73 -0
- package/dist/ports/jira-read.port.js +1 -0
- package/dist/ports/jira-write.port.d.ts +12 -0
- package/dist/ports/jira-write.port.js +1 -0
- package/dist/ports/telemetry.port.d.ts +13 -0
- package/dist/ports/telemetry.port.js +1 -0
- package/package.json +69 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir, platform } from "node:os";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
import { parse as parseYaml } from "yaml";
|
|
5
|
+
export const DEFAULT_PRESETS_PATH = join(homedir(), ".jira-agent", "presets.yaml");
|
|
6
|
+
/**
|
|
7
|
+
* Decide a project key from explicit sources only - JAM never guesses a Jira
|
|
8
|
+
* project from a folder or repo name. Order: `--project` flag, then
|
|
9
|
+
* `JAM_PROJECT_KEY`, then this user's binding for the workspace, then a
|
|
10
|
+
* matching entry in the legacy preset file. Returns undefined when none
|
|
11
|
+
* apply, which the caller must treat as "cannot decide safely".
|
|
12
|
+
*
|
|
13
|
+
* Flag over environment over persisted, because what someone typed for this
|
|
14
|
+
* run should beat what a shell exported, which should beat what was recorded
|
|
15
|
+
* some time ago.
|
|
16
|
+
*/
|
|
17
|
+
export function decideProjectKey(root, options = {}) {
|
|
18
|
+
const explicit = options.explicitKey?.trim();
|
|
19
|
+
if (explicit)
|
|
20
|
+
return { key: explicit, source: "explicit" };
|
|
21
|
+
const envKey = (options.env ?? process.env).JAM_PROJECT_KEY?.trim();
|
|
22
|
+
if (envKey)
|
|
23
|
+
return { key: envKey, source: "env" };
|
|
24
|
+
const bindingKey = options.bindingKey?.trim();
|
|
25
|
+
if (bindingKey)
|
|
26
|
+
return { key: bindingKey, source: "binding" };
|
|
27
|
+
const preset = findPresetKey(root, options.presetsPath ?? DEFAULT_PRESETS_PATH);
|
|
28
|
+
if (preset)
|
|
29
|
+
return { key: preset, source: "preset" };
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
function findPresetKey(root, presetsPath) {
|
|
33
|
+
if (!existsSync(presetsPath))
|
|
34
|
+
return undefined;
|
|
35
|
+
let parsed;
|
|
36
|
+
try {
|
|
37
|
+
parsed = (parseYaml(readFileSync(presetsPath, "utf8")) ?? {});
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
// A broken preset file should not crash bootstrap - just skip it.
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
const target = normalizePath(root);
|
|
44
|
+
for (const entry of parsed.projects ?? []) {
|
|
45
|
+
if (!entry.match || !entry.key)
|
|
46
|
+
continue;
|
|
47
|
+
if (normalizePath(entry.match) === target)
|
|
48
|
+
return entry.key;
|
|
49
|
+
}
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
/** Windows paths are case-insensitive; every other platform is compared as-is. */
|
|
53
|
+
export function normalizePath(p) {
|
|
54
|
+
const resolved = resolve(p);
|
|
55
|
+
return platform() === "win32" ? resolved.toLowerCase() : resolved;
|
|
56
|
+
}
|
|
57
|
+
const GENERATED_HEADER = "# Generated by JAM. Safe to commit.\n";
|
|
58
|
+
/**
|
|
59
|
+
* Writes the minimal `.jira-agent/project.yaml`. Only the key is set - every
|
|
60
|
+
* other field is left to the schema's built-in defaults so this file stays
|
|
61
|
+
* legible and merge-friendly.
|
|
62
|
+
*/
|
|
63
|
+
export function writeBootstrapConfig(root, key) {
|
|
64
|
+
const dir = join(root, ".jira-agent");
|
|
65
|
+
mkdirSync(dir, { recursive: true });
|
|
66
|
+
const path = join(dir, "project.yaml");
|
|
67
|
+
writeFileSync(path, `${GENERATED_HEADER}version: 1\n\nproject:\n key: ${key}\n`, "utf8");
|
|
68
|
+
return path;
|
|
69
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { type ProjectConfig } from "../config/schema.js";
|
|
2
|
+
import { type BootstrapSource } from "./project-config-bootstrapper.js";
|
|
3
|
+
import { type GitRemoteFn } from "./workspace-identity.js";
|
|
4
|
+
export type ResolvedProjectConfig = {
|
|
5
|
+
config: ProjectConfig;
|
|
6
|
+
configPath?: string;
|
|
7
|
+
root: string;
|
|
8
|
+
/**
|
|
9
|
+
* Where the key came from when no config file supplied it. Undefined when
|
|
10
|
+
* `configPath` is set - "from the file" is what `configPath` already says.
|
|
11
|
+
*/
|
|
12
|
+
keySource?: BootstrapSource;
|
|
13
|
+
};
|
|
14
|
+
export type ResolveConfigOptions = {
|
|
15
|
+
cwd?: string;
|
|
16
|
+
/**
|
|
17
|
+
* What to do when the project has no config file.
|
|
18
|
+
*
|
|
19
|
+
* - `"required"` - fall back to an explicit key (flag, env, personal
|
|
20
|
+
* binding, preset) and throw JAM_SETUP_REQUIRED when none applies.
|
|
21
|
+
* `jam serve` cannot start without one.
|
|
22
|
+
* - `"optional"` - resolve the same way, but fall back to schema defaults
|
|
23
|
+
* instead of throwing. `jam doctor` reports the state of the machine;
|
|
24
|
+
* refusing to load would leave it with nothing to report.
|
|
25
|
+
* - absent - no fallback at all.
|
|
26
|
+
*
|
|
27
|
+
* Resolution either way. Nothing here is ever written.
|
|
28
|
+
*/
|
|
29
|
+
keyFallback?: "required" | "optional";
|
|
30
|
+
/** `--project` override, only consulted when `keyFallback` is set. */
|
|
31
|
+
explicitKey?: string;
|
|
32
|
+
/** Injected by tests so a decision never depends on the machine's JAM_PROJECT_KEY. */
|
|
33
|
+
env?: NodeJS.ProcessEnv;
|
|
34
|
+
/** Injected by tests so a decision never reads the developer's own presets. */
|
|
35
|
+
presetsPath?: string;
|
|
36
|
+
/** Injected by tests to isolate `~/.jam`. */
|
|
37
|
+
home?: string;
|
|
38
|
+
/** Injected by tests so identity never depends on the checkout under test. */
|
|
39
|
+
git?: GitRemoteFn;
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* Find this project's JAM config, or decide its key in memory.
|
|
43
|
+
*
|
|
44
|
+
* Reads and decides; it never writes. Persisting a decision is `jam setup`'s
|
|
45
|
+
* job alone (`setup-apply.ts`), because creating a file in someone's
|
|
46
|
+
* repository is a change they asked for, not a side effect of starting a
|
|
47
|
+
* server.
|
|
48
|
+
*
|
|
49
|
+
* Throws JAM_SETUP_REQUIRED when a key is allowed to come from a fallback but
|
|
50
|
+
* no explicit source can supply one - JAM never guesses a Jira project from a
|
|
51
|
+
* repo or folder name.
|
|
52
|
+
*/
|
|
53
|
+
export declare function resolveProjectConfig(options?: ResolveConfigOptions): ResolvedProjectConfig;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { loadConfig } from "../config/load-config.js";
|
|
3
|
+
import { ProjectConfigSchema } from "../config/schema.js";
|
|
4
|
+
import { JamError } from "../domain/errors.js";
|
|
5
|
+
import { findProjectBinding } from "./project-bindings.js";
|
|
6
|
+
import { decideProjectKey } from "./project-config-bootstrapper.js";
|
|
7
|
+
import { resolveProjectRoot } from "./project-root-resolver.js";
|
|
8
|
+
import { workspaceIdentity } from "./workspace-identity.js";
|
|
9
|
+
/**
|
|
10
|
+
* Find this project's JAM config, or decide its key in memory.
|
|
11
|
+
*
|
|
12
|
+
* Reads and decides; it never writes. Persisting a decision is `jam setup`'s
|
|
13
|
+
* job alone (`setup-apply.ts`), because creating a file in someone's
|
|
14
|
+
* repository is a change they asked for, not a side effect of starting a
|
|
15
|
+
* server.
|
|
16
|
+
*
|
|
17
|
+
* Throws JAM_SETUP_REQUIRED when a key is allowed to come from a fallback but
|
|
18
|
+
* no explicit source can supply one - JAM never guesses a Jira project from a
|
|
19
|
+
* repo or folder name.
|
|
20
|
+
*/
|
|
21
|
+
export function resolveProjectConfig(options = {}) {
|
|
22
|
+
const cwd = resolve(options.cwd ?? process.cwd());
|
|
23
|
+
const { root, hasConfig, gitRoot } = resolveProjectRoot(cwd);
|
|
24
|
+
if (hasConfig) {
|
|
25
|
+
const loaded = loadConfig(root);
|
|
26
|
+
return { config: loaded.config, configPath: loaded.path, root };
|
|
27
|
+
}
|
|
28
|
+
if (!options.keyFallback) {
|
|
29
|
+
const loaded = loadConfig(cwd);
|
|
30
|
+
return { config: loaded.config, configPath: loaded.path, root };
|
|
31
|
+
}
|
|
32
|
+
// Read only: a personal binding is consulted here and written nowhere, so
|
|
33
|
+
// starting a server leaves the user's own files alone as well as the repo's.
|
|
34
|
+
const bindingKey = findProjectBinding(workspaceIdentity(root, {
|
|
35
|
+
...(gitRoot ? { gitRoot } : {}),
|
|
36
|
+
...(options.git ? { git: options.git } : {}),
|
|
37
|
+
}), options.home)?.key;
|
|
38
|
+
const decision = decideProjectKey(root, {
|
|
39
|
+
...(options.explicitKey ? { explicitKey: options.explicitKey } : {}),
|
|
40
|
+
...(options.env ? { env: options.env } : {}),
|
|
41
|
+
...(bindingKey ? { bindingKey } : {}),
|
|
42
|
+
...(options.presetsPath ? { presetsPath: options.presetsPath } : {}),
|
|
43
|
+
});
|
|
44
|
+
if (!decision) {
|
|
45
|
+
if (options.keyFallback === "optional") {
|
|
46
|
+
const loaded = loadConfig(cwd);
|
|
47
|
+
return { config: loaded.config, configPath: loaded.path, root };
|
|
48
|
+
}
|
|
49
|
+
throw new JamError("JAM_SETUP_REQUIRED", `No .jira-agent/project.yaml found under ${root}, and no project key could be determined safely (no --project, no JAM_PROJECT_KEY, no matching preset). Run \`jam setup --project <KEY>\`.`, { root });
|
|
50
|
+
}
|
|
51
|
+
// The file this used to write held nothing but the key: every other field
|
|
52
|
+
// came back from schema defaults on the next read. Parsing the key directly
|
|
53
|
+
// produces the same config without touching the repository.
|
|
54
|
+
return {
|
|
55
|
+
config: ProjectConfigSchema.parse({ project: { key: decision.key } }),
|
|
56
|
+
root,
|
|
57
|
+
keySource: decision.source,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export type ProjectRoot = {
|
|
2
|
+
root: string;
|
|
3
|
+
/** True when an existing `.jira-agent/project.yaml` was found at `root`. */
|
|
4
|
+
hasConfig: boolean;
|
|
5
|
+
/**
|
|
6
|
+
* The nearest enclosing repository, when there is one. Already found on the
|
|
7
|
+
* way up; reported so workspace identity does not have to walk again.
|
|
8
|
+
*/
|
|
9
|
+
gitRoot?: string;
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* Nearest-ancestor search from `startDir`:
|
|
13
|
+
* 1. the nearest `.jira-agent/project.yaml` - that directory is the root
|
|
14
|
+
* 2. else the nearest `.git` - a FILE in a worktree/submodule counts just as
|
|
15
|
+
* much as a directory, since `existsSync` doesn't care which
|
|
16
|
+
* 3. else `startDir` itself, so JAM still boots outside any repo
|
|
17
|
+
*
|
|
18
|
+
* This is deliberately the nearest ancestor, not the outermost repo root - a
|
|
19
|
+
* project nested inside a monorepo should not have JAM walk past its own
|
|
20
|
+
* `.git` into the monorepo's.
|
|
21
|
+
*/
|
|
22
|
+
export declare function resolveProjectRoot(startDir?: string): ProjectRoot;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
import { CONFIG_RELATIVE_PATH } from "../config/load-config.js";
|
|
4
|
+
/**
|
|
5
|
+
* Nearest-ancestor search from `startDir`:
|
|
6
|
+
* 1. the nearest `.jira-agent/project.yaml` - that directory is the root
|
|
7
|
+
* 2. else the nearest `.git` - a FILE in a worktree/submodule counts just as
|
|
8
|
+
* much as a directory, since `existsSync` doesn't care which
|
|
9
|
+
* 3. else `startDir` itself, so JAM still boots outside any repo
|
|
10
|
+
*
|
|
11
|
+
* This is deliberately the nearest ancestor, not the outermost repo root - a
|
|
12
|
+
* project nested inside a monorepo should not have JAM walk past its own
|
|
13
|
+
* `.git` into the monorepo's.
|
|
14
|
+
*/
|
|
15
|
+
export function resolveProjectRoot(startDir = process.cwd()) {
|
|
16
|
+
let dir = resolve(startDir);
|
|
17
|
+
let gitRoot;
|
|
18
|
+
let configRoot;
|
|
19
|
+
for (;;) {
|
|
20
|
+
if (configRoot === undefined && existsSync(join(dir, CONFIG_RELATIVE_PATH))) {
|
|
21
|
+
configRoot = dir;
|
|
22
|
+
// Keep walking only far enough to learn which repository this is in;
|
|
23
|
+
// the config still decides the root.
|
|
24
|
+
if (gitRoot !== undefined)
|
|
25
|
+
break;
|
|
26
|
+
}
|
|
27
|
+
if (gitRoot === undefined && existsSync(join(dir, ".git"))) {
|
|
28
|
+
gitRoot = dir;
|
|
29
|
+
if (configRoot !== undefined)
|
|
30
|
+
break;
|
|
31
|
+
}
|
|
32
|
+
const parent = dirname(dir);
|
|
33
|
+
if (parent === dir)
|
|
34
|
+
break;
|
|
35
|
+
dir = parent;
|
|
36
|
+
}
|
|
37
|
+
const root = configRoot ?? gitRoot ?? resolve(startDir);
|
|
38
|
+
return {
|
|
39
|
+
root,
|
|
40
|
+
hasConfig: configRoot !== undefined,
|
|
41
|
+
...(gitRoot ? { gitRoot } : {}),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { type HostRunner } from "./host-mcp.js";
|
|
2
|
+
import type { SetupChange, SetupPlan } from "./setup-plan.js";
|
|
3
|
+
export type AppliedChange = SetupChange & {
|
|
4
|
+
applied: true;
|
|
5
|
+
};
|
|
6
|
+
export type ApplyResult = {
|
|
7
|
+
applied: AppliedChange[];
|
|
8
|
+
/** Mirrors plan.changes.length > 0 - useful for agents deciding what to report. */
|
|
9
|
+
changesApplied: boolean;
|
|
10
|
+
};
|
|
11
|
+
export type ApplyOptions = {
|
|
12
|
+
/** Project root; defaults to the root recorded in the plan. */
|
|
13
|
+
root?: string;
|
|
14
|
+
/** The jam entry to write. Defaults to the canonical form. */
|
|
15
|
+
mcpEntry?: unknown;
|
|
16
|
+
/** Injected by tests to isolate ~/.jam. */
|
|
17
|
+
home?: string;
|
|
18
|
+
/** Injected by tests so no test ever registers JAM with a real host. */
|
|
19
|
+
runHost?: HostRunner;
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Execute exactly the changes a plan listed - nothing more.
|
|
23
|
+
*
|
|
24
|
+
* Apply deliberately re-decides nothing. If a change is not in the plan it
|
|
25
|
+
* does not happen, which is what makes `plan` a trustworthy preview and keeps
|
|
26
|
+
* human and agent paths honest about what they are about to do.
|
|
27
|
+
*
|
|
28
|
+
* Idempotent in practice: re-detecting after an apply produces a plan with no
|
|
29
|
+
* changes, so running setup twice is a no-op rather than a rewrite.
|
|
30
|
+
*/
|
|
31
|
+
export declare function applySetupPlan(plan: SetupPlan, options?: ApplyOptions): ApplyResult;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { defaultHostRunner } from "./host-mcp.js";
|
|
2
|
+
import { JAM_MCP_ENTRY, writeJamMcpEntry } from "./mcp-config-merger.js";
|
|
3
|
+
import { writeProjectBinding } from "./project-bindings.js";
|
|
4
|
+
import { writeBootstrapConfig } from "./project-config-bootstrapper.js";
|
|
5
|
+
/**
|
|
6
|
+
* Execute exactly the changes a plan listed - nothing more.
|
|
7
|
+
*
|
|
8
|
+
* Apply deliberately re-decides nothing. If a change is not in the plan it
|
|
9
|
+
* does not happen, which is what makes `plan` a trustworthy preview and keeps
|
|
10
|
+
* human and agent paths honest about what they are about to do.
|
|
11
|
+
*
|
|
12
|
+
* Idempotent in practice: re-detecting after an apply produces a plan with no
|
|
13
|
+
* changes, so running setup twice is a no-op rather than a rewrite.
|
|
14
|
+
*/
|
|
15
|
+
export function applySetupPlan(plan, options = {}) {
|
|
16
|
+
const root = options.root ?? plan.project?.root;
|
|
17
|
+
const applied = [];
|
|
18
|
+
for (const change of plan.changes) {
|
|
19
|
+
switch (change.target) {
|
|
20
|
+
case "project-config": {
|
|
21
|
+
if (!root)
|
|
22
|
+
throw new Error("Cannot apply a project-config change without a project root.");
|
|
23
|
+
writeBootstrapConfig(root, change.key);
|
|
24
|
+
applied.push({ ...change, applied: true });
|
|
25
|
+
break;
|
|
26
|
+
}
|
|
27
|
+
case "mcp-config": {
|
|
28
|
+
if (!root)
|
|
29
|
+
throw new Error("Cannot apply an mcp-config change without a project root.");
|
|
30
|
+
writeJamMcpEntry(root, options.mcpEntry ?? JAM_MCP_ENTRY);
|
|
31
|
+
applied.push({ ...change, applied: true });
|
|
32
|
+
break;
|
|
33
|
+
}
|
|
34
|
+
case "host-mcp": {
|
|
35
|
+
// The argv came from the plan, so this runs what was previewed and
|
|
36
|
+
// decides nothing. A failure is that host's failure, reported with the
|
|
37
|
+
// command that produced it - never retried against another host.
|
|
38
|
+
const run = options.runHost ?? defaultHostRunner;
|
|
39
|
+
const result = run({ command: change.command, args: change.args });
|
|
40
|
+
if (result.failed || result.status !== 0) {
|
|
41
|
+
throw new Error(`Registering JAM with ${change.host} failed: ${change.command} ${change.args.join(" ")}`);
|
|
42
|
+
}
|
|
43
|
+
applied.push({ ...change, applied: true });
|
|
44
|
+
break;
|
|
45
|
+
}
|
|
46
|
+
case "personal-binding": {
|
|
47
|
+
// Everything needed is in the change. The workspace was identified and
|
|
48
|
+
// the key decided at plan time, so applying cannot reach a different
|
|
49
|
+
// answer than the one previewed.
|
|
50
|
+
writeProjectBinding({
|
|
51
|
+
workspace: change.workspaceId,
|
|
52
|
+
key: change.key,
|
|
53
|
+
...(root ? { path: root } : {}),
|
|
54
|
+
}, options.home);
|
|
55
|
+
applied.push({ ...change, applied: true });
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return { applied, changesApplied: applied.length > 0 };
|
|
61
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { type HostId } from "./host-mcp.js";
|
|
2
|
+
import type { MigrationTarget } from "./migration-target.js";
|
|
3
|
+
import { type BootstrapSource } from "./project-config-bootstrapper.js";
|
|
4
|
+
import type { SetupState } from "./setup-state.js";
|
|
5
|
+
export type SetupStatus = "already_configured" | "ready_to_apply" | "user_action_required";
|
|
6
|
+
export type SetupCode = "JAM_PROJECT_SELECTION_REQUIRED" | "JAM_BINDINGS_UNREADABLE" | "JAM_AUTH_REQUIRED" | "JAM_RUNTIME_CONFIG_MISSING" | "JAM_PROJECT_CONFIG_INVALID" | "JAM_MCP_CONFIG_UNREADABLE" | "JAM_MIGRATION_TARGET_UNAVAILABLE";
|
|
7
|
+
export type SetupChange = {
|
|
8
|
+
type: "create";
|
|
9
|
+
target: "project-config";
|
|
10
|
+
path: string;
|
|
11
|
+
key: string;
|
|
12
|
+
keySource: BootstrapSource;
|
|
13
|
+
} | {
|
|
14
|
+
type: "create";
|
|
15
|
+
target: "mcp-config";
|
|
16
|
+
path: string;
|
|
17
|
+
} | {
|
|
18
|
+
type: "merge";
|
|
19
|
+
target: "mcp-config";
|
|
20
|
+
path: string;
|
|
21
|
+
preserveExisting: string[];
|
|
22
|
+
} | {
|
|
23
|
+
type: "replace";
|
|
24
|
+
target: "mcp-config";
|
|
25
|
+
path: string;
|
|
26
|
+
reason: "migrate";
|
|
27
|
+
} | {
|
|
28
|
+
type: "create" | "replace";
|
|
29
|
+
target: "personal-binding";
|
|
30
|
+
path: string;
|
|
31
|
+
workspaceId: string;
|
|
32
|
+
key: string;
|
|
33
|
+
keySource: BootstrapSource;
|
|
34
|
+
/** Present on a rebind, so the preview shows what is being replaced. */
|
|
35
|
+
previousKey?: string;
|
|
36
|
+
} | {
|
|
37
|
+
type: "create";
|
|
38
|
+
target: "host-mcp";
|
|
39
|
+
host: HostId;
|
|
40
|
+
/**
|
|
41
|
+
* The exact argv apply will run, decided here and never recomputed -
|
|
42
|
+
* so `plan --json` shows what is about to happen, and running a host's
|
|
43
|
+
* CLI stays on the apply side of the line.
|
|
44
|
+
*/
|
|
45
|
+
command: string;
|
|
46
|
+
args: string[];
|
|
47
|
+
};
|
|
48
|
+
export type SetupPlan = {
|
|
49
|
+
status: SetupStatus;
|
|
50
|
+
code?: SetupCode;
|
|
51
|
+
changes: SetupChange[];
|
|
52
|
+
requiresUserAction: boolean;
|
|
53
|
+
/** Populated by the caller when status is JAM_PROJECT_SELECTION_REQUIRED. */
|
|
54
|
+
projects?: {
|
|
55
|
+
key: string;
|
|
56
|
+
name: string;
|
|
57
|
+
}[];
|
|
58
|
+
/** Why a requested migration was refused, when status is JAM_MIGRATION_TARGET_UNAVAILABLE. */
|
|
59
|
+
migrationTarget?: MigrationTarget;
|
|
60
|
+
nextAction?: {
|
|
61
|
+
type: "authenticate" | "select_project" | "configure_runtime";
|
|
62
|
+
command?: string;
|
|
63
|
+
};
|
|
64
|
+
project?: {
|
|
65
|
+
root: string;
|
|
66
|
+
key?: string;
|
|
67
|
+
};
|
|
68
|
+
};
|
|
69
|
+
export type PlanOptions = {
|
|
70
|
+
/**
|
|
71
|
+
* `--shared`: adopt JAM for the team, writing `.jira-agent/project.yaml` and
|
|
72
|
+
* `.mcp.json` into the repository. Without it setup is personal and the
|
|
73
|
+
* repository is left alone - discovery is allowed, adoption is asked for.
|
|
74
|
+
*/
|
|
75
|
+
shared?: boolean;
|
|
76
|
+
/** `--project KEY`. */
|
|
77
|
+
explicitKey?: string;
|
|
78
|
+
/** `--migrate`: rewrite a legacy jam entry instead of leaving it alone. */
|
|
79
|
+
migrate?: boolean;
|
|
80
|
+
env?: NodeJS.ProcessEnv;
|
|
81
|
+
presetsPath?: string;
|
|
82
|
+
/** True when the existing jam entry does not match the current canonical form. */
|
|
83
|
+
jamEntryIsLegacy?: boolean;
|
|
84
|
+
/**
|
|
85
|
+
* Whether the package a `--migrate` rewrite would point at can be resolved.
|
|
86
|
+
* Observed by the caller, like `jamEntryIsLegacy` - the planner never probes.
|
|
87
|
+
* Absent means not verified, and an unverified target refuses the rewrite.
|
|
88
|
+
*/
|
|
89
|
+
migrationTarget?: MigrationTarget;
|
|
90
|
+
};
|
|
91
|
+
/**
|
|
92
|
+
* Decide what setup would do, changing nothing.
|
|
93
|
+
*
|
|
94
|
+
* Pure by construction: every input arrives through `state` and `options`, and
|
|
95
|
+
* the result is a list of changes for apply to execute verbatim. Keeping the
|
|
96
|
+
* decision separate from the mutation is what lets a human preview it, an
|
|
97
|
+
* agent reason about it, and both go through identical logic.
|
|
98
|
+
*
|
|
99
|
+
* Safe Bootstrap is preserved here: a project key comes from an explicit
|
|
100
|
+
* source or not at all. JAM never infers one from a repo or directory name.
|
|
101
|
+
*/
|
|
102
|
+
export declare function computeSetupPlan(state: SetupState, options?: PlanOptions): SetupPlan;
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { CONFIG_RELATIVE_PATH } from "../config/load-config.js";
|
|
3
|
+
import { hostRegistration } from "./host-mcp.js";
|
|
4
|
+
import { projectBindingsPath } from "./project-bindings.js";
|
|
5
|
+
import { decideProjectKey } from "./project-config-bootstrapper.js";
|
|
6
|
+
/**
|
|
7
|
+
* Decide what setup would do, changing nothing.
|
|
8
|
+
*
|
|
9
|
+
* Pure by construction: every input arrives through `state` and `options`, and
|
|
10
|
+
* the result is a list of changes for apply to execute verbatim. Keeping the
|
|
11
|
+
* decision separate from the mutation is what lets a human preview it, an
|
|
12
|
+
* agent reason about it, and both go through identical logic.
|
|
13
|
+
*
|
|
14
|
+
* Safe Bootstrap is preserved here: a project key comes from an explicit
|
|
15
|
+
* source or not at all. JAM never infers one from a repo or directory name.
|
|
16
|
+
*/
|
|
17
|
+
export function computeSetupPlan(state, options = {}) {
|
|
18
|
+
const changes = [];
|
|
19
|
+
const shared = options.shared ?? false;
|
|
20
|
+
// A config file that exists but cannot be parsed is a stop, not something to
|
|
21
|
+
// overwrite - the user's settings are in there. It is read in both scopes,
|
|
22
|
+
// so a broken one blocks either.
|
|
23
|
+
if (state.project.error) {
|
|
24
|
+
return {
|
|
25
|
+
status: "user_action_required",
|
|
26
|
+
code: "JAM_PROJECT_CONFIG_INVALID",
|
|
27
|
+
changes: [],
|
|
28
|
+
requiresUserAction: true,
|
|
29
|
+
project: { root: state.project.root },
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
// Personal setup never opens .mcp.json, so a broken one there is not its
|
|
33
|
+
// problem to report.
|
|
34
|
+
if (shared && state.mcp.unreadable) {
|
|
35
|
+
return {
|
|
36
|
+
status: "user_action_required",
|
|
37
|
+
code: "JAM_MCP_CONFIG_UNREADABLE",
|
|
38
|
+
changes: [],
|
|
39
|
+
requiresUserAction: true,
|
|
40
|
+
project: { root: state.project.root },
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
const key = resolveKey(state, options);
|
|
44
|
+
if (!key) {
|
|
45
|
+
return {
|
|
46
|
+
status: "user_action_required",
|
|
47
|
+
code: "JAM_PROJECT_SELECTION_REQUIRED",
|
|
48
|
+
changes: [],
|
|
49
|
+
requiresUserAction: true,
|
|
50
|
+
nextAction: { type: "select_project", command: "jam setup --project <KEY>" },
|
|
51
|
+
project: { root: state.project.root },
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
const project = { root: state.project.root, key: key.key };
|
|
55
|
+
if (!shared) {
|
|
56
|
+
// Personal scope: the record of "this workspace is that Jira project"
|
|
57
|
+
// lives with the user, and nothing in the repository is touched.
|
|
58
|
+
const bindingChange = planBindingChange(state, key);
|
|
59
|
+
if (bindingChange && state.bindingsUnreadable) {
|
|
60
|
+
return {
|
|
61
|
+
status: "user_action_required",
|
|
62
|
+
code: "JAM_BINDINGS_UNREADABLE",
|
|
63
|
+
changes: [],
|
|
64
|
+
requiresUserAction: true,
|
|
65
|
+
project,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
if (bindingChange)
|
|
69
|
+
changes.push(bindingChange);
|
|
70
|
+
changes.push(...planHostChanges(state));
|
|
71
|
+
return finish(changes, state, project);
|
|
72
|
+
}
|
|
73
|
+
if (!state.project.hasConfig) {
|
|
74
|
+
changes.push({
|
|
75
|
+
type: "create",
|
|
76
|
+
target: "project-config",
|
|
77
|
+
path: joinConfigPath(state.project.root),
|
|
78
|
+
key: key.key,
|
|
79
|
+
keySource: key.source,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
const mcpChange = planMcpChange(state, options);
|
|
83
|
+
// A migration replaces wiring the user already has working. Refuse to plan
|
|
84
|
+
// that against a destination nobody has confirmed is reachable - and answer
|
|
85
|
+
// the flag they typed before reporting anything else. The rest of the plan
|
|
86
|
+
// survives: declining the rewrite is no reason to leave the project unwired.
|
|
87
|
+
if (mcpChange?.type === "replace" && options.migrationTarget?.available !== true) {
|
|
88
|
+
return {
|
|
89
|
+
status: "user_action_required",
|
|
90
|
+
code: "JAM_MIGRATION_TARGET_UNAVAILABLE",
|
|
91
|
+
changes,
|
|
92
|
+
requiresUserAction: true,
|
|
93
|
+
...(options.migrationTarget ? { migrationTarget: options.migrationTarget } : {}),
|
|
94
|
+
project,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
if (mcpChange)
|
|
98
|
+
changes.push(mcpChange);
|
|
99
|
+
return finish(changes, state, project);
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* The stops that apply to either scope, and the verdict.
|
|
103
|
+
*
|
|
104
|
+
* Credentials and runtime are human boundaries: JAM can wire things up
|
|
105
|
+
* regardless, but it cannot authenticate on the user's behalf. Both stops
|
|
106
|
+
* therefore still carry their changes - apply them, then stop for the person.
|
|
107
|
+
*/
|
|
108
|
+
function finish(changes, state, project) {
|
|
109
|
+
if (!state.credentials.present) {
|
|
110
|
+
return {
|
|
111
|
+
status: "user_action_required",
|
|
112
|
+
code: "JAM_AUTH_REQUIRED",
|
|
113
|
+
changes,
|
|
114
|
+
requiresUserAction: true,
|
|
115
|
+
nextAction: { type: "authenticate" },
|
|
116
|
+
project,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
if (!state.runtime.configured) {
|
|
120
|
+
return {
|
|
121
|
+
status: "user_action_required",
|
|
122
|
+
code: "JAM_RUNTIME_CONFIG_MISSING",
|
|
123
|
+
changes,
|
|
124
|
+
requiresUserAction: true,
|
|
125
|
+
nextAction: { type: "configure_runtime", command: "jam runtime use package" },
|
|
126
|
+
project,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
status: changes.length === 0 ? "already_configured" : "ready_to_apply",
|
|
131
|
+
changes,
|
|
132
|
+
requiresUserAction: false,
|
|
133
|
+
project,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Register JAM with each coding agent that can be reached and does not have
|
|
138
|
+
* it yet.
|
|
139
|
+
*
|
|
140
|
+
* A host whose CLI is missing gets no change at all: JAM will not guess at
|
|
141
|
+
* another program's config file, and a half-wired host reported as done is
|
|
142
|
+
* worse than one the user is told about. The caller prints the command for
|
|
143
|
+
* those.
|
|
144
|
+
*/
|
|
145
|
+
function planHostChanges(state) {
|
|
146
|
+
const changes = [];
|
|
147
|
+
for (const host of state.hosts) {
|
|
148
|
+
if (!host.cliAvailable || host.hasJamEntry)
|
|
149
|
+
continue;
|
|
150
|
+
const registration = hostRegistration(host.id);
|
|
151
|
+
if (!registration)
|
|
152
|
+
continue;
|
|
153
|
+
changes.push({
|
|
154
|
+
type: "create",
|
|
155
|
+
target: "host-mcp",
|
|
156
|
+
host: host.id,
|
|
157
|
+
command: registration.command,
|
|
158
|
+
args: registration.args,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
return changes;
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* What the binding file should say, or nothing when it already says it.
|
|
165
|
+
*
|
|
166
|
+
* A repository that declares its own key needs no personal note: the team's
|
|
167
|
+
* file is already the answer, and recording a second copy would only create
|
|
168
|
+
* something to disagree with later.
|
|
169
|
+
*/
|
|
170
|
+
function planBindingChange(state, key) {
|
|
171
|
+
if (state.project.key)
|
|
172
|
+
return undefined;
|
|
173
|
+
const existing = state.project.binding;
|
|
174
|
+
if (existing?.key === key.key)
|
|
175
|
+
return undefined;
|
|
176
|
+
return {
|
|
177
|
+
type: existing ? "replace" : "create",
|
|
178
|
+
target: "personal-binding",
|
|
179
|
+
path: projectBindingsPath(),
|
|
180
|
+
workspaceId: state.workspaceId,
|
|
181
|
+
key: key.key,
|
|
182
|
+
keySource: key.source,
|
|
183
|
+
...(existing ? { previousKey: existing.key } : {}),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
function resolveKey(state, options) {
|
|
187
|
+
// An existing project.yaml wins: setup must never silently repoint a project,
|
|
188
|
+
// and a personal note must never override what the team committed.
|
|
189
|
+
if (state.project.key)
|
|
190
|
+
return { key: state.project.key, source: "explicit" };
|
|
191
|
+
const decideOptions = {};
|
|
192
|
+
if (options.explicitKey)
|
|
193
|
+
decideOptions.explicitKey = options.explicitKey;
|
|
194
|
+
if (options.env)
|
|
195
|
+
decideOptions.env = options.env;
|
|
196
|
+
if (state.project.binding)
|
|
197
|
+
decideOptions.bindingKey = state.project.binding.key;
|
|
198
|
+
if (options.presetsPath)
|
|
199
|
+
decideOptions.presetsPath = options.presetsPath;
|
|
200
|
+
return decideProjectKey(state.project.root, decideOptions);
|
|
201
|
+
}
|
|
202
|
+
function planMcpChange(state, options) {
|
|
203
|
+
if (!state.mcp.exists) {
|
|
204
|
+
return { type: "create", target: "mcp-config", path: state.mcp.path };
|
|
205
|
+
}
|
|
206
|
+
if (!state.mcp.hasJamEntry) {
|
|
207
|
+
return {
|
|
208
|
+
type: "merge",
|
|
209
|
+
target: "mcp-config",
|
|
210
|
+
path: state.mcp.path,
|
|
211
|
+
preserveExisting: state.mcp.otherServers,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
// An existing jam entry is left alone unless migration was asked for
|
|
215
|
+
// explicitly - overwriting someone's customised wiring is not setup's call.
|
|
216
|
+
const legacy = options.jamEntryIsLegacy ?? state.mcp.jamEntryIsLegacy;
|
|
217
|
+
if (options.migrate && legacy) {
|
|
218
|
+
return { type: "replace", target: "mcp-config", path: state.mcp.path, reason: "migrate" };
|
|
219
|
+
}
|
|
220
|
+
return undefined;
|
|
221
|
+
}
|
|
222
|
+
function joinConfigPath(root) {
|
|
223
|
+
return join(root, CONFIG_RELATIVE_PATH);
|
|
224
|
+
}
|