Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion packages/gittensory-mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ npm link --workspace @jsonbored/gittensory-mcp
gittensory-mcp login
gittensory-mcp logout
gittensory-mcp whoami
gittensory-mcp config
gittensory-mcp config --json
gittensory-mcp status
gittensory-mcp changelog
gittensory-mcp doctor
Expand Down Expand Up @@ -84,7 +86,9 @@ gittensory-mcp whoami
gittensory-mcp logout --profile work
```

Use `--profile <name>` on `login`, `logout`, `whoami`, `status`, and `doctor`, or set `GITTENSORY_PROFILE`. `logout` only clears the selected local profile unless `--all` is passed. Profile output redacts session tokens and local config paths.
Use `--profile <name>` on `login`, `logout`, `whoami`, `config`, `status`, and `doctor`, or set `GITTENSORY_PROFILE`. `logout` only clears the selected local profile unless `--all` is passed. Profile output redacts session tokens and local config paths.

`gittensory-mcp config` prints the resolved effective configuration and the source that supplied each value (`environment`, `profile`, `config`, or `default`): the active API URL and its source, active profile and profile count, whether a config file is present and which environment variable steers its location, the cache-dir source, and whether a token is configured and where it came from. It never prints token values or local absolute paths. Add `--json` for machine-readable output.

## Base-Agent Mode

Expand Down
54 changes: 54 additions & 0 deletions packages/gittensory-mcp/bin/gittensory-mcp.js
Original file line number Diff line number Diff line change
Expand Up @@ -492,6 +492,7 @@ async function runCli(args) {
if (command === "logout") return logout(options);
if (command === "profile" || command === "profiles") return profileCommand(args.slice(1));
if (command === "whoami") return whoami(options);
if (command === "config") return configCommand(options);
if (command === "status") return status(options);
if (command === "changelog") return changelog(options);
if (command === "doctor") return doctor(options);
Expand Down Expand Up @@ -725,6 +726,7 @@ function printHelp() {
gittensory-mcp login [--profile name] [--github-token <token>] [--json]
gittensory-mcp logout [--profile name] [--all] [--json]
gittensory-mcp whoami [--profile name] [--json]
gittensory-mcp config [--profile name] [--json]
gittensory-mcp status [--profile name] [--json]
gittensory-mcp profile list|create|switch|remove [name] [--json]
gittensory-mcp changelog [--json]
Expand Down Expand Up @@ -1203,6 +1205,58 @@ function selectProfileName(currentConfig, requestedName) {
return currentConfig?.profiles?.[defaultProfileName] || configured === defaultProfileName ? defaultProfileName : configured;
}

function resolvedApiUrlSource() {
if (process.env.GITTENSORY_API_URL) return "environment";
const profileApiUrl = typeof activeProfile.apiUrl === "string" ? activeProfile.apiUrl.replace(/\/+$/, "") : undefined;
if (profileApiUrl && !legacyDefaultApiUrls.has(profileApiUrl)) return "profile";
const globalApiUrl = typeof config.apiUrl === "string" ? config.apiUrl.replace(/\/+$/, "") : undefined;
if (globalApiUrl && !legacyDefaultApiUrls.has(globalApiUrl)) return "config";
return "default";
}

function resolvedConfigPathSource() {
if (process.env.GITTENSORY_CONFIG_PATH) return "GITTENSORY_CONFIG_PATH";
if (process.env.GITTENSORY_CONFIG_DIR) return "GITTENSORY_CONFIG_DIR";
if (process.env.XDG_CONFIG_HOME) return "XDG_CONFIG_HOME";
return "default";
}

function resolvedTokenSource() {
if (getEnvApiToken()) return "environment";
if (configuredProfileToken(activeProfileName)) return "profile";
return "none";
}

// Report the resolved effective configuration and where each value came from, without leaking
// local absolute paths or token values. Distinct from `status` (health/version), `doctor`
// (diagnostic checks), and `whoami` (session identity): this answers "what config is in effect
// and which source supplied it?".
function configCommand(options) {
const payload = {
apiUrl,
apiUrlSource: resolvedApiUrlSource(),
activeProfile: activeProfileName,
profileCount: profileList(config).length,
configured: existsSync(configPath),
configPathSource: resolvedConfigPathSource(),
cacheDirSource: process.env.GITTENSORY_CACHE_DIR ? "GITTENSORY_CACHE_DIR" : "default",
tokenConfigured: Boolean(getApiToken()),
tokenSource: resolvedTokenSource(),
sourceUpload: { default: false, supported: false },
profile: profilePublicState(activeProfileName),
};
if (options.json) {
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
return;
}
process.stdout.write(`API URL: ${payload.apiUrl} (${payload.apiUrlSource})\n`);
process.stdout.write(`Active profile: ${payload.activeProfile} (${payload.profileCount} configured)\n`);
process.stdout.write(`Config file: ${payload.configured ? "present" : "absent"} (location: ${payload.configPathSource})\n`);
process.stdout.write(`Cache dir: ${payload.cacheDirSource}\n`);
process.stdout.write(`Token: ${payload.tokenConfigured ? `configured (${payload.tokenSource})` : "not configured"}\n`);
process.stdout.write("Source upload: disabled (unsupported)\n");
}

function normalizeProfileName(value) {
const name = String(value ?? defaultProfileName).trim().toLowerCase();
if (!/^[a-z0-9][a-z0-9._-]{0,63}$/.test(name)) throw new Error("Profile names must be 1-64 characters and use letters, numbers, dots, dashes, or underscores.");
Expand Down
99 changes: 99 additions & 0 deletions test/unit/mcp-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -900,6 +900,105 @@ describe("gittensory-mcp CLI", () => {
it("rejects unsupported client snippets", () => {
expect(() => run(["init-client", "--print", "other"])).toThrow(/Unsupported client/);
});

it("reports resolved configuration provenance via config", () => {
const payload = JSON.parse(run(["config", "--json"])) as {
apiUrl: string;
apiUrlSource: string;
activeProfile: string;
profileCount: number;
configured: boolean;
configPathSource: string;
cacheDirSource: string;
tokenConfigured: boolean;
tokenSource: string;
sourceUpload: { default: boolean; supported: boolean };
};
// The run() harness sets GITTENSORY_CONFIG_DIR but no API URL or token.
expect(payload.apiUrl).toBe("https://gittensory-api.aethereal.dev");
expect(payload.apiUrlSource).toBe("default");
expect(payload.activeProfile).toBe("default");
expect(payload.profileCount).toBeGreaterThanOrEqual(1);
expect(payload.configured).toBe(false);
expect(payload.configPathSource).toBe("GITTENSORY_CONFIG_DIR");
expect(payload.cacheDirSource).toBe("default");
expect(payload.tokenConfigured).toBe(false);
expect(payload.tokenSource).toBe("none");
expect(payload.sourceUpload).toEqual({ default: false, supported: false });
});

it("attributes config values to environment overrides without leaking secrets", () => {
const secretDir = mkdtempSync(join(tmpdir(), "gittensory-config-secret-"));
try {
const out = run(["config"], {
GITTENSORY_API_URL: "https://example.test",
GITTENSORY_TOKEN: "super-secret-token",
GITTENSORY_CACHE_DIR: join(secretDir, "cache"),
GITTENSORY_CONFIG_DIR: secretDir,
});
expect(out).toContain("API URL: https://example.test (environment)");
expect(out).toContain("Token: configured (environment)");
expect(out).toContain("Cache dir: GITTENSORY_CACHE_DIR");
expect(out).toContain("Source upload: disabled (unsupported)");
// No token value or local absolute path may appear in output.
expect(out).not.toContain("super-secret-token");
expect(out).not.toContain(secretDir);
} finally {
rmSync(secretDir, { recursive: true, force: true });
}
});

it("attributes API URL and token to a named profile from the config file", () => {
const configDir = mkdtempSync(join(tmpdir(), "gittensory-config-profile-"));
try {
writeFileSync(
join(configDir, "config.json"),
JSON.stringify({
activeProfile: "work",
profiles: { work: { apiUrl: "https://profile.example", session: { token: "tok", login: "octocat", expiresAt: "2099-01-01T00:00:00Z" } } },
}),
{ mode: 0o600 },
);
const payload = JSON.parse(run(["config", "--json"], { GITTENSORY_CONFIG_DIR: configDir })) as {
apiUrl: string;
apiUrlSource: string;
activeProfile: string;
configured: boolean;
tokenConfigured: boolean;
tokenSource: string;
profile: { login: string };
};
expect(payload.activeProfile).toBe("work");
expect(payload.apiUrl).toBe("https://profile.example");
expect(payload.apiUrlSource).toBe("profile");
expect(payload.configured).toBe(true);
expect(payload.tokenConfigured).toBe(true);
expect(payload.tokenSource).toBe("profile");
expect(payload.profile.login).toBe("octocat");
} finally {
rmSync(configDir, { recursive: true, force: true });
}
});

it("attributes API URL to a global config file reached through a config-path override", () => {
const dir = mkdtempSync(join(tmpdir(), "gittensory-config-global-"));
const file = join(dir, "custom-config.json");
try {
writeFileSync(file, JSON.stringify({ apiUrl: "https://global.example" }), { mode: 0o600 });
const payload = JSON.parse(run(["config", "--json"], { GITTENSORY_CONFIG_PATH: file, GITTENSORY_CONFIG_DIR: "" })) as {
apiUrl: string;
apiUrlSource: string;
configPathSource: string;
configured: boolean;
};
expect(payload.apiUrl).toBe("https://global.example");
expect(payload.apiUrlSource).toBe("config");
expect(payload.configPathSource).toBe("GITTENSORY_CONFIG_PATH");
expect(payload.configured).toBe(true);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});

function run(args: string[], env: Record<string, string> = {}) {
Expand Down