kimaki 0.4.27 → 0.4.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -4,7 +4,7 @@
4
4
  // project channel creation, and launching the bot with opencode integration.
5
5
  import { cac } from 'cac';
6
6
  import { intro, outro, text, password, note, cancel, isCancel, confirm, log, multiselect, spinner, } from '@clack/prompts';
7
- import { deduplicateByKey, generateBotInstallUrl } from './utils.js';
7
+ import { deduplicateByKey, generateBotInstallUrl, abbreviatePath } from './utils.js';
8
8
  import { getChannelsWithDescriptions, createDiscordClient, getDatabase, startDiscordBot, initializeOpencodeForDirectory, ensureKimakiCategory, createProjectChannels, } from './discord-bot.js';
9
9
  import { Events, ChannelType, REST, Routes, SlashCommandBuilder, AttachmentBuilder, } from 'discord.js';
10
10
  import path from 'node:path';
@@ -12,10 +12,10 @@ import fs from 'node:fs';
12
12
  import { createLogger } from './logger.js';
13
13
  import { spawn, spawnSync, execSync } from 'node:child_process';
14
14
  import http from 'node:http';
15
+ import { setDataDir, getDataDir, getLockPort } from './config.js';
15
16
  const cliLogger = createLogger('CLI');
16
17
  const cli = cac('kimaki');
17
18
  process.title = 'kimaki';
