suemo 0.1.3 → 0.1.6

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.
@@ -12,6 +12,7 @@ import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, wr
12
12
  import { tmpdir } from 'node:os'
13
13
  import { basename, dirname, join, resolve as resolvePath } from 'node:path'
14
14
 
15
+ import OPENCODE_AGENTS_SNIPPET_TEXT from '@/src/AGENTS.md' with { type: 'text' }
15
16
  import template from '@/src/config.template' with { type: 'text' }
16
17
  import FASTEMBED_SCRIPT_TEXT from '@/src/embedding/fastembed-server.py' with { type: 'text' }
17
18
 
@@ -32,13 +33,30 @@ const init = app.sub('init')
32
33
  const SURREAL_PROFILES_DIR = '/opt/suemo/surreal'
33
34
  const SURREAL_LOCAL_ENV_PATH = '/opt/suemo/surreal/local.env'
34
35
  const SURREAL_SYSTEMD_UNIT_PATH = '/etc/systemd/system/suemo-surreal@.service'
36
+ const SURREAL_RUNIT_SERVICE_BASE_DIR = '/etc/sv'
37
+ const SURREAL_SERVICE_NAME_PREFIX = 'suemo-surreal'
35
38
  const SURREAL_DATA_DIR = '/var/lib/surrealdb'
36
39
  const FASTEMBED_INSTALL_DIR = '/opt/suemo'
37
- const FASTEMBED_LOCAL_ENV_DIR = '/opt/fastembed'
40
+ const FASTEMBED_LOCAL_ENV_DIR = '/opt/suemo/fastembed'
41
+ const FASTEMBED_COMMON_ENV_PATH = '/opt/suemo/fastembed/common.env'
38
42
  const FASTEMBED_SCRIPT_TARGET = '/opt/suemo/fastembed-server.py'
39
43
  const FASTEMBED_CACHE_DIR = '/var/cache/fastembed'
40
44
  const FASTEMBED_SERVICE_PATH = '/etc/systemd/system/suemo-fastembed.service'
45
+ const FASTEMBED_RUNIT_SERVICE_BASE_DIR = '/etc/sv'
46
+ const FASTEMBED_SERVICE_NAME = 'suemo-fastembed'
41
47
  const FASTEMBED_USER_HOME = '/var/lib/fastembed'
48
+ const RUNIT_SERVICE_DIR_CANDIDATES = ['/var/service', '/service', '/run/runit/service'] as const
49
+ const DEFAULT_RUNIT_SERVICE_DIR = '/var/service'
50
+
51
+ const SUEMO_AGENTS_START_MARKER = '<!-- SUEMO:START -->'
52
+ const SUEMO_AGENTS_END_MARKER = '<!-- SUEMO:END -->'
53
+
54
+ const FASTEMBED_DEFAULT_ENV = {
55
+ FASTEMBED_HOST: '127.0.0.1',
56
+ FASTEMBED_PORT: '8080',
57
+ FASTEMBED_MODEL: 'sentence-transformers/all-MiniLM-L6-v2',
58
+ FASTEMBED_CACHE_DIR: '/var/cache/fastembed',
59
+ }
42
60
 
43
61
  const SURREAL_TEMPLATE_UNIT = `# Generated by suemo init surreal
44
62
  [Unit]
@@ -118,7 +136,7 @@ SURREAL_TRANSACTION_TIMEOUT=15s
118
136
 
119
137
  # Capability allowlist (required for suemo workflows)
120
138
  SURREAL_CAPS_DENY_ALL=true
121
- SURREAL_CAPS_ALLOW_FUNC=fn,time,vector,search,math,rand,ml
139
+ SURREAL_CAPS_ALLOW_FUNC=fn,time,vector,search,math,rand,ml,array
122
140
 
123
141
  # Logging
124
142
  SURREAL_LOG=warn
@@ -165,16 +183,19 @@ SURREAL_EXTERNAL_SORTING_BUFFER_LIMIT=25000
165
183
  SURREAL_MEMORY_THRESHOLD=512m
166
184
  `
167
185
 
168
- const FASTEMBED_LOCAL_ENV_PATH = '/opt/fastembed/local.env'
186
+ const FASTEMBED_LOCAL_ENV_PATH = '/opt/suemo/fastembed/local.env'
187
+
188
+ const FASTEMBED_COMMON_ENV_TEMPLATE = `# Generated by suemo init fastembed
189
+ # DO NOT edit - managed by suemo
190
+
191
+ ${Object.entries(FASTEMBED_DEFAULT_ENV).map(([key, value]) => `${key}=${value}`).join('\n')}
192
+ `
169
193
 
170
194
  const FASTEMBED_LOCAL_ENV_TEMPLATE = `# User overrides - edit this file to customize settings
171
195
  # This file is NOT overwritten by suemo init fastembed
172
196
  # Uncomment and set values to override defaults
173
197
 
174
- #FASTEMBED_HOST=127.0.0.1
175
- #FASTEMBED_PORT=8080
176
- #FASTEMBED_MODEL=sentence-transformers/all-MiniLM-L6-v2
177
- #FASTEMBED_CACHE_DIR=/var/cache/fastembed
198
+ ${Object.entries(FASTEMBED_DEFAULT_ENV).map(([key, value]) => `#${key}=${value}`).join('\n')}
178
199
  `
179
200
 
180
201
  const FASTEMBED_SYSTEMD_SERVICE = `# Generated by suemo init fastembed
