diff --git a/kiloclaw/controller/src/bootstrap.test.ts b/kiloclaw/controller/src/bootstrap.test.ts index bc32f27ae..f8475c297 100644 --- a/kiloclaw/controller/src/bootstrap.test.ts +++ b/kiloclaw/controller/src/bootstrap.test.ts @@ -6,6 +6,8 @@ import { applyFeatureFlags, generateHooksToken, configureGitHub, + configureLinear, + updateToolsMdLinearSection, runOnboardOrDoctor, updateToolsMdKiloCliSection, updateToolsMd1PasswordSection, @@ -487,6 +489,47 @@ describe('configureGitHub', () => { }); }); +// ---- configureLinear ---- + +describe('configureLinear', () => { + it('logs configured when LINEAR_API_KEY is set', () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const env: Record = { + LINEAR_API_KEY: 'lin_api_test123', + }; + + configureLinear(env); + + expect(env.LINEAR_API_KEY).toBe('lin_api_test123'); + expect(logSpy).toHaveBeenCalledWith('Linear MCP configured via LINEAR_API_KEY'); + logSpy.mockRestore(); + }); + + it('cleans up empty LINEAR_API_KEY and logs not configured', () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const env: Record = { + LINEAR_API_KEY: '', + }; + + configureLinear(env); + + expect(env.LINEAR_API_KEY).toBeUndefined(); + expect(logSpy).toHaveBeenCalledWith('Linear: not configured'); + logSpy.mockRestore(); + }); + + it('logs not configured when LINEAR_API_KEY is absent', () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const env: Record = {}; + + configureLinear(env); + + expect(env.LINEAR_API_KEY).toBeUndefined(); + expect(logSpy).toHaveBeenCalledWith('Linear: not configured'); + logSpy.mockRestore(); + }); +}); + // ---- runOnboardOrDoctor ---- describe('runOnboardOrDoctor', () => { @@ -684,6 +727,79 @@ describe('updateToolsMd1PasswordSection', () => { }); }); +// ---- updateToolsMdLinearSection ---- + +describe('updateToolsMdLinearSection', () => { + it('adds Linear section when LINEAR_API_KEY is set', () => { + const harness = fakeDeps(); + (harness.deps.readFileSync as ReturnType).mockReturnValue('# TOOLS\n'); + + const env: Record = { + LINEAR_API_KEY: 'lin_api_test123', + }; + + updateToolsMdLinearSection(env, harness.deps); + + expect(harness.writeCalls).toHaveLength(1); + expect(harness.writeCalls[0]!.data).toContain(''); + expect(harness.writeCalls[0]!.data).toContain('## Linear'); + expect(harness.writeCalls[0]!.data).toContain(''); + }); + + it('skips adding when section already present', () => { + const harness = fakeDeps(); + (harness.deps.readFileSync as ReturnType).mockReturnValue( + '# TOOLS\n\nexisting\n' + ); + + const env: Record = { + LINEAR_API_KEY: 'lin_api_test123', + }; + + updateToolsMdLinearSection(env, harness.deps); + + expect(harness.writeCalls).toHaveLength(0); + }); + + it('removes stale section when key is absent', () => { + const harness = fakeDeps(); + (harness.deps.readFileSync as ReturnType).mockReturnValue( + '# TOOLS\n\nold section\n\n' + ); + + const env: Record = {}; + + updateToolsMdLinearSection(env, harness.deps); + + expect(harness.writeCalls).toHaveLength(1); + expect(harness.writeCalls[0]!.data).not.toContain(''); + }); + + it('no-ops when TOOLS.md does not exist', () => { + const harness = fakeDeps(); + (harness.deps.existsSync as ReturnType).mockReturnValue(false); + + const env: Record = { + LINEAR_API_KEY: 'lin_api_test123', + }; + + updateToolsMdLinearSection(env, harness.deps); + + expect(harness.writeCalls).toHaveLength(0); + }); + + it('no-ops when key absent and no stale section exists', () => { + const harness = fakeDeps(); + (harness.deps.readFileSync as ReturnType).mockReturnValue('# TOOLS\n'); + + const env: Record = {}; + + updateToolsMdLinearSection(env, harness.deps); + + expect(harness.writeCalls).toHaveLength(0); + }); +}); + // ---- buildGatewayArgs ---- describe('buildGatewayArgs', () => { @@ -743,7 +859,14 @@ describe('bootstrap', () => { await bootstrap(env, phase => phases.push(phase), harness.deps); - expect(phases).toEqual(['decrypting', 'directories', 'feature-flags', 'github', 'onboard']); + expect(phases).toEqual([ + 'decrypting', + 'directories', + 'feature-flags', + 'github', + 'linear', + 'onboard', + ]); }); it('reports doctor phase when config exists', async () => { diff --git a/kiloclaw/controller/src/bootstrap.ts b/kiloclaw/controller/src/bootstrap.ts index ef7a87977..a734df877 100644 --- a/kiloclaw/controller/src/bootstrap.ts +++ b/kiloclaw/controller/src/bootstrap.ts @@ -324,7 +324,24 @@ export function configureGitHub(env: EnvLike, deps: BootstrapDeps = defaultDeps) } } -// ---- Step 6: Onboard / doctor + config patching ---- +// ---- Step 6: Linear config ---- + +/** + * Configure or clean up Linear MCP access. + * Linear access is provided via the Linear MCP server configured in mcporter. + * When LINEAR_API_KEY is present, mcporter uses it to authenticate. + * When absent, we just clean up the env var. No on-disk artifacts to clean. + */ +export function configureLinear(env: EnvLike): void { + if (env.LINEAR_API_KEY) { + console.log('Linear MCP configured via LINEAR_API_KEY'); + } else { + delete env.LINEAR_API_KEY; + console.log('Linear: not configured'); + } +} + +// ---- Step 7: Onboard / doctor + config patching ---- /** * Run openclaw onboard (first boot) or openclaw doctor (subsequent boots), @@ -386,7 +403,7 @@ export function runOnboardOrDoctor(env: EnvLike, deps: BootstrapDeps = defaultDe } } -// ---- Step 7: TOOLS.md Google Workspace section ---- +// ---- Step 8: TOOLS.md Google Workspace section ---- const GOG_MARKER_BEGIN = ''; const GOG_MARKER_END = ''; @@ -539,7 +556,60 @@ export function updateToolsMd1PasswordSection(env: EnvLike, deps: BootstrapDeps) } } -// ---- Step 10: Gateway args ---- +// ---- Step 10: TOOLS.md Linear section ---- + +const LINEAR_MARKER_BEGIN = ''; +const LINEAR_MARKER_END = ''; + +const LINEAR_TOOLS_SECTION = ` +${LINEAR_MARKER_BEGIN} +## Linear + +Linear is configured as your project management tool. Use it to track issues, plan projects, and manage product roadmaps. +You can interact with the \`Linear\` MCP server using your \`mcporter\` skill. + + ${LINEAR_MARKER_END}`; + +/** + * Manage the Linear section in TOOLS.md. + * + * When LINEAR_API_KEY is present, append a bounded section so the agent + * knows Linear MCP is available. When absent, remove any stale section. + * Idempotent: skips if the marker is already present. + */ +export function updateToolsMdLinearSection(env: EnvLike, deps: BootstrapDeps): void { + if (!deps.existsSync(TOOLS_MD_DEST)) return; + + const content = deps.readFileSync(TOOLS_MD_DEST, 'utf8'); + + if (env.LINEAR_API_KEY) { + // Linear configured — add section if not already present + if (!content.includes(LINEAR_MARKER_BEGIN)) { + deps.writeFileSync(TOOLS_MD_DEST, content + LINEAR_TOOLS_SECTION); + console.log('TOOLS.md: added Linear section'); + } else { + console.log('TOOLS.md: Linear section already present'); + } + } else { + // Linear not configured — remove stale section if present + if (content.includes(LINEAR_MARKER_BEGIN)) { + const beginIdx = content.indexOf(LINEAR_MARKER_BEGIN); + const endIdx = content.indexOf(LINEAR_MARKER_END); + if (beginIdx !== -1 && endIdx !== -1) { + const before = content.slice(0, beginIdx).replace(/\n+$/, '\n'); + const after = content.slice(endIdx + LINEAR_MARKER_END.length).replace(/^\n+/, ''); + deps.writeFileSync(TOOLS_MD_DEST, before + after); + console.log('TOOLS.md: removed stale Linear section'); + } else { + console.warn( + 'TOOLS.md: Linear BEGIN marker found but END marker missing, skipping removal' + ); + } + } + } +} + +// ---- Step 11: Gateway args ---- /** * Build the gateway CLI arguments array. @@ -588,6 +658,10 @@ export async function bootstrap( configureGitHub(env, deps); await yieldToEventLoop(); + setPhase('linear'); + configureLinear(env); + await yieldToEventLoop(); + const configExists = deps.existsSync(CONFIG_PATH); setPhase(configExists ? 'doctor' : 'onboard'); runOnboardOrDoctor(env, deps); @@ -596,6 +670,7 @@ export async function bootstrap( updateToolsMdKiloCliSection(env, deps); updateToolsMdGoogleSection(env, deps); updateToolsMd1PasswordSection(env, deps); + updateToolsMdLinearSection(env, deps); // Write mcporter config for MCP servers (AgentCard, etc.) writeMcporterConfig(env); diff --git a/kiloclaw/controller/src/config-writer.test.ts b/kiloclaw/controller/src/config-writer.test.ts index f7c3af61b..614bd207b 100644 --- a/kiloclaw/controller/src/config-writer.test.ts +++ b/kiloclaw/controller/src/config-writer.test.ts @@ -3,6 +3,7 @@ import { backupConfigFile, generateBaseConfig, writeBaseConfig, + writeMcporterConfig, MAX_CONFIG_BACKUPS, } from './config-writer'; @@ -756,3 +757,106 @@ describe('writeBaseConfig', () => { expect(config.tools.exec.host).toBe('gateway'); }); }); + +function mcporterFakeDeps(existingMcporterConfig?: string) { + const written: { path: string; data: string }[] = []; + return { + deps: { + readFileSync: vi.fn((filePath: string) => { + if (existingMcporterConfig !== undefined) return existingMcporterConfig; + throw new Error(`ENOENT: no such file: ${filePath}`); + }), + writeFileSync: vi.fn((filePath: string, data: string) => { + written.push({ path: filePath, data }); + }), + renameSync: vi.fn(), + copyFileSync: vi.fn(), + readdirSync: vi.fn(() => []), + unlinkSync: vi.fn(), + existsSync: vi.fn((filePath: string) => { + if (existingMcporterConfig !== undefined && filePath.endsWith('mcporter.json')) return true; + return false; + }), + execFileSync: vi.fn(), + }, + written, + }; +} + +describe('writeMcporterConfig', () => { + it('adds Linear MCP server when LINEAR_API_KEY is set', () => { + const { deps, written } = mcporterFakeDeps(); + const env = { LINEAR_API_KEY: 'lin_api_test123' }; + + writeMcporterConfig(env, '/tmp/mcporter.json', deps); + + expect(written).toHaveLength(1); + const config = JSON.parse(written[0].data); + expect(config.mcpServers.linear).toEqual({ + url: 'https://mcp.linear.app/mcp', + headers: { Authorization: 'Bearer ${LINEAR_API_KEY}' }, + }); + }); + + it('removes Linear MCP server when LINEAR_API_KEY is absent', () => { + const existing = JSON.stringify({ + mcpServers: { + linear: { + url: 'https://mcp.linear.app/mcp', + headers: { Authorization: 'Bearer ${LINEAR_API_KEY}' }, + }, + }, + }); + const { deps, written } = mcporterFakeDeps(existing); + const env: Record = {}; + + writeMcporterConfig(env, '/tmp/mcporter.json', deps); + + expect(written).toHaveLength(1); + const config = JSON.parse(written[0].data); + expect(config.mcpServers.linear).toBeUndefined(); + }); + + it('preserves user-added servers when adding Linear', () => { + const existing = JSON.stringify({ + mcpServers: { + custom: { url: 'https://custom.example.com/mcp' }, + }, + }); + const { deps, written } = mcporterFakeDeps(existing); + const env = { LINEAR_API_KEY: 'lin_api_test123' }; + + writeMcporterConfig(env, '/tmp/mcporter.json', deps); + + expect(written).toHaveLength(1); + const config = JSON.parse(written[0].data); + expect(config.mcpServers.custom).toEqual({ url: 'https://custom.example.com/mcp' }); + expect(config.mcpServers.linear).toBeDefined(); + }); + + it('adds both AgentCard and Linear when both keys are set', () => { + const { deps, written } = mcporterFakeDeps(); + const env = { + AGENTCARD_API_KEY: 'ac_test123', + LINEAR_API_KEY: 'lin_api_test123', + }; + + writeMcporterConfig(env, '/tmp/mcporter.json', deps); + + expect(written).toHaveLength(1); + const config = JSON.parse(written[0].data); + expect(config.mcpServers.agentcard).toBeDefined(); + expect(config.mcpServers.linear).toBeDefined(); + }); + + it('uses literal ${LINEAR_API_KEY} in authorization header (not interpolated)', () => { + const { deps, written } = mcporterFakeDeps(); + const env = { LINEAR_API_KEY: 'lin_api_test123' }; + + writeMcporterConfig(env, '/tmp/mcporter.json', deps); + + const config = JSON.parse(written[0].data); + // The header should contain the literal string ${LINEAR_API_KEY}, not the actual value + expect(config.mcpServers.linear.headers.Authorization).toBe('Bearer ${LINEAR_API_KEY}'); + }); +}); diff --git a/kiloclaw/controller/src/config-writer.ts b/kiloclaw/controller/src/config-writer.ts index 69d432569..1448d08dd 100644 --- a/kiloclaw/controller/src/config-writer.ts +++ b/kiloclaw/controller/src/config-writer.ts @@ -349,6 +349,19 @@ export function writeMcporterConfig( } } + if (env.LINEAR_API_KEY) { + existingServers['linear'] = { + url: 'https://mcp.linear.app/mcp', + headers: { Authorization: 'Bearer ${LINEAR_API_KEY}' }, + }; + console.log('Linear MCP server configured (via mcporter)'); + } else { + if ('linear' in existingServers) { + delete existingServers['linear']; + console.log('Linear MCP server removed from mcporter config'); + } + } + // Only write if there are servers to configure or we need to clean up if (Object.keys(existingServers).length === 0 && !deps.existsSync(configPath)) { return; diff --git a/kiloclaw/packages/secret-catalog/src/__tests__/catalog.test.ts b/kiloclaw/packages/secret-catalog/src/__tests__/catalog.test.ts index 68d381092..0d276f0b6 100644 --- a/kiloclaw/packages/secret-catalog/src/__tests__/catalog.test.ts +++ b/kiloclaw/packages/secret-catalog/src/__tests__/catalog.test.ts @@ -43,6 +43,7 @@ describe('Secret Catalog', () => { 'slack', 'key', 'github', + 'linear', 'credit-card', 'lock', 'brave', @@ -121,6 +122,7 @@ describe('Secret Catalog', () => { 'GITHUB_USERNAME', 'GITHUB_EMAIL', 'BRAVE_API_KEY', + 'LINEAR_API_KEY', ]); const catalogEnvVars = new Set(FIELD_KEY_TO_ENV_VAR.values()); @@ -198,11 +200,12 @@ describe('Secret Catalog', () => { it('returns all tool entries sorted by order', () => { const tools = getEntriesByCategory('tool'); - expect(tools.length).toBe(4); + expect(tools.length).toBe(5); expect(tools[0].id).toBe('github'); expect(tools[1].id).toBe('agentcard'); expect(tools[2].id).toBe('onepassword'); expect(tools[3].id).toBe('brave-search'); + expect(tools[4].id).toBe('linear'); }); it('returns empty array for categories with no entries', () => { @@ -226,10 +229,11 @@ describe('Secret Catalog', () => { expect(keys).toContain('githubToken'); expect(keys).toContain('githubUsername'); expect(keys).toContain('githubEmail'); + expect(keys).toContain('linearApiKey'); expect(keys).toContain('agentcardApiKey'); expect(keys).toContain('onepasswordServiceAccountToken'); expect(keys).toContain('braveSearchApiKey'); - expect(keys.size).toBe(6); + expect(keys.size).toBe(7); }); it('returns empty set for categories with no entries', () => { diff --git a/kiloclaw/packages/secret-catalog/src/catalog.ts b/kiloclaw/packages/secret-catalog/src/catalog.ts index 0e42f2571..36ddc9abe 100644 --- a/kiloclaw/packages/secret-catalog/src/catalog.ts +++ b/kiloclaw/packages/secret-catalog/src/catalog.ts @@ -197,6 +197,28 @@ const SECRET_CATALOG_RAW = [ helpText: 'Get an API key from the Brave Search dashboard.', helpUrl: 'https://brave.com/search/api/', }, + { + id: 'linear', + label: 'Linear', + category: 'tool', + icon: 'linear', + order: 5, + fields: [ + { + key: 'linearApiKey', + label: 'API Key', + placeholder: 'lin_api_...', + placeholderConfigured: 'Enter new API key to replace', + envVar: 'LINEAR_API_KEY', + validationPattern: '^lin_api_[a-zA-Z0-9]{40}$', + validationMessage: + 'Linear API keys start with lin_api_ followed by 40 alphanumeric characters.', + maxLength: 100, + }, + ], + helpText: 'Generate an API key from your Linear account security settings.', + helpUrl: 'https://linear.app/settings/account/security', + }, ] as const satisfies readonly SecretCatalogEntry[]; // Runtime validation — fails fast at module load if catalog data is malformed diff --git a/kiloclaw/packages/secret-catalog/src/types.ts b/kiloclaw/packages/secret-catalog/src/types.ts index a05dbc836..d695d4784 100644 --- a/kiloclaw/packages/secret-catalog/src/types.ts +++ b/kiloclaw/packages/secret-catalog/src/types.ts @@ -10,6 +10,7 @@ export const SecretIconKeySchema = z.enum([ 'slack', 'key', 'github', + 'linear', 'credit-card', 'lock', 'brave', diff --git a/kiloclaw/src/routes/kiloclaw.test.ts b/kiloclaw/src/routes/kiloclaw.test.ts index 0f1d602dd..f4da60fe7 100644 --- a/kiloclaw/src/routes/kiloclaw.test.ts +++ b/kiloclaw/src/routes/kiloclaw.test.ts @@ -16,6 +16,7 @@ describe('buildConfiguredSecrets', () => { discord: false, slack: false, github: false, + linear: false, agentcard: false, onepassword: false, 'brave-search': false, @@ -112,9 +113,10 @@ describe('buildConfiguredSecrets', () => { expect(keys).toContain('telegram'); expect(keys).toContain('discord'); expect(keys).toContain('slack'); + expect(keys).toContain('linear'); expect(keys).toContain('onepassword'); expect(keys).toContain('brave-search'); - expect(keys).toHaveLength(7); + expect(keys).toHaveLength(8); }); it('treats null values as not configured', () => { diff --git a/src/app/(app)/claw/components/SettingsTab.tsx b/src/app/(app)/claw/components/SettingsTab.tsx index 656b843cf..4fe085207 100644 --- a/src/app/(app)/claw/components/SettingsTab.tsx +++ b/src/app/(app)/claw/components/SettingsTab.tsx @@ -857,7 +857,7 @@ export function SettingsTab({ )} {/* ── Developer Tools ── */} - {toolEntries.some(e => e.id === 'github') && ( + {toolEntries.some(e => e.id === 'github' || e.id === 'linear') && (

