Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
e36411a
feat(kiloclaw): add Linear CLI integration
evanjacobson Mar 23, 2026
573d2b8
style: format bootstrap test array
evanjacobson Mar 23, 2026
fb36afa
Merge branch 'main' of github.com:Kilo-Org/cloud into feature/kilocla…
evanjacobson Mar 25, 2026
24753c7
Use the official Linear logo as the icon. Add missing pieces of the i…
evanjacobson Mar 26, 2026
6104be8
Move Linear to end of tool catalog and use dedicated SVG icon
evanjacobson Mar 26, 2026
43a3aca
Linear API key validation correction, add redeploy trigger to save se…
evanjacobson Mar 26, 2026
1a4d5d4
Make Linear icon more visible
evanjacobson Mar 26, 2026
8c79231
Update Dockerfile to keep xz-utils until after linear install
evanjacobson Mar 26, 2026
a280237
Optimize the Linear bootstrap in TOOLS.md
evanjacobson Mar 26, 2026
f3c1b8c
Add more details to the TOOLS.md linear section
evanjacobson Mar 26, 2026
e916293
Change --status to --state
evanjacobson Mar 26, 2026
d7b3ae7
Format svg
evanjacobson Mar 26, 2026
3f7035a
Final pass at the TOOLS.md bootstrap
evanjacobson Mar 26, 2026
4a9670e
Merge branch 'main' of github.com:Kilo-Org/cloud into feature/kilocla…
evanjacobson Mar 26, 2026
ade1b4e
Fix API key regex and improve the short description for Linear
evanjacobson Mar 26, 2026
298b918
Clean up persisted Linear CLI credentials when API key is removed
evanjacobson Mar 26, 2026
eb2418b
Format bootstrap test file
evanjacobson Mar 26, 2026
b410d4d
Also remove ~/.linear.toml when LINEAR_API_KEY is unset
evanjacobson Mar 26, 2026
1e65e2f
Harden Linear credential cleanup error handling
evanjacobson Mar 26, 2026
dc6785b
Add tests for remaining Linear credential cleanup failure paths
evanjacobson Mar 26, 2026
b3fe47b
Format files
evanjacobson Mar 26, 2026
b466318
Add missing test
evanjacobson Mar 26, 2026
432f2fd
chore(kiloclaw): remove Linear CLI from Dockerfiles
evanjacobson Mar 26, 2026
3878f20
refactor(kiloclaw): replace Linear CLI with Linear MCP in bootstrap
evanjacobson Mar 26, 2026
edac005
feat(kiloclaw): add Linear MCP server to mcporter config
evanjacobson Mar 26, 2026
b9f6bbf
Small tweak to Linear bootstrap
evanjacobson Mar 26, 2026
5ee5389
Final tweak to Linear's bootstrap content
evanjacobson Mar 27, 2026
c1f3cee
Flip baseUrl --> url
evanjacobson Mar 27, 2026
d33284e
Flip baseUrl to url in test as well
evanjacobson Mar 27, 2026
e9491a6
refactor(kiloclaw): replace Linear CLI with Linear MCP server (#1635)
evanjacobson Mar 27, 2026
458dc89
Fix Linear heading in test
evanjacobson Mar 27, 2026
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
125 changes: 124 additions & 1 deletion kiloclaw/controller/src/bootstrap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import {
applyFeatureFlags,
generateHooksToken,
configureGitHub,
configureLinear,
updateToolsMdLinearSection,
runOnboardOrDoctor,
updateToolsMdKiloCliSection,
updateToolsMd1PasswordSection,
Expand Down Expand Up @@ -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<string, string | undefined> = {
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<string, string | undefined> = {
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<string, string | undefined> = {};

configureLinear(env);

expect(env.LINEAR_API_KEY).toBeUndefined();
expect(logSpy).toHaveBeenCalledWith('Linear: not configured');
logSpy.mockRestore();
});
});

// ---- runOnboardOrDoctor ----

describe('runOnboardOrDoctor', () => {
Expand Down Expand Up @@ -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<typeof vi.fn>).mockReturnValue('# TOOLS\n');

const env: Record<string, string | undefined> = {
LINEAR_API_KEY: 'lin_api_test123',
};

updateToolsMdLinearSection(env, harness.deps);

expect(harness.writeCalls).toHaveLength(1);
expect(harness.writeCalls[0]!.data).toContain('<!-- BEGIN:linear -->');
expect(harness.writeCalls[0]!.data).toContain('## Linear');
expect(harness.writeCalls[0]!.data).toContain('<!-- END:linear -->');
});

it('skips adding when section already present', () => {
const harness = fakeDeps();
(harness.deps.readFileSync as ReturnType<typeof vi.fn>).mockReturnValue(
'# TOOLS\n<!-- BEGIN:linear -->\nexisting\n<!-- END:linear -->'
);

const env: Record<string, string | undefined> = {
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<typeof vi.fn>).mockReturnValue(
'# TOOLS\n<!-- BEGIN:linear -->\nold section\n<!-- END:linear -->\n'
);

const env: Record<string, string | undefined> = {};

updateToolsMdLinearSection(env, harness.deps);

expect(harness.writeCalls).toHaveLength(1);
expect(harness.writeCalls[0]!.data).not.toContain('<!-- BEGIN:linear -->');
});

it('no-ops when TOOLS.md does not exist', () => {
const harness = fakeDeps();
(harness.deps.existsSync as ReturnType<typeof vi.fn>).mockReturnValue(false);

const env: Record<string, string | undefined> = {
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<typeof vi.fn>).mockReturnValue('# TOOLS\n');

const env: Record<string, string | undefined> = {};

updateToolsMdLinearSection(env, harness.deps);

expect(harness.writeCalls).toHaveLength(0);
});
});

// ---- buildGatewayArgs ----

describe('buildGatewayArgs', () => {
Expand Down Expand Up @@ -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 () => {
Expand Down
81 changes: 78 additions & 3 deletions kiloclaw/controller/src/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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 = '<!-- BEGIN:google-workspace -->';
const GOG_MARKER_END = '<!-- END:google-workspace -->';
Expand Down Expand Up @@ -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 = '<!-- BEGIN:linear -->';
const LINEAR_MARKER_END = '<!-- END:linear -->';

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.
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down
104 changes: 104 additions & 0 deletions kiloclaw/controller/src/config-writer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
backupConfigFile,
generateBaseConfig,
writeBaseConfig,
writeMcporterConfig,
MAX_CONFIG_BACKUPS,
} from './config-writer';

Expand Down Expand Up @@ -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<string, string | undefined> = {};

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}');
});
});
Loading
Loading