@@ -188,11 +209,8 @@ AmbientCapabilities=CAP_NET_BIND_SERVICE
188
209
  User=fastembed
189
210
  Group=fastembed
190
211
  WorkingDirectory=/opt/suemo
191
- Environment=FASTEMBED_HOST=127.0.0.1
192
- Environment=FASTEMBED_PORT=8080
193
- Environment=FASTEMBED_MODEL=sentence-transformers/all-MiniLM-L6-v2
194
- Environment=FASTEMBED_CACHE_DIR=/var/cache/fastembed
195
- EnvironmentFile=-/opt/fastembed/local.env
212
+ EnvironmentFile=/opt/suemo/fastembed/common.env
213
+ EnvironmentFile=-/opt/suemo/fastembed/local.env
196
214
  ExecStart=/usr/bin/python /opt/suemo/fastembed-server.py
197
215
  Restart=on-failure
198
216
  RestartSec=5s
@@ -200,7 +218,7 @@ NoNewPrivileges=true
200
218
  PrivateTmp=true
201
219
  ProtectSystem=strict
202
220
  ProtectHome=true
203
- ReadWritePaths=/var/cache/fastembed
221
+ ReadWritePaths=/var/cache/fastembed /var/lib/fastembed
204
222
  StandardOutput=journal
205
223
  StandardError=journal
206
224
  SyslogIdentifier=suemo-fastembed
@@ -209,6 +227,50 @@ SyslogIdentifier=suemo-fastembed
209
227
  WantedBy=multi-user.target
