@stackmemoryai/stackmemory
Version:
Lossless, project-scoped memory for AI coding tools. Durable context across sessions with 56 MCP tools, FTS5 search, conductor orchestrator, loop/watch monitoring, snapshot capture, pre-flight overlap checks, Claude/Codex/OpenCode wrappers, Linear sync, a
538 lines (537 loc) • 20.4 kB
JavaScript
import { fileURLToPath as __fileURLToPath } from 'url';
import { dirname as __pathDirname } from 'path';
const __filename = __fileURLToPath(import.meta.url);
const __dirname = __pathDirname(__filename);
import chalk from "chalk";
import { LinearAuthManager } from "../../integrations/linear/auth.js";
import { LinearOAuthServer } from "../../integrations/linear/oauth-server.js";
import {
LinearSyncEngine,
DEFAULT_SYNC_CONFIG
} from "../../integrations/linear/sync.js";
import {
LinearSyncManager,
DEFAULT_SYNC_MANAGER_CONFIG
} from "../../integrations/linear/sync-manager.js";
import { LinearConfigManager } from "../../integrations/linear/config.js";
import { LinearTaskManager } from "../../features/tasks/linear-task-manager.js";
import { LinearClient } from "../../integrations/linear/client.js";
import { LinearRestClient } from "../../integrations/linear/rest-client.js";
import Database from "better-sqlite3";
import { join } from "path";
import { existsSync } from "fs";
import { logger } from "../../core/monitoring/logger.js";
import Table from "cli-table3";
function displaySyncResult(result) {
if (result.success) {
console.log(chalk.green("\u2713 Sync completed successfully"));
} else {
console.log(chalk.yellow("\u26A0 Sync completed with issues"));
}
if (result.synced.toLinear > 0 || result.synced.fromLinear > 0 || result.synced.updated > 0) {
console.log(chalk.cyan(" \u{1F4CA} Summary:"));
if (result.synced.toLinear > 0) {
console.log(` \u2192 Linear: ${result.synced.toLinear} tasks`);
}
if (result.synced.fromLinear > 0) {
console.log(` \u2190 Linear: ${result.synced.fromLinear} tasks`);
}
if (result.synced.updated > 0) {
console.log(` \u2194 Updated: ${result.synced.updated} tasks`);
}
}
if (result.conflicts.length > 0) {
console.log(chalk.yellow(` \u26A0 Conflicts: ${result.conflicts.length}`));
result.conflicts.slice(0, 3).forEach((conflict) => {
console.log(` - ${conflict.reason}`);
});
if (result.conflicts.length > 3) {
console.log(` ... and ${result.conflicts.length - 3} more`);
}
}
if (result.errors.length > 0) {
console.log(chalk.red(` \u274C Errors: ${result.errors.length}`));
result.errors.slice(0, 3).forEach((error) => {
console.log(` - ${error.substring(0, 80)}`);
});
if (result.errors.length > 3) {
console.log(` ... and ${result.errors.length - 3} more`);
}
}
}
function registerLinearCommands(parent) {
const linear = parent.command("linear").description("Linear API integration commands");
linear.command("list").alias("ls").description("List Linear tasks (memory-cached)").option("--limit <n>", "Number of tasks to show", "20").option(
"--status <status>",
"Filter by status (backlog, started, completed, etc.)"
).option("--my", "Show only tasks assigned to me").option("--cache", "Show cache stats only").option("--refresh", "Force refresh cache").option("--count", "Show count by status only").action(async (options) => {
try {
const apiKey = process.env["LINEAR_API_KEY"];
if (!apiKey) {
console.log(
chalk.yellow("\u26A0 Set LINEAR_API_KEY environment variable")
);
return;
}
const restClient = new LinearRestClient(apiKey);
if (options.cache) {
const stats = restClient.getCacheStats();
console.log(chalk.cyan("\u{1F4CA} Cache Stats:"));
console.log(` Size: ${stats.size} tasks`);
console.log(` Age: ${Math.round(stats.age / 1e3)}s`);
console.log(` Fresh: ${stats.fresh ? "yes" : "no"}`);
console.log(
` Last sync: ${new Date(stats.lastSync).toLocaleString()}`
);
return;
}
if (options.count) {
const counts = await restClient.getTaskCounts();
console.log(chalk.cyan("\u{1F4CA} Task Counts:"));
Object.entries(counts).sort(([, a], [, b]) => b - a).forEach(([status, count]) => {
console.log(` ${status}: ${count}`);
});
return;
}
let tasks;
if (options.my) {
tasks = await restClient.getMyTasks();
} else if (options.status) {
tasks = await restClient.getTasksByStatus(options.status);
} else {
tasks = await restClient.getAllTasks(options.refresh);
}
if (!tasks || tasks.length === 0) {
console.log(chalk.gray("No tasks found"));
return;
}
const limit = parseInt(options.limit);
const displayTasks = tasks.slice(0, limit);
console.log(
chalk.cyan(
`
\u{1F4CB} Linear Tasks (${displayTasks.length}/${tasks.length}):`
)
);
displayTasks.forEach((task) => {
const priority = task.priority ? `P${task.priority}` : "";
const assignee = task.assignee ? ` @${task.assignee.name}` : "";
const statusColor = task.state.type === "completed" ? chalk.green : task.state.type === "started" ? chalk.yellow : chalk.gray;
console.log(`${chalk.blue(task.identifier)} ${task.title}`);
console.log(
chalk.gray(
` ${statusColor(task.state.name)} ${priority}${assignee}`
)
);
});
console.log(
chalk.gray(
`
${displayTasks.length} shown, ${tasks.length} total tasks`
)
);
} catch (error) {
console.error(
chalk.red("Failed to list tasks:"),
error.message
);
}
});
linear.command("auth").description("Authenticate with Linear").option("--api-key <key>", "Use API key instead of OAuth").option("--no-browser", "Do not open browser automatically").action(async (options) => {
try {
if (options.apiKey) {
process.env["LINEAR_API_KEY"] = options.apiKey;
console.log(chalk.green("\u2713 Linear API key set"));
const client = new LinearClient({ apiKey: options.apiKey });
const user = await client.getViewer();
if (user) {
console.log(
chalk.cyan(`Connected as: ${user.name} (${user.email})`)
);
}
} else {
const authManager = new LinearAuthManager(process.cwd());
const clientId = process.env["LINEAR_CLIENT_ID"];
const clientSecret = process.env["LINEAR_CLIENT_SECRET"];
if (!clientId || !clientSecret) {
console.log(chalk.yellow("\n\u26A0 Linear OAuth app not configured"));
console.log(chalk.cyan("\n\u{1F4DD} Setup Instructions:"));
console.log(
" 1. Create a Linear OAuth app at: https://linear.app/settings/api"
);
console.log(
" 2. Set redirect URI to: http://localhost:3456/auth/linear/callback"
);
console.log(" 3. Copy your Client ID and Client Secret");
console.log(" 4. Set environment variables:");
console.log(
chalk.gray(' export LINEAR_CLIENT_ID="your_client_id"')
);
console.log(
chalk.gray(
' export LINEAR_CLIENT_SECRET="your_client_secret"'
)
);
console.log(" 5. Run this command again");
return;
}
authManager.saveConfig({
clientId,
clientSecret,
redirectUri: "http://localhost:3456/auth/linear/callback",
scopes: ["read", "write", "admin"]
});
const oauthServer = new LinearOAuthServer(process.cwd());
const { url } = await oauthServer.start();
if (options.browser !== false) {
const open = (await import("open")).default;
await open(url);
console.log(
chalk.green("\n\u2713 Browser opened with authorization page")
);
} else {
console.log(chalk.cyan("\n\u{1F517} Open this URL in your browser:"));
console.log(chalk.underline(url));
}
console.log(chalk.gray("\nWaiting for authorization..."));
console.log(
chalk.gray(
"The server will automatically shut down after authorization."
)
);
}
} catch (error) {
console.error(
chalk.red("Authentication failed:"),
error.message
);
process.exit(1);
}
});
linear.command("sync").description("Sync tasks with Linear").option(
"-d, --direction <dir>",
"Sync direction: bidirectional, to_linear, from_linear",
"bidirectional"
).option("-t, --team <id>", "Default Linear team ID").option("--dry-run", "Preview sync without making changes").option("--daemon", "Run in daemon mode with periodic sync").option(
"-i, --interval <minutes>",
"Sync interval in minutes (default: 15)"
).action(async (options) => {
try {
const projectRoot = process.cwd();
const dbPath = join(projectRoot, ".stackmemory", "context.db");
if (!existsSync(dbPath)) {
console.log(chalk.red("\u274C StackMemory not initialized"));
return;
}
const db = new Database(dbPath);
const taskStore = new LinearTaskManager(projectRoot, db);
const authManager = new LinearAuthManager(projectRoot);
const config = {
...DEFAULT_SYNC_CONFIG,
direction: options.direction,
defaultTeamId: options.team,
enabled: true
};
if (options.daemon) {
const managerConfig = {
...DEFAULT_SYNC_MANAGER_CONFIG,
...config,
autoSyncInterval: parseInt(options.interval) || 15
};
const syncManager = new LinearSyncManager(
taskStore,
authManager,
managerConfig,
projectRoot
);
console.log(chalk.green("\u{1F680} Starting Linear sync daemon"));
console.log(
chalk.cyan(
` Sync interval: ${managerConfig.autoSyncInterval} minutes`
)
);
console.log(chalk.cyan(` Direction: ${managerConfig.direction}`));
console.log(chalk.gray(" Press Ctrl+C to stop\n"));
const initialResult = await syncManager.syncOnStart();
if (initialResult) {
displaySyncResult(initialResult);
}
syncManager.on("sync:started", ({ trigger }) => {
console.log(
chalk.yellow(
`
\u{1F504} ${(/* @__PURE__ */ new Date()).toLocaleTimeString()} - Starting ${trigger} sync...`
)
);
});
syncManager.on("sync:completed", ({ result }) => {
displaySyncResult(result);
});
syncManager.on("sync:failed", ({ result }) => {
console.log(chalk.red("\u274C Sync failed"));
if (result.errors.length > 0) {
result.errors.forEach((error) => {
console.log(chalk.red(` - ${error}`));
});
}
});
process.on("SIGINT", async () => {
console.log(chalk.yellow("\n\u23F9 Stopping sync daemon..."));
await syncManager.syncOnEnd();
syncManager.stop();
db.close();
process.exit(0);
});
process.stdin.resume();
} else {
const syncEngine = new LinearSyncEngine(
taskStore,
authManager,
config
);
if (!syncEngine.isConfigured) {
console.log(
chalk.gray(
'\u2139 Linear API key not configured \u2014 skipping sync. Set LINEAR_API_KEY or run "stackmemory linear setup".'
)
);
db.close();
return;
}
console.log(chalk.yellow("\u{1F504} Syncing with Linear..."));
if (options.dryRun) {
console.log(chalk.gray("(Dry run - no changes will be made)"));
}
const result = await syncEngine.sync();
displaySyncResult(result);
db.close();
}
} catch (error) {
logger.error("Sync failed", error);
console.error(chalk.red("Sync failed:"), error.message);
process.exit(1);
}
});
linear.command("status").description("Show Linear sync status").action(async () => {
try {
const authManager = new LinearAuthManager(process.cwd());
const tokens = authManager.loadTokens();
const apiKey = process.env["LINEAR_API_KEY"];
if (!tokens && !apiKey) {
console.log(chalk.yellow("\u26A0 Not authenticated with Linear"));
console.log('Run "stackmemory linear auth" to connect');
return;
}
const client = apiKey ? new LinearClient({ apiKey }) : new LinearClient({
apiKey: tokens.accessToken,
useBearer: true,
onUnauthorized: async () => {
const refreshed = await authManager.refreshAccessToken();
return refreshed.accessToken;
}
});
const user = await client.getViewer();
if (user) {
console.log(chalk.green("\u2713 Connected to Linear"));
console.log(chalk.cyan(` User: ${user.name} (${user.email})`));
const teams = await client.getTeams();
if (teams && teams.length > 0) {
console.log(chalk.cyan("\n\u{1F4CB} Teams:"));
teams.forEach((team) => {
console.log(` - ${team.name} (${team.key})`);
});
}
} else {
console.log(chalk.red("\u274C Could not connect to Linear"));
}
} catch (error) {
console.error(
chalk.red("Status check failed:"),
error.message
);
}
});
linear.command("tasks").description("List Linear tasks").option("--limit <n>", "Number of tasks to show", "50").option(
"--status <status>",
"Filter by status (backlog, started, completed, etc.)"
).option("--my", "Show only tasks assigned to me").option("--cache", "Show cache stats").option("--refresh", "Force refresh cache").action(async (options) => {
try {
const apiKey = process.env["LINEAR_API_KEY"];
if (!apiKey) {
console.log(
chalk.yellow("\u26A0 Set LINEAR_API_KEY environment variable")
);
return;
}
const restClient = new LinearRestClient(apiKey);
if (options.cache) {
const stats = restClient.getCacheStats();
console.log(chalk.cyan("\u{1F4CA} Cache Stats:"));
console.log(` Size: ${stats.size} tasks`);
console.log(` Age: ${Math.round(stats.age / 1e3)}s`);
console.log(` Fresh: ${stats.fresh ? "yes" : "no"}`);
console.log(
` Last sync: ${new Date(stats.lastSync).toLocaleString()}`
);
return;
}
let tasks;
if (options.my) {
tasks = await restClient.getMyTasks();
} else if (options.status) {
tasks = await restClient.getTasksByStatus(options.status);
} else {
tasks = await restClient.getAllTasks(options.refresh);
}
if (!tasks || tasks.length === 0) {
console.log(chalk.gray("No tasks found"));
return;
}
const limit = parseInt(options.limit);
const displayTasks = tasks.slice(0, limit);
const table = new Table({
head: ["ID", "Title", "State", "Priority", "Assignee"],
style: { head: ["cyan"] }
});
displayTasks.forEach((task) => {
table.push([
task.identifier,
task.title.substring(0, 40) + (task.title.length > 40 ? "..." : ""),
task.state?.name || "-",
task.priority ? `P${task.priority}` : "-",
task.assignee?.name || "-"
]);
});
console.log(table.toString());
const counts = await restClient.getTaskCounts();
console.log(chalk.cyan("\n\u{1F4CA} Task Summary:"));
Object.entries(counts).forEach(([status, count]) => {
console.log(` ${status}: ${count}`);
});
console.log(
chalk.gray(
`
Showing ${displayTasks.length} of ${tasks.length} total tasks`
)
);
const cacheStats = restClient.getCacheStats();
console.log(
chalk.gray(
`Cache: ${cacheStats.size} tasks, age: ${Math.round(cacheStats.age / 1e3)}s`
)
);
} catch (error) {
console.error(
chalk.red("Failed to list tasks:"),
error.message
);
}
});
linear.command("update <issueId>").description("Update Linear task status").option(
"-s, --status <status>",
"New status (todo, in-progress, done, canceled)"
).option("-t, --title <title>", "Update task title").option("-d, --description <desc>", "Update task description").option(
"-p, --priority <priority>",
"Set priority (1=urgent, 2=high, 3=medium, 4=low)"
).action(async (issueId, options) => {
try {
const authManager = new LinearAuthManager(process.cwd());
const tokens = authManager.loadTokens();
if (!tokens) {
console.error(
chalk.red("Not authenticated. Run: stackmemory linear auth")
);
return;
}
const client = new LinearClient({
apiKey: tokens.accessToken
});
let issue = await client.getIssue(issueId);
if (!issue) {
issue = await client.findIssueByIdentifier(issueId);
}
if (!issue) {
console.error(chalk.red(`Issue ${issueId} not found`));
return;
}
const updates = {};
if (options.status) {
const team = await client.getTeam();
const states = await client.getWorkflowStates(team.id);
const statusMap = {
todo: "unstarted",
"in-progress": "started",
done: "completed",
canceled: "cancelled"
};
const targetType = statusMap[options.status.toLowerCase()] || options.status;
const targetState = states.find((s) => s.type === targetType);
if (!targetState) {
console.error(chalk.red(`Invalid status: ${options.status}`));
console.log(chalk.gray("Available states:"));
states.forEach(
(s) => console.log(chalk.gray(` - ${s.name} (${s.type})`))
);
return;
}
updates.stateId = targetState.id;
}
if (options.title) updates.title = options.title;
if (options.description) updates.description = options.description;
if (options.priority) updates.priority = parseInt(options.priority);
const updatedIssue = await client.updateIssue(issue.id, updates);
console.log(
chalk.green(
`\u2713 Updated ${updatedIssue.identifier}: ${updatedIssue.title}`
)
);
if (options.status) {
console.log(chalk.cyan(` Status: ${updatedIssue.state.name}`));
}
console.log(chalk.gray(` ${updatedIssue.url}`));
} catch (error) {
console.error(
chalk.red("Failed to update task:"),
error.message
);
}
});
linear.command("config").description("Configure Linear sync settings").option("--team <id>", "Set default team ID").option("--interval <minutes>", "Auto-sync interval in minutes").option("--direction <dir>", "Sync direction").option("--conflict <strategy>", "Conflict resolution strategy").action(async (options) => {
try {
const configManager = new LinearConfigManager(process.cwd());
const config = configManager.loadConfig() || configManager.getDefaultConfig();
let updated = false;
if (options.team) {
logger.info("Team ID configuration not yet implemented", {
teamId: options.team
});
}
if (options.interval) {
config.interval = parseInt(options.interval);
updated = true;
}
if (options.direction) {
config.direction = options.direction;
updated = true;
}
if (options.conflict) {
config.conflictResolution = options.conflict;
updated = true;
}
if (updated) {
configManager.saveConfig(config);
console.log(chalk.green("\u2713 Configuration updated"));
}
console.log(chalk.cyan("\n\u{1F4CB} Current Configuration:"));
console.log(` Enabled: ${config.enabled ? "yes" : "no"}`);
console.log(` Interval: ${config.interval} minutes`);
console.log(` Direction: ${config.direction}`);
console.log(` Conflicts: ${config.conflictResolution}`);
} catch (error) {
console.error(chalk.red("Config failed:"), error.message);
}
});
}
export {
registerLinearCommands
};