Developer Tools

@@ -898,6 +898,19 @@ export function SettingsTab({ } /> ))} + {toolEntries + .filter(e => e.id === 'linear') + .map(entry => ( + + ))}
)} diff --git a/src/app/(app)/claw/components/icons/LinearIcon.tsx b/src/app/(app)/claw/components/icons/LinearIcon.tsx new file mode 100644 index 000000000..6fd0cb473 --- /dev/null +++ b/src/app/(app)/claw/components/icons/LinearIcon.tsx @@ -0,0 +1,12 @@ +export function LinearIcon({ className }: { className?: string }) { + return ( + + + + ); +} + +export default LinearIcon; diff --git a/src/app/(app)/claw/components/secret-ui-adapter.ts b/src/app/(app)/claw/components/secret-ui-adapter.ts index 2ff1beb1c..851f638ce 100644 --- a/src/app/(app)/claw/components/secret-ui-adapter.ts +++ b/src/app/(app)/claw/components/secret-ui-adapter.ts @@ -7,6 +7,7 @@ import { SlackIcon } from './icons/SlackIcon'; import { GitHubIcon } from './icons/GitHubIcon'; import { AgentCardIcon } from './icons/AgentCardIcon'; import { BraveSearchIcon } from './icons/BraveSearchIcon'; +import { LinearIcon } from './icons/LinearIcon'; const ICON_MAP: Record> = { send: TelegramIcon, @@ -14,6 +15,7 @@ const ICON_MAP: Record = { discord: 'Connect your Discord bot', slack: 'Connect your Slack workspace', github: 'Connect a GitHub account for code operations', + linear: 'Connect your Linear account for issue tracking', agentcard: 'Give your bot virtual debit cards for spending', onepassword: 'Look up credentials and manage vault items via the op CLI', 'brave-search': 'Add a Brave Search API key for web search',