210
228
  `
211
229
 
230
+ const SURREAL_RUNIT_RUN_TEMPLATE = `#!/bin/sh
231
+ set -a
232
+ . /opt/suemo/surreal/common.env
233
+ . /opt/suemo/surreal/__PROFILE__.env
234
+ [ -r /opt/suemo/surreal/local.env ] && . /opt/suemo/surreal/local.env
235
+ set +a
236
+ exec 2>&1
237
+ exec chpst -u surrealdb:surrealdb /usr/bin/surreal start
238
+ `
239
+
240
+ const FASTEMBED_RUNIT_RUN_TEMPLATE = `#!/bin/sh
241
+ set -a
242
+ . /opt/suemo/fastembed/common.env
243
+ [ -r /opt/suemo/fastembed/local.env ] && . /opt/suemo/fastembed/local.env
244
+ set +a
245
+ exec 2>&1
246
+ exec chpst -u fastembed:fastembed /usr/bin/python /opt/suemo/fastembed-server.py
247
+ `
248
+
249
+ type ServiceManager =
250
+ | { kind: 'systemd' }
251
+ | { kind: 'runit'; serviceDir: string }
252
+
253
+ type OpenCodeConfigMode = 'mcp'
254
+ type OpenCodeConfigStatus = 'added' | 'already-present' | 'unchanged'
255
+ type OpenCodeAgentsStatus = 'added' | 'updated' | 'unchanged'
256
+
257
+ interface OpenCodeConfigResolution {
258
+ path: string
259
+ format: 'jsonc' | 'json'
260
+ existed: boolean
261
+ }
262
+
263
+ interface OpenCodeInitResult {
264
+ openCodeDir: string
265
+ configPath: string
266
+ configFormat: 'jsonc' | 'json'
267
+ configStatus: OpenCodeConfigStatus
268
+ configMode: OpenCodeConfigMode
269
+ agentsPath: string
270
+ agentsStatus: OpenCodeAgentsStatus
271
+ dryRun: boolean
272
+ }
273
+
212
274
  type InitAction =
213
275
  | {
214
276
  kind: 'mkdir'
@@ -233,6 +295,12 @@ type CredentialStatus = 'preserved' | 'generated'
233
295
  interface SurrealInitResult {
234
296
  actions: InitAction[]
235
297
  credentialStatus: CredentialStatus
298
+ serviceName: string
299
+ }
300
+
301
+ interface FastembedInitResult {
302
+ actions: InitAction[]
303
+ serviceName: string
236
304
  }
237
305
 
238
306
  function modeText(mode: number): string {
@@ -285,6 +353,269 @@ function requireArchPackages(packages: string[]): void {
285
353
  }
286
354
  }
287
355
 
356
+ function detectServiceManager(): ServiceManager {
357
+ const hasSystemd = commandExists('systemctl') && existsSync('/run/systemd/system')
358
+ if (hasSystemd) return { kind: 'systemd' }
359
+
360
+ const runitServiceDir = RUNIT_SERVICE_DIR_CANDIDATES.find((candidate) => existsSync(candidate))
361
+ const hasRunitTooling = commandExists('sv') || commandExists('runsvdir') || commandExists('chpst')
362
+ if (runitServiceDir || hasRunitTooling) {
363
+ return {
364
+ kind: 'runit',
365
+ serviceDir: runitServiceDir ?? DEFAULT_RUNIT_SERVICE_DIR,
366
+ }
367
+ }
368
+
369
+ throw new Error(
370
+ 'systemd is not detected. Install runit first (e.g. `sudo pacman -S runit`) and ensure /var/service (or /service) exists.',
371
+ )
372
+ }
373
+
374
+ function ensureObjectValue(
375
+ container: Record<string, unknown>,
376
+ key: string,
377
+ defaultValue: Record<string, unknown>,
378
+ ): Record<string, unknown> {
379
+ const existing = container[key]
380
+ if (existing && typeof existing === 'object' && !Array.isArray(existing)) {
381
+ return existing as Record<string, unknown>
382
+ }
383
+ container[key] = defaultValue
384
+ return defaultValue
385
+ }
386
+
387
+ function stripJsonComments(input: string): string {
388
+ let out = ''
389
+ let inString = false
390
+ let escaped = false
391
+ let inLineComment = false
392
+ let inBlockComment = false
393
+
394
+ for (let i = 0; i < input.length; i += 1) {
395
+ const ch = input[i]!
396
+ const next = input[i + 1]
397
+
398
+ if (inLineComment) {
399
+ if (ch === '\n') {
400
+ inLineComment = false
401
+ out += ch
402
+ }
403
+ continue
404
+ }
405
+
406
+ if (inBlockComment) {
407
+ if (ch === '*' && next === '/') {
408
+ inBlockComment = false
409
+ i += 1
410
+ }
411
+ continue
412
+ }
413
+
414
+ if (inString) {
415
+ out += ch
416
+ if (escaped) {
417
+ escaped = false
418
+ continue
419
+ }
420
+ if (ch === '\\') {
421
+ escaped = true
422
+ continue
423
+ }
424
+ if (ch === '"') {
425
+ inString = false
426
+ }
427
+ continue
428
+ }
429
+
430
+ if (ch === '"') {
431
+ inString = true
432
+ out += ch
433
+ continue
434
+ }
435
+
436
+ if (ch === '/' && next === '/') {
437
+ inLineComment = true
438
+ i += 1
439
+ continue
440
+ }
441
+
442
+ if (ch === '/' && next === '*') {
443
+ inBlockComment = true
444
+ i += 1
445
+ continue
446
+ }
447
+
448
+ out += ch
449
+ }
450
+
451
+ return out
452
+ }
453
+
454
+ function parseJsoncObject(content: string, filePath: string): Record<string, unknown> {
455
+ const withoutComments = stripJsonComments(content)
456
+ const normalized = withoutComments.replace(/,\s*([}\]])/g, '$1')
457
+ let parsed: unknown
458
+ try {
459
+ parsed = JSON.parse(normalized)
460
+ } catch (error) {
461
+ throw new Error(`Failed to parse ${filePath}: ${error instanceof Error ? error.message : String(error)}`)
462
+ }
463
+
464
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
465
+ throw new Error(`Expected top-level object in ${filePath}`)
466
+ }
467
+
468
+ return parsed as Record<string, unknown>
469
+ }
470
+
471
+ function resolveOpenCodeConfig(openCodeDir: string): OpenCodeConfigResolution {
472
+ const jsoncPath = join(openCodeDir, 'opencode.jsonc')
473
+ if (existsSync(jsoncPath)) {
474
+ return { path: jsoncPath, format: 'jsonc', existed: true }
475
+ }
476
+
477
+ const jsonPath = join(openCodeDir, 'opencode.json')
478
+ if (existsSync(jsonPath)) {
479
+ return { path: jsonPath, format: 'json', existed: true }
480
+ }
481
+
482
+ return { path: jsoncPath, format: 'jsonc', existed: false }
483
+ }
484
+
485
+ function upsertSuemoOpenCodeConfig(config: Record<string, unknown>, configPath: string): {
486
+ status: OpenCodeConfigStatus
487
+ mode: OpenCodeConfigMode
488
+ changed: boolean
489
+ } {
490
+ const defaultCommand = ['suemo', 'serve', '--stdio', '--config', configPath]
491
+ const mcp = ensureObjectValue(config, 'mcp', {})
492
+ const hasMcpSuemo = Boolean(mcp.suemo && typeof mcp.suemo === 'object')
493
+
494
+ const mcpServers = config.mcpServers
495
+ if (mcpServers && typeof mcpServers === 'object' && !Array.isArray(mcpServers)) {
496
+ const servers = mcpServers as Record<string, unknown>
497
+ const legacy = servers.suemo
498
+ if (legacy && typeof legacy === 'object' && !Array.isArray(legacy)) {
499
+ if (!hasMcpSuemo) {
500
+ const legacyRecord = legacy as Record<string, unknown>
501
+ const legacyCommand = legacyRecord.command
502
+ const legacyArgs = legacyRecord.args
503
+ if (
504
+ typeof legacyCommand === 'string' && Array.isArray(legacyArgs)
505
+ && legacyArgs.every((arg) => typeof arg === 'string')
506
+ ) {
507
+ mcp.suemo = {
508
+ type: 'local',
509
+ command: [legacyCommand, ...(legacyArgs as string[])],
510
+ enabled: true,
511
+ }
512
+ } else {
513
+ mcp.suemo = {
514
+ type: 'local',
515
+ command: defaultCommand,
516
+ enabled: true,
517
+ }
518
+ }
519
+ delete servers.suemo
520
+ return { status: 'added', mode: 'mcp', changed: true }
521
+ }
522
+ delete servers.suemo
523
+ return { status: 'already-present', mode: 'mcp', changed: true }
524
+ }
525
+ }
526
+
527
+ if (hasMcpSuemo) {
528
+ return { status: 'already-present', mode: 'mcp', changed: false }
529
+ }
530
+
531
+ mcp.suemo = {
532
+ type: 'local',
533
+ command: defaultCommand,
534
+ enabled: true,
535
+ }
536
+ return { status: 'added', mode: 'mcp', changed: true }
537
+ }
538
+
539
+ function renderAgentsSnippetBlock(): string {
540
+ return [SUEMO_AGENTS_START_MARKER, OPENCODE_AGENTS_SNIPPET_TEXT.trim(), SUEMO_AGENTS_END_MARKER, ''].join('\n')
541
+ }
542
+
543
+ function upsertAgentsSnippet(existing: string): { content: string; status: OpenCodeAgentsStatus } {
544
+ const snippet = renderAgentsSnippetBlock()
545
+ const start = existing.indexOf(SUEMO_AGENTS_START_MARKER)
546
+ const end = existing.indexOf(SUEMO_AGENTS_END_MARKER)
547
+
548
+ if (start !== -1 && end !== -1 && end > start) {
549
+ const blockEnd = end + SUEMO_AGENTS_END_MARKER.length
550
+ const currentBlock = existing.slice(start, blockEnd).trim()
551
+ const nextBlock = snippet.trim()
552
+ if (currentBlock === nextBlock) {
553
+ return { content: existing, status: 'unchanged' }
554
+ }
555
+ const before = existing.slice(0, start).replace(/\s*$/, '\n\n')
556
+ const after = existing.slice(blockEnd).replace(/^\s*/, '\n\n')
557
+ return {
558
+ content: `${before}${snippet}${after}`.replace(/\n{3,}/g, '\n\n').trimEnd() + '\n',
559
+ status: 'updated',
560
+ }
561
+ }
562
+
563
+ if (existing.includes(SUEMO_AGENTS_START_MARKER) || existing.includes(SUEMO_AGENTS_END_MARKER)) {
564
+ const cleaned = existing
565
+ .replace(SUEMO_AGENTS_START_MARKER, '')
566
+ .replace(SUEMO_AGENTS_END_MARKER, '')
567
+ .trimEnd()
568
+ return { content: `${cleaned}\n\n${snippet}`.replace(/\n{3,}/g, '\n\n'), status: 'updated' }
569
+ }
570
+
571
+ if (existing.trim().length === 0) {
572
+ return { content: snippet, status: 'added' }
573
+ }
574
+
575
+ return {
576
+ content: `${existing.trimEnd()}\n\n${snippet}`.replace(/\n{3,}/g, '\n\n'),
577
+ status: 'added',
578
+ }
579
+ }
580
+
581
+ function initOpenCodeFiles(configPath: string, dryRun: boolean): OpenCodeInitResult {
582
+ const home = process.env.HOME ?? process.env.USERPROFILE
583
+ if (!home) throw new Error('HOME/USERPROFILE is not set; cannot resolve OpenCode config path')
584
+
585
+ const openCodeDir = join(home, '.config', 'opencode')
586
+ const configResolution = resolveOpenCodeConfig(openCodeDir)
587
+ const rawConfig = configResolution.existed ? readFileSync(configResolution.path, 'utf-8') : '{}\n'
588
+ const parsedConfig = parseJsoncObject(rawConfig, configResolution.path)
589
+ const configWriteResult = upsertSuemoOpenCodeConfig(parsedConfig, configPath)
590
+ const nextConfigText = `${JSON.stringify(parsedConfig, null, '\t')}\n`
591
+ const configStatus: OpenCodeConfigStatus = configWriteResult.changed ? configWriteResult.status : 'unchanged'
592
+
593
+ const agentsPath = join(openCodeDir, 'AGENTS.md')
594
+ const existingAgents = existsSync(agentsPath) ? readFileSync(agentsPath, 'utf-8') : ''
595
+ const agentsResult = upsertAgentsSnippet(existingAgents)
596
+
597
+ if (!dryRun) {
598
+ mkdirSync(openCodeDir, { recursive: true })
599
+ if (!configResolution.existed || configWriteResult.changed) {
600
+ writeFileSync(configResolution.path, nextConfigText, 'utf-8')
601
+ }
602
+ if (!existsSync(agentsPath) || agentsResult.content !== existingAgents) {
603
+ writeFileSync(agentsPath, agentsResult.content, 'utf-8')
604
+ }
605
+ }
606
+
607
+ return {
608
+ openCodeDir,
609
+ configPath: configResolution.path,
610
+ configFormat: configResolution.format,
611
+ configStatus,
612
+ configMode: configWriteResult.mode,
613
+ agentsPath,
614
+ agentsStatus: agentsResult.status,
615
+ dryRun,
616
+ }
617
+ }
618
+
288
619
  function commandExists(command: string): boolean {
289
620
  const result = spawnSync('sh', ['-lc', `command -v ${command}`], { stdio: 'ignore' })
290
621
  return (result.status ?? 1) === 0
@@ -393,15 +724,38 @@ function extractEnvValue(content: string, key: string): string | null {
393
724
  return match[1].trim()
394
725
  }
395
726
 
727
+ function runitServiceDefinitionPath(baseDir: string, serviceName: string): string {
728
+ return join(baseDir, serviceName)
729
+ }
730
+
731
+ function runitServiceLinkPath(serviceDir: string, serviceName: string): string {
732
+ return join(serviceDir, serviceName)
733
+ }
734
+
735
+ function buildRunitEnableActions(
736
+ baseDir: string,
737
+ serviceName: string,
738
+ manager: Extract<ServiceManager, { kind: 'runit' }>,
739
+ ): InitAction[] {
740
+ const definitionPath = runitServiceDefinitionPath(baseDir, serviceName)
741
+ const linkPath = runitServiceLinkPath(manager.serviceDir, serviceName)
742
+ return [
743
+ { kind: 'mkdir', path: manager.serviceDir, mode: 0o755 },
744
+ { kind: 'run', command: 'ln', args: ['-sfn', definitionPath, linkPath], requireRoot: true },
745
+ ]
746
+ }
747
+
396
748
  function buildSurrealActions(
397
749
  profile: '2gb' | '6gb',
398
750
  existingPassword: string | null,
399
751
  force: boolean,
752
+ manager: ServiceManager,
400
753
  ): SurrealInitResult {
401
754
  const commonEnvPath = join(SURREAL_PROFILES_DIR, 'common.env')
402
755
  const profileEnvPath = join(SURREAL_PROFILES_DIR, `${profile}.env`)
403
756
  const localEnvPath = SURREAL_LOCAL_ENV_PATH
404
757
  const actions: InitAction[] = []
758
+ const runitServiceName = `${SURREAL_SERVICE_NAME_PREFIX}-${profile}`
405
759
 
406
760
  log.debug('buildSurrealActions: start', { profile, existingPassword: existingPassword ? '***' : null, force })
407
761
 
@@ -458,6 +812,32 @@ function buildSurrealActions(
458
812
 
459
813
  const localEnvExists = existsSync(localEnvPath)
460
814
 
815
+ const managerActions: InitAction[] = manager.kind === 'systemd'
816
+ ? [
817
+ { kind: 'write', path: SURREAL_SYSTEMD_UNIT_PATH, mode: 0o644, content: SURREAL_TEMPLATE_UNIT },
818
+ { kind: 'run', command: 'systemctl', args: ['daemon-reload'], requireRoot: true },
819
+ {
820
+ kind: 'run',
821
+ command: 'systemctl',
822
+ args: ['enable', '--now', `suemo-surreal@${profile}.service`],
823
+ requireRoot: true,
824
+ },
825
+ ]
826
+ : [
827
+ {
828
+ kind: 'mkdir',
829
+ path: runitServiceDefinitionPath(SURREAL_RUNIT_SERVICE_BASE_DIR, runitServiceName),
830
+ mode: 0o755,
831
+ },
832
+ {
833
+ kind: 'write',
834
+ path: join(runitServiceDefinitionPath(SURREAL_RUNIT_SERVICE_BASE_DIR, runitServiceName), 'run'),
835
+ mode: 0o755,
836
+ content: SURREAL_RUNIT_RUN_TEMPLATE.replace('__PROFILE__', profile),
837
+ },
838
+ ...buildRunitEnableActions(SURREAL_RUNIT_SERVICE_BASE_DIR, runitServiceName, manager),
839
+ ]
840
+
461
841
  actions.push(
462
842
  { kind: 'mkdir', path: SURREAL_PROFILES_DIR, mode: 0o750 },
463
843
  { kind: 'mkdir', path: SURREAL_DATA_DIR, mode: 0o750 },
@@ -466,7 +846,7 @@ function buildSurrealActions(
466
846
  ...(!localEnvExists
467
847
  ? [{ kind: 'write' as const, path: localEnvPath, mode: 0o644, content: SURREAL_LOCAL_ENV_TEMPLATE }]
468
848
  : []),
469
- { kind: 'write', path: SURREAL_SYSTEMD_UNIT_PATH, mode: 0o644, content: SURREAL_TEMPLATE_UNIT },
849
+ ...managerActions,
470
850
  { kind: 'run', command: 'chown', args: ['-R', 'surrealdb:surrealdb', SURREAL_DATA_DIR], requireRoot: true },
471
851
  {
472
852
  kind: 'run',
@@ -480,22 +860,20 @@ function buildSurrealActions(
480
860
  args: ['root:surrealdb', profileEnvPath],
481
861
  requireRoot: true,
482
862
  },
483
- { kind: 'run', command: 'systemctl', args: ['daemon-reload'], requireRoot: true },
484
- {
485
- kind: 'run',
486
- command: 'systemctl',
487
- args: ['enable', '--now', `suemo-surreal@${profile}.service`],
488
- requireRoot: true,
489
- },
490
863
  )
491
864
 
492
865
  log.debug('buildSurrealActions: complete', { actionCount: actions.length })
493
- return { actions, credentialStatus }
866
+ return {
867
+ actions,
868
+ credentialStatus,
869
+ serviceName: manager.kind === 'systemd' ? `suemo-surreal@${profile}.service` : runitServiceName,
870
+ }
494
871
  }
495
872
 
496
- function buildFastembedActions(scriptContent: string): InitAction[] {
873
+ function buildFastembedActions(scriptContent: string, manager: ServiceManager): FastembedInitResult {
497
874
  const actions: InitAction[] = []
498
875
  const localEnvExists = existsSync(FASTEMBED_LOCAL_ENV_PATH)
876
+ const runitServiceName = FASTEMBED_SERVICE_NAME
499
877
 
500
878
  if (!systemUserExists('fastembed')) {
501
879
  actions.push({
@@ -514,37 +892,69 @@ function buildFastembedActions(scriptContent: string): InitAction[] {
514
892
  })
515
893
  }
516
894
 
895
+ const managerActions: InitAction[] = manager.kind === 'systemd'
896
+ ? [
897
+ { kind: 'write', path: FASTEMBED_SERVICE_PATH, mode: 0o644, content: FASTEMBED_SYSTEMD_SERVICE },
898
+ { kind: 'run', command: 'systemctl', args: ['daemon-reload'], requireRoot: true },
899
+ { kind: 'run', command: 'systemctl', args: ['enable', '--now', FASTEMBED_SERVICE_NAME], requireRoot: true },
900
+ ]
901
+ : [
902
+ {
903
+ kind: 'mkdir',
904
+ path: runitServiceDefinitionPath(FASTEMBED_RUNIT_SERVICE_BASE_DIR, runitServiceName),
905
+ mode: 0o755,
906
+ },
907
+ {
908
+ kind: 'write',
909
+ path: join(runitServiceDefinitionPath(FASTEMBED_RUNIT_SERVICE_BASE_DIR, runitServiceName), 'run'),
910
+ mode: 0o755,
911
+ content: FASTEMBED_RUNIT_RUN_TEMPLATE,
912
+ },
913
+ ...buildRunitEnableActions(FASTEMBED_RUNIT_SERVICE_BASE_DIR, runitServiceName, manager),
914
+ ]
915
+
517
916
  actions.push(
518
917
  { kind: 'mkdir', path: FASTEMBED_INSTALL_DIR, mode: 0o755 },
519
918
  { kind: 'mkdir', path: FASTEMBED_LOCAL_ENV_DIR, mode: 0o755 },
520
919
  { kind: 'mkdir', path: FASTEMBED_CACHE_DIR, mode: 0o755 },
920
+ { kind: 'write', path: FASTEMBED_COMMON_ENV_PATH, mode: 0o644, content: FASTEMBED_COMMON_ENV_TEMPLATE },
521
921
  { kind: 'write', path: FASTEMBED_SCRIPT_TARGET, mode: 0o755, content: scriptContent },
522
922
  ...(!localEnvExists
523
923
  ? [{ kind: 'write' as const, path: FASTEMBED_LOCAL_ENV_PATH, mode: 0o644, content: FASTEMBED_LOCAL_ENV_TEMPLATE }]
524
924
  : []),
525
- { kind: 'write', path: FASTEMBED_SERVICE_PATH, mode: 0o644, content: FASTEMBED_SYSTEMD_SERVICE },
925
+ ...managerActions,
526
926
  { kind: 'run', command: 'chown', args: ['-R', 'fastembed:fastembed', FASTEMBED_INSTALL_DIR], requireRoot: true },
927
+ { kind: 'run', command: 'chown', args: ['root:root', FASTEMBED_COMMON_ENV_PATH], requireRoot: true },
527
928
  { kind: 'run', command: 'chown', args: ['root:root', FASTEMBED_LOCAL_ENV_DIR], requireRoot: true },
528
929
  { kind: 'run', command: 'chown', args: ['root:root', FASTEMBED_LOCAL_ENV_PATH], requireRoot: true },
529
930
  { kind: 'run', command: 'chown', args: ['-R', 'fastembed:fastembed', FASTEMBED_CACHE_DIR], requireRoot: true },
530
- { kind: 'run', command: 'systemctl', args: ['daemon-reload'], requireRoot: true },
531
- { kind: 'run', command: 'systemctl', args: ['enable', '--now', 'suemo-fastembed.service'], requireRoot: true },
532
931
  )
533
932
 
534
- return actions
933
+ return {
934
+ actions,
935
+ serviceName: manager.kind === 'systemd' ? `${FASTEMBED_SERVICE_NAME}.service` : runitServiceName,
936
+ }
535
937
  }
536
938
 
537
- function printInitSystemSummary(kind: 'surreal' | 'fastembed', dryRun: boolean, profile?: '2gb' | '6gb'): void {
939
+ function printInitSystemSummary(
940
+ kind: 'surreal' | 'fastembed',
941
+ dryRun: boolean,
942
+ manager: ServiceManager,
943
+ serviceName: string,
944
+ ): void {
538
945
  if (kind === 'surreal') {
539
946
  if (dryRun) {
540
947
  console.log('Dry-run complete for SurrealDB setup.')
541
948
  } else {
542
949
  console.log('✓ SurrealDB setup complete.')
543
950
  }
544
- if (profile) {
545
- console.log(`Service: suemo-surreal@${profile}.service`)
546
- console.log(`Status: systemctl status suemo-surreal@${profile}.service`)
547
- console.log(`Logs: journalctl -u suemo-surreal@${profile}.service -f`)
951
+ console.log(`Service: ${serviceName}`)
952
+ if (manager.kind === 'systemd') {
953
+ console.log(`Status: systemctl status ${serviceName}`)
954
+ console.log(`Logs: journalctl -u ${serviceName} -f`)
955
+ } else {
956
+ console.log(`Status: sv status ${serviceName}`)
957
+ console.log(`Control: sv up ${serviceName} | sv down ${serviceName}`)
548
958
  }
549
959
  return
550
960
  }
@@ -554,9 +964,14 @@ function printInitSystemSummary(kind: 'surreal' | 'fastembed', dryRun: boolean,
554
964
  } else {
555
965
  console.log('✓ fastembed setup complete.')
556
966
  }
557
- console.log('Service: suemo-fastembed.service')
558
- console.log('Status: systemctl status suemo-fastembed.service')
559
- console.log('Logs: journalctl -u suemo-fastembed.service -f')
967
+ console.log(`Service: ${serviceName}`)
968
+ if (manager.kind === 'systemd') {
969
+ console.log(`Status: systemctl status ${serviceName}`)
970
+ console.log(`Logs: journalctl -u ${serviceName} -f`)
971
+ } else {
972
+ console.log(`Status: sv status ${serviceName}`)
973
+ console.log(`Control: sv up ${serviceName} | sv down ${serviceName}`)
974
+ }
560
975
  }
561
976
 
562
977
  function homeConfigPath(): string {
@@ -714,8 +1129,9 @@ const initSchemaCmd = init.sub('schema')
714
1129
  })
715
1130
 
716
1131
  const initOpenCodeCmd = init.sub('opencode')
717
- .meta({ description: 'Print OpenCode MCP + AGENTS.md setup snippets' })
1132
+ .meta({ description: 'Initialize OpenCode MCP config + AGENTS.md suemo snippet' })
718
1133
  .flags({
1134
+ 'dry-run': { type: 'boolean', description: 'Show changes without writing files', default: false },
719
1135
  json: { type: 'boolean', description: 'Machine-readable output mode' },
720
1136
  'verbose-json': { type: 'boolean', description: 'Include embeddings in JSON output (implies --json)' },
721
1137
  pretty: { type: 'boolean', description: 'Human-readable output (default)' },
@@ -730,19 +1146,21 @@ const initOpenCodeCmd = init.sub('opencode')
730
1146
  })
731
1147
 
732
1148
  const configPath = flags.config?.trim() || '~/.suemo/suemo.ts'
1149
+ const dryRun = Boolean(flags['dry-run'])
733
1150
  const npmVersion = packageJson.version ?? '0.0.0'
1151
+ const result = initOpenCodeFiles(configPath, dryRun)
734
1152
 
735
1153
  if (outputMode === 'json') {
736
1154
  console.log(JSON.stringify(
737
1155
  {
738
1156
  opencode: {
739
- mcp: {
740
- suemo: {
741
- command: 'suemo',
742
- args: ['serve', '--stdio', '--config', configPath],
743
- },
744
- },
745
- agentsSnippetPath: 'data/AGENTS.md',
1157
+ configPath: result.configPath,
1158
+ configFormat: result.configFormat,
1159
+ configStatus: result.configStatus,
1160
+ configMode: result.configMode,
1161
+ agentsPath: result.agentsPath,
1162
+ agentsStatus: result.agentsStatus,
1163
+ dryRun: result.dryRun,
746
1164
  },
747
1165
  suemoVersion: npmVersion,
748
1166
  },
@@ -752,26 +1170,12 @@ const initOpenCodeCmd = init.sub('opencode')
752
1170
  return
753
1171
  }
754
1172
 
755
- console.log('OpenCode MCP snippet (add to your OpenCode MCP config):')
756
- console.log('{')
757
- console.log(' "mcpServers": {')
758
- console.log(' "suemo": {')
759
- console.log(' "command": "suemo",')
760
- console.log(` "args": ["serve", "--stdio", "--config", "${configPath}"]`)
761
- console.log(' }')
762
- console.log(' }')
763
- console.log('}')
764
- console.log('\nMinimal AGENTS.md snippet:')
765
- console.log('```md')
766
- console.log('Before starting significant work:')
767
- console.log('- query("what do I know about <topic>")')
768
- console.log('- suemo skill (or suemo skill core-workflow) when you need latest workflow/docs')
769
- console.log('During work:')
770
- console.log('- observe("...") / believe("...")')
771
- console.log('After completing work:')
772
- console.log('- episode_end(session_id, summary="...")')
773
- console.log('```')
774
- console.log('\nAGENTS.md guidance source: data/AGENTS.md')
1173
+ console.log(`OpenCode config: ${result.configPath} (${result.configFormat})`)
1174
+ console.log(`MCP entry: ${result.configStatus} (${result.configMode})`)
1175
+ console.log(`AGENTS.md: ${result.agentsStatus} at ${result.agentsPath}`)
1176
+ if (dryRun) {
1177
+ console.log('Dry-run mode: no files were written.')
1178
+ }
775
1179
  console.log(`Installed suemo version: ${npmVersion}`)
