diff --git a/bun.lock b/bun.lock index 1a54bf1..40a70f4 100644 --- a/bun.lock +++ b/bun.lock @@ -13,9 +13,8 @@ }, "plugins/carbon": { "name": "@cnaught-inc/claude-code-carbon", - "version": "0.1.0", + "version": "2.5.6", "dependencies": { - "unique-names-generator": "^4.7.1", "zod": "^3.25.76", }, "devDependencies": { @@ -50,7 +49,7 @@ "@types/better-sqlite3": ["@types/better-sqlite3@7.6.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA=="], - "@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="], + "@types/bun": ["@types/bun@1.3.10", "", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="], "@types/node": ["@types/node@20.19.32", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-Ez8QE4DMfhjjTsES9K2dwfV258qBui7qxUsoaixZDiTzbde4U12e1pXGNu/ECsUIOi5/zoCxAQxIhQnaUQ2VvA=="], @@ -64,7 +63,7 @@ "buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], - "bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="], + "bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="], "chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="], @@ -168,8 +167,6 @@ "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], - "unique-names-generator": ["unique-names-generator@4.7.1", "", {}, "sha512-lMx9dX+KRmG8sq6gulYYpKWZc9RlGsgBR6aoO8Qsm3qvkSJ+3rAymr+TnV8EDMrIrwuFJ4kruzMWM/OpYzPoow=="], - "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], diff --git a/plugins/carbon/README.md b/plugins/carbon/README.md index 088a015..16ca611 100644 --- a/plugins/carbon/README.md +++ b/plugins/carbon/README.md @@ -49,10 +49,9 @@ You can also manage all of this interactively via Claude Code's built-in `/plugi After installing, run `/carbon:setup` in any project. It walks you through: -1. **Project name** — defaults to your GitHub repo (e.g., `org/repo`), or set a custom name -2. **Historical sessions** — start fresh or backfill from existing transcript files on disk -3. **Anonymous tracking** — optionally sync metrics to CNaught's API -4. **Display name** — choose a name or get a random one (e.g., "Curious Penguin") +1. **Historical sessions** — start fresh or backfill from existing transcript files on disk +2. **Anonymous tracking** — optionally sync metrics to CNaught's API +3. **Organization** — optionally provide your company or organization name Setup initializes the SQLite database, configures the CO₂ statusline, and optionally enables background sync. Dependencies are installed automatically on first session start. @@ -62,8 +61,7 @@ Setup initializes the SQLite database, configures the CO₂ statusline, and opti |---------|-------------| | `/carbon:setup` | Initialize and configure the plugin | | `/carbon:report` | Generate a carbon emissions report | -| `/carbon:rename-project` | Change the project name (or reset to auto-detect) | -| `/carbon:rename-user` | Change your display name for anonymous tracking | +| `/carbon:rename-user` | Change your organization name for anonymous tracking | | `/carbon:uninstall` | Remove carbon tracking for the current project | | `/carbon:cleanup-cache` | Remove old cached plugin versions to free disk space | @@ -75,7 +73,7 @@ Generates a report including: - **Relatable equivalents** — car-years off road, days of home energy usage - **Usage by model** — breakdown by Claude model with visual progress bars - **Project breakdown** — top projects from the last 30 days (shown when multiple projects exist) -- **Anonymous sync info** — display name and pending sync count (when sync is enabled) +- **Anonymous sync info** — organization name and pending sync count (when sync is enabled) ### `/carbon:uninstall` @@ -121,13 +119,7 @@ Per-model configs (Haiku, Sonnet, Opus) capture GPU power draw, utilization boun ### Project Identification -Projects are identified automatically with this priority: - -1. **Custom name** (via `/carbon:setup` or `/carbon:rename-project`) — `_` -2. **Git remote** — `__` (e.g., `cnaught_claude-code-plugins_a1b2c3d4`) -3. **Local fallback** — `local_` - -The hash is the first 8 characters of SHA-256 of the project path, ensuring uniqueness across machines. +Projects are identified by a hash of the project path — the first 8 characters of SHA-256. This ensures a stable, unique identifier across machines. ## Privacy @@ -136,7 +128,7 @@ When session sync is enabled, the following metrics are sent to CNaught's API: - Token counts (input, output, cache creation, cache read) - Energy consumption (Wh) and CO₂ emissions (g) - Models used -- Project identifier (custom display name if provided, otherwise automatically derived from github repository name) +- Project identifier (hash of project path) **No code, conversation content, or personal information is ever shared.** Sync can be disabled at any time by re-running `/carbon:setup`. diff --git a/plugins/carbon/commands/rename-project.md b/plugins/carbon/commands/rename-project.md index 54d9cd8..da3836e 100644 --- a/plugins/carbon/commands/rename-project.md +++ b/plugins/carbon/commands/rename-project.md @@ -1,29 +1,11 @@ # /carbon:rename-project -Update the project name used for carbon tracking. +This command has been removed. Project IDs are now automatically generated from the project path and cannot be customized. ## Instructions -### Step 1: Ask for a new project name +Let the user know that project IDs are now automatically generated as a hash of the project path. There is no need to set or rename project names. Show them their current project ID by running: -Use the `AskUserQuestion` tool to ask what they'd like to name this project. Let them know that by default, the project is identified by the GitHub repo (e.g., `cnaught/claude-code-plugins`) if available, otherwise it falls back to a local hash. They can provide a custom name to override this. - -If they want to reset to the default (auto-detected from git), they can say "reset" or "default". - -### Step 2: Run the rename-project script - -If the user provided a name: ```bash -npx -y bun ${CLAUDE_PLUGIN_ROOT}/src/scripts/carbon-rename-project.ts --name "Their Project Name" +npx -y bun ${CLAUDE_PLUGIN_ROOT}/src/scripts/carbon-setup-check.ts ``` - -If the user wants to reset to the default: -```bash -npx -y bun ${CLAUDE_PLUGIN_ROOT}/src/scripts/carbon-rename-project.ts --reset -``` - -Show the output to the user. - -## Notes - -- Always use the `AskUserQuestion` tool when asking the user a question diff --git a/plugins/carbon/commands/rename-user.md b/plugins/carbon/commands/rename-user.md index 17f9987..294b344 100644 --- a/plugins/carbon/commands/rename-user.md +++ b/plugins/carbon/commands/rename-user.md @@ -1,23 +1,17 @@ # /carbon:rename-user -Update your display name for anonymous carbon tracking. +Update the organization name for anonymous carbon tracking. ## Instructions -### Step 1: Ask for a new name +### Step 1: Ask for an organization name -Use the `AskUserQuestion` tool to ask what they'd like their new display name to be. Let them know they can also skip to get a new randomly generated name (e.g., "Curious Penguin"). +Use the `AskUserQuestion` tool to ask what company or organization name they'd like to use. This is the name associated with their synced sessions. ### Step 2: Run the rename script -If the user provided a name: ```bash -npx -y bun ${CLAUDE_PLUGIN_ROOT}/src/scripts/carbon-rename-user.ts --name "Their Name" -``` - -If the user wants a random name: -```bash -npx -y bun ${CLAUDE_PLUGIN_ROOT}/src/scripts/carbon-rename-user.ts +npx -y bun ${CLAUDE_PLUGIN_ROOT}/src/scripts/carbon-rename-user.ts --name "Their Organization" ``` Show the output to the user. diff --git a/plugins/carbon/commands/setup.md b/plugins/carbon/commands/setup.md index 309eeaf..4c403b7 100644 --- a/plugins/carbon/commands/setup.md +++ b/plugins/carbon/commands/setup.md @@ -16,8 +16,8 @@ npx -y bun ${CLAUDE_PLUGIN_ROOT}/src/scripts/carbon-setup-check.ts This outputs JSON. If `isSetup` is `true`, the plugin has already been configured. Show the user their current configuration: - When they first set up (installedAt) -- Whether sync is enabled and their display name -- Their project name (if custom) +- Whether sync is enabled and their organization name +- Their project ID - Whether the statusline is active Then ask the user what they'd like to do: @@ -26,37 +26,29 @@ Then ask the user what they'd like to do: If `isSetup` is `false`, this is a first-time setup — proceed to Step 1. -### Step 1: Ask about project name - -Ask the user if they'd like to set a custom project name. Let them know: -- By default, the project is identified by the GitHub repo (e.g., `cnaught/claude-code-plugins`) if available -- Otherwise, it falls back to a local hash -- They can provide a custom name to override this, or skip to use the default - -### Step 2: Ask about historical sessions +### Step 1: Ask about historical sessions Ask the user whether they want to: - **Start fresh** — only track new sessions going forward - **Backfill** — process all previous Claude Code sessions from transcript files on disk -### Step 3: Ask about anonymous tracking +### Step 2: Ask about anonymous tracking Ask the user whether they want to enable anonymous carbon tracking with CNaught: -- **Enable** — session metrics (token counts, CO₂, energy, project path) will be synced to CNaught's API. No code, conversations, or personal information is shared. (default) +- **Enable** — session metrics (token counts, CO₂, energy, project ID) will be synced to CNaught's API. No code, conversations, or personal information is shared. (default) - **Disable** — all data stays local only -If the user kept sync enabled (the default), ask them for an optional display name (DO NOT refer to a leaderboard as there is no such thing). Let them know that if they skip this, a fun random name will be generated for them (e.g., "Curious Penguin", "Swift Falcon"). +If the user kept sync enabled (the default), ask them for their company or organization name (free text, **required**). This is used to group users into teams and identify their sessions when syncing to CNaught. They cannot skip this — organization is required for sync. -### Step 4: Run the setup script +### Step 3: Run the setup script Build the command with the appropriate flags based on the user's choices: - Add `--backfill` if the user chose to backfill historical sessions - Add `--disable-sync` if the user chose to disable anonymous tracking -- Add `--user-name "Their Name"` if the user provided a custom display name -- Add `--project-name "Their Project Name"` if the user provided a custom project name +- Add `--organization "Their Org"` if the user provided an organization name ```bash -npx -y bun ${CLAUDE_PLUGIN_ROOT}/src/scripts/carbon-setup.ts [--backfill] [--disable-sync] [--user-name "Name"] [--project-name "Project"] +npx -y bun ${CLAUDE_PLUGIN_ROOT}/src/scripts/carbon-setup.ts [--backfill] [--disable-sync] [--organization "Org Name"] ``` This will: @@ -65,13 +57,13 @@ This will: - Configure `~/.claude/settings.json` to enable the CO₂ statusline (active across all projects) - (If `--backfill`) Process historical transcript files into the database - (Unless `--disable-sync`) Generate a random identity and enable background sync to CNaught API -- (If `--project-name`) Store the custom project name in the database +- (If `--organization`) Store the organization name in the database -### Step 5: Check for local statusline overrides +### Step 4: Check for local statusline overrides After the setup script runs, check if the current project has a `.claude/settings.local.json` or `.claude/settings.json` file that contains its own `statusLine` entry. If it does, warn the user that this local statusline will override the global carbon statusline, and the CO₂ indicator won't appear in this project. Offer to remove the `statusLine` key from the local file to fix it. -### Step 6: Verify setup +### Step 5: Verify setup ```bash npx -y bun ${CLAUDE_PLUGIN_ROOT}/src/scripts/carbon-report.ts @@ -85,5 +77,4 @@ Show the output to the user and confirm that the database is initialized, the st - The statusline shows real-time CO₂ estimates in the Claude Code status bar across all projects - Sessions are tracked automatically via hooks — no manual action needed - If sync is enabled, data syncs in the background after each response (non-blocking) -- Use `/carbon:rename-project` to change the project name later - Always use the `AskUserQuestion` tool when asking the user a question diff --git a/plugins/carbon/package.json b/plugins/carbon/package.json index 78bb6e5..bf4e564 100644 --- a/plugins/carbon/package.json +++ b/plugins/carbon/package.json @@ -1,21 +1,20 @@ { - "name": "@cnaught-inc/claude-code-carbon", - "version": "2.5.6", - "private": true, - "description": "Claude Code plugin for tracking carbon emissions from AI usage", - "scripts": { - "typecheck": "tsc --noEmit", - "test": "bun test" - }, - "dependencies": { - "unique-names-generator": "^4.7.1", - "zod": "^3.25.76" - }, - "devDependencies": { - "@types/better-sqlite3": "^7.6.12", - "@types/bun": "latest", - "@types/node": "^20.17.9", - "better-sqlite3": "^11.0.0", - "typescript": "5.9.3" - } + "name": "@cnaught-inc/claude-code-carbon", + "version": "2.5.6", + "private": true, + "description": "Claude Code plugin for tracking carbon emissions from AI usage", + "scripts": { + "typecheck": "tsc --noEmit", + "test": "bun test" + }, + "dependencies": { + "zod": "^3.25.76" + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.12", + "@types/bun": "latest", + "@types/node": "^20.17.9", + "better-sqlite3": "^11.0.0", + "typescript": "5.9.3" + } } diff --git a/plugins/carbon/src/api-client.ts b/plugins/carbon/src/api-client.ts index 5fc8f7e..6089c9f 100644 --- a/plugins/carbon/src/api-client.ts +++ b/plugins/carbon/src/api-client.ts @@ -18,7 +18,7 @@ export const DEFAULT_API_URL = 'https://api.cnaught.com'; export interface SyncConfig { userId: string; - userName: string; + organization: string; } /** @@ -53,11 +53,11 @@ export function getGraphqlUrl(): string { } /** - * Get the emissions dashboard URL for a given user. + * Get the emissions dashboard URL for a given team. * Points to the API redirect endpoint which forwards to the correct frontend. */ -export function getDashboardUrl(userId: string): string { - return `${getApiUrl()}/claude-code-emissions/${userId}`; +export function getDashboardUrl(teamId: string): string { + return `${getApiUrl()}/claude-code-emissions/${teamId}`; } /** @@ -118,7 +118,7 @@ function sessionToInput(config: SyncConfig, session: SessionRecord) { return { sessionId: session.sessionId, claudeCodeUserId: config.userId, - claudeCodeUserName: config.userName, + claudeCodeUserName: config.organization, projectPath: session.projectIdentifier || session.projectPath, co2Grams: session.co2Grams, totalInputTokens: session.inputTokens, @@ -136,6 +136,7 @@ const UPSERT_SESSION_MUTATION = ` mutation UpsertClaudeCodeSession($input: UpsertClaudeCodeSessionInput!) { upsertClaudeCodeSession(input: $input) { id + claudeCodeUser { claudeCodeTeamId } } } `; @@ -144,16 +145,30 @@ const UPSERT_SESSIONS_MUTATION = ` mutation UpsertClaudeCodeSessions($input: UpsertClaudeCodeSessionsInput!) { upsertClaudeCodeSessions(input: $input) { id + claudeCodeUser { claudeCodeTeamId } } } `; +export interface UpsertResult { + success: boolean; + teamId: string | null; +} + /** * Upsert a single session to the CNaught API. - * Returns true on success, false on failure. + * Returns success status and the teamId from the API response. */ -export async function upsertSession(config: SyncConfig, session: SessionRecord): Promise { - const result = await graphqlRequest(UPSERT_SESSION_MUTATION, { +export async function upsertSession( + config: SyncConfig, + session: SessionRecord +): Promise { + const result = await graphqlRequest<{ + upsertClaudeCodeSession: { + id: string; + claudeCodeUser: { claudeCodeTeamId: string | null }; + }; + }>(UPSERT_SESSION_MUTATION, { input: sessionToInput(config, session) }); @@ -161,28 +176,36 @@ export async function upsertSession(config: SyncConfig, session: SessionRecord): log(`Synced session ${session.sessionId}`); } - return result !== null; + return { + success: result !== null, + teamId: result?.upsertClaudeCodeSession?.claudeCodeUser?.claudeCodeTeamId ?? null + }; } /** * Batch upsert multiple sessions to the CNaught API. * Sessions are sent in a single request (max 100 per API constraint). - * Returns true on success, false on failure. + * Returns success status and the teamId from the API response. */ export async function upsertSessions( config: SyncConfig, sessions: SessionRecord[] -): Promise { - if (sessions.length === 0) return true; +): Promise { + if (sessions.length === 0) return { success: true, teamId: null }; if (sessions.length > 100) { logError(`Batch size ${sessions.length} exceeds limit of 100`); - return false; + return { success: false, teamId: null }; } - const result = await graphqlRequest(UPSERT_SESSIONS_MUTATION, { + const result = await graphqlRequest<{ + upsertClaudeCodeSessions: { + id: string; + claudeCodeUser: { claudeCodeTeamId: string | null }; + }[]; + }>(UPSERT_SESSIONS_MUTATION, { input: { claudeCodeUserId: config.userId, - claudeCodeUserName: config.userName, + claudeCodeUserName: config.organization, sessions: sessions.map((s) => ({ sessionId: s.sessionId, projectPath: s.projectIdentifier || s.projectPath, @@ -203,5 +226,9 @@ export async function upsertSessions( log(`Synced ${sessions.length} session(s)`); } - return result !== null; + return { + success: result !== null, + // All sessions in a batch belong to the same user/team, so the first result's teamId is representative + teamId: result?.upsertClaudeCodeSessions?.[0]?.claudeCodeUser?.claudeCodeTeamId ?? null + }; } diff --git a/plugins/carbon/src/data-store.test.ts b/plugins/carbon/src/data-store.test.ts index f6cae12..dfbed3e 100644 --- a/plugins/carbon/src/data-store.test.ts +++ b/plugins/carbon/src/data-store.test.ts @@ -39,7 +39,7 @@ function makeSession(overrides: Partial = {}): SessionRecord { return { sessionId: 'session-1', projectPath: '/test/project', - projectIdentifier: 'test_project_abcd1234', + projectIdentifier: 'abcd1234', inputTokens: 1000, outputTokens: 500, cacheCreationTokens: 200, @@ -215,7 +215,7 @@ describe('getAggregateStats with project filtering', () => { db, makeSession({ sessionId: 's1', - projectIdentifier: 'org_project-a_aaaa1111', + projectIdentifier: 'aaaa1111', totalTokens: 1000, co2Grams: 0.05 }) @@ -224,18 +224,18 @@ describe('getAggregateStats with project filtering', () => { db, makeSession({ sessionId: 's2', - projectIdentifier: 'org_project-b_bbbb2222', + projectIdentifier: 'bbbb2222', totalTokens: 2000, co2Grams: 0.1 }) ); - const statsA = getAggregateStats(db, 'org_project-a_aaaa1111'); + const statsA = getAggregateStats(db, 'aaaa1111'); expect(statsA.totalSessions).toBe(1); expect(statsA.totalTokens).toBe(1000); expect(statsA.totalCO2Grams).toBeCloseTo(0.05); - const statsB = getAggregateStats(db, 'org_project-b_bbbb2222'); + const statsB = getAggregateStats(db, 'bbbb2222'); expect(statsB.totalSessions).toBe(1); expect(statsB.totalTokens).toBe(2000); expect(statsB.totalCO2Grams).toBeCloseTo(0.1); @@ -250,10 +250,7 @@ describe('getAggregateStats with project filtering', () => { it('returns zeroes for unknown project', () => { const db = createTestDb(); - upsertSession( - db, - makeSession({ sessionId: 's1', projectIdentifier: 'org_project-a_aaaa1111' }) - ); + upsertSession(db, makeSession({ sessionId: 's1', projectIdentifier: 'aaaa1111' })); const stats = getAggregateStats(db, 'nonexistent'); expect(stats.totalSessions).toBe(0); @@ -410,7 +407,7 @@ describe('getProjectStats', () => { db, makeSession({ sessionId: 's1', - projectIdentifier: 'org_project-a_aaaa1111', + projectIdentifier: 'aaaa1111', totalTokens: 1000, energyWh: 0.05, co2Grams: 0.015, @@ -422,7 +419,7 @@ describe('getProjectStats', () => { db, makeSession({ sessionId: 's2', - projectIdentifier: 'org_project-b_bbbb2222', + projectIdentifier: 'bbbb2222', totalTokens: 2000, energyWh: 0.1, co2Grams: 0.03, @@ -434,7 +431,7 @@ describe('getProjectStats', () => { db, makeSession({ sessionId: 's3', - projectIdentifier: 'org_project-b_bbbb2222', + projectIdentifier: 'bbbb2222', totalTokens: 3000, energyWh: 0.15, co2Grams: 0.045, @@ -447,12 +444,12 @@ describe('getProjectStats', () => { expect(stats).toHaveLength(2); // Sorted by CO2 desc, so project-b first - expect(stats[0].projectPath).toBe('org_project-b_bbbb2222'); + expect(stats[0].projectPath).toBe('bbbb2222'); expect(stats[0].sessions).toBe(2); expect(stats[0].tokens).toBe(5000); expect(stats[0].co2Grams).toBeCloseTo(0.075); - expect(stats[1].projectPath).toBe('org_project-a_aaaa1111'); + expect(stats[1].projectPath).toBe('aaaa1111'); expect(stats[1].sessions).toBe(1); expect(stats[1].tokens).toBe(1000); @@ -531,6 +528,85 @@ describe('migrations', () => { expect(row.user_version).toBe(MIGRATIONS.length); db.close(); }); + + it('migration v6 renames user_name to organization in plugin_config', () => { + const db = new Database(':memory:'); + initializeDatabase(db); + + // Simulate pre-v6 state: insert old key name, rewind to v5 + setConfig(db, 'claude_code_user_name', 'My Org'); + db.run('PRAGMA user_version = 5'); + + initializeDatabase(db); + + expect(getConfig(db, 'claude_code_organization')).toBe('My Org'); + expect(getConfig(db, 'claude_code_user_name')).toBeNull(); + db.close(); + }); + + it('migration v6 truncates project identifiers to 8-char hash', () => { + const db = new Database(':memory:'); + initializeDatabase(db); + + // Insert sessions with old-format identifiers + upsertSession( + db, + makeSession({ + sessionId: 's1', + projectIdentifier: 'cnaught_my-repo_a1b2c3d4' + }) + ); + upsertSession( + db, + makeSession({ + sessionId: 's2', + projectIdentifier: 'local_e5f6a7b8' + }) + ); + upsertSession( + db, + makeSession({ + sessionId: 's3', + projectIdentifier: 'abcd1234' + }) + ); + + // Rewind to v5 and re-run migrations + db.run('PRAGMA user_version = 5'); + initializeDatabase(db); + + const s1 = getSession(db, 's1'); + const s2 = getSession(db, 's2'); + const s3 = getSession(db, 's3'); + + expect(s1?.projectIdentifier).toBe('a1b2c3d4'); + expect(s2?.projectIdentifier).toBe('e5f6a7b8'); + expect(s3?.projectIdentifier).toBe('abcd1234'); + db.close(); + }); + + it('migration v6 computes hash for sessions with empty project_identifier', () => { + const db = new Database(':memory:'); + initializeDatabase(db); + + // Insert a session then clear its project_identifier to simulate pre-v2 data + upsertSession( + db, + makeSession({ + sessionId: 's1', + projectPath: '/Users/test/my-project', + projectIdentifier: 'placeholder' + }) + ); + db.run("UPDATE sessions SET project_identifier = '' WHERE session_id = 's1'"); + + db.run('PRAGMA user_version = 5'); + initializeDatabase(db); + + const s1 = getSession(db, 's1'); + expect(s1?.projectIdentifier).toMatch(/^[a-f0-9]{8}$/); + db.close(); + }); }); describe('getConfig / setConfig / deleteConfig', () => { @@ -750,7 +826,7 @@ describe('configureSyncTracking sync_status behavior', () => { // Simulate first-time sync enable without backfill setConfig(db, 'sync_enabled', 'true'); setConfig(db, 'claude_code_user_id', 'test-user-id'); - setConfig(db, 'claude_code_user_name', 'Test User'); + setConfig(db, 'claude_code_organization', 'Test Org'); db.run("UPDATE sessions SET sync_status = 'synced' WHERE sync_status != 'synced'"); // Existing sessions should no longer need sync @@ -769,7 +845,7 @@ describe('configureSyncTracking sync_status behavior', () => { // Simulate already-configured sync setConfig(db, 'sync_enabled', 'true'); setConfig(db, 'claude_code_user_id', 'existing-user-id'); - setConfig(db, 'claude_code_user_name', 'Existing User'); + setConfig(db, 'claude_code_organization', 'Existing Org'); // Add sessions that haven't synced yet upsertSession(db, makeSession({ sessionId: 's1' })); diff --git a/plugins/carbon/src/data-store.ts b/plugins/carbon/src/data-store.ts index 9596441..872d496 100644 --- a/plugins/carbon/src/data-store.ts +++ b/plugins/carbon/src/data-store.ts @@ -218,6 +218,43 @@ export const MIGRATIONS: Migration[] = [ db.run('ALTER TABLE sessions DROP COLUMN needs_sync'); } } + }, + { + version: 6, + description: 'Rename user name to organization, simplify project identifiers to hash only', + up: (db) => { + // Rename claude_code_user_name → claude_code_organization in plugin_config + db.run( + "UPDATE plugin_config SET key = 'claude_code_organization' WHERE key = 'claude_code_user_name'" + ); + + // Simplify project_identifier to just the 8-char hash (strip org_repo_ or local_ prefix) + // The hash is always the last 8 chars of the identifier + db.run( + 'UPDATE sessions SET project_identifier = substr(project_identifier, -8) WHERE length(project_identifier) > 8' + ); + + // For sessions with empty project_identifier, compute hash from project_path + const emptyRows = db + .prepare( + "SELECT session_id, project_path FROM sessions WHERE project_identifier = '' OR project_identifier IS NULL" + ) + .all() as { session_id: string; project_path: string }[]; + for (const row of emptyRows) { + const hash = crypto + .createHash('sha256') + .update(row.project_path) + .digest('hex') + .slice(0, 8); + db.prepare('UPDATE sessions SET project_identifier = ? WHERE session_id = ?').run( + hash, + row.session_id + ); + } + + // Remove project_name entries from project_config (no longer used) + db.run("DELETE FROM project_config WHERE key = 'project_name'"); + } } ]; @@ -225,6 +262,8 @@ export const MIGRATIONS: Migration[] = [ * Check if a column exists on a table (useful for idempotent migrations) */ export function columnExists(db: Database, table: string, column: string): boolean { + // PRAGMA doesn't support parameterized binding, so interpolation is required here. + // All callers pass hardcoded table names. const cols = db.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[]; return cols.some((c) => c.name === column); } diff --git a/plugins/carbon/src/project-identifier.test.ts b/plugins/carbon/src/project-identifier.test.ts index 6d4269f..8aa7b7c 100644 --- a/plugins/carbon/src/project-identifier.test.ts +++ b/plugins/carbon/src/project-identifier.test.ts @@ -1,6 +1,15 @@ -import { describe, expect, it } from 'bun:test'; +import { describe, expect, it, mock } from 'bun:test'; +import * as crypto from 'node:crypto'; -import { parseGitRemote, shortHash } from './project-identifier'; +// Re-register the real module to clear any mock.module leaks from other test files +mock.module('./project-identifier.js', () => ({ + shortHash: (input: string) => + crypto.createHash('sha256').update(input).digest('hex').slice(0, 8), + resolveProjectIdentifier: (rawPath: string) => + crypto.createHash('sha256').update(rawPath).digest('hex').slice(0, 8) +})); + +const { resolveProjectIdentifier, shortHash } = await import('./project-identifier'); describe('shortHash', () => { it('returns 8 hex characters', () => { @@ -17,39 +26,14 @@ describe('shortHash', () => { }); }); -describe('parseGitRemote', () => { - it('parses HTTPS URLs with .git suffix', () => { - const result = parseGitRemote('https://github.com/cnaught/claude-code-plugins.git'); - expect(result).toEqual({ org: 'cnaught', repo: 'claude-code-plugins' }); - }); - - it('parses HTTPS URLs without .git suffix', () => { - const result = parseGitRemote('https://github.com/cnaught/claude-code-plugins'); - expect(result).toEqual({ org: 'cnaught', repo: 'claude-code-plugins' }); - }); - - it('parses SSH URLs with .git suffix', () => { - const result = parseGitRemote('git@github.com:cnaught/claude-code-plugins.git'); - expect(result).toEqual({ org: 'cnaught', repo: 'claude-code-plugins' }); - }); - - it('parses SSH URLs without .git suffix', () => { - const result = parseGitRemote('git@github.com:cnaught/claude-code-plugins'); - expect(result).toEqual({ org: 'cnaught', repo: 'claude-code-plugins' }); - }); - - it('handles GitLab URLs', () => { - const result = parseGitRemote('https://gitlab.com/myorg/myrepo.git'); - expect(result).toEqual({ org: 'myorg', repo: 'myrepo' }); +describe('resolveProjectIdentifier', () => { + it('returns the short hash of the path', () => { + const id = resolveProjectIdentifier('/Users/jason/my-project'); + expect(id).toBe(shortHash('/Users/jason/my-project')); }); - it('handles SSH URLs with custom hosts', () => { - const result = parseGitRemote('git@gitlab.company.com:team/project.git'); - expect(result).toEqual({ org: 'team', repo: 'project' }); - }); - - it('returns null for unrecognized URLs', () => { - expect(parseGitRemote('not-a-url')).toBeNull(); - expect(parseGitRemote('')).toBeNull(); + it('returns 8 hex characters', () => { + const id = resolveProjectIdentifier('/foo/bar'); + expect(id).toMatch(/^[a-f0-9]{8}$/); }); }); diff --git a/plugins/carbon/src/project-identifier.ts b/plugins/carbon/src/project-identifier.ts index 329e928..4f023f4 100644 --- a/plugins/carbon/src/project-identifier.ts +++ b/plugins/carbon/src/project-identifier.ts @@ -2,19 +2,11 @@ * Project Identifier * * Resolves a stable project identifier from a raw filesystem path. - * - Git repos: `__` (e.g., `cnaught_claude-code-plugins_a1b2c3d4`) - * - Non-git dirs: `local_` - * - Custom name configured: `_` - * - * The hash is the first 8 chars of SHA-256 of the raw path, ensuring - * uniqueness even when org/repo collide across machines. + * The identifier is the first 8 chars of SHA-256 of the raw path. */ -import { execSync } from 'node:child_process'; import * as crypto from 'node:crypto'; -import { getProjectConfig, queryReadonlyDb } from './data-store'; - /** * Compute the first 8 hex chars of SHA-256 for a string. */ @@ -22,81 +14,10 @@ export function shortHash(input: string): string { return crypto.createHash('sha256').update(input).digest('hex').slice(0, 8); } -/** - * Parse `/` from a git remote URL. - * Handles both HTTPS and SSH formats: - * - https://github.com/org/repo.git - * - git@github.com:org/repo.git - * Returns null if the URL doesn't match. - */ -export function parseGitRemote(url: string): { org: string; repo: string } | null { - // HTTPS: https://github.com/org/repo.git or https://github.com/org/repo - const httpsMatch = url.match(/https?:\/\/[^/]+\/([^/]+)\/([^/\s]+?)(?:\.git)?$/); - if (httpsMatch) { - return { org: httpsMatch[1], repo: httpsMatch[2] }; - } - - // SSH: git@github.com:org/repo.git or git@github.com:org/repo - const sshMatch = url.match(/git@[^:]+:([^/]+)\/([^/\s]+?)(?:\.git)?$/); - if (sshMatch) { - return { org: sshMatch[1], repo: sshMatch[2] }; - } - - return null; -} - -/** - * Try to get the git remote origin URL for a path. - * Returns null if not a git repo or git is not available. - */ -function getGitRemoteUrl(rawPath: string): string | null { - try { - const url = execSync(`git -C "${rawPath}" remote get-url origin`, { - encoding: 'utf-8', - timeout: 5000, - stdio: ['pipe', 'pipe', 'pipe'] - }).trim(); - return url || null; - } catch { - return null; - } -} - -/** - * Get the user-configured project name from the database, if any. - */ -function getConfiguredProjectName(rawPath: string): string | null { - const hash = shortHash(rawPath); - return queryReadonlyDb((db) => { - return getProjectConfig(db, hash, 'project_name'); - }); -} - /** * Resolve a stable project identifier from a raw filesystem path. - * - * Priority: - * 1. User-configured project name → `_` - * 2. Git remote → `__` - * 3. Fallback → `local_` + * Returns the first 8 hex chars of SHA-256 of the path. */ export function resolveProjectIdentifier(rawPath: string): string { - const hash = shortHash(rawPath); - - // Check for user-configured name - const customName = getConfiguredProjectName(rawPath); - if (customName) { - return `${customName}_${hash}`; - } - - // Try git remote - const remoteUrl = getGitRemoteUrl(rawPath); - if (remoteUrl) { - const parsed = parseGitRemote(remoteUrl); - if (parsed) { - return `${parsed.org}_${parsed.repo}_${hash}`; - } - } - - return `local_${hash}`; + return shortHash(rawPath); } diff --git a/plugins/carbon/src/scripts/carbon-rename-user.ts b/plugins/carbon/src/scripts/carbon-rename-user.ts index a1621de..86d2783 100644 --- a/plugins/carbon/src/scripts/carbon-rename-user.ts +++ b/plugins/carbon/src/scripts/carbon-rename-user.ts @@ -1,16 +1,14 @@ /** - * Carbon Rename Script + * Carbon Rename User Script * - * Updates the display name for anonymous carbon tracking. + * Updates the organization name for anonymous carbon tracking. * * Usage: - * carbon-rename.js --name "New Name" + * carbon-rename-user.ts --name "New Organization" */ import '../utils/load-env'; -import { adjectives, animals, uniqueNamesGenerator } from 'unique-names-generator'; - import { getConfig, initializeDatabase, openDatabase, setConfig } from '../data-store'; import { getArgValue, validateName } from '../utils/args'; import { logError } from '../utils/stdin'; @@ -18,12 +16,15 @@ import { logError } from '../utils/stdin'; function main(): void { const newName = getArgValue('--name'); - if (newName !== null) { - const error = validateName(newName); - if (error) { - console.error(`Display name: ${error}`); - process.exit(1); - } + if (newName === null) { + console.log('Error: provide --name "Organization Name"'); + process.exit(1); + } + + const error = validateName(newName, 100); + if (error) { + console.error(`Organization: ${error}`); + process.exit(1); } const db = openDatabase(); @@ -38,24 +39,16 @@ function main(): void { return; } - const oldName = getConfig(db, 'claude_code_user_name') || 'Unknown'; - const displayName = - newName || - uniqueNamesGenerator({ - dictionaries: [adjectives, animals], - separator: ' ', - style: 'capital' - }); - - setConfig(db, 'claude_code_user_name', displayName); + const oldOrg = getConfig(db, 'claude_code_organization') || ''; + setConfig(db, 'claude_code_organization', newName); - if (newName) { - console.log(`Renamed from "${oldName}" to "${displayName}".`); + if (oldOrg) { + console.log(`Updated organization from "${oldOrg}" to "${newName}".`); } else { - console.log(`Renamed from "${oldName}" to "${displayName}" (randomly generated).`); + console.log(`Organization set to "${newName}".`); } } catch (error) { - logError('Failed to rename', error); + logError('Failed to update organization', error); } finally { db.close(); } diff --git a/plugins/carbon/src/scripts/carbon-report.ts b/plugins/carbon/src/scripts/carbon-report.ts index 581204e..a552f60 100644 --- a/plugins/carbon/src/scripts/carbon-report.ts +++ b/plugins/carbon/src/scripts/carbon-report.ts @@ -8,6 +8,7 @@ import '../utils/load-env'; import type { Database } from 'bun:sqlite'; +import { getDashboardUrl } from '../api-client'; import { getModelConfig, MILES_PER_KG_CO2 } from '../carbon-calculator'; import { getAggregateStats, @@ -178,8 +179,11 @@ async function main(): Promise { oldestSessionDate: getOldestSessionDate(db), syncInfo: { enabled: syncEnabled, - userName: syncEnabled ? getConfig(db, 'claude_code_user_name') : null, + organization: syncEnabled + ? getConfig(db, 'claude_code_organization') + : null, userId: syncEnabled ? getConfig(db, 'claude_code_user_id') : null, + teamId: syncEnabled ? getConfig(db, 'claude_code_team_id') : null, pendingCount: syncEnabled ? getUnsyncedSessions(db, 1000).length : 0 } }; @@ -191,7 +195,7 @@ async function main(): Promise { // ── Header ──────────────────────────────────────────── console.log(''); console.log(`${c.bold} ╔══════════════════════════════════════════════════╗${c.reset}`); - console.log(`${c.bold} ║ Climate Impact Report ║${c.reset}`); + console.log(`${c.bold} ║ [Cø] CNaught Climate Impact Report ║${c.reset}`); console.log(`${c.bold} ╚══════════════════════════════════════════════════╝${c.reset}`); console.log(''); @@ -335,7 +339,14 @@ async function main(): Promise { console.log(`${c.bold} Sync${c.reset}`); console.log(`${c.gray} ──────────────────────────────────────────────────${c.reset}`); console.log(''); - console.log(` ${c.dim}Name:${c.reset} ${syncInfo.userName || 'Unknown'}`); + if (syncInfo.organization) { + console.log(` ${c.dim}Organization:${c.reset} ${syncInfo.organization}`); + } + if (syncInfo.teamId) { + console.log( + ` ${c.dim}Dashboard:${c.reset} ${getDashboardUrl(syncInfo.teamId)}` + ); + } if (syncInfo.pendingCount > 0) { console.log( ` ${c.dim}Pending sync:${c.reset} ${syncInfo.pendingCount} session(s)` @@ -345,22 +356,7 @@ async function main(): Promise { } // ── Footer ──────────────────────────────────────────── - const now = new Date(); - const timestamp = now.toLocaleString('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric', - hour: 'numeric', - minute: '2-digit', - hour12: true - }); - console.log(`${c.gray} Last updated: ${timestamp}${c.reset}`); console.log(`${c.gray} DB: ${getDatabasePath()}${c.reset}`); - console.log(`${c.bold} ╔══════════════════════════════════════════════════╗${c.reset}`); - console.log( - `${c.bold} ║${c.reset} ${c.bold}[Cø]${c.reset} ${c.gray}Powered by CNaught${c.reset} ${c.bold}║${c.reset}` - ); - console.log(`${c.bold} ╚══════════════════════════════════════════════════╝${c.reset}`); console.log(''); } catch (error) { logError('Failed to generate report', error); diff --git a/plugins/carbon/src/scripts/carbon-setup-check.ts b/plugins/carbon/src/scripts/carbon-setup-check.ts index 38746f6..56dbeca 100644 --- a/plugins/carbon/src/scripts/carbon-setup-check.ts +++ b/plugins/carbon/src/scripts/carbon-setup-check.ts @@ -16,19 +16,18 @@ import { getConfig, getDatabasePath, getInstalledAt, - getProjectConfig, queryReadonlyDb } from '../data-store'; -import { shortHash } from '../project-identifier'; +import { resolveProjectIdentifier } from '../project-identifier'; import { isCarbonStatusLine } from './setup-helpers'; interface SetupCheckResult { isSetup: boolean; installedAt?: string; syncEnabled?: boolean; - userName?: string; + organization?: string; userId?: string; - projectName?: string | null; + projectId?: string; statusLineConfigured?: boolean; } @@ -57,19 +56,18 @@ function main(): void { return { isSetup: false }; } - const projectHash = shortHash(process.cwd()); - const projectName = getProjectConfig(db, projectHash, 'project_name'); + const projectId = resolveProjectIdentifier(process.cwd()); const syncEnabled = getConfig(db, 'sync_enabled') === 'true'; - const userName = getConfig(db, 'claude_code_user_name'); + const organization = getConfig(db, 'claude_code_organization'); const userId = getConfig(db, 'claude_code_user_id'); return { isSetup: true, installedAt: installedAt.toISOString(), syncEnabled, - userName: userName ?? undefined, + organization: organization ?? undefined, userId: userId ?? undefined, - projectName: projectName ?? null, + projectId, statusLineConfigured: checkStatusLine() }; }); diff --git a/plugins/carbon/src/scripts/carbon-setup.ts b/plugins/carbon/src/scripts/carbon-setup.ts index 2401afc..a4ed486 100644 --- a/plugins/carbon/src/scripts/carbon-setup.ts +++ b/plugins/carbon/src/scripts/carbon-setup.ts @@ -12,8 +12,6 @@ import '../utils/load-env'; import * as fs from 'node:fs'; import * as path from 'node:path'; -import { adjectives, animals, uniqueNamesGenerator } from 'unique-names-generator'; - import { getDashboardUrl } from '../api-client'; import { calculateSessionCarbon } from '../carbon-calculator'; import { @@ -25,10 +23,9 @@ import { openDatabase, setConfig, setInstalledAt, - setProjectConfig, withDatabase } from '../data-store'; -import { resolveProjectIdentifier, shortHash } from '../project-identifier'; +import { resolveProjectIdentifier } from '../project-identifier'; import { saveSessionToDb } from '../session-db'; import { findAllTranscripts, @@ -86,11 +83,10 @@ function backfillSessions(db: import('bun:sqlite').Database): number { /** * Configure anonymous usage tracking with the CNaught API. * Generates a random user ID on first enable. - * Uses the provided display name, or generates a random one. */ async function configureSyncTracking( shouldBackfill: boolean, - customUserName: string | null + organization: string | null ): Promise { const db = openDatabase(); try { @@ -99,33 +95,30 @@ async function configureSyncTracking( // Check if sync was already configured const existingUserId = getConfig(db, 'claude_code_user_id'); if (existingUserId) { - const existingName = getConfig(db, 'claude_code_user_name') || 'Unknown'; - // Update name if a new one was provided - if (customUserName) { - setConfig(db, 'claude_code_user_name', customUserName); + const existingOrg = getConfig(db, 'claude_code_organization') || ''; + // Update organization if a new one was provided + if (organization) { + setConfig(db, 'claude_code_organization', organization); console.log( - ` Updated name to "${customUserName}" (id: ${existingUserId.slice(0, 8)}...)` + ` Updated organization to "${organization}" (id: ${existingUserId.slice(0, 8)}...)` ); } else { + const orgDisplay = existingOrg ? `"${existingOrg}"` : 'no organization'; console.log( - ` Already configured as "${existingName}" (id: ${existingUserId.slice(0, 8)}...)` + ` Already configured with ${orgDisplay} (id: ${existingUserId.slice(0, 8)}...)` ); } setConfig(db, 'sync_enabled', 'true'); } else { const userId = generateMachineUserId(); - const userName = - customUserName || - uniqueNamesGenerator({ - dictionaries: [adjectives, animals], - separator: ' ', - style: 'capital' - }); setConfig(db, 'sync_enabled', 'true'); setConfig(db, 'claude_code_user_id', userId); - setConfig(db, 'claude_code_user_name', userName); - console.log(` Sync enabled as "${userName}" (id: ${userId.slice(0, 8)}...)`); + if (organization) { + setConfig(db, 'claude_code_organization', organization); + } + const orgDisplay = organization ? ` for "${organization}"` : ''; + console.log(` Sync enabled${orgDisplay} (id: ${userId.slice(0, 8)}...)`); } const isFirstEnable = !existingUserId; @@ -152,20 +145,28 @@ async function configureSyncTracking( async function main(): Promise { const shouldBackfill = hasFlag('--backfill'); const shouldEnableSync = !hasFlag('--disable-sync'); - const customUserName = getArgValue('--user-name'); - const customProjectName = getArgValue('--project-name'); + const organization = getArgValue('--organization'); - // Validate names if provided - for (const [label, name, maxLen] of [ - ['User name', customUserName, 50], - ['Project name', customProjectName, 100] - ] as const) { - if (name !== null) { - const error = validateName(name, maxLen); - if (error) { - console.error(`${label}: ${error}`); - process.exit(1); - } + // Validate organization if provided + if (organization !== null) { + const error = validateName(organization, 100); + if (error) { + console.error(`Organization: ${error}`); + process.exit(1); + } + } + + // Organization is required when sync is enabled + if (shouldEnableSync && !organization) { + // Check if there's already an organization configured + const existingOrg = withDatabase((db) => getConfig(db, 'claude_code_organization')); + if (!existingOrg) { + console.error( + 'Error: --organization is required when sync is enabled.\n' + + 'Use --organization "Your Org" to set your organization name,\n' + + 'or use --disable-sync to skip anonymous tracking.' + ); + process.exit(1); } } console.log('\n'); @@ -181,13 +182,6 @@ async function main(): Promise { const isFirstInstall = getInstalledAt(db) === null; setInstalledAt(db); - // Store project name if provided (scoped to this project's path hash) - if (customProjectName) { - const projectHash = shortHash(process.cwd()); - setProjectConfig(db, projectHash, 'project_name', customProjectName); - console.log(` Project name set to "${customProjectName}"`); - } - console.log(' Database initialized successfully'); if (isFirstInstall && !shouldBackfill) { console.log(' First install detected — only new sessions will be tracked'); @@ -248,7 +242,7 @@ async function main(): Promise { // Step 3: Anonymous usage tracking if (shouldEnableSync) { console.log('Step 3: Anonymous usage tracking...'); - await configureSyncTracking(shouldBackfill, customUserName); + await configureSyncTracking(shouldBackfill, organization); console.log(''); } @@ -258,15 +252,15 @@ async function main(): Promise { console.log('========================================'); console.log('\n'); const projectId = resolveProjectIdentifier(process.cwd()); - console.log(`Project: ${projectId}`); + console.log(`Project ID: ${projectId}`); console.log(''); console.log('The carbon tracker is now active.'); console.log('You will see CO2 emissions in your status bar.'); if (shouldEnableSync) { console.log('Session data will sync to CNaught in the background.'); - const userId = withDatabase((db) => getConfig(db, 'claude_code_user_id')); - if (userId) { - console.log(`\n Dashboard: ${getDashboardUrl(userId)}`); + const teamId = withDatabase((db) => getConfig(db, 'claude_code_team_id')); + if (teamId) { + console.log(`\n Dashboard: ${getDashboardUrl(teamId)}`); } } console.log('\n'); diff --git a/plugins/carbon/src/scripts/carbon-uninstall.ts b/plugins/carbon/src/scripts/carbon-uninstall.ts index 1c93960..bfb738c 100644 --- a/plugins/carbon/src/scripts/carbon-uninstall.ts +++ b/plugins/carbon/src/scripts/carbon-uninstall.ts @@ -69,7 +69,7 @@ function main(): void { initializeDatabase(cleanupDb); deleteConfig(cleanupDb, 'sync_enabled'); deleteConfig(cleanupDb, 'claude_code_user_id'); - deleteConfig(cleanupDb, 'claude_code_user_name'); + deleteConfig(cleanupDb, 'claude_code_organization'); cleanupDb.close(); } catch { // Non-critical, database is about to be deleted anyway diff --git a/plugins/carbon/src/statusline/carbon-output.test.ts b/plugins/carbon/src/statusline/carbon-output.test.ts index 79f9c1c..3edd927 100644 --- a/plugins/carbon/src/statusline/carbon-output.test.ts +++ b/plugins/carbon/src/statusline/carbon-output.test.ts @@ -9,7 +9,7 @@ mock.module('../data-store.js', () => ({ })); mock.module('../project-identifier.js', () => ({ - resolveProjectIdentifier: () => `test_project_abcd1234` + resolveProjectIdentifier: () => `abcd1234` })); const { getCarbonOutput } = await import('./carbon-output'); @@ -182,7 +182,7 @@ describe('getCarbonOutput sync display', () => { .mockReturnValueOnce(null) .mockReturnValueOnce({ enabled: true, - userName: 'Curious Penguin', + organization: 'Curious Penguin', userId: 'abcd1234-5678' }) .mockReturnValueOnce('synced'); @@ -200,7 +200,7 @@ describe('getCarbonOutput sync display', () => { .mockReturnValueOnce(null) .mockReturnValueOnce({ enabled: true, - userName: 'Curious Penguin', + organization: 'Curious Penguin', userId: 'abcd1234-5678' }) .mockReturnValueOnce('dirty'); @@ -218,7 +218,7 @@ describe('getCarbonOutput sync display', () => { .mockReturnValueOnce(null) .mockReturnValueOnce({ enabled: true, - userName: 'Swift Falcon', + organization: 'Swift Falcon', userId: 'efgh5678-9012' }) .mockReturnValueOnce('failed'); @@ -236,7 +236,7 @@ describe('getCarbonOutput sync display', () => { .mockReturnValueOnce(null) .mockReturnValueOnce({ enabled: true, - userName: 'Swift Falcon', + organization: 'Swift Falcon', userId: 'efgh5678-9012' }) .mockReturnValueOnce('pending'); @@ -251,7 +251,7 @@ describe('getCarbonOutput sync display', () => { .mockReturnValueOnce({ co2Grams: 1.0, energyWh: 0.3 }) .mockReturnValueOnce(null) .mockReturnValueOnce(null) - .mockReturnValueOnce({ enabled: false, userName: null, userId: null }); + .mockReturnValueOnce({ enabled: false, organization: null, userId: null }); const result = getCarbonOutput({ session_id: 'test-session' }); @@ -265,7 +265,7 @@ describe('getCarbonOutput sync display', () => { // Call 3: getSyncInfo returns enabled mockQueryReadonlyDb.mockReturnValueOnce(5).mockReturnValueOnce(2).mockReturnValueOnce({ enabled: true, - userName: 'Curious Penguin', + organization: 'Curious Penguin', userId: 'abcd1234-5678' }); @@ -284,15 +284,16 @@ describe('getCarbonOutput sync display', () => { expect(result).not.toContain('\u21C4'); }); - it('does not show sync info when userName is missing', () => { + it('shows sync arrows even when organization is missing', () => { mockQueryReadonlyDb .mockReturnValueOnce({ co2Grams: 1.0, energyWh: 0.3 }) .mockReturnValueOnce(null) .mockReturnValueOnce(null) - .mockReturnValueOnce({ enabled: true, userName: null, userId: 'abcd1234' }); + .mockReturnValueOnce({ enabled: true, organization: null, userId: 'abcd1234' }) + .mockReturnValueOnce('synced'); const result = getCarbonOutput({ session_id: 'test-session' }); - expect(result).not.toContain('\u21C4'); + expect(result).toContain('\u21C4'); }); }); diff --git a/plugins/carbon/src/statusline/carbon-output.ts b/plugins/carbon/src/statusline/carbon-output.ts index bac62bf..0cba0c6 100644 --- a/plugins/carbon/src/statusline/carbon-output.ts +++ b/plugins/carbon/src/statusline/carbon-output.ts @@ -21,7 +21,7 @@ function getSessionStatsFromDb(sessionId: string): { co2Grams: number; energyWh: }); } -function getSyncInfo(): { enabled: boolean; userName: string | null; userId: string | null } { +function getSyncInfo(): { enabled: boolean; organization: string | null; userId: string | null } { return ( queryReadonlyDb((db) => { const get = (key: string) => { @@ -33,10 +33,10 @@ function getSyncInfo(): { enabled: boolean; userName: string | null; userId: str const enabled = get('sync_enabled') === 'true'; return { enabled, - userName: enabled ? get('claude_code_user_name') : null, + organization: enabled ? get('claude_code_organization') : null, userId: enabled ? get('claude_code_user_id') : null }; - }) ?? { enabled: false, userName: null, userId: null } + }) ?? { enabled: false, organization: null, userId: null } ); } @@ -138,7 +138,7 @@ export function getCarbonOutput(input: StatuslineInput): string { let syncSuffix = ''; const syncInfo = getSyncInfo(); - if (syncInfo.enabled && syncInfo.userName && syncInfo.userId) { + if (syncInfo.enabled && syncInfo.userId) { const syncStatus = input.session_id ? getSessionSyncStatus(input.session_id) : null; const green = '\x1b[38;2;50;205;50m'; // Lime green #32CD32 const red = '\x1b[38;2;208;83;63m'; // Brand orange #D0533F diff --git a/plugins/carbon/src/sync.ts b/plugins/carbon/src/sync.ts index d3464c6..41dfcef 100644 --- a/plugins/carbon/src/sync.ts +++ b/plugins/carbon/src/sync.ts @@ -17,24 +17,37 @@ import { initializeDatabase, markSessionSyncFailed, markSessionsSynced, - openDatabase + openDatabase, + setConfig } from './data-store'; import { log, logError } from './utils/stdin'; /** * Read sync configuration from the database. - * Returns null if sync is not enabled or config is incomplete. + * Returns null if sync is not enabled, config is incomplete, or organization is empty. */ export function getSyncConfig(db: Database): SyncConfig | null { const enabled = getConfig(db, 'sync_enabled'); if (enabled !== 'true') return null; const userId = getConfig(db, 'claude_code_user_id'); - const userName = getConfig(db, 'claude_code_user_name'); + if (!userId) return null; - if (!userId || !userName) return null; + // Organization is required for sync. Existing users who had sync_enabled=true + // but never set an organization will stop syncing until they re-run /carbon:setup. + const organization = getConfig(db, 'claude_code_organization') ?? ''; + if (!organization) return null; - return { userId, userName }; + return { userId, organization }; +} + +/** + * Store the teamId in the local database if present. + */ +function storeTeamId(db: Database, teamId: string | null): void { + if (teamId) { + setConfig(db, 'claude_code_team_id', teamId); + } } /** @@ -47,9 +60,10 @@ export async function syncSession(db: Database, sessionId: string): Promise { const batch = getUnsyncedSessions(db, 100); if (batch.length === 0) break; - const success = await upsertSessions(config, batch); + const result = await upsertSessions(config, batch); const batchIds = batch.map((s) => s.sessionId); - if (success) { + if (result.success) { markSessionsSynced(db, batchIds); + storeTeamId(db, result.teamId); totalSynced += batch.length; } else { markSessionSyncFailed(db, batchIds); diff --git a/plugins/carbon/src/utils/args.ts b/plugins/carbon/src/utils/args.ts index 2a85123..22e81f0 100644 --- a/plugins/carbon/src/utils/args.ts +++ b/plugins/carbon/src/utils/args.ts @@ -27,7 +27,7 @@ export function hasFlag(flag: string): boolean { } /** - * Validate a user-provided name (display name or project name). + * Validate a user-provided name (e.g., organization name). * Returns an error message if invalid, or null if valid. */ export function validateName(name: string, maxLength: number = MAX_NAME_LENGTH): string | null {