Skip to content

Commit e9491a6

Browse files
authored
refactor(kiloclaw): replace Linear CLI with Linear MCP server (#1635)
## Summary A refactor targeting #1407: After exploring other options for the Linear integration, the official Linear MCP server turned out to be a better fit than the CLI. All OpenClaw instances are already deployed with the `mcporter` package and skill, which give KiloClaw MCP abilities anyways. Importantly, unlike the CLI, mcporter also allows us to avoid ever writing our actual API key to a file, which significantly reduces the overhead for managing this integration. This PR targets feature/kiloclaw-linear-cli with the scoped changes to switch-over to this MCP integration only, to simplify the reviewer's experience. ## Changes - Undo the changes to both `Dockerfile`s — the target branch will have no diff after this PR merges. - Strip all on-disk credential cleanup logic from `configureLinear()` — no new files to manage anymore, so the function no longer needs `BootstrapDeps` - Add Linear MCP server entry to `writeMcporterConfig()` when `LINEAR_API_KEY` is set; remove it when unset. Preserves existing user-configured servers. The key is stored in the file as `${LINEAR_API_KEY}` verbatim and read from the environment by mcporter. - Replace CLI usage documentation in the TOOLS.md agent section with a pointer to the Linear MCP server and mcporter skill - Simplify `configureLinear` tests (no more fs/exec mocking) and add `writeMcporterConfig` tests covering add, remove, preserve, and multi-server scenarios ## Verification - [x] `pnpm format:check` passes - [x] `pnpm lint` passes - [x] Tests pass (`vitest` in kiloclaw workspace) - [x] E2E tested on a fresh instance ## New Loom — Kilo Team only https://www.loom.com/share/5ca9897104e84a3baf560818b7a3bcf7 ## Action for reviewer Please let me know your preference for merge, whether it's into the feature/kiloclaw-cli branch, or into main directly.
2 parents b466318 + d33284e commit e9491a6

File tree

6 files changed

+145
-234
lines changed

6 files changed

+145
-234
lines changed

kiloclaw/Dockerfile

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ RUN apt-get update \
3131
| gpg --dearmor --output /usr/share/debsig/keyrings/AC2D62742012EA22/debsig.gpg \
3232
&& apt-get update \
3333
&& apt-get install -y --no-install-recommends gh 1password-cli \
34+
&& apt-get purge -y xz-utils \
35+
&& apt-get autoremove -y \
3436
&& rm -rf /var/lib/apt/lists/* \
3537
&& node --version \
3638
&& npm --version
@@ -55,13 +57,6 @@ RUN npm install -g @steipete/summarize@0.12.0
5557
# Install Kilo CLI (agentic coding assistant for the terminal)
5658
RUN npm install -g @kilocode/cli@7.0.46
5759

58-
# Install Linear CLI (issue tracker)
59-
RUN npm install -g @schpet/linear-cli@1.11.1
60-
61-
# Clean up xz-utils now that Node.js and linear-cli are installed
62-
RUN apt-get purge -y xz-utils \
63-
&& apt-get autoremove -y \
64-
&& rm -rf /var/lib/apt/lists/*
6560

6661
# Install Go (available at runtime for users to `go install` additional tools)
6762
ENV GO_VERSION=1.26.0

kiloclaw/Dockerfile.local

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ RUN apt-get update \
3131
| gpg --dearmor --output /usr/share/debsig/keyrings/AC2D62742012EA22/debsig.gpg \
3232
&& apt-get update \
3333
&& apt-get install -y --no-install-recommends gh 1password-cli=2.32.1-1 \
34+
&& apt-get purge -y xz-utils \
35+
&& apt-get autoremove -y \
3436
&& rm -rf /var/lib/apt/lists/* \
3537
&& node --version \
3638
&& npm --version
@@ -56,14 +58,6 @@ RUN npm install -g mcporter@0.7.3
5658
# Install summarize (web page summarization CLI)
5759
RUN npm install -g @steipete/summarize@0.11.1
5860

59-
# Install Linear CLI (issue tracker)
60-
RUN npm install -g @schpet/linear-cli@1.11.1
61-
62-
# Clean up xz-utils now that Node.js and linear-cli are installed
63-
RUN apt-get purge -y xz-utils \
64-
&& apt-get autoremove -y \
65-
&& rm -rf /var/lib/apt/lists/*
66-
6761
# Install Go (available at runtime for users to `go install` additional tools)
6862
ENV GO_VERSION=1.26.0
6963
RUN ARCH="$(dpkg --print-architecture)" \

kiloclaw/controller/src/bootstrap.test.ts

Lines changed: 12 additions & 148 deletions
Original file line numberDiff line numberDiff line change
@@ -494,174 +494,38 @@ describe('configureGitHub', () => {
494494
describe('configureLinear', () => {
495495
it('logs configured when LINEAR_API_KEY is set', () => {
496496
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
497-
const { deps } = fakeDeps();
498497
const env: Record<string, string | undefined> = {
499498
LINEAR_API_KEY: 'lin_api_test123',
500499
};
501500

502-
configureLinear(env, deps);
501+
configureLinear(env);
503502

504503
expect(env.LINEAR_API_KEY).toBe('lin_api_test123');
505-
expect(logSpy).toHaveBeenCalledWith('Linear CLI configured via LINEAR_API_KEY');
504+
expect(logSpy).toHaveBeenCalledWith('Linear MCP configured via LINEAR_API_KEY');
506505
logSpy.mockRestore();
507506
});
508507

509-
it('removes persisted config directory when no LINEAR_API_KEY', () => {
508+
it('cleans up empty LINEAR_API_KEY and logs not configured', () => {
510509
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
511-
const { deps, execCalls } = fakeDeps();
512-
const env: Record<string, string | undefined> = {};
510+
const env: Record<string, string | undefined> = {
511+
LINEAR_API_KEY: '',
512+
};
513513

514-
configureLinear(env, deps);
514+
configureLinear(env);
515515

516516
expect(env.LINEAR_API_KEY).toBeUndefined();
517-
expect(execCalls).toContainEqual({
518-
cmd: 'rm',
519-
args: ['-rf', '/root/.config/linear'],
520-
input: undefined,
521-
});
522-
expect(execCalls).toContainEqual({
523-
cmd: 'rm',
524-
args: ['-f', '/root/.linear.toml'],
525-
input: undefined,
526-
});
527-
expect(logSpy).toHaveBeenCalledWith('Linear: not configured (credentials cleared)');
528-
logSpy.mockRestore();
529-
});
530-
531-
it('still removes ~/.linear.toml when /root/.config/linear removal fails', () => {
532-
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
533-
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
534-
const { deps } = fakeDeps();
535-
const env: Record<string, string | undefined> = {};
536-
537-
// Make the first rm call throw, second should still succeed
538-
const origExec = deps.execFileSync;
539-
deps.execFileSync = vi.fn(
540-
(cmd: string, args: string[], opts?: Parameters<BootstrapDeps['execFileSync']>[2]) => {
541-
if (cmd === 'rm' && args.includes('/root/.config/linear')) {
542-
throw new Error('permission denied');
543-
}
544-
return origExec(cmd, args, opts);
545-
}
546-
) as typeof deps.execFileSync;
547-
548-
configureLinear(env, deps);
549-
550-
expect(warnSpy).toHaveBeenCalledWith(
551-
'WARNING: failed to remove /root/.config/linear: permission denied'
552-
);
553-
// Second rm should still have been attempted
554-
expect(deps.execFileSync).toHaveBeenCalledWith('rm', ['-f', '/root/.linear.toml'], {
555-
stdio: 'pipe',
556-
});
557-
logSpy.mockRestore();
558-
warnSpy.mockRestore();
559-
});
560-
561-
it('warns when /root/.config/linear removal fails', () => {
562-
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
563-
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
564-
const { deps } = fakeDeps();
565-
const env: Record<string, string | undefined> = {};
566-
567-
const origExec = deps.execFileSync;
568-
deps.execFileSync = vi.fn(
569-
(cmd: string, args: string[], opts?: Parameters<BootstrapDeps['execFileSync']>[2]) => {
570-
if (cmd === 'rm' && args.includes('/root/.config/linear')) {
571-
throw new Error('permission denied');
572-
}
573-
return origExec(cmd, args, opts);
574-
}
575-
) as typeof deps.execFileSync;
576-
577-
configureLinear(env, deps);
578-
579-
expect(warnSpy).toHaveBeenCalledWith(
580-
'WARNING: failed to remove /root/.config/linear: permission denied'
581-
);
582-
expect(logSpy).toHaveBeenCalledWith('Linear: not configured (credentials cleared)');
583-
logSpy.mockRestore();
584-
warnSpy.mockRestore();
585-
});
586-
587-
it('warns when ~/.linear.toml removal fails', () => {
588-
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
589-
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
590-
const { deps } = fakeDeps();
591-
const env: Record<string, string | undefined> = {};
592-
593-
const origExec = deps.execFileSync;
594-
deps.execFileSync = vi.fn(
595-
(cmd: string, args: string[], opts?: Parameters<BootstrapDeps['execFileSync']>[2]) => {
596-
if (cmd === 'rm' && args.includes('/root/.linear.toml')) {
597-
throw new Error('read-only filesystem');
598-
}
599-
return origExec(cmd, args, opts);
600-
}
601-
) as typeof deps.execFileSync;
602-
603-
configureLinear(env, deps);
604-
605-
expect(warnSpy).toHaveBeenCalledWith(
606-
'WARNING: failed to remove /root/.linear.toml: read-only filesystem'
607-
);
608-
expect(logSpy).toHaveBeenCalledWith('Linear: not configured (credentials cleared)');
609-
logSpy.mockRestore();
610-
warnSpy.mockRestore();
611-
});
612-
613-
it('warns for both when both rm calls fail', () => {
614-
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
615-
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
616-
const { deps } = fakeDeps();
617-
const env: Record<string, string | undefined> = {};
618-
619-
deps.execFileSync = vi.fn((cmd: string) => {
620-
if (cmd === 'rm') throw new Error('disk full');
621-
return '';
622-
}) as typeof deps.execFileSync;
623-
624-
configureLinear(env, deps);
625-
626-
expect(warnSpy).toHaveBeenCalledWith(
627-
'WARNING: failed to remove /root/.config/linear: disk full'
628-
);
629-
expect(warnSpy).toHaveBeenCalledWith('WARNING: failed to remove /root/.linear.toml: disk full');
630-
expect(logSpy).toHaveBeenCalledWith('Linear: not configured (credentials cleared)');
517+
expect(logSpy).toHaveBeenCalledWith('Linear: not configured');
631518
logSpy.mockRestore();
632-
warnSpy.mockRestore();
633519
});
634520

635-
it('formats non-Error thrown values in warning', () => {
521+
it('logs not configured when LINEAR_API_KEY is absent', () => {
636522
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
637-
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
638-
const { deps } = fakeDeps();
639523
const env: Record<string, string | undefined> = {};
640524

641-
deps.execFileSync = vi.fn((cmd: string) => {
642-
if (cmd === 'rm') throw 'unexpected string error';
643-
return '';
644-
}) as typeof deps.execFileSync;
645-
646-
configureLinear(env, deps);
647-
648-
expect(warnSpy).toHaveBeenCalledWith(
649-
'WARNING: failed to remove /root/.config/linear: unexpected string error'
650-
);
651-
logSpy.mockRestore();
652-
warnSpy.mockRestore();
653-
});
654-
655-
it('cleans up empty LINEAR_API_KEY', () => {
656-
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
657-
const { deps } = fakeDeps();
658-
const env: Record<string, string | undefined> = {
659-
LINEAR_API_KEY: '',
660-
};
661-
662-
configureLinear(env, deps);
525+
configureLinear(env);
663526

664527
expect(env.LINEAR_API_KEY).toBeUndefined();
528+
expect(logSpy).toHaveBeenCalledWith('Linear: not configured');
665529
logSpy.mockRestore();
666530
});
667531
});
@@ -878,7 +742,7 @@ describe('updateToolsMdLinearSection', () => {
878742

879743
expect(harness.writeCalls).toHaveLength(1);
880744
expect(harness.writeCalls[0]!.data).toContain('<!-- BEGIN:linear -->');
881-
expect(harness.writeCalls[0]!.data).toContain('linear issue list');
745+
expect(harness.writeCalls[0]!.data).toContain('Linear MCP');
882746
expect(harness.writeCalls[0]!.data).toContain('<!-- END:linear -->');
883747
});
884748

kiloclaw/controller/src/bootstrap.ts

Lines changed: 12 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -327,44 +327,17 @@ export function configureGitHub(env: EnvLike, deps: BootstrapDeps = defaultDeps)
327327
// ---- Step 6: Linear config ----
328328

329329
/**
330-
* Configure or clean up Linear CLI access.
331-
* When LINEAR_API_KEY is present the CLI reads it natively.
332-
* When absent, clean up any on-disk credentials left by a previous
333-
* `linear auth login --plaintext` on the persistent volume.
334-
* Best-effort: logs warnings on failure, does not throw.
335-
*
336-
* The CLI stores two files under ~/.config/linear/:
337-
* - credentials.toml — workspace list + inline API keys (plaintext mode)
338-
* - linear.toml — global config that can also carry an api_key field
339-
* It may also create ~/.linear.toml (a project-level or legacy config file).
340-
* The system keyring is not available in this container (no libsecret-tools),
341-
* so these files are the only persistence locations.
330+
* Configure or clean up Linear MCP access.
331+
* Linear access is provided via the Linear MCP server configured in mcporter.
332+
* When LINEAR_API_KEY is present, mcporter uses it to authenticate.
333+
* When absent, we just clean up the env var. No on-disk artifacts to clean.
342334
*/
343-
export function configureLinear(env: EnvLike, deps: BootstrapDeps = defaultDeps): void {
335+
export function configureLinear(env: EnvLike): void {
344336
if (env.LINEAR_API_KEY) {
345-
console.log('Linear CLI configured via LINEAR_API_KEY');
337+
console.log('Linear MCP configured via LINEAR_API_KEY');
346338
} else {
347-
// Clean up env var if explicitly set to empty
348339
delete env.LINEAR_API_KEY;
349-
// Remove any previously stored credentials from the persistent volume.
350-
// The CLI recreates ~/.config/linear/ via ensureDir on next auth login.
351-
// rm -rf/-f exit 0 when the target is absent, so errors here are
352-
// genuine failures (permissions, I/O) worth surfacing.
353-
try {
354-
deps.execFileSync('rm', ['-rf', '/root/.config/linear'], { stdio: 'pipe' });
355-
} catch (err) {
356-
console.warn(
357-
`WARNING: failed to remove /root/.config/linear: ${err instanceof Error ? err.message : err}`
358-
);
359-
}
360-
try {
361-
deps.execFileSync('rm', ['-f', '/root/.linear.toml'], { stdio: 'pipe' });
362-
} catch (err) {
363-
console.warn(
364-
`WARNING: failed to remove /root/.linear.toml: ${err instanceof Error ? err.message : err}`
365-
);
366-
}
367-
console.log('Linear: not configured (credentials cleared)');
340+
console.log('Linear: not configured');
368341
}
369342
}
370343

@@ -590,50 +563,18 @@ const LINEAR_MARKER_END = '<!-- END:linear -->';
590563

591564
const LINEAR_TOOLS_SECTION = `
592565
${LINEAR_MARKER_BEGIN}
593-
## Linear
594-
595-
The \`linear\` CLI is configured with your Linear API key. Use it to read and manage issues.
596-
597-
- Run \`linear --help\` for full command reference; \`--help\` after any subcommand for details.
598-
- If you don't know the team key, run \`linear team list\`.
599-
600-
### Listing issues
601-
602-
Example — list all issues by priority:
603-
\`\`\`
604-
linear issue list --team <key> --sort priority --all-states --all-assignees
605-
\`\`\`
606-
607-
**Flags that silently filter results when omitted:**
608-
- \`--state\` defaults to \`backlog\`. Use \`--all-states\` for all, or \`--state <value>\` to filter to one: triage, backlog, unstarted, started, completed, canceled
609-
- \`--assignee\` defaults to \`me\`. Use \`--all-assignees\` for all, or \`--assignee <user>\` to filter to one
610-
611-
### Writing issue content
612-
Use file flags for markdown with newlines or special characters:
613-
- \`--description-file <path>\` for \`issue create/update\`
614-
- \`--body-file <path>\` for \`comment add/update\`
615-
616-
### Config file
617-
Avoid repeated \`--team\` and \`--sort\` flags with \`.linear.toml\` in the project directory:
618-
\`\`\`toml
619-
team = "TEAM_KEY"
620-
sort = "priority"
621-
\`\`\`
566+
## Linear
622567
623-
### Gotchas
624-
- \`--no-pager\` only works on \`issue list\` — errors on other commands
625-
- GraphQL non-null types (\`String!\`) require heredoc: \`linear api --variable key=val <<'GRAPHQL'\`
568+
Linear is configured as your project management tool. Use it to track issues, plan projects, and manage product roadmaps.
569+
You can interact with the \`Linear\` MCP server using your \`mcporter\` skill.
626570
627-
### Advanced
628-
- Get API token: \`linear auth token\`
629-
- Direct GraphQL: \`curl -s -X POST https://api.linear.app/graphql -H "Authorization: $(linear auth token)" -d '{"query":"..."}'\`
630571
${LINEAR_MARKER_END}`;
631572

632573
/**
633574
* Manage the Linear section in TOOLS.md.
634575
*
635576
* When LINEAR_API_KEY is present, append a bounded section so the agent
636-
* knows the linear CLI is available. When absent, remove any stale section.
577+
* knows Linear MCP is available. When absent, remove any stale section.
637578
* Idempotent: skips if the marker is already present.
638579
*/
639580
export function updateToolsMdLinearSection(env: EnvLike, deps: BootstrapDeps): void {
@@ -718,7 +659,7 @@ export async function bootstrap(
718659
await yieldToEventLoop();
719660

720661
setPhase('linear');
721-
configureLinear(env, deps);
662+
configureLinear(env);
722663
await yieldToEventLoop();
723664

724665
const configExists = deps.existsSync(CONFIG_PATH);

0 commit comments

Comments
 (0)