776
1180
  })
777
1181
 
@@ -794,8 +1198,15 @@ const initSurrealCmd = init.sub('surreal')
794
1198
  quiet: flags.quiet,
795
1199
  })
796
1200
  requireRootForInit('init surreal <2gb|6gb>')
797
-
798
- requireCommands(['pacman', 'systemctl', 'install', 'chown', 'id'])
1201
+ const manager = detectServiceManager()
1202
+
1203
+ requireCommands([
1204
+ 'pacman',
1205
+ 'install',
1206
+ 'chown',
1207
+ 'id',
1208
+ ...(manager.kind === 'systemd' ? ['systemctl'] : ['ln', 'chpst']),
1209
+ ])
799
1210
  requireArchPackages(['surrealdb'])
800
1211
 
801
1212
  const profileRaw = (args as { profile: string }).profile.trim().toLowerCase()
@@ -818,23 +1229,28 @@ const initSurrealCmd = init.sub('surreal')
818
1229
 
819
1230
  if (dryRun) {
820
1231
  if (outputMode === 'json') {
1232
+ const dryRunResult = buildSurrealActions(profile, existingPassword, force, manager)
821
1233
  printCliJson({
822
1234
  ok: true,
823
1235
  dryRun: true,
824
1236
  profile,
1237
+ serviceManager: manager.kind,
1238
+ service: dryRunResult.serviceName,
825
1239
  existingPassword: existingPassword ?? 'none (not set)',
826
- actions: [],
1240
+ actions: dryRunResult.actions,
827
1241
  }, flags)
828
1242
  return
829
1243
  }
830
- printInitSystemSummary('surreal', true, profile)
1244
+ const dryRunResult = buildSurrealActions(profile, existingPassword, force, manager)
1245
+ printDryRunActions(dryRunResult.actions)
1246
+ printInitSystemSummary('surreal', true, manager, dryRunResult.serviceName)
831
1247
  console.log(`Existing SURREAL_PASS preview: ${existingPassword ?? 'none (not set)'}`)
832
1248
  return
833
1249
  }
