diff --git a/README.md b/README.md
index b2b798d1..9b8c0914 100644
--- a/README.md
+++ b/README.md
@@ -122,6 +122,15 @@ Each loom is a fully isolated container for your work:
* **Environment Variables:** Each loom has its own environment files (`.env`, `.env.local`, `.env.development`, `.env.development.local`). Uses `development` by default, override with `DOTENV_FLOW_NODE_ENV`. See [Secret Storage Limitations](#multi-language-project-support) for frameworks with encrypted credentials.
+ When inside a loom shell (`il shell`), the following environment variables are automatically set:
+
+ | Variable | Description | Example |
+ |----------|-------------|---------|
+ | `ILOOM_LOOM` | Loom identifier for PS1 customization | `issue-87` |
+ | `ILOOM_COLOR_HEX` | Hex color assigned to this loom (if available) | `#dcebff` |
+
+ `ILOOM_COLOR_HEX` is useful for downstream tools that want to visually distinguish looms. For example, a Vite app can read it via `import.meta.env.VITE_ILOOM_COLOR_HEX` to tint the UI. See [Vite Integration Guide](docs/vite-iloom-color.md) for details.
+
* **Unique Runtime:**
* **Web Apps:** Runs on a deterministic port (e.g., base port 3000 + issue #25 = 3025).
diff --git a/docs/vite-iloom-color.md b/docs/vite-iloom-color.md
new file mode 100644
index 00000000..a5cf0d1a
--- /dev/null
+++ b/docs/vite-iloom-color.md
@@ -0,0 +1,101 @@
+# Using ILOOM_COLOR_HEX in a Vite App
+
+When you run a dev server inside a loom shell (`il shell`), the `ILOOM_COLOR_HEX` environment variable is automatically set to the hex color assigned to that loom (e.g., `#dcebff`). This lets your app visually distinguish which loom it's running in.
+
+## Setup
+
+### 1. Prefix the variable for Vite
+
+Vite only exposes env vars that start with `VITE_`. Add a `.env.local` (or use your existing one) in the project root:
+
+```bash
+# .env.local
+VITE_ILOOM_COLOR_HEX=$ILOOM_COLOR_HEX
+```
+
+Or, if you use `il shell` and then start the dev server manually, you can set it inline:
+
+```bash
+VITE_ILOOM_COLOR_HEX=$ILOOM_COLOR_HEX pnpm dev
+```
+
+### 2. Access it in your app
+
+```ts
+// src/main.ts (or any client-side file)
+const loomColor = import.meta.env.VITE_ILOOM_COLOR_HEX
+
+if (loomColor) {
+ document.documentElement.style.setProperty('--loom-color', loomColor)
+}
+```
+
+### 3. Use the CSS variable
+
+```css
+/* src/styles.css */
+:root {
+ --loom-color: transparent; /* fallback when not in a loom */
+}
+
+body {
+ border-top: 4px solid var(--loom-color);
+}
+```
+
+## Alternative: Use `define` in vite.config.ts
+
+If you don't want an extra `.env.local` entry, you can inject the value at build time:
+
+```ts
+// vite.config.ts
+import { defineConfig } from 'vite'
+
+export default defineConfig({
+ define: {
+ __ILOOM_COLOR_HEX__: JSON.stringify(process.env.ILOOM_COLOR_HEX ?? ''),
+ },
+})
+```
+
+Then in your app:
+
+```ts
+declare const __ILOOM_COLOR_HEX__: string
+
+if (__ILOOM_COLOR_HEX__) {
+ document.documentElement.style.setProperty('--loom-color', __ILOOM_COLOR_HEX__)
+}
+```
+
+## React Example
+
+```tsx
+// src/components/LoomIndicator.tsx
+function LoomIndicator() {
+ const color = import.meta.env.VITE_ILOOM_COLOR_HEX
+ if (!color) return null
+
+ return (
+
+ )
+}
+```
+
+## How It Works
+
+1. `il shell ` reads the loom's metadata (stored in `~/.config/iloom-ai/looms/`) and exports `ILOOM_COLOR_HEX` into the shell environment.
+2. Any process spawned from that shell inherits the variable.
+3. Vite picks it up (via `VITE_` prefix or `define`) and makes it available to client code.
+
+When you're not inside a loom shell, the variable is simply absent and your fallback styles apply.
diff --git a/src/commands/dev-server.test.ts b/src/commands/dev-server.test.ts
index a565181e..b8ecf8ed 100644
--- a/src/commands/dev-server.test.ts
+++ b/src/commands/dev-server.test.ts
@@ -1,6 +1,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { DevServerCommand } from './dev-server.js'
import { GitWorktreeManager } from '../lib/GitWorktreeManager.js'
+import { MetadataManager } from '../lib/MetadataManager.js'
import { ProjectCapabilityDetector } from '../lib/ProjectCapabilityDetector.js'
import { DevServerManager } from '../lib/DevServerManager.js'
import { SettingsManager } from '../lib/SettingsManager.js'
@@ -12,6 +13,7 @@ import fs from 'fs-extra'
// Mock dependencies
vi.mock('../lib/GitWorktreeManager.js')
+vi.mock('../lib/MetadataManager.js')
vi.mock('../lib/ProjectCapabilityDetector.js')
vi.mock('../lib/DevServerManager.js')
vi.mock('../utils/IdentifierParser.js')
@@ -40,6 +42,7 @@ vi.mock('../utils/logger.js', () => ({
describe('DevServerCommand', () => {
let command: DevServerCommand
let mockGitWorktreeManager: GitWorktreeManager
+ let mockMetadataManager: MetadataManager
let mockCapabilityDetector: ProjectCapabilityDetector
let mockDevServerManager: DevServerManager
let mockIdentifierParser: IdentifierParser
@@ -54,11 +57,17 @@ describe('DevServerCommand', () => {
beforeEach(() => {
mockGitWorktreeManager = new GitWorktreeManager()
+ mockMetadataManager = new MetadataManager()
mockCapabilityDetector = new ProjectCapabilityDetector()
mockDevServerManager = new DevServerManager()
mockIdentifierParser = new IdentifierParser(mockGitWorktreeManager)
mockSettingsManager = new SettingsManager()
+ // Mock MetadataManager - default to returning metadata with color
+ vi.mocked(mockMetadataManager.readMetadata).mockResolvedValue({
+ colorHex: '#dcebff',
+ })
+
// Mock DevServerManager methods
vi.mocked(mockDevServerManager.isServerRunning).mockResolvedValue(false)
vi.mocked(mockDevServerManager.runServerForeground).mockImplementation(
@@ -83,7 +92,8 @@ describe('DevServerCommand', () => {
mockCapabilityDetector,
mockIdentifierParser,
mockDevServerManager,
- mockSettingsManager
+ mockSettingsManager,
+ mockMetadataManager
)
})
@@ -458,7 +468,7 @@ describe('DevServerCommand', () => {
3087,
false,
expect.any(Function),
- { DATABASE_URL: 'postgres://test', API_KEY: 'secret' }
+ expect.objectContaining({ DATABASE_URL: 'postgres://test', API_KEY: 'secret', ILOOM_LOOM: '87' })
)
})
@@ -475,7 +485,7 @@ describe('DevServerCommand', () => {
3087,
false,
expect.any(Function),
- {}
+ expect.objectContaining({ ILOOM_LOOM: '87' })
)
})
@@ -490,7 +500,7 @@ describe('DevServerCommand', () => {
3087,
false,
expect.any(Function),
- {}
+ expect.objectContaining({ ILOOM_LOOM: '87' })
)
})
@@ -513,7 +523,7 @@ describe('DevServerCommand', () => {
3087,
false,
expect.any(Function),
- {}
+ expect.objectContaining({ ILOOM_LOOM: '87' })
)
})
@@ -533,4 +543,77 @@ describe('DevServerCommand', () => {
expect(mockDevServerManager.runServerForeground).toHaveBeenCalled()
})
})
+
+ describe('loom environment variables', () => {
+ beforeEach(() => {
+ vi.mocked(mockIdentifierParser.parseForPatternDetection).mockResolvedValue({
+ type: 'issue',
+ number: 87,
+ originalInput: '87',
+ })
+
+ vi.mocked(mockGitWorktreeManager.findWorktreeForIssue).mockResolvedValue(mockWorktree)
+
+ const mockCapabilities: ProjectCapabilities = {
+ capabilities: ['web'],
+ binEntries: {},
+ }
+ vi.mocked(mockCapabilityDetector.detectCapabilities).mockResolvedValue(mockCapabilities)
+
+ vi.mocked(fs.pathExists).mockResolvedValue(true)
+ vi.mocked(fs.readFile).mockResolvedValue('PORT=3087\n')
+ })
+
+ it('should set ILOOM_LOOM env var to original input', async () => {
+ await command.execute({ identifier: '87' })
+
+ const envArg = vi.mocked(mockDevServerManager.runServerForeground).mock.calls[0]?.[4]
+ expect(envArg).toHaveProperty('ILOOM_LOOM', '87')
+ })
+
+ it('should set ILOOM_LOOM for PR identifier', async () => {
+ vi.mocked(mockIdentifierParser.parseForPatternDetection).mockResolvedValue({
+ type: 'pr',
+ number: 42,
+ originalInput: '42',
+ })
+ vi.mocked(mockGitWorktreeManager.findWorktreeForPR).mockResolvedValue(mockWorktree)
+
+ await command.execute({ identifier: '42' })
+
+ const envArg = vi.mocked(mockDevServerManager.runServerForeground).mock.calls[0]?.[4]
+ expect(envArg).toHaveProperty('ILOOM_LOOM', '42')
+ })
+
+ it('should set ILOOM_COLOR_HEX from metadata', async () => {
+ vi.mocked(mockMetadataManager.readMetadata).mockResolvedValue({
+ colorHex: '#dcebff',
+ })
+
+ await command.execute({ identifier: '87' })
+
+ const envArg = vi.mocked(mockDevServerManager.runServerForeground).mock.calls[0]?.[4]
+ expect(envArg).toHaveProperty('ILOOM_COLOR_HEX', '#dcebff')
+ })
+
+ it('should not set ILOOM_COLOR_HEX when metadata has no colorHex', async () => {
+ vi.mocked(mockMetadataManager.readMetadata).mockResolvedValue({
+ colorHex: null,
+ })
+
+ await command.execute({ identifier: '87' })
+
+ const envArg = vi.mocked(mockDevServerManager.runServerForeground).mock.calls[0]?.[4]
+ expect(envArg).not.toHaveProperty('ILOOM_COLOR_HEX')
+ })
+
+ it('should not set ILOOM_COLOR_HEX when metadata is null', async () => {
+ vi.mocked(mockMetadataManager.readMetadata).mockResolvedValue(null)
+
+ await command.execute({ identifier: '87' })
+
+ const envArg = vi.mocked(mockDevServerManager.runServerForeground).mock.calls[0]?.[4]
+ expect(envArg).not.toHaveProperty('ILOOM_COLOR_HEX')
+ })
+ })
})
diff --git a/src/commands/dev-server.ts b/src/commands/dev-server.ts
index c1739f72..993f6183 100644
--- a/src/commands/dev-server.ts
+++ b/src/commands/dev-server.ts
@@ -1,5 +1,6 @@
import path from 'path'
import { GitWorktreeManager } from '../lib/GitWorktreeManager.js'
+import { MetadataManager } from '../lib/MetadataManager.js'
import { ProjectCapabilityDetector } from '../lib/ProjectCapabilityDetector.js'
import { DevServerManager } from '../lib/DevServerManager.js'
import { SettingsManager } from '../lib/SettingsManager.js'
@@ -42,7 +43,8 @@ export class DevServerCommand {
private capabilityDetector = new ProjectCapabilityDetector(),
private identifierParser = new IdentifierParser(new GitWorktreeManager()),
private devServerManager = new DevServerManager(),
- private settingsManager = new SettingsManager()
+ private settingsManager = new SettingsManager(),
+ private metadataManager = new MetadataManager()
) {}
/**
@@ -82,6 +84,15 @@ export class DevServerCommand {
}
}
+ // 3b. Set ILOOM_LOOM for loom identification
+ envOverrides.ILOOM_LOOM = this.formatLoomIdentifier(parsed)
+
+ // 3c. Set ILOOM_COLOR_HEX from loom metadata if available
+ const metadata = await this.metadataManager.readMetadata(worktree.path)
+ if (metadata?.colorHex) {
+ envOverrides.ILOOM_COLOR_HEX = metadata.colorHex
+ }
+
// 4. Detect project capabilities
const { capabilities } =
await this.capabilityDetector.detectCapabilities(worktree.path)
@@ -312,4 +323,11 @@ export class DevServerCommand {
}
return `branch "${parsed.branchName}"${autoLabel}`
}
+
+ /**
+ * Format loom identifier for ILOOM_LOOM env var
+ */
+ private formatLoomIdentifier(parsed: ParsedDevServerInput): string {
+ return parsed.originalInput
+ }
}
diff --git a/src/commands/shell.test.ts b/src/commands/shell.test.ts
index 044786f1..63721038 100644
--- a/src/commands/shell.test.ts
+++ b/src/commands/shell.test.ts
@@ -1,6 +1,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { ShellCommand } from './shell.js'
import { GitWorktreeManager } from '../lib/GitWorktreeManager.js'
+import { MetadataManager } from '../lib/MetadataManager.js'
import { IdentifierParser } from '../utils/IdentifierParser.js'
import { SettingsManager } from '../lib/SettingsManager.js'
import type { GitWorktree } from '../types/worktree.js'
@@ -9,6 +10,7 @@ import { execa } from 'execa'
// Mock dependencies
vi.mock('../lib/GitWorktreeManager.js')
+vi.mock('../lib/MetadataManager.js')
vi.mock('../utils/IdentifierParser.js')
vi.mock('../lib/SettingsManager.js')
vi.mock('fs-extra')
@@ -37,6 +39,7 @@ import { loadWorkspaceEnv, getDotenvFlowFiles } from '../utils/env.js'
describe('ShellCommand', () => {
let command: ShellCommand
let mockGitWorktreeManager: GitWorktreeManager
+ let mockMetadataManager: MetadataManager
let mockIdentifierParser: IdentifierParser
let mockSettingsManager: SettingsManager
@@ -49,6 +52,7 @@ describe('ShellCommand', () => {
beforeEach(() => {
mockGitWorktreeManager = new GitWorktreeManager()
+ mockMetadataManager = new MetadataManager()
mockIdentifierParser = new IdentifierParser(mockGitWorktreeManager)
mockSettingsManager = new SettingsManager()
@@ -57,6 +61,27 @@ describe('ShellCommand', () => {
sourceEnvOnStart: true,
})
+ // Default metadata mock - return colorHex
+ vi.mocked(mockMetadataManager.readMetadata).mockResolvedValue({
+ description: 'test',
+ created_at: null,
+ branchName: null,
+ worktreePath: null,
+ issueType: null,
+ issueKey: null,
+ issue_numbers: [],
+ pr_numbers: [],
+ issueTracker: null,
+ colorHex: '#dcebff',
+ sessionId: null,
+ projectPath: null,
+ issueUrls: {},
+ prUrls: {},
+ draftPrNumber: null,
+ capabilities: [],
+ parentLoom: null,
+ })
+
// Set up env mocks
vi.mocked(loadWorkspaceEnv).mockReturnValue({ parsed: { PORT: '3087', NODE_ENV: 'development' } })
vi.mocked(getDotenvFlowFiles).mockReturnValue(['.env', '.env.local', '.env.development', '.env.development.local'])
@@ -64,7 +89,8 @@ describe('ShellCommand', () => {
command = new ShellCommand(
mockGitWorktreeManager,
mockIdentifierParser,
- mockSettingsManager
+ mockSettingsManager,
+ mockMetadataManager
)
})
@@ -227,6 +253,52 @@ describe('ShellCommand', () => {
const envArg = execaCall[2]?.env as Record
expect(envArg.ILOOM_LOOM).toBe('pr-42')
})
+
+ it('should set ILOOM_COLOR_HEX when metadata has colorHex', async () => {
+ await command.execute({ identifier: '87' })
+
+ const execaCall = vi.mocked(execa).mock.calls[0]
+ const envArg = execaCall[2]?.env as Record
+ expect(envArg.ILOOM_COLOR_HEX).toBe('#dcebff')
+ })
+
+ it('should not set ILOOM_COLOR_HEX when metadata is null', async () => {
+ vi.mocked(mockMetadataManager.readMetadata).mockResolvedValue(null)
+
+ await command.execute({ identifier: '87' })
+
+ const execaCall = vi.mocked(execa).mock.calls[0]
+ const envArg = execaCall[2]?.env as Record
+ expect(envArg.ILOOM_COLOR_HEX).toBeUndefined()
+ })
+
+ it('should not set ILOOM_COLOR_HEX when metadata.colorHex is null', async () => {
+ vi.mocked(mockMetadataManager.readMetadata).mockResolvedValue({
+ description: 'test',
+ created_at: null,
+ branchName: null,
+ worktreePath: null,
+ issueType: null,
+ issueKey: null,
+ issue_numbers: [],
+ pr_numbers: [],
+ issueTracker: null,
+ colorHex: null,
+ sessionId: null,
+ projectPath: null,
+ issueUrls: {},
+ prUrls: {},
+ draftPrNumber: null,
+ capabilities: [],
+ parentLoom: null,
+ })
+
+ await command.execute({ identifier: '87' })
+
+ const execaCall = vi.mocked(execa).mock.calls[0]
+ const envArg = execaCall[2]?.env as Record
+ expect(envArg.ILOOM_COLOR_HEX).toBeUndefined()
+ })
})
describe('shell detection', () => {
diff --git a/src/commands/shell.ts b/src/commands/shell.ts
index 4a2cd09e..c97d8595 100644
--- a/src/commands/shell.ts
+++ b/src/commands/shell.ts
@@ -2,6 +2,7 @@ import path from 'path'
import { execa } from 'execa'
import fs from 'fs-extra'
import { GitWorktreeManager } from '../lib/GitWorktreeManager.js'
+import { MetadataManager } from '../lib/MetadataManager.js'
import { SettingsManager } from '../lib/SettingsManager.js'
import { IdentifierParser } from '../utils/IdentifierParser.js'
import { loadWorkspaceEnv, getDotenvFlowFiles } from '../utils/env.js'
@@ -29,7 +30,8 @@ export class ShellCommand {
constructor(
private gitWorktreeManager = new GitWorktreeManager(),
private identifierParser = new IdentifierParser(new GitWorktreeManager()),
- private settingsManager = new SettingsManager()
+ private settingsManager = new SettingsManager(),
+ private metadataManager = new MetadataManager()
) {}
async execute(input: ShellCommandInput): Promise {
@@ -66,6 +68,12 @@ export class ShellCommand {
const loomIdentifier = this.formatLoomIdentifier(parsed)
envVars.ILOOM_LOOM = loomIdentifier
+ // 5b. Set ILOOM_COLOR_HEX from loom metadata if available
+ const metadata = await this.metadataManager.readMetadata(worktree.path)
+ if (metadata?.colorHex) {
+ envVars.ILOOM_COLOR_HEX = metadata.colorHex
+ }
+
// 6. Detect shell
const shell = this.detectShell()