18
- const LOCK_PORT = 29988;
19
19
  async function killProcessOnPort(port) {
20
20
  const isWindows = process.platform === 'win32';
21
21
  const myPid = process.pid;
@@ -58,13 +58,14 @@ async function killProcessOnPort(port) {
58
58
  return false;
59
59
  }
60
60
  async function checkSingleInstance() {
61
+ const lockPort = getLockPort();
61
62
  try {
62
- const response = await fetch(`http://127.0.0.1:${LOCK_PORT}`, {
63
+ const response = await fetch(`http://127.0.0.1:${lockPort}`, {
63
64
  signal: AbortSignal.timeout(1000),
64
65
  });
65
66
  if (response.ok) {
66
- cliLogger.log('Another kimaki instance detected');
67
- await killProcessOnPort(LOCK_PORT);
67
+ cliLogger.log(`Another kimaki instance detected for data dir: ${getDataDir()}`);
68
+ await killProcessOnPort(lockPort);
68
69
  // Wait a moment for port to be released
69
70
  await new Promise((resolve) => { setTimeout(resolve, 500); });
70
71
  }
@@ -74,22 +75,24 @@ async function checkSingleInstance() {
74
75
  }
75
76
  }
76
77
  async function startLockServer() {
78
+ const lockPort = getLockPort();
77
79
  return new Promise((resolve, reject) => {
78
80
  const server = http.createServer((req, res) => {
79
81
  res.writeHead(200);
80
82
  res.end('kimaki');
81
83
  });
82
- server.listen(LOCK_PORT, '127.0.0.1');
84
+ server.listen(lockPort, '127.0.0.1');
83
85
  server.once('listening', () => {
86
+ cliLogger.debug(`Lock server started on port ${lockPort}`);
84
87
  resolve();
85
88
  });
86
89
  server.on('error', async (err) => {
87
90
  if (err.code === 'EADDRINUSE') {
88
91
  cliLogger.log('Port still in use, retrying...');
89
- await killProcessOnPort(LOCK_PORT);
92
+ await killProcessOnPort(lockPort);
90
93
  await new Promise((r) => { setTimeout(r, 500); });
91
94
  // Retry once
92
- server.listen(LOCK_PORT, '127.0.0.1');
95
+ server.listen(lockPort, '127.0.0.1');
93
96
  }
94
97
  else {
95
98
  reject(err);
@@ -461,7 +464,15 @@ async function run({ restart, addChannels }) {
461
464
  .filter((ch) => ch.kimakiDirectory && ch.kimakiApp === appId)
462
465
  .map((ch) => ch.kimakiDirectory)
463
466
  .filter(Boolean));
464
- const availableProjects = deduplicateByKey(projects.filter((project) => !existingDirs.includes(project.worktree)), (x) => x.worktree);
467
+ const availableProjects = deduplicateByKey(projects.filter((project) => {
468
+ if (existingDirs.includes(project.worktree)) {
469
+ return false;
470
+ }
471
+ if (path.basename(project.worktree).startsWith('opencode-test-')) {
472
+ return false;
473
+ }
474
+ return true;
475
+ }), (x) => x.worktree);
465
476
  if (availableProjects.length === 0) {
466
477
  note('All OpenCode projects already have Discord channels', 'No New Projects');
467
478
  }
@@ -471,7 +482,7 @@ async function run({ restart, addChannels }) {
471
482
  message: 'Select projects to create Discord channels for:',
472
483
  options: availableProjects.map((project) => ({
473
484
  value: project.id,
474
- label: `${path.basename(project.worktree)} (${project.worktree})`,
485
+ label: `${path.basename(project.worktree)} (${abbreviatePath(project.worktree)})`,
475
486
  })),
476
487
  required: false,
477
488
  });
@@ -585,13 +596,20 @@ cli
585
596
  .command('', 'Set up and run the Kimaki Discord bot')
586
597
  .option('--restart', 'Prompt for new credentials even if saved')
587
598
  .option('--add-channels', 'Select OpenCode projects to create Discord channels before starting')
599
+ .option('--data-dir <path>', 'Data directory for config and database (default: ~/.kimaki)')
588
600
  .action(async (options) => {
589
601
  try {
602
+ // Set data directory early, before any database access
603
+ if (options.dataDir) {
604
+ setDataDir(options.dataDir);
605
+ cliLogger.log(`Using data directory: ${getDataDir()}`);
606
+ }
590
607
  await checkSingleInstance();
591
608
  await startLockServer();
592
609
  await run({
593
610
  restart: options.restart,
594
611
  addChannels: options.addChannels,
612
+ dataDir: options.dataDir,
595
613
  });
596
614
  }
597
615
  catch (error) {
@@ -5,6 +5,7 @@ import { getDatabase } from '../database.js';
5
5
  import { initializeOpencodeForDirectory } from '../opencode.js';
6
6
  import { createProjectChannels } from '../channel-management.js';
7
7
  import { createLogger } from '../logger.js';
8
+ import { abbreviatePath } from '../utils.js';
8
9
  const logger = createLogger('ADD-PROJECT');
9
10
  export async function handleAddProjectCommand({ command, appId, }) {
10
11
  await command.deferReply({ ephemeral: false });
@@ -69,7 +70,15 @@ export async function handleAddProjectAutocomplete({ interaction, appId, }) {
69
70
  .prepare('SELECT DISTINCT directory FROM channel_directories WHERE channel_type = ?')
70
71
  .all('text');
71
72
  const existingDirSet = new Set(existingDirs.map((row) => row.directory));
72
- const availableProjects = projectsResponse.data.filter((project) => !existingDirSet.has(project.worktree));
73
+ const availableProjects = projectsResponse.data.filter((project) => {
74
+ if (existingDirSet.has(project.worktree)) {
75
+ return false;
76
+ }
77
+ if (path.basename(project.worktree).startsWith('opencode-test-')) {
78
+ return false;
79
+ }
80
+ return true;
81
+ });
73
82
  const projects = availableProjects
74
83
  .filter((project) => {
75
84
  const baseName = path.basename(project.worktree);
@@ -83,7 +92,7 @@ export async function handleAddProjectAutocomplete({ interaction, appId, }) {
83
92
  })
84
93
  .slice(0, 25)
85
94
  .map((project) => {
86
- const name = `${path.basename(project.worktree)} (${project.worktree})`;
95
+ const name = `${path.basename(project.worktree)} (${abbreviatePath(project.worktree)})`;
87
96
  return {
88
97
  name: name.length > 100 ? name.slice(0, 99) + '…' : name,
89
98
  value: project.id,
@@ -1,8 +1,8 @@
1
1
  // /create-new-project command - Create a new project folder, initialize git, and start a session.
2
2
  import { ChannelType } from 'discord.js';
3
3
  import fs from 'node:fs';
4
- import os from 'node:os';
5
4
  import path from 'node:path';
5
+ import { getProjectsDir } from '../config.js';
6
6
  import { createProjectChannels } from '../channel-management.js';
7
7
  import { handleOpencodeSession } from '../session-handler.js';
8
8
  import { SILENT_MESSAGE_FLAGS } from '../discord-utils.js';
@@ -31,12 +31,12 @@ export async function handleCreateNewProjectCommand({ command, appId, }) {
31
31
  await command.editReply('Invalid project name');
32
32
  return;
33
33
  }
34
- const kimakiDir = path.join(os.homedir(), 'kimaki');
35
- const projectDirectory = path.join(kimakiDir, sanitizedName);
34
+ const projectsDir = getProjectsDir();
35
+ const projectDirectory = path.join(projectsDir, sanitizedName);
36
36
  try {
37
- if (!fs.existsSync(kimakiDir)) {
38
- fs.mkdirSync(kimakiDir, { recursive: true });
39
- logger.log(`Created kimaki directory: ${kimakiDir}`);
37
+ if (!fs.existsSync(projectsDir)) {
38
+ fs.mkdirSync(projectsDir, { recursive: true });
39
+ logger.log(`Created projects directory: ${projectsDir}`);
40
40
  }
41
41
  if (fs.existsSync(projectDirectory)) {
42
42
  await command.editReply(`Project directory already exists: ${projectDirectory}`);
@@ -3,7 +3,7 @@
3
3
  // with Accept, Accept Always, and Deny options.
4
4
  import { StringSelectMenuBuilder, StringSelectMenuInteraction, ActionRowBuilder, } from 'discord.js';
5
5
  import crypto from 'node:crypto';
6
- import { initializeOpencodeForDirectory } from '../opencode.js';
6
+ import { getOpencodeClientV2 } from '../opencode.js';
7
7
  import { NOTIFY_MESSAGE_FLAGS } from '../discord-utils.js';
8
8
  import { createLogger } from '../logger.js';
9
9
  const logger = createLogger('PERMISSIONS');
@@ -76,13 +76,13 @@ export async function handlePermissionSelectMenu(interaction) {
76
76
  await interaction.deferUpdate();
77
77
  const response = interaction.values[0];
78
78
  try {
79
- const getClient = await initializeOpencodeForDirectory(context.directory);
80
- await getClient().postSessionIdPermissionsPermissionId({
81
- path: {
82
- id: context.permission.sessionID,
83
- permissionID: context.permission.id,
84
- },
85
- body: { response },
79
+ const clientV2 = getOpencodeClientV2(context.directory);
80
+ if (!clientV2) {
81
+ throw new Error('OpenCode server not found for directory');
82
+ }
83
+ await clientV2.permission.reply({
84
+ requestID: context.permission.id,
85
+ reply: response,
86
86
  });
87
87
  pendingPermissionContexts.delete(contextHash);
88
88
  // Update message: show result and remove dropdown
package/dist/config.js ADDED
@@ -0,0 +1,59 @@
1
+ // Runtime configuration for Kimaki bot.
2
+ // Stores data directory path and provides accessors for other modules.
3
+ // Must be initialized before database or other path-dependent modules are used.
4
+ import fs from 'node:fs';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+ const DEFAULT_DATA_DIR = path.join(os.homedir(), '.kimaki');
8
+ let dataDir = null;
9
+ /**
10
+ * Get the data directory path.
11
+ * Falls back to ~/.kimaki if not explicitly set.
12
+ */
13
+ export function getDataDir() {
14
+ if (!dataDir) {
15
+ dataDir = DEFAULT_DATA_DIR;
16
+ }
17
+ return dataDir;
18
+ }
19
+ /**
20
+ * Set the data directory path.
21
+ * Creates the directory if it doesn't exist.
22
+ * Must be called before any database or path-dependent operations.
23
+ */
24
+ export function setDataDir(dir) {
25
+ const resolvedDir = path.resolve(dir);
26
+ if (!fs.existsSync(resolvedDir)) {
27
+ fs.mkdirSync(resolvedDir, { recursive: true });
28
+ }
29
+ dataDir = resolvedDir;
30
+ }
31
+ /**
32
+ * Get the projects directory path (for /create-new-project command).
33
+ * Returns <dataDir>/projects
34
+ */
35
+ export function getProjectsDir() {
36
+ return path.join(getDataDir(), 'projects');
37
+ }
38
+ const DEFAULT_LOCK_PORT = 29988;
39
+ /**
40
+ * Derive a lock port from the data directory path.
41
+ * Returns 29988 for the default ~/.kimaki directory (backwards compatible).
42
+ * For custom data dirs, uses a hash to generate a port in the range 30000-39999.
43
+ */
44
+ export function getLockPort() {
45
+ const dir = getDataDir();
46
+ // Use original port for default data dir (backwards compatible)
47
+ if (dir === DEFAULT_DATA_DIR) {
48
+ return DEFAULT_LOCK_PORT;
49
+ }
50
+ // Hash-based port for custom data dirs
51
+ let hash = 0;
52
+ for (let i = 0; i < dir.length; i++) {
53
+ const char = dir.charCodeAt(i);
54
+ hash = ((hash << 5) - hash) + char;
55
+ hash = hash & hash; // Convert to 32bit integer
56
+ }
57
+ // Map to port range 30000-39999
58
+ return 30000 + (Math.abs(hash) % 10000);
59
+ }
package/dist/database.js CHANGED
@@ -1,23 +1,23 @@
1
1
  // SQLite database manager for persistent bot state.
2
2
  // Stores thread-session mappings, bot tokens, channel directories,
3
- // API keys, and model preferences in ~/.kimaki/discord-sessions.db.
3
+ // API keys, and model preferences in <dataDir>/discord-sessions.db.
4
4
  import Database from 'better-sqlite3';
5
5
  import fs from 'node:fs';
6
- import os from 'node:os';
7
6
  import path from 'node:path';
8
7
  import { createLogger } from './logger.js';
8
+ import { getDataDir } from './config.js';
9
9
  const dbLogger = createLogger('DB');
10
10
  let db = null;
11
11
  export function getDatabase() {
12
12
  if (!db) {
13
- const kimakiDir = path.join(os.homedir(), '.kimaki');
13
+ const dataDir = getDataDir();
14
14
  try {
15
- fs.mkdirSync(kimakiDir, { recursive: true });
15
+ fs.mkdirSync(dataDir, { recursive: true });
16
16
  }
17
17
  catch (error) {
18
- dbLogger.error('Failed to create ~/.kimaki directory:', error);
18
+ dbLogger.error(`Failed to create data directory ${dataDir}:`, error);
19
19
  }
20
- const dbPath = path.join(kimakiDir, 'discord-sessions.db');
20
+ const dbPath = path.join(dataDir, 'discord-sessions.db');
21
21
  dbLogger.log(`Opening database at: ${dbPath}`);
22
22
  db = new Database(dbPath);
23
23
  db.exec(`
@@ -136,13 +136,13 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
136
136
  if (pendingPerm) {
137
137
  try {
138
138
  sessionLogger.log(`[PERMISSION] Auto-rejecting pending permission ${pendingPerm.permission.id} due to new message`);
139
- await getClient().postSessionIdPermissionsPermissionId({
140
- path: {
141
- id: pendingPerm.permission.sessionID,
142
- permissionID: pendingPerm.permission.id,
143
- },
144
- body: { response: 'reject' },
145
- });
139
+ const clientV2 = getOpencodeClientV2(directory);
140
+ if (clientV2) {
141
+ await clientV2.permission.reply({
142
+ requestID: pendingPerm.permission.id,
143
+ reply: 'reject',
144
+ });
145
+ }
146
146
  // Clean up both the pending permission and its dropdown context
147
147
  cleanupPermissionContext(pendingPerm.contextHash);
148
148
  pendingPermissions.delete(thread.id);
package/dist/utils.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // General utility functions for the bot.
2
2
  // Includes Discord OAuth URL generation, array deduplication,
3
3
  // abort error detection, and date/time formatting helpers.
4
+ import os from 'node:os';
4
5
  import { PermissionsBitField } from 'discord.js';
5
6
  export function generateBotInstallUrl({ clientId, permissions = [
6
7
  PermissionsBitField.Flags.ViewChannel,
@@ -83,3 +84,10 @@ const dtf = new Intl.DateTimeFormat('en-US', {
83
84
  export function formatDateTime(date) {
84
85
  return dtf.format(date);
85
86
  }
87
+ export function abbreviatePath(fullPath) {
88
+ const home = os.homedir();
89
+ if (fullPath.startsWith(home)) {
90
+ return '~' + fullPath.slice(home.length);
91
+ }
92
+ return fullPath;
93
+ }
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "kimaki",
3
3
  "module": "index.ts",
4
4
  "type": "module",
5
- "version": "0.4.27",
5
+ "version": "0.4.29",
6
6
  "scripts": {
7
7
  "dev": "tsx --env-file .env src/cli.ts",
8
8
  "prepublishOnly": "pnpm tsc",
package/src/cli.ts CHANGED
@@ -16,7 +16,7 @@ import {
16
16
  multiselect,
17
17
  spinner,
18
18
  } from '@clack/prompts'
19
- import { deduplicateByKey, generateBotInstallUrl } from './utils.js'
19
+ import { deduplicateByKey, generateBotInstallUrl, abbreviatePath } from './utils.js'
20
20
  import {
21
21
  getChannelsWithDescriptions,
22
22
  createDiscordClient,
@@ -45,14 +45,13 @@ import fs from 'node:fs'
45
45
  import { createLogger } from './logger.js'
46
46
  import { spawn, spawnSync, execSync, type ExecSyncOptions } from 'node:child_process'
47
47
  import http from 'node:http'
48
+ import { setDataDir, getDataDir, getLockPort } from './config.js'
48
49
 
49
50
  const cliLogger = createLogger('CLI')
50
51
  const cli = cac('kimaki')
51
52
 
52
53
  process.title = 'kimaki'
53
54
 
54
- const LOCK_PORT = 29988
55
-
56
55
  async function killProcessOnPort(port: number): Promise<boolean> {
57
56
  const isWindows = process.platform === 'win32'
58
57
  const myPid = process.pid
@@ -95,13 +94,14 @@ async function killProcessOnPort(port: number): Promise<boolean> {
95
94
  }
96
95
 
97
96
  async function checkSingleInstance(): Promise<void> {
97
+ const lockPort = getLockPort()
98
98
  try {
99
- const response = await fetch(`http://127.0.0.1:${LOCK_PORT}`, {
99
+ const response = await fetch(`http://127.0.0.1:${lockPort}`, {
100
100
  signal: AbortSignal.timeout(1000),
101
101
  })
102
102
  if (response.ok) {
103
- cliLogger.log('Another kimaki instance detected')
104
- await killProcessOnPort(LOCK_PORT)
103
+ cliLogger.log(`Another kimaki instance detected for data dir: ${getDataDir()}`)
104
+ await killProcessOnPort(lockPort)
105
105
  // Wait a moment for port to be released
106
106
  await new Promise((resolve) => { setTimeout(resolve, 500) })
107
107
  }
@@ -111,22 +111,24 @@ async function checkSingleInstance(): Promise<void> {
111
111
  }
112
112
 
113
113
  async function startLockServer(): Promise<void> {
114
+ const lockPort = getLockPort()
114
115
  return new Promise((resolve, reject) => {
115
116
  const server = http.createServer((req, res) => {
116
117
  res.writeHead(200)
117
118
  res.end('kimaki')
118
119
  })
119
- server.listen(LOCK_PORT, '127.0.0.1')
120
+ server.listen(lockPort, '127.0.0.1')
120
121
  server.once('listening', () => {
122
+ cliLogger.debug(`Lock server started on port ${lockPort}`)
121
123
  resolve()
122
124
  })
123
125
  server.on('error', async (err: NodeJS.ErrnoException) => {
124
126
  if (err.code === 'EADDRINUSE') {
125
127
  cliLogger.log('Port still in use, retrying...')
126
- await killProcessOnPort(LOCK_PORT)
128
+ await killProcessOnPort(lockPort)
127
129
  await new Promise((r) => { setTimeout(r, 500) })
128
130
  // Retry once
129
- server.listen(LOCK_PORT, '127.0.0.1')
131
+ server.listen(lockPort, '127.0.0.1')
130
132
  } else {
131
133
  reject(err)
132
134
  }
@@ -151,6 +153,7 @@ type Project = {
151
153
  type CliOptions = {
152
154
  restart?: boolean
153
155
  addChannels?: boolean
156
+ dataDir?: string
154
157
  }
155
158
 
156
159
  // Commands to skip when registering user commands (reserved names)
@@ -644,7 +647,15 @@ async function run({ restart, addChannels }: CliOptions) {
644
647
  )
645
648
 
646
649
  const availableProjects = deduplicateByKey(
647
- projects.filter((project) => !existingDirs.includes(project.worktree)),
650
+ projects.filter((project) => {
651
+ if (existingDirs.includes(project.worktree)) {
652
+ return false
653
+ }
654
+ if (path.basename(project.worktree).startsWith('opencode-test-')) {
655
+ return false
656
+ }
657
+ return true
658
+ }),
648
659
  (x) => x.worktree,
649
660
  )
650
661
 
@@ -663,7 +674,7 @@ async function run({ restart, addChannels }: CliOptions) {
663
674
  message: 'Select projects to create Discord channels for:',
664
675
  options: availableProjects.map((project) => ({
665
676
  value: project.id,
666
- label: `${path.basename(project.worktree)} (${project.worktree})`,
677
+ label: `${path.basename(project.worktree)} (${abbreviatePath(project.worktree)})`,
667
678
  })),
668
679
  required: false,
669
680
  })
@@ -823,13 +834,24 @@ cli
823
834
  '--add-channels',
824
835
  'Select OpenCode projects to create Discord channels before starting',
825
836
  )
826
- .action(async (options: { restart?: boolean; addChannels?: boolean }) => {
837
+ .option(
838
+ '--data-dir <path>',
839
+ 'Data directory for config and database (default: ~/.kimaki)',
840
+ )
841
+ .action(async (options: { restart?: boolean; addChannels?: boolean; dataDir?: string }) => {
827
842
  try {
843
+ // Set data directory early, before any database access
844
+ if (options.dataDir) {
845
+ setDataDir(options.dataDir)
846
+ cliLogger.log(`Using data directory: ${getDataDir()}`)
847
+ }
848
+
828
849
  await checkSingleInstance()
829
850
  await startLockServer()
830
851
  await run({
831
852
  restart: options.restart,
832
853
  addChannels: options.addChannels,
854
+ dataDir: options.dataDir,
833
855
  })
834
856
  } catch (error) {
835
857
  cliLogger.error(
@@ -7,6 +7,7 @@ import { getDatabase } from '../database.js'
7
7
  import { initializeOpencodeForDirectory } from '../opencode.js'
8
8
  import { createProjectChannels } from '../channel-management.js'
9
9
  import { createLogger } from '../logger.js'
10
+ import { abbreviatePath } from '../utils.js'
10
11
 
11
12
  const logger = createLogger('ADD-PROJECT')
12
13
 
@@ -107,9 +108,15 @@ export async function handleAddProjectAutocomplete({
107
108
  .all('text') as { directory: string }[]
108
109
  const existingDirSet = new Set(existingDirs.map((row) => row.directory))
109
110
 
110
- const availableProjects = projectsResponse.data.filter(
111
- (project) => !existingDirSet.has(project.worktree),
112
- )
111
+ const availableProjects = projectsResponse.data.filter((project) => {
112
+ if (existingDirSet.has(project.worktree)) {
113
+ return false
114
+ }
115
+ if (path.basename(project.worktree).startsWith('opencode-test-')) {
116
+ return false
117
+ }
118
+ return true
119
+ })
113
120
 
114
121
  const projects = availableProjects
115
122
  .filter((project) => {
@@ -124,7 +131,7 @@ export async function handleAddProjectAutocomplete({
124
131
  })
125
132
  .slice(0, 25)
126
133
  .map((project) => {
127
- const name = `${path.basename(project.worktree)} (${project.worktree})`
134
+ const name = `${path.basename(project.worktree)} (${abbreviatePath(project.worktree)})`
128
135
  return {
129
136
  name: name.length > 100 ? name.slice(0, 99) + '…' : name,
130
137
  value: project.id,
@@ -2,9 +2,9 @@
2
2
 
3
3
  import { ChannelType, type TextChannel } from 'discord.js'
4
4
  import fs from 'node:fs'
5
- import os from 'node:os'
6
5
  import path from 'node:path'
7
6
  import type { CommandContext } from './types.js'
7
+ import { getProjectsDir } from '../config.js'
8
8
  import { createProjectChannels } from '../channel-management.js'
9
9
  import { handleOpencodeSession } from '../session-handler.js'
10
10
  import { SILENT_MESSAGE_FLAGS } from '../discord-utils.js'
@@ -44,13 +44,13 @@ export async function handleCreateNewProjectCommand({
44
44
  return
45
45
  }
46
46
 
47
- const kimakiDir = path.join(os.homedir(), 'kimaki')
48
- const projectDirectory = path.join(kimakiDir, sanitizedName)
47
+ const projectsDir = getProjectsDir()
48
+ const projectDirectory = path.join(projectsDir, sanitizedName)
49
49
 
50
50
  try {
51
- if (!fs.existsSync(kimakiDir)) {
52
- fs.mkdirSync(kimakiDir, { recursive: true })
53
- logger.log(`Created kimaki directory: ${kimakiDir}`)
51
+ if (!fs.existsSync(projectsDir)) {
52
+ fs.mkdirSync(projectsDir, { recursive: true })
53
+ logger.log(`Created projects directory: ${projectsDir}`)
54
54
  }
55
55
 
56
56
  if (fs.existsSync(projectDirectory)) {
@@ -10,7 +10,7 @@ import {
10
10
  } from 'discord.js'
11
11
  import crypto from 'node:crypto'
12
12
  import type { PermissionRequest } from '@opencode-ai/sdk/v2'
13
- import { initializeOpencodeForDirectory } from '../opencode.js'
13
+ import { getOpencodeClientV2 } from '../opencode.js'
14
14
  import { NOTIFY_MESSAGE_FLAGS } from '../discord-utils.js'
15
15
  import { createLogger } from '../logger.js'
16
16
 
@@ -120,13 +120,13 @@ export async function handlePermissionSelectMenu(
120
120
  const response = interaction.values[0] as 'once' | 'always' | 'reject'
121
121
 
122
122
  try {
123
- const getClient = await initializeOpencodeForDirectory(context.directory)
124
- await getClient().postSessionIdPermissionsPermissionId({
125
- path: {
126
- id: context.permission.sessionID,
127
- permissionID: context.permission.id,
128
- },
129
- body: { response },
123
+ const clientV2 = getOpencodeClientV2(context.directory)
124
+ if (!clientV2) {
125
+ throw new Error('OpenCode server not found for directory')
126
+ }
127
+ await clientV2.permission.reply({
128
+ requestID: context.permission.id,
129
+ reply: response,
130
130
  })
131
131
 
132
132
  pendingPermissionContexts.delete(contextHash)
package/src/config.ts ADDED
@@ -0,0 +1,71 @@
1
+ // Runtime configuration for Kimaki bot.
2
+ // Stores data directory path and provides accessors for other modules.
3
+ // Must be initialized before database or other path-dependent modules are used.
4
+
5
+ import fs from 'node:fs'
6
+ import os from 'node:os'
7
+ import path from 'node:path'
8
+
9
+ const DEFAULT_DATA_DIR = path.join(os.homedir(), '.kimaki')
10
+
11
+ let dataDir: string | null = null
12
+
13
+ /**
14
+ * Get the data directory path.
15
+ * Falls back to ~/.kimaki if not explicitly set.
16
+ */
17
+ export function getDataDir(): string {
18
+ if (!dataDir) {
19
+ dataDir = DEFAULT_DATA_DIR
20
+ }
21
+ return dataDir
22
+ }
23
+
24
+ /**
25
+ * Set the data directory path.
26
+ * Creates the directory if it doesn't exist.
27
+ * Must be called before any database or path-dependent operations.
28
+ */
29
+ export function setDataDir(dir: string): void {
30
+ const resolvedDir = path.resolve(dir)
31
+
32
+ if (!fs.existsSync(resolvedDir)) {
33
+ fs.mkdirSync(resolvedDir, { recursive: true })
34
+ }
35
+
36
+ dataDir = resolvedDir
37
+ }
38
+
39
+ /**
40
+ * Get the projects directory path (for /create-new-project command).
41
+ * Returns <dataDir>/projects
42
+ */
43
+ export function getProjectsDir(): string {
44
+ return path.join(getDataDir(), 'projects')
45
+ }
46
+
47
+ const DEFAULT_LOCK_PORT = 29988
48
+
49
+ /**
50
+ * Derive a lock port from the data directory path.
51
+ * Returns 29988 for the default ~/.kimaki directory (backwards compatible).
52
+ * For custom data dirs, uses a hash to generate a port in the range 30000-39999.
53
+ */
54
+ export function getLockPort(): number {
55
+ const dir = getDataDir()
56
+
57
+ // Use original port for default data dir (backwards compatible)
58
+ if (dir === DEFAULT_DATA_DIR) {
59
+ return DEFAULT_LOCK_PORT
60
+ }
61
+
62
+ // Hash-based port for custom data dirs
63
+ let hash = 0
64
+ for (let i = 0; i < dir.length; i++) {
65
+ const char = dir.charCodeAt(i)
66
+ hash = ((hash << 5) - hash) + char
67
+ hash = hash & hash // Convert to 32bit integer
68
+ }
69
+ // Map to port range 30000-39999
70
+ return 30000 + (Math.abs(hash) % 10000)
71
+ }
package/src/database.ts CHANGED
@@ -1,12 +1,12 @@
1
1
  // SQLite database manager for persistent bot state.
2
2
  // Stores thread-session mappings, bot tokens, channel directories,
3
- // API keys, and model preferences in ~/.kimaki/discord-sessions.db.
3
+ // API keys, and model preferences in <dataDir>/discord-sessions.db.
4
4
 
5
5
  import Database from 'better-sqlite3'
6
6
  import fs from 'node:fs'
7
- import os from 'node:os'
8
7
  import path from 'node:path'
9
8
  import { createLogger } from './logger.js'
9
+ import { getDataDir } from './config.js'
10
10
 
11
11
  const dbLogger = createLogger('DB')
12
12
 
@@ -14,15 +14,15 @@ let db: Database.Database | null = null
14
14
 
15
15
  export function getDatabase(): Database.Database {
16
16
  if (!db) {
17
- const kimakiDir = path.join(os.homedir(), '.kimaki')
17
+ const dataDir = getDataDir()
18
18
 
19
19
  try {
20
- fs.mkdirSync(kimakiDir, { recursive: true })
20
+ fs.mkdirSync(dataDir, { recursive: true })
21
21
  } catch (error) {
22
- dbLogger.error('Failed to create ~/.kimaki directory:', error)
22
+ dbLogger.error(`Failed to create data directory ${dataDir}:`, error)
23
23
  }
24
24
 
25
- const dbPath = path.join(kimakiDir, 'discord-sessions.db')
25
+ const dbPath = path.join(dataDir, 'discord-sessions.db')
26
26
 
27
27
  dbLogger.log(`Opening database at: ${dbPath}`)
28
28
  db = new Database(dbPath)
@@ -221,13 +221,13 @@ export async function handleOpencodeSession({
221
221
  if (pendingPerm) {
222
222
  try {
223
223
  sessionLogger.log(`[PERMISSION] Auto-rejecting pending permission ${pendingPerm.permission.id} due to new message`)
224
- await getClient().postSessionIdPermissionsPermissionId({
225
- path: {
226
- id: pendingPerm.permission.sessionID,
227
- permissionID: pendingPerm.permission.id,
228
- },
229
- body: { response: 'reject' },
230
- })
224
+ const clientV2 = getOpencodeClientV2(directory)
225
+ if (clientV2) {
226
+ await clientV2.permission.reply({
227
+ requestID: pendingPerm.permission.id,
228
+ reply: 'reject',
229
+ })
230
+ }
231
231
  // Clean up both the pending permission and its dropdown context
232
232
  cleanupPermissionContext(pendingPerm.contextHash)
233
233
  pendingPermissions.delete(thread.id)
package/src/utils.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  // Includes Discord OAuth URL generation, array deduplication,
3
3
  // abort error detection, and date/time formatting helpers.
4
4
 
5
+ import os from 'node:os'
5
6
  import { PermissionsBitField } from 'discord.js'
6
7
 
7
8
  type GenerateInstallUrlOptions = {
@@ -116,3 +117,11 @@ const dtf = new Intl.DateTimeFormat('en-US', {
116
117
  export function formatDateTime(date: Date): string {
117
118
  return dtf.format(date)
118
119
  }
120
+
121
+ export function abbreviatePath(fullPath: string): string {
122
+ const home = os.homedir()
123
+ if (fullPath.startsWith(home)) {
124
+ return '~' + fullPath.slice(home.length)
125
+ }
126
+ return fullPath
127
+ }