834
1250
 
835
- const result = buildSurrealActions(profile, existingPassword, force)
1251
+ const result = buildSurrealActions(profile, existingPassword, force, manager)
836
1252
  applyActions(result.actions)
837
- printInitSystemSummary('surreal', false, profile)
1253
+ printInitSystemSummary('surreal', false, manager, result.serviceName)
838
1254
 
839
1255
  if (result.credentialStatus === 'preserved') {
840
1256
  console.log(
@@ -849,7 +1265,8 @@ const initSurrealCmd = init.sub('surreal')
849
1265
  ok: true,
850
1266
  dryRun: false,
851
1267
  profile,
852
- service: `suemo-surreal@${profile}.service`,
1268
+ serviceManager: manager.kind,
1269
+ service: result.serviceName,
853
1270
  credentialStatus: result.credentialStatus,
854
1271
  }, flags)
855
1272
  return
@@ -873,29 +1290,42 @@ const initFastembedCmd = init.sub('fastembed')
873
1290
  quiet: flags.quiet,
874
1291
  })
875
1292
  requireRootForInit('init fastembed')
1293
+ const manager = detectServiceManager()
876
1294
 
877
- requireCommands(['pacman', 'systemctl', 'install', 'chown', 'id'])
878
- requireArchPackages(['python-fastembed', 'python-fastapi', 'uvicorn'])
1295
+ requireCommands([
1296
+ 'pacman',
1297
+ 'install',
1298
+ 'chown',
1299
+ 'id',
1300
+ ...(manager.kind === 'systemd' ? ['systemctl'] : ['ln', 'chpst']),
1301
+ ])
1302
+ requireArchPackages(['python-fastembed', 'python-fastapi', 'python-uvicorn'])
879
1303
 
