Skip to content

Commit cea36ed

Browse files
Merge pull request #12 from anand-testcompare/11-stop-writing-unsupported-palantir_mcp-key-to-opencodejsonc-annotate-profile-in-agent-descriptions
fix: stop writing unsupported palantir_mcp key to opencode.jsonc
2 parents 7d6225d + 922b4a7 commit cea36ed

7 files changed

Lines changed: 345 additions & 37 deletions

File tree

scripts/dev/opencode-smoke-tmux.sh

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
SOCKET_NAME="opencode-smoke"
5+
SESSION_NAME="opencode-smoke"
6+
REPO_PATH="${1:-}"
7+
FOUNDRY_URL="${2:-}"
8+
9+
if [[ -z "${REPO_PATH}" ]]; then
10+
echo "Usage: scripts/dev/opencode-smoke-tmux.sh <repoPath> [foundryUrl]" >&2
11+
echo "Example: scripts/dev/opencode-smoke-tmux.sh ../palantir-compute-module-pipeline-search https://23dimethyl.usw-3.palantirfoundry.com" >&2
12+
exit 2
13+
fi
14+
15+
if ! command -v tmux >/dev/null 2>&1; then
16+
echo "[ERROR] tmux is not installed." >&2
17+
exit 1
18+
fi
19+
20+
# If the session already exists, pick a unique suffix.
21+
if tmux -L "${SOCKET_NAME}" has-session -t "${SESSION_NAME}" 2>/dev/null; then
22+
TS="$(date +%Y%m%d-%H%M%S)"
23+
SESSION_NAME="${SESSION_NAME}-${TS}"
24+
fi
25+
26+
opencode_bin=""
27+
if [[ -n "${OPENCODE_BIN:-}" ]]; then
28+
opencode_bin="${OPENCODE_BIN}"
29+
elif [[ -x "${HOME}/.opencode/bin/opencode" ]]; then
30+
opencode_bin="${HOME}/.opencode/bin/opencode"
31+
else
32+
opencode_bin="$(command -v opencode || true)"
33+
fi
34+
35+
if [[ -z "${opencode_bin}" ]]; then
36+
echo "[ERROR] opencode binary not found. Install opencode or set OPENCODE_BIN." >&2
37+
exit 1
38+
fi
39+
40+
CMD="cd '$(pwd)' && \
41+
export OPENCODE_SMOKE_REPO='${REPO_PATH}' && \
42+
export OPENCODE_BIN='${opencode_bin}' && \
43+
${FOUNDRY_URL:+export OPENCODE_SMOKE_FOUNDRY_URL='${FOUNDRY_URL}' && }\
44+
echo '[smoke] running vitest smoke...' && \
45+
bun test src/__tests__/opencodeSmoke.test.ts; \
46+
ec=\$?; \
47+
echo \"[smoke] done (exit=\$ec).\"; \
48+
exec bash"
49+
50+
tmux -L "${SOCKET_NAME}" new-session -d -s "${SESSION_NAME}" "bash -lc \"${CMD}\""
51+
52+
echo "Started tmux session: ${SESSION_NAME}" >&2
53+
echo "Attach with: tmux -L ${SOCKET_NAME} attach -t ${SESSION_NAME}" >&2
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
import { describe, it, expect } from 'vitest';
2+
import fs from 'node:fs';
3+
import path from 'node:path';
4+
5+
import type { CommandHook, CommandHookOutput, MinimalPlugin } from './testTypes.ts';
6+
import {
7+
readOpencodeJsonc,
8+
stringifyJsonc,
9+
writeFileAtomic,
10+
} from '../palantir-mcp/opencode-config.ts';
11+
12+
function resolveOpencodeBin(): string {
13+
const fromEnv: string | undefined = process.env.OPENCODE_BIN;
14+
if (fromEnv && fromEnv.trim().length > 0) return fromEnv.trim();
15+
16+
// Prefer the opencode-managed install if present (common when multiple installs exist).
17+
const preferred: string = '/home/anandpant/.opencode/bin/opencode';
18+
if (fs.existsSync(preferred)) return preferred;
19+
20+
return 'opencode';
21+
}
22+
23+
function readDotEnvValue(envPath: string, key: string): string | null {
24+
let text: string;
25+
try {
26+
text = fs.readFileSync(envPath, 'utf8');
27+
} catch {
28+
return null;
29+
}
30+
31+
for (const line of text.split('\n')) {
32+
const trimmed: string = line.trim();
33+
if (!trimmed || trimmed.startsWith('#')) continue;
34+
if (!trimmed.startsWith(`${key}=`)) continue;
35+
36+
const raw: string = trimmed.slice(key.length + 1);
37+
let val: string = raw.trim();
38+
if (
39+
val.length >= 2 &&
40+
((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'")))
41+
) {
42+
val = val.slice(1, -1);
43+
}
44+
if (!val) return null;
45+
return val;
46+
}
47+
48+
return null;
49+
}
50+
51+
async function runCommand(
52+
cmd: string[],
53+
opts: { cwd: string }
54+
): Promise<{ exitCode: number; stdout: string; stderr: string }> {
55+
const proc = Bun.spawn(cmd, {
56+
cwd: opts.cwd,
57+
stdout: 'pipe',
58+
stderr: 'pipe',
59+
env: process.env,
60+
});
61+
62+
const stdout: string = await new Response(proc.stdout).text();
63+
const stderr: string = await new Response(proc.stderr).text();
64+
const exitCode: number = await proc.exited;
65+
return { exitCode, stdout, stderr };
66+
}
67+
68+
async function runCommandWithRetries(
69+
cmd: string[],
70+
opts: { cwd: string; attempts: number; delayMs: number }
71+
): Promise<{ exitCode: number; stdout: string; stderr: string; attempt: number }> {
72+
let last: { exitCode: number; stdout: string; stderr: string } | null = null;
73+
for (let attempt = 1; attempt <= opts.attempts; attempt += 1) {
74+
const res = await runCommand(cmd, { cwd: opts.cwd });
75+
last = res;
76+
if (res.exitCode === 0) return { ...res, attempt };
77+
await new Promise((r) => setTimeout(r, opts.delayMs));
78+
}
79+
return { ...(last ?? { exitCode: 1, stdout: '', stderr: '' }), attempt: opts.attempts };
80+
}
81+
82+
function safeWriteBackupIfExists(filePath: string): void {
83+
if (!fs.existsSync(filePath)) return;
84+
const ts: string = new Date().toISOString().replace(/[:.]/g, '-');
85+
const backup: string = `${filePath}.smoke.bak.${ts}`;
86+
fs.copyFileSync(filePath, backup);
87+
}
88+
89+
const canRunSmoke: boolean = !!process.env.OPENCODE_SMOKE_REPO;
90+
91+
const describeSmoke = canRunSmoke ? describe : describe.skip;
92+
93+
describeSmoke('opencode smoke: setup-palantir-mcp -> opencode debug', () => {
94+
it('writes schema-valid config (no palantir_mcp) and opencode debug loads agents', async () => {
95+
const repo: string = path.resolve(process.env.OPENCODE_SMOKE_REPO ?? '');
96+
const opencodeBin: string = resolveOpencodeBin();
97+
98+
const urlFromEnv: string | undefined = process.env.OPENCODE_SMOKE_FOUNDRY_URL;
99+
const urlFromDotEnv: string | null = readDotEnvValue(path.join(repo, '.env'), 'FOUNDRY_URL');
100+
const foundryUrl: string | null = (urlFromEnv && urlFromEnv.trim()) || urlFromDotEnv;
101+
102+
expect(fs.existsSync(repo)).toBe(true);
103+
104+
const cfgPath: string = path.join(repo, 'opencode.jsonc');
105+
safeWriteBackupIfExists(cfgPath);
106+
107+
const hasToken: boolean =
108+
typeof process.env.FOUNDRY_TOKEN === 'string' && process.env.FOUNDRY_TOKEN.length > 0;
109+
110+
if (!fs.existsSync(cfgPath)) {
111+
// If no config exists, only /setup can create it, which requires URL + token.
112+
expect(foundryUrl).toBeTruthy();
113+
expect(hasToken).toBe(true);
114+
115+
const plugin = (await import('../index.ts')).default as unknown as MinimalPlugin;
116+
const hooks = await plugin({ worktree: repo });
117+
const hook = hooks['command.execute.before'];
118+
expect(typeof hook).toBe('function');
119+
120+
const output: CommandHookOutput = { parts: [] };
121+
await (hook as CommandHook)(
122+
{
123+
command: 'setup-palantir-mcp',
124+
sessionID: 'smoke-session',
125+
arguments: String(foundryUrl),
126+
},
127+
output
128+
);
129+
} else {
130+
// If config exists (even if invalid), fix it without requiring Foundry access.
131+
const read = await readOpencodeJsonc(repo);
132+
expect(read.ok).toBe(true);
133+
if (!read.ok) throw new Error('unreachable');
134+
135+
const data: unknown = read.data;
136+
expect(!!data && typeof data === 'object' && !Array.isArray(data)).toBe(true);
137+
const root = data as Record<string, unknown>;
138+
139+
if (root.palantir_mcp !== undefined) {
140+
delete root.palantir_mcp;
141+
await writeFileAtomic(cfgPath, stringifyJsonc(root));
142+
}
143+
}
144+
145+
expect(fs.existsSync(cfgPath)).toBe(true);
146+
147+
const cfgText: string = fs.readFileSync(cfgPath, 'utf8');
148+
expect(cfgText).not.toContain('"palantir_mcp"');
149+
150+
const debugConfig = await runCommandWithRetries(
151+
[opencodeBin, 'debug', 'config', '--log-level', 'ERROR'],
152+
{ cwd: repo, attempts: 3, delayMs: 750 }
153+
);
154+
if (debugConfig.exitCode !== 0) {
155+
throw new Error(
156+
`opencode debug config failed (attempt ${debugConfig.attempt})\\n` +
157+
`stderr:\\n${debugConfig.stderr.slice(-4000)}\\n` +
158+
`stdout:\\n${debugConfig.stdout.slice(-4000)}`
159+
);
160+
}
161+
expect(debugConfig.stderr).not.toContain('Unrecognized key');
162+
163+
const debugLibrarian = await runCommandWithRetries(
164+
[opencodeBin, 'debug', 'agent', 'foundry-librarian', '--log-level', 'ERROR'],
165+
{ cwd: repo, attempts: 3, delayMs: 750 }
166+
);
167+
if (debugLibrarian.exitCode !== 0) {
168+
throw new Error(
169+
`opencode debug agent foundry-librarian failed (attempt ${debugLibrarian.attempt})\\n` +
170+
`stderr:\\n${debugLibrarian.stderr.slice(-4000)}\\n` +
171+
`stdout:\\n${debugLibrarian.stdout.slice(-4000)}`
172+
);
173+
}
174+
expect(debugLibrarian.stdout).toContain('foundry-librarian');
175+
expect(debugLibrarian.stdout).toContain('palantir-mcp_');
176+
177+
const debugFoundry = await runCommandWithRetries(
178+
[opencodeBin, 'debug', 'agent', 'foundry', '--log-level', 'ERROR'],
179+
{
180+
cwd: repo,
181+
attempts: 3,
182+
delayMs: 750,
183+
}
184+
);
185+
if (debugFoundry.exitCode !== 0) {
186+
throw new Error(
187+
`opencode debug agent foundry failed (attempt ${debugFoundry.attempt})\\n` +
188+
`stderr:\\n${debugFoundry.stderr.slice(-4000)}\\n` +
189+
`stdout:\\n${debugFoundry.stdout.slice(-4000)}`
190+
);
191+
}
192+
expect(debugFoundry.stdout).toContain('foundry');
193+
}, 60_000);
194+
});

src/__tests__/palantirMcpRescan.test.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ type CommandHookOutput = { parts: unknown[] };
1313
type CommandHook = (input: CommandHookInput, output: CommandHookOutput) => Promise<void>;
1414

1515
type OpencodeConfig = {
16-
agent?: Record<string, { tools?: Record<string, unknown> }>;
16+
agent?: Record<string, { description?: string; tools?: Record<string, unknown> }>;
1717
};
1818

1919
function isRecord(value: unknown): value is Record<string, unknown> {
@@ -114,6 +114,13 @@ describe('/rescan-palantir-mcp-tools', () => {
114114
expect(result.text).toContain('preserved');
115115

116116
const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8')) as OpencodeConfig;
117+
expect((cfg as unknown as Record<string, unknown>)['palantir_mcp']).toBeUndefined();
118+
119+
expect(cfg.agent?.foundry?.description).toContain(
120+
'Generated by opencode-palantir /setup-palantir-mcp.'
121+
);
122+
expect(cfg.agent?.foundry?.description).toContain('Profile:');
123+
117124
expect(cfg.agent?.foundry?.tools?.['palantir-mcp_list_datasets']).toBe(false);
118125
expect(cfg.agent?.foundry?.tools?.['palantir-mcp_get_dataset']).toBe(true);
119126
});

src/__tests__/palantirMcpSetup.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ type McpServerConfig = {
1919
};
2020

2121
type AgentConfig = {
22+
description?: string;
2223
tools?: Record<string, unknown>;
2324
};
2425

@@ -184,6 +185,16 @@ describe('/setup-palantir-mcp', () => {
184185
) as OpencodeConfig;
185186

186187
expect(cfg.tools?.['palantir-mcp_*']).toBe(false);
188+
expect((cfg as unknown as Record<string, unknown>)['palantir_mcp']).toBeUndefined();
189+
190+
expect(cfg.agent?.['foundry-librarian']?.description).toContain(
191+
'Generated by opencode-palantir /setup-palantir-mcp.'
192+
);
193+
expect(cfg.agent?.['foundry-librarian']?.description).toContain('Profile:');
194+
expect(cfg.agent?.foundry?.description).toContain(
195+
'Generated by opencode-palantir /setup-palantir-mcp.'
196+
);
197+
expect(cfg.agent?.foundry?.description).toContain('Profile:');
187198

188199
expect(cfg.agent?.['foundry-librarian']?.tools?.get_doc_page).toBe(true);
189200
expect(cfg.agent?.['foundry-librarian']?.tools?.list_all_docs).toBe(true);

src/__tests__/testTypes.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export type MinimalHooks = Record<string, unknown>;
2+
export type MinimalPlugin = (input: { worktree: string }) => Promise<MinimalHooks>;
3+
4+
export type CommandHookInput = { command: string; sessionID: string; arguments: string };
5+
export type CommandHookOutput = { parts: unknown[] };
6+
export type CommandHook = (input: CommandHookInput, output: CommandHookOutput) => Promise<void>;

src/palantir-mcp/commands.ts

Lines changed: 14 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import {
1111
patchConfigForSetup,
1212
readLegacyOpencodeJson,
1313
readOpencodeJsonc,
14-
readProfileOverride,
1514
renameLegacyToBak,
1615
stringifyJsonc,
1716
writeFileAtomic,
@@ -48,30 +47,17 @@ function formatPatchSummary(patch: PatchResult): string {
4847
return lines.join('\n');
4948
}
5049

51-
async function resolveProfile(
52-
worktree: string,
53-
baseConfig: Record<string, unknown>
54-
): Promise<{
50+
async function resolveProfile(worktree: string): Promise<{
5551
profile: ProfileId;
5652
reasons: string[];
57-
usedOverride: boolean;
5853
}> {
59-
const override: ProfileId | null = readProfileOverride(baseConfig);
60-
if (override)
61-
return {
62-
profile: override,
63-
reasons: ['Using palantir_mcp.profile override'],
64-
usedOverride: true,
65-
};
66-
6754
try {
6855
const scan = await scanRepoForProfile(worktree);
69-
return { profile: scan.profile, reasons: scan.reasons, usedOverride: false };
56+
return { profile: scan.profile, reasons: scan.reasons };
7057
} catch (err) {
7158
return {
7259
profile: 'unknown',
7360
reasons: [`Repo scan failed; falling back to unknown: ${formatError(err)}`],
74-
usedOverride: false,
7561
};
7662
}
7763
}
@@ -98,6 +84,11 @@ export async function setupPalantirMcp(worktree: string, rawArgs: string): Promi
9884
'[ERROR] FOUNDRY_TOKEN is not set in your environment.',
9985
'',
10086
'palantir-mcp tool discovery requires a token. Export FOUNDRY_TOKEN and retry.',
87+
'',
88+
'Tip: if `echo $FOUNDRY_TOKEN` prints a value but this still errors, it is likely ' +
89+
'not exported.',
90+
'Run `export FOUNDRY_TOKEN` (or set `export FOUNDRY_TOKEN=...` in your shell ' +
91+
'secrets) and retry.',
10192
].join('\n');
10293
}
10394

@@ -116,7 +107,7 @@ export async function setupPalantirMcp(worktree: string, rawArgs: string): Promi
116107
const existingMcpUrlRaw: string | null = extractFoundryApiUrlFromMcpConfig(merged);
117108
const existingMcpUrlNorm = existingMcpUrlRaw ? normalizeFoundryBaseUrl(existingMcpUrlRaw) : null;
118109

119-
const { profile } = await resolveProfile(worktree, merged);
110+
const { profile } = await resolveProfile(worktree);
120111
const discoveryUrl: string =
121112
existingMcpUrlNorm && 'url' in existingMcpUrlNorm ? existingMcpUrlNorm.url : normalized.url;
122113
let toolNames: string[];
@@ -182,6 +173,11 @@ export async function rescanPalantirMcpTools(worktree: string): Promise<string>
182173
'[ERROR] FOUNDRY_TOKEN is not set in your environment.',
183174
'',
184175
'palantir-mcp tool discovery requires a token. Export FOUNDRY_TOKEN and retry.',
176+
'',
177+
'Tip: if `echo $FOUNDRY_TOKEN` prints a value but this still errors, it is likely ' +
178+
'not exported.',
179+
'Run `export FOUNDRY_TOKEN` (or set `export FOUNDRY_TOKEN=...` in your shell ' +
180+
'secrets) and retry.',
185181
].join('\n');
186182
}
187183

@@ -208,7 +204,7 @@ export async function rescanPalantirMcpTools(worktree: string): Promise<string>
208204
const normalized = normalizeFoundryBaseUrl(foundryUrlRaw);
209205
if ('error' in normalized) return `[ERROR] Invalid Foundry URL in config: ${normalized.error}`;
210206

211-
const { profile } = await resolveProfile(worktree, baseData);
207+
const { profile } = await resolveProfile(worktree);
212208
let toolNames: string[];
213209
try {
214210
toolNames = await listPalantirMcpTools(normalized.url);

0 commit comments

Comments
 (0)