880
1304
  const scriptContent = FASTEMBED_SCRIPT_TEXT
881
- const actions = buildFastembedActions(scriptContent)
1305
+ const result = buildFastembedActions(scriptContent, manager)
882
1306
  const dryRun = Boolean(flags['dry-run'])
883
1307
 
884
1308
  if (dryRun) {
885
1309
  if (outputMode === 'json') {
886
- printCliJson({ ok: true, dryRun: true, actions }, flags)
1310
+ printCliJson({
1311
+ ok: true,
1312
+ dryRun: true,
1313
+ serviceManager: manager.kind,
1314
+ service: result.serviceName,
1315
+ actions: result.actions,
1316
+ }, flags)
887
1317
  return
888
1318
  }
889
- printDryRunActions(actions)
890
- printInitSystemSummary('fastembed', true)
1319
+ printDryRunActions(result.actions)
1320
+ printInitSystemSummary('fastembed', true, manager, result.serviceName)
891
1321
  return
892
1322
  }
893
1323
 
894
- applyActions(actions)
895
- printInitSystemSummary('fastembed', false)
1324
+ applyActions(result.actions)
1325
+ printInitSystemSummary('fastembed', false, manager, result.serviceName)
896
1326
 
897
1327
  if (outputMode === 'json') {
898
- printCliJson({ ok: true, dryRun: false, service: 'suemo-fastembed.service' }, flags)
1328
+ printCliJson({ ok: true, dryRun: false, serviceManager: manager.kind, service: result.serviceName }, flags)
899
1329
  return
900
1330
  }
901
1331
  })
@@ -922,7 +1352,7 @@ export const initCmd = init
922
1352
  console.log('Use one of:')
923
1353
  console.log(' suemo init config [--force]')
924
1354
  console.log(' suemo init schema [--yes]')
925
- console.log(' suemo init opencode')
1355
+ console.log(' suemo init opencode [--dry-run]')
926
1356
  console.log(' suemo init surreal <2gb|6gb> [--force] [--dry-run]')
927
1357
  console.log(' suemo init fastembed [--dry-run]')
928
1358
  console.log(' suemo skill [reference]')