diff --git a/.claude/skills/persuasion-review/scripts/probe_harness.py b/.claude/skills/persuasion-review/scripts/probe_harness.py index d6087161..57598050 100644 --- a/.claude/skills/persuasion-review/scripts/probe_harness.py +++ b/.claude/skills/persuasion-review/scripts/probe_harness.py @@ -33,6 +33,7 @@ def wait_http_ready(url: str, timeout_sec: float) -> bool: deadline = time.time() + timeout_sec while time.time() < deadline: try: + # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected urllib.request.urlopen(url, timeout=1).read() return True except Exception: diff --git a/packages/cli/src/__tests__/default-command.test.ts b/packages/cli/src/__tests__/default-command.test.ts index 29ba9f45..58423fee 100644 --- a/packages/cli/src/__tests__/default-command.test.ts +++ b/packages/cli/src/__tests__/default-command.test.ts @@ -1,36 +1,36 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import { makeDefaultCommand } from '../commands/default.js' -import type { ExternalDeps } from '../deps.js' +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { makeDefaultCommand } from "../commands/default.js"; +import type { ExternalDeps } from "../deps.js"; const MOCK_CONFIG = { - token: 'test-token', - apiUrl: 'https://api.example.com', - userId: 'user-1', - email: 'test@example.com', -} + token: "test-token", + apiUrl: "https://api.example.com", + userId: "user-1", + email: "test@example.com", +}; const MOCK_PROJECT = { - projectId: 'proj-1', - orgId: 'org-1', - orgSlug: 'test-org', - orgName: 'Test Org', - projectName: 'test-project', - apiUrl: 'https://api.example.com', -} + projectId: "proj-1", + orgId: "org-1", + orgSlug: "test-org", + orgName: "Test Org", + projectName: "test-project", + apiUrl: "https://api.example.com", +}; const MOCK_LOGIN_RESPONSE = { - token: 'test-token', - user: { id: 'user-1', email: 'test@example.com' }, -} + token: "test-token", + user: { id: "user-1", email: "test@example.com" }, +}; const MOCK_CREATE_PROJECT_RESPONSE = { - projectId: 'proj-1', - orgId: 'org-1', - orgSlug: 'test-org', - orgName: 'Test Org', - projectName: 'test-project', - projectSlug: 'test-project', -} + projectId: "proj-1", + orgId: "org-1", + orgSlug: "test-org", + orgName: "Test Org", + projectName: "test-project", + projectSlug: "test-project", +}; function makeMockDeps(overrides: Partial = {}): ExternalDeps { return { @@ -41,7 +41,12 @@ function makeMockDeps(overrides: Partial = {}): ExternalDeps { }, project: { find: vi.fn().mockReturnValue(MOCK_PROJECT), - findWithPath: vi.fn().mockReturnValue({ config: MOCK_PROJECT, configPath: '/test/cwd/.argos/project.json' }), + findWithPath: vi + .fn() + .mockReturnValue({ + config: MOCK_PROJECT, + configPath: "/test/cwd/.argos/project.json", + }), write: vi.fn(), }, auth: { @@ -55,11 +60,11 @@ function makeMockDeps(overrides: Partial = {}): ExternalDeps { revokeToken: vi.fn().mockResolvedValue(undefined), }, hooks: { - inject: vi.fn().mockReturnValue('already_present'), + inject: vi.fn().mockReturnValue("already_present"), fileExists: vi.fn().mockReturnValue(false), }, prompt: { - input: vi.fn().mockResolvedValue('test-project'), + input: vi.fn().mockResolvedValue("test-project"), }, transcript: { extractUsage: vi.fn().mockResolvedValue(null), @@ -69,191 +74,343 @@ function makeMockDeps(overrides: Partial = {}): ExternalDeps { events: { sendBackground: vi.fn(), }, - cwd: vi.fn().mockReturnValue('/test/cwd'), + cwd: vi.fn().mockReturnValue("/test/cwd"), ...overrides, - } as ExternalDeps + } as ExternalDeps; } -describe('makeDefaultCommand', () => { +describe("makeDefaultCommand", () => { beforeEach(() => { - vi.spyOn(process, 'exit').mockImplementation((() => {}) as never) - }) + vi.spyOn(process, "exit").mockImplementation((() => {}) as never); + }); afterEach(() => { - vi.clearAllMocks() - }) + vi.clearAllMocks(); + }); - describe('Flow 1: config 없음 + project 없음 → Full Setup', () => { - it('deps.auth.login 이 호출된다', async () => { + describe("Flow 1: config 없음 + project 없음 → Full Setup", () => { + it("deps.auth.login 이 호출된다", async () => { const deps = makeMockDeps({ - config: { read: vi.fn().mockReturnValue(null), write: vi.fn(), delete: vi.fn() }, - project: { find: vi.fn().mockReturnValue(null), findWithPath: vi.fn().mockReturnValue(null), write: vi.fn() }, - }) - await makeDefaultCommand(deps)({}) - expect(deps.auth.login).toHaveBeenCalled() - }) + config: { + read: vi.fn().mockReturnValue(null), + write: vi.fn(), + delete: vi.fn(), + }, + project: { + find: vi.fn().mockReturnValue(null), + findWithPath: vi.fn().mockReturnValue(null), + write: vi.fn(), + }, + }); + await makeDefaultCommand(deps)({}); + expect(deps.auth.login).toHaveBeenCalled(); + }); - it('deps.config.write 이 호출된다', async () => { + it("deps.config.write 이 호출된다", async () => { const deps = makeMockDeps({ - config: { read: vi.fn().mockReturnValue(null), write: vi.fn(), delete: vi.fn() }, - project: { find: vi.fn().mockReturnValue(null), findWithPath: vi.fn().mockReturnValue(null), write: vi.fn() }, - }) - await makeDefaultCommand(deps)({}) - expect(deps.config.write).toHaveBeenCalled() - }) + config: { + read: vi.fn().mockReturnValue(null), + write: vi.fn(), + delete: vi.fn(), + }, + project: { + find: vi.fn().mockReturnValue(null), + findWithPath: vi.fn().mockReturnValue(null), + write: vi.fn(), + }, + }); + await makeDefaultCommand(deps)({}); + expect(deps.config.write).toHaveBeenCalled(); + }); - it('deps.api.createProject 이 호출된다', async () => { + it("deps.api.createProject 이 호출된다", async () => { const deps = makeMockDeps({ - config: { read: vi.fn().mockReturnValue(null), write: vi.fn(), delete: vi.fn() }, - project: { find: vi.fn().mockReturnValue(null), findWithPath: vi.fn().mockReturnValue(null), write: vi.fn() }, - }) - await makeDefaultCommand(deps)({}) - expect(deps.api.createProject).toHaveBeenCalled() - }) + config: { + read: vi.fn().mockReturnValue(null), + write: vi.fn(), + delete: vi.fn(), + }, + project: { + find: vi.fn().mockReturnValue(null), + findWithPath: vi.fn().mockReturnValue(null), + write: vi.fn(), + }, + }); + await makeDefaultCommand(deps)({}); + expect(deps.api.createProject).toHaveBeenCalled(); + }); - it('deps.project.write 이 호출된다', async () => { + it("deps.project.write 이 호출된다", async () => { const deps = makeMockDeps({ - config: { read: vi.fn().mockReturnValue(null), write: vi.fn(), delete: vi.fn() }, - project: { find: vi.fn().mockReturnValue(null), findWithPath: vi.fn().mockReturnValue(null), write: vi.fn() }, - }) - await makeDefaultCommand(deps)({}) - expect(deps.project.write).toHaveBeenCalled() - }) + config: { + read: vi.fn().mockReturnValue(null), + write: vi.fn(), + delete: vi.fn(), + }, + project: { + find: vi.fn().mockReturnValue(null), + findWithPath: vi.fn().mockReturnValue(null), + write: vi.fn(), + }, + }); + await makeDefaultCommand(deps)({}); + expect(deps.project.write).toHaveBeenCalled(); + }); - it('deps.hooks.inject 이 호출된다', async () => { + it("deps.hooks.inject 이 호출된다", async () => { const deps = makeMockDeps({ - config: { read: vi.fn().mockReturnValue(null), write: vi.fn(), delete: vi.fn() }, - project: { find: vi.fn().mockReturnValue(null), findWithPath: vi.fn().mockReturnValue(null), write: vi.fn() }, - }) - await makeDefaultCommand(deps)({}) - expect(deps.hooks.inject).toHaveBeenCalled() - }) - }) + config: { + read: vi.fn().mockReturnValue(null), + write: vi.fn(), + delete: vi.fn(), + }, + project: { + find: vi.fn().mockReturnValue(null), + findWithPath: vi.fn().mockReturnValue(null), + write: vi.fn(), + }, + }); + await makeDefaultCommand(deps)({}); + expect(deps.hooks.inject).toHaveBeenCalled(); + }); + }); - describe('Flow 2: config 없음 + project 있음 → Login & Join', () => { - it('deps.auth.login 이 호출된다', async () => { + describe("Flow 2: config 없음 + project 있음 → Login & Join", () => { + it("deps.auth.login 이 호출된다", async () => { const deps = makeMockDeps({ - config: { read: vi.fn().mockReturnValue(null), write: vi.fn(), delete: vi.fn() }, - project: { find: vi.fn().mockReturnValue(MOCK_PROJECT), findWithPath: vi.fn().mockReturnValue({ config: MOCK_PROJECT, configPath: '/test/cwd/.argos/project.json' }), write: vi.fn() }, - }) - await makeDefaultCommand(deps)({}) - expect(deps.auth.login).toHaveBeenCalled() - }) + config: { + read: vi.fn().mockReturnValue(null), + write: vi.fn(), + delete: vi.fn(), + }, + project: { + find: vi.fn().mockReturnValue(MOCK_PROJECT), + findWithPath: vi + .fn() + .mockReturnValue({ + config: MOCK_PROJECT, + configPath: "/test/cwd/.argos/project.json", + }), + write: vi.fn(), + }, + }); + await makeDefaultCommand(deps)({}); + expect(deps.auth.login).toHaveBeenCalled(); + }); - it('deps.api.joinOrg 이 project.orgSlug 인자와 함께 호출된다', async () => { + it("deps.api.joinOrg 이 project.orgSlug 인자와 함께 호출된다", async () => { const deps = makeMockDeps({ - config: { read: vi.fn().mockReturnValue(null), write: vi.fn(), delete: vi.fn() }, - project: { find: vi.fn().mockReturnValue(MOCK_PROJECT), findWithPath: vi.fn().mockReturnValue({ config: MOCK_PROJECT, configPath: '/test/cwd/.argos/project.json' }), write: vi.fn() }, - }) - await makeDefaultCommand(deps)({}) + config: { + read: vi.fn().mockReturnValue(null), + write: vi.fn(), + delete: vi.fn(), + }, + project: { + find: vi.fn().mockReturnValue(MOCK_PROJECT), + findWithPath: vi + .fn() + .mockReturnValue({ + config: MOCK_PROJECT, + configPath: "/test/cwd/.argos/project.json", + }), + write: vi.fn(), + }, + }); + await makeDefaultCommand(deps)({}); expect(deps.api.joinOrg).toHaveBeenCalledWith( MOCK_PROJECT.orgSlug, expect.any(String), - expect.any(String) - ) - }) + expect.any(String), + ); + }); - it('deps.config.write 이 호출된다', async () => { + it("deps.config.write 이 호출된다", async () => { const deps = makeMockDeps({ - config: { read: vi.fn().mockReturnValue(null), write: vi.fn(), delete: vi.fn() }, - project: { find: vi.fn().mockReturnValue(MOCK_PROJECT), findWithPath: vi.fn().mockReturnValue({ config: MOCK_PROJECT, configPath: '/test/cwd/.argos/project.json' }), write: vi.fn() }, - }) - await makeDefaultCommand(deps)({}) - expect(deps.config.write).toHaveBeenCalled() - }) + config: { + read: vi.fn().mockReturnValue(null), + write: vi.fn(), + delete: vi.fn(), + }, + project: { + find: vi.fn().mockReturnValue(MOCK_PROJECT), + findWithPath: vi + .fn() + .mockReturnValue({ + config: MOCK_PROJECT, + configPath: "/test/cwd/.argos/project.json", + }), + write: vi.fn(), + }, + }); + await makeDefaultCommand(deps)({}); + expect(deps.config.write).toHaveBeenCalled(); + }); - it('deps.api.createProject 이 호출되지 않는다', async () => { + it("deps.api.createProject 이 호출되지 않는다", async () => { const deps = makeMockDeps({ - config: { read: vi.fn().mockReturnValue(null), write: vi.fn(), delete: vi.fn() }, - project: { find: vi.fn().mockReturnValue(MOCK_PROJECT), findWithPath: vi.fn().mockReturnValue({ config: MOCK_PROJECT, configPath: '/test/cwd/.argos/project.json' }), write: vi.fn() }, - }) - await makeDefaultCommand(deps)({}) - expect(deps.api.createProject).not.toHaveBeenCalled() - }) + config: { + read: vi.fn().mockReturnValue(null), + write: vi.fn(), + delete: vi.fn(), + }, + project: { + find: vi.fn().mockReturnValue(MOCK_PROJECT), + findWithPath: vi + .fn() + .mockReturnValue({ + config: MOCK_PROJECT, + configPath: "/test/cwd/.argos/project.json", + }), + write: vi.fn(), + }, + }); + await makeDefaultCommand(deps)({}); + expect(deps.api.createProject).not.toHaveBeenCalled(); + }); - it('orgSlug 누락된 legacy project.json 에서는 orgId 로 joinOrg 가 호출된다', async () => { - const legacyProject = { ...MOCK_PROJECT, orgSlug: undefined } as unknown as typeof MOCK_PROJECT + it("orgSlug 누락된 legacy project.json 에서는 orgId 로 joinOrg 가 호출된다", async () => { + const legacyProject = { + ...MOCK_PROJECT, + orgSlug: undefined, + } as unknown as typeof MOCK_PROJECT; const deps = makeMockDeps({ - config: { read: vi.fn().mockReturnValue(null), write: vi.fn(), delete: vi.fn() }, - project: { find: vi.fn().mockReturnValue(legacyProject), findWithPath: vi.fn().mockReturnValue({ config: legacyProject, configPath: '/test/cwd/.argos/project.json' }), write: vi.fn() }, - }) - await makeDefaultCommand(deps)({}) + config: { + read: vi.fn().mockReturnValue(null), + write: vi.fn(), + delete: vi.fn(), + }, + project: { + find: vi.fn().mockReturnValue(legacyProject), + findWithPath: vi + .fn() + .mockReturnValue({ + config: legacyProject, + configPath: "/test/cwd/.argos/project.json", + }), + write: vi.fn(), + }, + }); + await makeDefaultCommand(deps)({}); expect(deps.api.joinOrg).toHaveBeenCalledWith( MOCK_PROJECT.orgId, expect.any(String), - expect.any(String) - ) - }) - }) + expect.any(String), + ); + }); + }); - describe('Flow 3: config 있음 + project 없음 → Project Init', () => { - it('deps.auth.login 이 호출되지 않는다 (이미 로그인)', async () => { + describe("Flow 3: config 있음 + project 없음 → Project Init", () => { + it("deps.auth.login 이 호출되지 않는다 (이미 로그인)", async () => { const deps = makeMockDeps({ - config: { read: vi.fn().mockReturnValue(MOCK_CONFIG), write: vi.fn(), delete: vi.fn() }, - project: { find: vi.fn().mockReturnValue(null), findWithPath: vi.fn().mockReturnValue(null), write: vi.fn() }, - }) - await makeDefaultCommand(deps)({}) - expect(deps.auth.login).not.toHaveBeenCalled() - }) + config: { + read: vi.fn().mockReturnValue(MOCK_CONFIG), + write: vi.fn(), + delete: vi.fn(), + }, + project: { + find: vi.fn().mockReturnValue(null), + findWithPath: vi.fn().mockReturnValue(null), + write: vi.fn(), + }, + }); + await makeDefaultCommand(deps)({}); + expect(deps.auth.login).not.toHaveBeenCalled(); + }); - it('deps.api.createProject 이 호출된다', async () => { + it("deps.api.createProject 이 호출된다", async () => { const deps = makeMockDeps({ - config: { read: vi.fn().mockReturnValue(MOCK_CONFIG), write: vi.fn(), delete: vi.fn() }, - project: { find: vi.fn().mockReturnValue(null), findWithPath: vi.fn().mockReturnValue(null), write: vi.fn() }, - }) - await makeDefaultCommand(deps)({}) - expect(deps.api.createProject).toHaveBeenCalled() - }) + config: { + read: vi.fn().mockReturnValue(MOCK_CONFIG), + write: vi.fn(), + delete: vi.fn(), + }, + project: { + find: vi.fn().mockReturnValue(null), + findWithPath: vi.fn().mockReturnValue(null), + write: vi.fn(), + }, + }); + await makeDefaultCommand(deps)({}); + expect(deps.api.createProject).toHaveBeenCalled(); + }); - it('deps.project.write 이 호출된다', async () => { + it("deps.project.write 이 호출된다", async () => { const deps = makeMockDeps({ - config: { read: vi.fn().mockReturnValue(MOCK_CONFIG), write: vi.fn(), delete: vi.fn() }, - project: { find: vi.fn().mockReturnValue(null), findWithPath: vi.fn().mockReturnValue(null), write: vi.fn() }, - }) - await makeDefaultCommand(deps)({}) - expect(deps.project.write).toHaveBeenCalled() - }) + config: { + read: vi.fn().mockReturnValue(MOCK_CONFIG), + write: vi.fn(), + delete: vi.fn(), + }, + project: { + find: vi.fn().mockReturnValue(null), + findWithPath: vi.fn().mockReturnValue(null), + write: vi.fn(), + }, + }); + await makeDefaultCommand(deps)({}); + expect(deps.project.write).toHaveBeenCalled(); + }); - it('deps.hooks.inject 이 호출된다', async () => { + it("deps.hooks.inject 이 호출된다", async () => { const deps = makeMockDeps({ - config: { read: vi.fn().mockReturnValue(MOCK_CONFIG), write: vi.fn(), delete: vi.fn() }, - project: { find: vi.fn().mockReturnValue(null), findWithPath: vi.fn().mockReturnValue(null), write: vi.fn() }, - }) - await makeDefaultCommand(deps)({}) - expect(deps.hooks.inject).toHaveBeenCalled() - }) - }) + config: { + read: vi.fn().mockReturnValue(MOCK_CONFIG), + write: vi.fn(), + delete: vi.fn(), + }, + project: { + find: vi.fn().mockReturnValue(null), + findWithPath: vi.fn().mockReturnValue(null), + write: vi.fn(), + }, + }); + await makeDefaultCommand(deps)({}); + expect(deps.hooks.inject).toHaveBeenCalled(); + }); + }); - describe('Flow 4: config 있음 + project 있음 → Show Status', () => { - it('deps.api.ensureMembership 이 호출된다', async () => { - const deps = makeMockDeps() - await makeDefaultCommand(deps)({}) - expect(deps.api.ensureMembership).toHaveBeenCalled() - }) + describe("Flow 4: config 있음 + project 있음 → Show Status", () => { + it("deps.api.ensureMembership 이 호출된다", async () => { + const deps = makeMockDeps(); + await makeDefaultCommand(deps)({}); + expect(deps.api.ensureMembership).toHaveBeenCalled(); + }); - it('orgSlug 누락된 legacy project.json 에서는 orgId 로 ensureMembership 이 호출된다', async () => { - const legacyProject = { ...MOCK_PROJECT, orgSlug: undefined } as unknown as typeof MOCK_PROJECT + it("orgSlug 누락된 legacy project.json 에서는 orgId 로 ensureMembership 이 호출된다", async () => { + const legacyProject = { + ...MOCK_PROJECT, + orgSlug: undefined, + } as unknown as typeof MOCK_PROJECT; const deps = makeMockDeps({ - project: { find: vi.fn().mockReturnValue(legacyProject), findWithPath: vi.fn().mockReturnValue({ config: legacyProject, configPath: '/test/cwd/.argos/project.json' }), write: vi.fn() }, - }) - await makeDefaultCommand(deps)({}) + project: { + find: vi.fn().mockReturnValue(legacyProject), + findWithPath: vi + .fn() + .mockReturnValue({ + config: legacyProject, + configPath: "/test/cwd/.argos/project.json", + }), + write: vi.fn(), + }, + }); + await makeDefaultCommand(deps)({}); expect(deps.api.ensureMembership).toHaveBeenCalledWith( MOCK_PROJECT.orgId, expect.any(String), - expect.any(String) - ) - }) + expect.any(String), + ); + }); - it('deps.auth.login 이 호출되지 않는다', async () => { - const deps = makeMockDeps() - await makeDefaultCommand(deps)({}) - expect(deps.auth.login).not.toHaveBeenCalled() - }) + it("deps.auth.login 이 호출되지 않는다", async () => { + const deps = makeMockDeps(); + await makeDefaultCommand(deps)({}); + expect(deps.auth.login).not.toHaveBeenCalled(); + }); - it('deps.api.createProject 이 호출되지 않는다', async () => { - const deps = makeMockDeps() - await makeDefaultCommand(deps)({}) - expect(deps.api.createProject).not.toHaveBeenCalled() - }) - }) -}) + it("deps.api.createProject 이 호출되지 않는다", async () => { + const deps = makeMockDeps(); + await makeDefaultCommand(deps)({}); + expect(deps.api.createProject).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/cli/src/__tests__/hook-command.test.ts b/packages/cli/src/__tests__/hook-command.test.ts index 196bf1b8..3b9a9140 100644 --- a/packages/cli/src/__tests__/hook-command.test.ts +++ b/packages/cli/src/__tests__/hook-command.test.ts @@ -1,147 +1,171 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import { Readable } from 'stream' -import { mkdtempSync, rmSync, writeFileSync } from 'fs' -import { join } from 'path' -import { tmpdir } from 'os' - -import { convertEventType, buildPayload, makeHookCommand, detectAgent } from '../commands/hook.js' -import type { ExternalDeps } from '../deps.js' +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { Readable } from "stream"; +import { mkdtempSync, rmSync, writeFileSync } from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; + +import { + convertEventType, + buildPayload, + makeHookCommand, + detectAgent, +} from "../commands/hook.js"; +import type { ExternalDeps } from "../deps.js"; // --------------------------------------------------------------------------- // convertEventType // --------------------------------------------------------------------------- -describe('convertEventType', () => { +describe("convertEventType", () => { it.each([ - ['SessionStart', 'SESSION_START'], - ['PreToolUse', 'PRE_TOOL_USE'], - ['PostToolUse', 'POST_TOOL_USE'], - ['Stop', 'STOP'], - ['SubagentStop', 'SUBAGENT_STOP'], - ])('converts %s → %s', (input, expected) => { - expect(convertEventType(input)).toBe(expected) - }) -}) + ["SessionStart", "SESSION_START"], + ["PreToolUse", "PRE_TOOL_USE"], + ["PostToolUse", "POST_TOOL_USE"], + ["Stop", "STOP"], + ["SubagentStop", "SUBAGENT_STOP"], + ])("converts %s → %s", (input, expected) => { + expect(convertEventType(input)).toBe(expected); + }); +}); // --------------------------------------------------------------------------- // buildPayload // --------------------------------------------------------------------------- -describe('buildPayload', () => { - const project = { projectId: 'proj-1', apiUrl: 'https://api.example.com' } +describe("buildPayload", () => { + const project = { projectId: "proj-1", apiUrl: "https://api.example.com" }; + + it("sets base fields correctly", () => { + const payload = buildPayload( + { hook_event_name: "Stop", session_id: "sess-abc" }, + project, + ); + + expect(payload.projectId).toBe("proj-1"); + expect(payload.sessionId).toBe("sess-abc"); + expect(payload.hookEventName).toBe("STOP"); + }); - it('sets base fields correctly', () => { + it("omits optional fields when not provided", () => { const payload = buildPayload( - { hook_event_name: 'Stop', session_id: 'sess-abc' }, - project - ) - - expect(payload.projectId).toBe('proj-1') - expect(payload.sessionId).toBe('sess-abc') - expect(payload.hookEventName).toBe('STOP') - }) - - it('omits optional fields when not provided', () => { - const payload = buildPayload({ hook_event_name: 'Stop', session_id: 'x' }, project) - - expect(payload.toolName).toBeUndefined() - expect(payload.toolInput).toBeUndefined() - expect(payload.toolResponse).toBeUndefined() - expect(payload.exitCode).toBeUndefined() - expect(payload.agentId).toBeUndefined() - }) - - it('includes tool fields when present', () => { + { hook_event_name: "Stop", session_id: "x" }, + project, + ); + + expect(payload.toolName).toBeUndefined(); + expect(payload.toolInput).toBeUndefined(); + expect(payload.toolResponse).toBeUndefined(); + expect(payload.exitCode).toBeUndefined(); + expect(payload.agentId).toBeUndefined(); + }); + + it("includes tool fields when present", () => { const payload = buildPayload( { - hook_event_name: 'PreToolUse', - session_id: 'x', - tool_name: 'Bash', - tool_input: { command: 'ls' }, + hook_event_name: "PreToolUse", + session_id: "x", + tool_name: "Bash", + tool_input: { command: "ls" }, }, - project - ) + project, + ); - expect(payload.toolName).toBe('Bash') - expect(payload.toolInput).toEqual({ command: 'ls' }) - }) + expect(payload.toolName).toBe("Bash"); + expect(payload.toolInput).toEqual({ command: "ls" }); + }); - it('truncates tool_response to 2000 characters', () => { - const longResponse = 'x'.repeat(3000) + it("truncates tool_response to 2000 characters", () => { + const longResponse = "x".repeat(3000); const payload = buildPayload( - { hook_event_name: 'PostToolUse', session_id: 'x', tool_response: longResponse }, - project - ) + { + hook_event_name: "PostToolUse", + session_id: "x", + tool_response: longResponse, + }, + project, + ); - expect(payload.toolResponse!.length).toBe(2000) - }) + expect(payload.toolResponse!.length).toBe(2000); + }); - it('keeps tool_response as-is when under 2000 characters', () => { + it("keeps tool_response as-is when under 2000 characters", () => { const payload = buildPayload( - { hook_event_name: 'PostToolUse', session_id: 'x', tool_response: 'short output' }, - project - ) + { + hook_event_name: "PostToolUse", + session_id: "x", + tool_response: "short output", + }, + project, + ); - expect(payload.toolResponse).toBe('short output') - }) + expect(payload.toolResponse).toBe("short output"); + }); - it('includes exit_code when provided (including 0)', () => { + it("includes exit_code when provided (including 0)", () => { const payload = buildPayload( - { hook_event_name: 'Stop', session_id: 'x', exit_code: 0 }, - project - ) - expect(payload.exitCode).toBe(0) - }) + { hook_event_name: "Stop", session_id: "x", exit_code: 0 }, + project, + ); + expect(payload.exitCode).toBe(0); + }); - it('includes agent_id when provided', () => { + it("includes agent_id when provided", () => { const payload = buildPayload( - { hook_event_name: 'SubagentStop', session_id: 'x', agent_id: 'agent-123' }, - project - ) - expect(payload.agentId).toBe('agent-123') - }) -}) + { + hook_event_name: "SubagentStop", + session_id: "x", + agent_id: "agent-123", + }, + project, + ); + expect(payload.agentId).toBe("agent-123"); + }); +}); // --------------------------------------------------------------------------- // makeHookCommand — orchestration // --------------------------------------------------------------------------- const MOCK_PROJECT = { - projectId: 'proj-1', - orgId: 'org-1', - orgSlug: 'test-org', - orgName: 'Test Org', - projectName: 'Test Project', - apiUrl: 'https://api.example.com', -} + projectId: "proj-1", + orgId: "org-1", + orgSlug: "test-org", + orgName: "Test Org", + projectName: "Test Project", + apiUrl: "https://api.example.com", +}; const MOCK_CONFIG = { - token: 'test-token', - apiUrl: 'https://api.example.com', - userId: 'user-1', - email: 'test@example.com', -} + token: "test-token", + apiUrl: "https://api.example.com", + userId: "user-1", + email: "test@example.com", +}; function makeStdin(data: string): Readable { - const stream = new Readable({ read() {} }) - stream.push(data) - stream.push(null) - return stream + const stream = new Readable({ read() {} }); + stream.push(data); + stream.push(null); + return stream; } function setStdin(stream: Readable) { - Object.defineProperty(process, 'stdin', { value: stream, writable: true, configurable: true }) + Object.defineProperty(process, "stdin", { + value: stream, + writable: true, + configurable: true, + }); } -const MOCK_PROJECT_JSON_PATH = '/test/cwd/.argos/project.json' +const MOCK_PROJECT_JSON_PATH = "/test/cwd/.argos/project.json"; function makeMockDeps(overrides: Partial = {}): ExternalDeps { - const sendBackground = vi.fn() - const extractUsage = vi.fn().mockResolvedValue(null) - const extractUsagePerTurn = vi.fn().mockResolvedValue([]) - const detectSlashCommand = vi.fn().mockResolvedValue(null) - const extractMessages = vi.fn().mockResolvedValue([]) - const extractSummary = vi.fn().mockResolvedValue(null) - const extractUsageCodex = vi.fn().mockResolvedValue(null) - const extractUsagePerTurnCodex = vi.fn().mockResolvedValue([]) - const extractMessagesCodex = vi.fn().mockResolvedValue([]) + const sendBackground = vi.fn(); + const extractUsage = vi.fn().mockResolvedValue(null); + const extractUsagePerTurn = vi.fn().mockResolvedValue([]); + const detectSlashCommand = vi.fn().mockResolvedValue(null); + const extractMessages = vi.fn().mockResolvedValue([]); + const extractSummary = vi.fn().mockResolvedValue(null); + const extractUsageCodex = vi.fn().mockResolvedValue(null); + const extractUsagePerTurnCodex = vi.fn().mockResolvedValue([]); + const extractMessagesCodex = vi.fn().mockResolvedValue([]); return { config: { @@ -151,7 +175,12 @@ function makeMockDeps(overrides: Partial = {}): ExternalDeps { }, project: { find: vi.fn().mockReturnValue(MOCK_PROJECT), - findWithPath: vi.fn().mockReturnValue({ config: MOCK_PROJECT, configPath: MOCK_PROJECT_JSON_PATH }), + findWithPath: vi + .fn() + .mockReturnValue({ + config: MOCK_PROJECT, + configPath: MOCK_PROJECT_JSON_PATH, + }), write: vi.fn(), }, auth: { @@ -165,7 +194,7 @@ function makeMockDeps(overrides: Partial = {}): ExternalDeps { revokeToken: vi.fn(), }, hooks: { - inject: vi.fn().mockReturnValue('already_present'), + inject: vi.fn().mockReturnValue("already_present"), fileExists: vi.fn().mockReturnValue(false), }, prompt: { @@ -184,81 +213,109 @@ function makeMockDeps(overrides: Partial = {}): ExternalDeps { events: { sendBackground, }, - cwd: vi.fn().mockReturnValue('/test/cwd'), + cwd: vi.fn().mockReturnValue("/test/cwd"), ...overrides, - } as ExternalDeps + } as ExternalDeps; } -describe('makeHookCommand orchestration', () => { - let originalStdin: NodeJS.ReadStream - let tempDir: string +describe("makeHookCommand orchestration", () => { + let originalStdin: NodeJS.ReadStream; + let tempDir: string; beforeEach(() => { - originalStdin = process.stdin - tempDir = mkdtempSync(join(tmpdir(), 'argos-hook-test-')) - vi.spyOn(process, 'exit').mockImplementation((() => {}) as never) - }) + originalStdin = process.stdin; + tempDir = mkdtempSync(join(tmpdir(), "argos-hook-test-")); + vi.spyOn(process, "exit").mockImplementation((() => {}) as never); + }); afterEach(() => { - Object.defineProperty(process, 'stdin', { value: originalStdin, writable: true, configurable: true }) - rmSync(tempDir, { recursive: true, force: true }) - vi.clearAllMocks() - }) - - it('always exits with code 0', async () => { - const deps = makeMockDeps() - setStdin(makeStdin(JSON.stringify({ hook_event_name: 'PreToolUse', session_id: 'x' }))) - await makeHookCommand(deps)({}) - expect(process.exit).toHaveBeenCalledWith(0) - }) - - it('exits 0 immediately when stdin has no data', async () => { - const deps = makeMockDeps() - const emptyStream = new Readable({ read() {} }) - emptyStream.push(null) - setStdin(emptyStream) - - await makeHookCommand(deps)({}) - expect(process.exit).toHaveBeenCalledWith(0) - expect(deps.events.sendBackground).not.toHaveBeenCalled() - }) - - it('exits 0 immediately when project config is missing', async () => { + Object.defineProperty(process, "stdin", { + value: originalStdin, + writable: true, + configurable: true, + }); + rmSync(tempDir, { recursive: true, force: true }); + vi.clearAllMocks(); + }); + + it("always exits with code 0", async () => { + const deps = makeMockDeps(); + setStdin( + makeStdin( + JSON.stringify({ hook_event_name: "PreToolUse", session_id: "x" }), + ), + ); + await makeHookCommand(deps)({}); + expect(process.exit).toHaveBeenCalledWith(0); + }); + + it("exits 0 immediately when stdin has no data", async () => { + const deps = makeMockDeps(); + const emptyStream = new Readable({ read() {} }); + emptyStream.push(null); + setStdin(emptyStream); + + await makeHookCommand(deps)({}); + expect(process.exit).toHaveBeenCalledWith(0); + expect(deps.events.sendBackground).not.toHaveBeenCalled(); + }); + + it("exits 0 immediately when project config is missing", async () => { const deps = makeMockDeps({ - project: { find: vi.fn().mockReturnValue(null), findWithPath: vi.fn().mockReturnValue(null), write: vi.fn() }, - }) - setStdin(makeStdin(JSON.stringify({ hook_event_name: 'Stop', session_id: 'x' }))) + project: { + find: vi.fn().mockReturnValue(null), + findWithPath: vi.fn().mockReturnValue(null), + write: vi.fn(), + }, + }); + setStdin( + makeStdin(JSON.stringify({ hook_event_name: "Stop", session_id: "x" })), + ); - await makeHookCommand(deps)({}) - expect(process.exit).toHaveBeenCalledWith(0) - expect(deps.events.sendBackground).not.toHaveBeenCalled() - }) + await makeHookCommand(deps)({}); + expect(process.exit).toHaveBeenCalledWith(0); + expect(deps.events.sendBackground).not.toHaveBeenCalled(); + }); - it('exits 0 immediately when user config is missing', async () => { + it("exits 0 immediately when user config is missing", async () => { const deps = makeMockDeps({ - config: { read: vi.fn().mockReturnValue(null), write: vi.fn(), delete: vi.fn() }, - }) - setStdin(makeStdin(JSON.stringify({ hook_event_name: 'Stop', session_id: 'x' }))) - - await makeHookCommand(deps)({}) - expect(process.exit).toHaveBeenCalledWith(0) - expect(deps.events.sendBackground).not.toHaveBeenCalled() - }) - - it('calls sendBackground for a valid event', async () => { - const deps = makeMockDeps() - setStdin(makeStdin(JSON.stringify({ hook_event_name: 'PreToolUse', session_id: 'x' }))) - await makeHookCommand(deps)({}) - expect(deps.events.sendBackground).toHaveBeenCalled() - }) - - it('passes projectJsonPath and currentConfig to sendBackground for self-heal', async () => { - const deps = makeMockDeps() - setStdin(makeStdin(JSON.stringify({ hook_event_name: 'PreToolUse', session_id: 'x' }))) - await makeHookCommand(deps)({}) + config: { + read: vi.fn().mockReturnValue(null), + write: vi.fn(), + delete: vi.fn(), + }, + }); + setStdin( + makeStdin(JSON.stringify({ hook_event_name: "Stop", session_id: "x" })), + ); + + await makeHookCommand(deps)({}); + expect(process.exit).toHaveBeenCalledWith(0); + expect(deps.events.sendBackground).not.toHaveBeenCalled(); + }); + + it("calls sendBackground for a valid event", async () => { + const deps = makeMockDeps(); + setStdin( + makeStdin( + JSON.stringify({ hook_event_name: "PreToolUse", session_id: "x" }), + ), + ); + await makeHookCommand(deps)({}); + expect(deps.events.sendBackground).toHaveBeenCalled(); + }); + + it("passes projectJsonPath and currentConfig to sendBackground for self-heal", async () => { + const deps = makeMockDeps(); + setStdin( + makeStdin( + JSON.stringify({ hook_event_name: "PreToolUse", session_id: "x" }), + ), + ); + await makeHookCommand(deps)({}); expect(deps.events.sendBackground).toHaveBeenCalledWith( expect.objectContaining({ - url: 'https://api.example.com/api/events', + url: "https://api.example.com/api/events", token: MOCK_CONFIG.token, projectJsonPath: MOCK_PROJECT_JSON_PATH, currentConfig: expect.objectContaining({ @@ -266,218 +323,278 @@ describe('makeHookCommand orchestration', () => { orgId: MOCK_PROJECT.orgId, orgSlug: MOCK_PROJECT.orgSlug, }), - }) - ) - }) + }), + ); + }); - it('calls extractUsage and extractMessages for Stop event', async () => { - const deps = makeMockDeps() - const transcriptPath = join(tempDir, 'transcript.jsonl') - writeFileSync(transcriptPath, '', 'utf8') + it("calls extractUsage and extractMessages for Stop event", async () => { + const deps = makeMockDeps(); + const transcriptPath = join(tempDir, "transcript.jsonl"); + writeFileSync(transcriptPath, "", "utf8"); setStdin( makeStdin( - JSON.stringify({ hook_event_name: 'Stop', session_id: 'x', transcript_path: transcriptPath }) - ) - ) - await makeHookCommand(deps)({}) + JSON.stringify({ + hook_event_name: "Stop", + session_id: "x", + transcript_path: transcriptPath, + }), + ), + ); + await makeHookCommand(deps)({}); - expect(deps.transcript.extractUsage).toHaveBeenCalledWith(transcriptPath) - expect(deps.transcript.extractMessages).toHaveBeenCalledWith(transcriptPath) - }) + expect(deps.transcript.extractUsage).toHaveBeenCalledWith(transcriptPath); + expect(deps.transcript.extractMessages).toHaveBeenCalledWith( + transcriptPath, + ); + }); - it('skips SubagentStop events — sub-agent activity is not tracked', async () => { - const deps = makeMockDeps() - const transcriptPath = join(tempDir, 'agent.jsonl') - writeFileSync(transcriptPath, '', 'utf8') + it("skips SubagentStop events — sub-agent activity is not tracked", async () => { + const deps = makeMockDeps(); + const transcriptPath = join(tempDir, "agent.jsonl"); + writeFileSync(transcriptPath, "", "utf8"); setStdin( makeStdin( JSON.stringify({ - hook_event_name: 'SubagentStop', - session_id: 'x', + hook_event_name: "SubagentStop", + session_id: "x", agent_transcript_path: transcriptPath, - }) - ) - ) - await makeHookCommand(deps)({}) - - expect(deps.transcript.extractUsage).not.toHaveBeenCalled() - expect(deps.transcript.extractMessages).not.toHaveBeenCalled() - expect(deps.events.sendBackground).not.toHaveBeenCalled() - expect(process.exit).toHaveBeenCalledWith(0) - }) - - it('skips events carrying agent_id — they originate from a sub-agent', async () => { - const deps = makeMockDeps() + }), + ), + ); + await makeHookCommand(deps)({}); + + expect(deps.transcript.extractUsage).not.toHaveBeenCalled(); + expect(deps.transcript.extractMessages).not.toHaveBeenCalled(); + expect(deps.events.sendBackground).not.toHaveBeenCalled(); + expect(process.exit).toHaveBeenCalledWith(0); + }); + + it("skips events carrying agent_id — they originate from a sub-agent", async () => { + const deps = makeMockDeps(); setStdin( makeStdin( JSON.stringify({ - hook_event_name: 'PreToolUse', - session_id: 'x', - agent_id: 'agent-123', - tool_name: 'Bash', - }) - ) - ) - await makeHookCommand(deps)({}) - - expect(deps.events.sendBackground).not.toHaveBeenCalled() - expect(process.exit).toHaveBeenCalledWith(0) - }) - - it('calls detectSlashCommand for SessionStart event', async () => { - const deps = makeMockDeps() - const transcriptPath = join(tempDir, 'transcript.jsonl') - writeFileSync(transcriptPath, '', 'utf8') + hook_event_name: "PreToolUse", + session_id: "x", + agent_id: "agent-123", + tool_name: "Bash", + }), + ), + ); + await makeHookCommand(deps)({}); + + expect(deps.events.sendBackground).not.toHaveBeenCalled(); + expect(process.exit).toHaveBeenCalledWith(0); + }); + + it("calls detectSlashCommand for SessionStart event", async () => { + const deps = makeMockDeps(); + const transcriptPath = join(tempDir, "transcript.jsonl"); + writeFileSync(transcriptPath, "", "utf8"); setStdin( makeStdin( JSON.stringify({ - hook_event_name: 'SessionStart', - session_id: 'x', + hook_event_name: "SessionStart", + session_id: "x", transcript_path: transcriptPath, - }) - ) - ) - await makeHookCommand(deps)({}) + }), + ), + ); + await makeHookCommand(deps)({}); - expect(deps.transcript.detectSlashCommand).toHaveBeenCalledWith(transcriptPath) - expect(deps.transcript.extractUsage).not.toHaveBeenCalled() - }) + expect(deps.transcript.detectSlashCommand).toHaveBeenCalledWith( + transcriptPath, + ); + expect(deps.transcript.extractUsage).not.toHaveBeenCalled(); + }); - it('sends SessionStart slash commands as Skill events', async () => { + it("sends SessionStart slash commands as Skill events", async () => { const deps = makeMockDeps({ transcript: { extractUsage: vi.fn().mockResolvedValue(null), extractUsagePerTurn: vi.fn().mockResolvedValue([]), - detectSlashCommand: vi.fn().mockResolvedValue('new-task-doc'), + detectSlashCommand: vi.fn().mockResolvedValue("new-task-doc"), extractMessages: vi.fn().mockResolvedValue([]), extractSummary: vi.fn().mockResolvedValue(null), extractUsageCodex: vi.fn().mockResolvedValue(null), extractUsagePerTurnCodex: vi.fn().mockResolvedValue([]), extractMessagesCodex: vi.fn().mockResolvedValue([]), }, - }) - const transcriptPath = join(tempDir, 'transcript.jsonl') - writeFileSync(transcriptPath, '', 'utf8') + }); + const transcriptPath = join(tempDir, "transcript.jsonl"); + writeFileSync(transcriptPath, "", "utf8"); setStdin( makeStdin( JSON.stringify({ - hook_event_name: 'SessionStart', - session_id: 'x', + hook_event_name: "SessionStart", + session_id: "x", transcript_path: transcriptPath, - }) - ) - ) - await makeHookCommand(deps)({}) + }), + ), + ); + await makeHookCommand(deps)({}); expect(deps.events.sendBackground).toHaveBeenCalledWith( expect.objectContaining({ - url: 'https://api.example.com/api/events', + url: "https://api.example.com/api/events", token: MOCK_CONFIG.token, payload: expect.objectContaining({ - hookEventName: 'SESSION_START', + hookEventName: "SESSION_START", isSlashCommand: true, - toolName: 'Skill', - toolInput: { skill: 'new-task-doc' }, + toolName: "Skill", + toolInput: { skill: "new-task-doc" }, }), - }) - ) - }) + }), + ); + }); - it('does NOT call transcript functions for PreToolUse event', async () => { - const deps = makeMockDeps() + it("does NOT call transcript functions for PreToolUse event", async () => { + const deps = makeMockDeps(); setStdin( - makeStdin(JSON.stringify({ hook_event_name: 'PreToolUse', session_id: 'x', tool_name: 'Bash' })) - ) - await makeHookCommand(deps)({}) + makeStdin( + JSON.stringify({ + hook_event_name: "PreToolUse", + session_id: "x", + tool_name: "Bash", + }), + ), + ); + await makeHookCommand(deps)({}); - expect(deps.transcript.extractUsage).not.toHaveBeenCalled() - expect(deps.transcript.detectSlashCommand).not.toHaveBeenCalled() - expect(deps.transcript.extractMessages).not.toHaveBeenCalled() - }) + expect(deps.transcript.extractUsage).not.toHaveBeenCalled(); + expect(deps.transcript.detectSlashCommand).not.toHaveBeenCalled(); + expect(deps.transcript.extractMessages).not.toHaveBeenCalled(); + }); - it('exits 0 even when an unexpected error occurs', async () => { + it("exits 0 even when an unexpected error occurs", async () => { const deps = makeMockDeps({ project: { - find: vi.fn().mockImplementation(() => { throw new Error('unexpected failure') }), - findWithPath: vi.fn().mockImplementation(() => { throw new Error('unexpected failure') }), + find: vi.fn().mockImplementation(() => { + throw new Error("unexpected failure"); + }), + findWithPath: vi.fn().mockImplementation(() => { + throw new Error("unexpected failure"); + }), write: vi.fn(), }, - }) - setStdin(makeStdin(JSON.stringify({ hook_event_name: 'Stop', session_id: 'x' }))) + }); + setStdin( + makeStdin(JSON.stringify({ hook_event_name: "Stop", session_id: "x" })), + ); - await makeHookCommand(deps)({}) - expect(process.exit).toHaveBeenCalledWith(0) - }) -}) + await makeHookCommand(deps)({}); + expect(process.exit).toHaveBeenCalledWith(0); + }); +}); // --------------------------------------------------------------------------- // detectAgent // --------------------------------------------------------------------------- -describe('detectAgent', () => { - it('explicit --agent flag wins', () => { - expect(detectAgent({ agent: 'codex' }, {})).toBe('codex') - expect(detectAgent({ agent: 'claude' }, { transcript_path: '/x/.codex/sessions/a.jsonl' })).toBe('claude') - }) - - it('infers codex from transcript_path containing /.codex/', () => { - expect(detectAgent({}, { transcript_path: '/Users/x/.codex/sessions/2026/05/r.jsonl' })).toBe('codex') - expect(detectAgent({}, { agent_transcript_path: '/Users/x/.codex/sessions/r.jsonl' })).toBe('codex') - }) - - it('defaults to claude', () => { - expect(detectAgent({}, {})).toBe('claude') - expect(detectAgent({}, { transcript_path: '/Users/x/.claude/projects/p/t.jsonl' })).toBe('claude') - }) -}) +describe("detectAgent", () => { + it("explicit --agent flag wins", () => { + expect(detectAgent({ agent: "codex" }, {})).toBe("codex"); + expect( + detectAgent( + { agent: "claude" }, + { transcript_path: "/x/.codex/sessions/a.jsonl" }, + ), + ).toBe("claude"); + }); + + it("infers codex from transcript_path containing /.codex/", () => { + expect( + detectAgent( + {}, + { transcript_path: "/Users/x/.codex/sessions/2026/05/r.jsonl" }, + ), + ).toBe("codex"); + expect( + detectAgent( + {}, + { agent_transcript_path: "/Users/x/.codex/sessions/r.jsonl" }, + ), + ).toBe("codex"); + }); + + it("defaults to claude", () => { + expect(detectAgent({}, {})).toBe("claude"); + expect( + detectAgent( + {}, + { transcript_path: "/Users/x/.claude/projects/p/t.jsonl" }, + ), + ).toBe("claude"); + }); +}); // --------------------------------------------------------------------------- // makeHookCommand — Codex agent branch // --------------------------------------------------------------------------- -describe('makeHookCommand Codex branch', () => { - let originalStdin: NodeJS.ReadStream +describe("makeHookCommand Codex branch", () => { + let originalStdin: NodeJS.ReadStream; beforeEach(() => { - originalStdin = process.stdin - vi.spyOn(process, 'exit').mockImplementation((() => {}) as never) - }) + originalStdin = process.stdin; + vi.spyOn(process, "exit").mockImplementation((() => {}) as never); + }); afterEach(() => { - Object.defineProperty(process, 'stdin', { value: originalStdin, writable: true, configurable: true }) - vi.clearAllMocks() - }) - - it('Stop with --agent codex uses the Codex parser, not the Claude one', async () => { - const deps = makeMockDeps() + Object.defineProperty(process, "stdin", { + value: originalStdin, + writable: true, + configurable: true, + }); + vi.clearAllMocks(); + }); + + it("Stop with --agent codex uses the Codex parser, not the Claude one", async () => { + const deps = makeMockDeps(); deps.transcript.extractUsageCodex = vi.fn().mockResolvedValue({ - inputTokens: 100, outputTokens: 20, cacheCreationTokens: 0, cacheReadTokens: 50, model: undefined, - }) - deps.transcript.extractMessagesCodex = vi.fn().mockResolvedValue([]) - setStdin(makeStdin(JSON.stringify({ - hook_event_name: 'Stop', session_id: 'x', model: 'gpt-5.5', - transcript_path: '/Users/x/.codex/sessions/r.jsonl', - }))) - - await makeHookCommand(deps)({ agent: 'codex' }) - - expect(deps.transcript.extractUsageCodex).toHaveBeenCalled() - expect(deps.transcript.extractUsage).not.toHaveBeenCalled() + inputTokens: 100, + outputTokens: 20, + cacheCreationTokens: 0, + cacheReadTokens: 50, + model: undefined, + }); + deps.transcript.extractMessagesCodex = vi.fn().mockResolvedValue([]); + setStdin( + makeStdin( + JSON.stringify({ + hook_event_name: "Stop", + session_id: "x", + model: "gpt-5.5", + transcript_path: "/Users/x/.codex/sessions/r.jsonl", + }), + ), + ); + + await makeHookCommand(deps)({ agent: "codex" }); + + expect(deps.transcript.extractUsageCodex).toHaveBeenCalled(); + expect(deps.transcript.extractUsage).not.toHaveBeenCalled(); // model 이 transcript 에 없으면 hook stdin 의 model 로 보강 - const sent = (deps.events.sendBackground as ReturnType).mock.calls[0][0] - expect(sent.payload.usage.model).toBe('gpt-5.5') - }) - - it('Codex SessionStart does not attempt Claude slash detection', async () => { - const deps = makeMockDeps() - setStdin(makeStdin(JSON.stringify({ - hook_event_name: 'SessionStart', session_id: 'x', - transcript_path: '/Users/x/.codex/sessions/r.jsonl', - }))) - - await makeHookCommand(deps)({ agent: 'codex' }) - - expect(deps.transcript.detectSlashCommand).not.toHaveBeenCalled() - expect(deps.events.sendBackground).toHaveBeenCalled() - }) -}) + const sent = (deps.events.sendBackground as ReturnType).mock + .calls[0][0]; + expect(sent.payload.usage.model).toBe("gpt-5.5"); + }); + + it("Codex SessionStart does not attempt Claude slash detection", async () => { + const deps = makeMockDeps(); + setStdin( + makeStdin( + JSON.stringify({ + hook_event_name: "SessionStart", + session_id: "x", + transcript_path: "/Users/x/.codex/sessions/r.jsonl", + }), + ), + ); + + await makeHookCommand(deps)({ agent: "codex" }); + + expect(deps.transcript.detectSlashCommand).not.toHaveBeenCalled(); + expect(deps.events.sendBackground).toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/__tests__/hooks-inject.test.ts b/packages/cli/src/__tests__/hooks-inject.test.ts index 7f90e515..697cabc3 100644 --- a/packages/cli/src/__tests__/hooks-inject.test.ts +++ b/packages/cli/src/__tests__/hooks-inject.test.ts @@ -1,238 +1,262 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest' -import { mkdtempSync, rmSync, readFileSync, writeFileSync, mkdirSync } from 'fs' -import { join } from 'path' -import { tmpdir } from 'os' -import { injectHooks } from '../lib/hooks-inject.js' - -const HOOK_EVENTS = ['SessionStart', 'PreToolUse', 'PostToolUse', 'Stop', 'SubagentStop'] -const ARGOS_COMMAND = 'argos hook' +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { + mkdtempSync, + rmSync, + readFileSync, + writeFileSync, + mkdirSync, +} from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; +import { injectHooks } from "../lib/hooks-inject.js"; + +const HOOK_EVENTS = [ + "SessionStart", + "PreToolUse", + "PostToolUse", + "Stop", + "SubagentStop", +]; +const ARGOS_COMMAND = "argos hook"; const ARGOS_SESSION_START_COMMAND = - 'command -v argos >/dev/null 2>&1 || npm install -g argos-ai@latest; argos hook' + "command -v argos >/dev/null 2>&1 || npm install -g argos-ai@latest; argos hook"; interface HookEntry { - matcher: string - hooks: { type: string; command: string }[] + matcher: string; + hooks: { type: string; command: string }[]; } function isArgosCommand(cmd: string): boolean { // 프로덕션 로직과 동일하게 claude/codex/bootstrap 변형을 모두 인식한다. - return cmd.includes('argos hook') + return cmd.includes("argos hook"); } function hasArgosHook(entries: HookEntry[]): boolean { return entries.some((entry) => - entry.hooks?.some((h) => isArgosCommand(h.command)) - ) + entry.hooks?.some((h) => isArgosCommand(h.command)), + ); } function argosHookCount(entries: HookEntry[]): number { return entries.filter((entry) => - entry.hooks?.some((h) => isArgosCommand(h.command)) - ).length + entry.hooks?.some((h) => isArgosCommand(h.command)), + ).length; } -describe('injectHooks', () => { - let tempDir: string - let settingsPath: string +describe("injectHooks", () => { + let tempDir: string; + let settingsPath: string; beforeEach(() => { - tempDir = mkdtempSync(join(tmpdir(), 'argos-test-')) - settingsPath = join(tempDir, '.claude', 'settings.json') - }) + tempDir = mkdtempSync(join(tmpdir(), "argos-test-")); + settingsPath = join(tempDir, ".claude", "settings.json"); + }); afterEach(() => { - rmSync(tempDir, { recursive: true, force: true }) - }) + rmSync(tempDir, { recursive: true, force: true }); + }); - it('creates settings.json with all 5 hooks when file does not exist', () => { - const result = injectHooks(settingsPath) + it("creates settings.json with all 5 hooks when file does not exist", () => { + const result = injectHooks(settingsPath); - expect(result).toBe('injected') + expect(result).toBe("injected"); - const settings = JSON.parse(readFileSync(settingsPath, 'utf8')) + const settings = JSON.parse(readFileSync(settingsPath, "utf8")); for (const event of HOOK_EVENTS) { - expect(settings.hooks[event], `missing hook for ${event}`).toBeDefined() - expect(hasArgosHook(settings.hooks[event]), `argos hook not found in ${event}`).toBe(true) + expect(settings.hooks[event], `missing hook for ${event}`).toBeDefined(); + expect( + hasArgosHook(settings.hooks[event]), + `argos hook not found in ${event}`, + ).toBe(true); } - }) + }); - it('returns already_present on second call without duplicating hooks', () => { - injectHooks(settingsPath) - const result = injectHooks(settingsPath) + it("returns already_present on second call without duplicating hooks", () => { + injectHooks(settingsPath); + const result = injectHooks(settingsPath); - expect(result).toBe('already_present') + expect(result).toBe("already_present"); - const settings = JSON.parse(readFileSync(settingsPath, 'utf8')) + const settings = JSON.parse(readFileSync(settingsPath, "utf8")); for (const event of HOOK_EVENTS) { - expect(argosHookCount(settings.hooks[event]), `duplicate in ${event}`).toBe(1) + expect( + argosHookCount(settings.hooks[event]), + `duplicate in ${event}`, + ).toBe(1); } - }) + }); - it('is idempotent across 3+ repeated calls', () => { + it("is idempotent across 3+ repeated calls", () => { for (let i = 0; i < 5; i++) { - injectHooks(settingsPath) + injectHooks(settingsPath); } - const settings = JSON.parse(readFileSync(settingsPath, 'utf8')) + const settings = JSON.parse(readFileSync(settingsPath, "utf8")); for (const event of HOOK_EVENTS) { - expect(argosHookCount(settings.hooks[event])).toBe(1) + expect(argosHookCount(settings.hooks[event])).toBe(1); } - }) + }); - it('preserves existing hooks when injecting', () => { - mkdirSync(join(tempDir, '.claude'), { recursive: true }) + it("preserves existing hooks when injecting", () => { + mkdirSync(join(tempDir, ".claude"), { recursive: true }); const existing = { hooks: { SessionStart: [ - { matcher: 'my-tool', hooks: [{ type: 'command', command: 'my-custom-script' }] }, + { + matcher: "my-tool", + hooks: [{ type: "command", command: "my-custom-script" }], + }, ], }, - } - writeFileSync(settingsPath, JSON.stringify(existing), 'utf8') + }; + writeFileSync(settingsPath, JSON.stringify(existing), "utf8"); - injectHooks(settingsPath) + injectHooks(settingsPath); - const settings = JSON.parse(readFileSync(settingsPath, 'utf8')) + const settings = JSON.parse(readFileSync(settingsPath, "utf8")); // Original hook is preserved - expect(settings.hooks.SessionStart).toHaveLength(2) - expect(settings.hooks.SessionStart[0].hooks[0].command).toBe('my-custom-script') + expect(settings.hooks.SessionStart).toHaveLength(2); + expect(settings.hooks.SessionStart[0].hooks[0].command).toBe( + "my-custom-script", + ); // Argos hook is appended - expect(hasArgosHook(settings.hooks.SessionStart)).toBe(true) - }) + expect(hasArgosHook(settings.hooks.SessionStart)).toBe(true); + }); - it('does not re-inject if argos hook is nested inside an existing entry', () => { + it("does not re-inject if argos hook is nested inside an existing entry", () => { // Simulate a settings.json where the argos hook was already injected // in a non-standard position (e.g., bundled with another hook) - mkdirSync(join(tempDir, '.claude'), { recursive: true }) + mkdirSync(join(tempDir, ".claude"), { recursive: true }); const existing = { hooks: { Stop: [ { - matcher: '', + matcher: "", hooks: [ - { type: 'command', command: 'other-hook' }, - { type: 'command', command: ARGOS_COMMAND }, + { type: "command", command: "other-hook" }, + { type: "command", command: ARGOS_COMMAND }, ], }, ], }, - } - writeFileSync(settingsPath, JSON.stringify(existing), 'utf8') + }; + writeFileSync(settingsPath, JSON.stringify(existing), "utf8"); // Partial inject: Stop already has argos hook, others don't - const result = injectHooks(settingsPath) - expect(result).toBe('injected') // other 4 events still need injection + const result = injectHooks(settingsPath); + expect(result).toBe("injected"); // other 4 events still need injection - const settings = JSON.parse(readFileSync(settingsPath, 'utf8')) + const settings = JSON.parse(readFileSync(settingsPath, "utf8")); // Stop should not have been duplicated - expect(argosHookCount(settings.hooks.Stop)).toBe(1) - }) + expect(argosHookCount(settings.hooks.Stop)).toBe(1); + }); - it('handles corrupted settings.json by starting fresh', () => { - mkdirSync(join(tempDir, '.claude'), { recursive: true }) - writeFileSync(settingsPath, '{ this is not valid json }', 'utf8') + it("handles corrupted settings.json by starting fresh", () => { + mkdirSync(join(tempDir, ".claude"), { recursive: true }); + writeFileSync(settingsPath, "{ this is not valid json }", "utf8"); - const result = injectHooks(settingsPath) - expect(result).toBe('injected') + const result = injectHooks(settingsPath); + expect(result).toBe("injected"); // Output should be valid JSON with all hooks - const settings = JSON.parse(readFileSync(settingsPath, 'utf8')) - expect(settings.hooks).toBeDefined() + const settings = JSON.parse(readFileSync(settingsPath, "utf8")); + expect(settings.hooks).toBeDefined(); for (const event of HOOK_EVENTS) { - expect(hasArgosHook(settings.hooks[event])).toBe(true) + expect(hasArgosHook(settings.hooks[event])).toBe(true); } - }) + }); - it('creates the .claude directory if it does not exist', () => { + it("creates the .claude directory if it does not exist", () => { // tempDir has no .claude subdir - expect(() => injectHooks(settingsPath)).not.toThrow() - const settings = JSON.parse(readFileSync(settingsPath, 'utf8')) - expect(settings.hooks).toBeDefined() - }) + expect(() => injectHooks(settingsPath)).not.toThrow(); + const settings = JSON.parse(readFileSync(settingsPath, "utf8")); + expect(settings.hooks).toBeDefined(); + }); - it('writes hooks with correct structure (matcher, type, command)', () => { - injectHooks(settingsPath) + it("writes hooks with correct structure (matcher, type, command)", () => { + injectHooks(settingsPath); - const settings = JSON.parse(readFileSync(settingsPath, 'utf8')) - const stopEntries = settings.hooks.Stop as HookEntry[] + const settings = JSON.parse(readFileSync(settingsPath, "utf8")); + const stopEntries = settings.hooks.Stop as HookEntry[]; const argosEntry = stopEntries.find((e) => - e.hooks?.some((h) => h.command === ARGOS_COMMAND) - ) + e.hooks?.some((h) => h.command === ARGOS_COMMAND), + ); - expect(argosEntry).toBeDefined() - expect(argosEntry!.matcher).toBe('') - expect(argosEntry!.hooks[0].type).toBe('command') - expect(argosEntry!.hooks[0].command).toBe(ARGOS_COMMAND) - }) + expect(argosEntry).toBeDefined(); + expect(argosEntry!.matcher).toBe(""); + expect(argosEntry!.hooks[0].type).toBe("command"); + expect(argosEntry!.hooks[0].command).toBe(ARGOS_COMMAND); + }); - it('uses bootstrap command for SessionStart so fresh clones auto-install argos', () => { - injectHooks(settingsPath) + it("uses bootstrap command for SessionStart so fresh clones auto-install argos", () => { + injectHooks(settingsPath); - const settings = JSON.parse(readFileSync(settingsPath, 'utf8')) - const sessionStartEntries = settings.hooks.SessionStart as HookEntry[] + const settings = JSON.parse(readFileSync(settingsPath, "utf8")); + const sessionStartEntries = settings.hooks.SessionStart as HookEntry[]; const argosEntry = sessionStartEntries.find((e) => - e.hooks?.some((h) => isArgosCommand(h.command)) - ) + e.hooks?.some((h) => isArgosCommand(h.command)), + ); - expect(argosEntry).toBeDefined() - expect(argosEntry!.hooks[0].command).toBe(ARGOS_SESSION_START_COMMAND) - }) + expect(argosEntry).toBeDefined(); + expect(argosEntry!.hooks[0].command).toBe(ARGOS_SESSION_START_COMMAND); + }); - it('treats existing `argos hook` in SessionStart as already present (no duplicate bootstrap)', () => { - mkdirSync(join(tempDir, '.claude'), { recursive: true }) + it("treats existing `argos hook` in SessionStart as already present (no duplicate bootstrap)", () => { + mkdirSync(join(tempDir, ".claude"), { recursive: true }); const existing = { hooks: { SessionStart: [ - { matcher: '', hooks: [{ type: 'command', command: ARGOS_COMMAND }] }, + { matcher: "", hooks: [{ type: "command", command: ARGOS_COMMAND }] }, ], }, - } - writeFileSync(settingsPath, JSON.stringify(existing), 'utf8') + }; + writeFileSync(settingsPath, JSON.stringify(existing), "utf8"); - injectHooks(settingsPath) + injectHooks(settingsPath); - const settings = JSON.parse(readFileSync(settingsPath, 'utf8')) - expect(argosHookCount(settings.hooks.SessionStart)).toBe(1) - }) -}) + const settings = JSON.parse(readFileSync(settingsPath, "utf8")); + expect(argosHookCount(settings.hooks.SessionStart)).toBe(1); + }); +}); -describe('injectHooks (Codex agent)', () => { - let tempDir: string - let codexPath: string +describe("injectHooks (Codex agent)", () => { + let tempDir: string; + let codexPath: string; beforeEach(() => { - tempDir = mkdtempSync(join(tmpdir(), 'argos-codex-')) - codexPath = join(tempDir, '.codex', 'hooks.json') - }) - afterEach(() => rmSync(tempDir, { recursive: true, force: true })) - - it('writes .codex/hooks.json with `argos hook --agent codex` for all 5 events', () => { - const result = injectHooks(codexPath, 'codex') - expect(result).toBe('injected') - const settings = JSON.parse(readFileSync(codexPath, 'utf8')) + tempDir = mkdtempSync(join(tmpdir(), "argos-codex-")); + codexPath = join(tempDir, ".codex", "hooks.json"); + }); + afterEach(() => rmSync(tempDir, { recursive: true, force: true })); + + it("writes .codex/hooks.json with `argos hook --agent codex` for all 5 events", () => { + const result = injectHooks(codexPath, "codex"); + expect(result).toBe("injected"); + const settings = JSON.parse(readFileSync(codexPath, "utf8")); for (const event of HOOK_EVENTS) { - const entries = settings.hooks[event] as HookEntry[] - expect(entries, `missing ${event}`).toBeDefined() - const cmd = entries[entries.length - 1].hooks[0].command - expect(cmd).toContain('argos hook --agent codex') + const entries = settings.hooks[event] as HookEntry[]; + expect(entries, `missing ${event}`).toBeDefined(); + const cmd = entries[entries.length - 1].hooks[0].command; + expect(cmd).toContain("argos hook --agent codex"); } - }) - - it('SessionStart uses bootstrap + --agent codex', () => { - injectHooks(codexPath, 'codex') - const settings = JSON.parse(readFileSync(codexPath, 'utf8')) - const cmd = (settings.hooks.SessionStart as HookEntry[])[0].hooks[0].command - expect(cmd).toContain('npm install -g argos-ai@latest') - expect(cmd).toContain('argos hook --agent codex') - }) - - it('is idempotent and does not duplicate across calls', () => { - injectHooks(codexPath, 'codex') - const result = injectHooks(codexPath, 'codex') - expect(result).toBe('already_present') - const settings = JSON.parse(readFileSync(codexPath, 'utf8')) + }); + + it("SessionStart uses bootstrap + --agent codex", () => { + injectHooks(codexPath, "codex"); + const settings = JSON.parse(readFileSync(codexPath, "utf8")); + const cmd = (settings.hooks.SessionStart as HookEntry[])[0].hooks[0] + .command; + expect(cmd).toContain("npm install -g argos-ai@latest"); + expect(cmd).toContain("argos hook --agent codex"); + }); + + it("is idempotent and does not duplicate across calls", () => { + injectHooks(codexPath, "codex"); + const result = injectHooks(codexPath, "codex"); + expect(result).toBe("already_present"); + const settings = JSON.parse(readFileSync(codexPath, "utf8")); for (const event of HOOK_EVENTS) { - expect(argosHookCount(settings.hooks[event])).toBe(1) + expect(argosHookCount(settings.hooks[event])).toBe(1); } - }) -}) + }); +}); diff --git a/packages/cli/src/__tests__/logout-command.test.ts b/packages/cli/src/__tests__/logout-command.test.ts index 06dde7e5..71498964 100644 --- a/packages/cli/src/__tests__/logout-command.test.ts +++ b/packages/cli/src/__tests__/logout-command.test.ts @@ -1,13 +1,13 @@ -import { describe, it, expect, vi, afterEach } from 'vitest' -import { makeLogoutCommand } from '../commands/logout.js' -import type { ExternalDeps } from '../deps.js' +import { describe, it, expect, vi, afterEach } from "vitest"; +import { makeLogoutCommand } from "../commands/logout.js"; +import type { ExternalDeps } from "../deps.js"; const MOCK_CONFIG = { - token: 'test-token', - apiUrl: 'https://api.example.com', - userId: 'user-1', - email: 'test@example.com', -} + token: "test-token", + apiUrl: "https://api.example.com", + userId: "user-1", + email: "test@example.com", +}; function makeMockDeps(overrides: Partial = {}): ExternalDeps { return { @@ -31,7 +31,7 @@ function makeMockDeps(overrides: Partial = {}): ExternalDeps { revokeToken: vi.fn().mockResolvedValue(undefined), }, hooks: { - inject: vi.fn().mockReturnValue('already_present'), + inject: vi.fn().mockReturnValue("already_present"), fileExists: vi.fn().mockReturnValue(false), }, prompt: { @@ -45,61 +45,72 @@ function makeMockDeps(overrides: Partial = {}): ExternalDeps { events: { sendBackground: vi.fn(), }, - cwd: vi.fn().mockReturnValue('/test/cwd'), + cwd: vi.fn().mockReturnValue("/test/cwd"), ...overrides, - } as ExternalDeps + } as ExternalDeps; } -describe('makeLogoutCommand', () => { +describe("makeLogoutCommand", () => { afterEach(() => { - vi.clearAllMocks() - }) + vi.clearAllMocks(); + }); - describe('정상 로그아웃', () => { - it('config가 있으면 deps.api.revokeToken 이 호출된다', async () => { - const deps = makeMockDeps() - await makeLogoutCommand(deps)({}) - expect(deps.api.revokeToken).toHaveBeenCalledWith(MOCK_CONFIG.token, MOCK_CONFIG.apiUrl) - }) + describe("정상 로그아웃", () => { + it("config가 있으면 deps.api.revokeToken 이 호출된다", async () => { + const deps = makeMockDeps(); + await makeLogoutCommand(deps)({}); + expect(deps.api.revokeToken).toHaveBeenCalledWith( + MOCK_CONFIG.token, + MOCK_CONFIG.apiUrl, + ); + }); - it('deps.config.delete 이 호출된다', async () => { - const deps = makeMockDeps() - await makeLogoutCommand(deps)({}) - expect(deps.config.delete).toHaveBeenCalled() - }) - }) + it("deps.config.delete 이 호출된다", async () => { + const deps = makeMockDeps(); + await makeLogoutCommand(deps)({}); + expect(deps.config.delete).toHaveBeenCalled(); + }); + }); - describe('로그인 안 된 상태', () => { - it('config가 null이면 deps.api.revokeToken 이 호출되지 않는다', async () => { + describe("로그인 안 된 상태", () => { + it("config가 null이면 deps.api.revokeToken 이 호출되지 않는다", async () => { const deps = makeMockDeps({ - config: { read: vi.fn().mockReturnValue(null), write: vi.fn(), delete: vi.fn() }, - }) - await makeLogoutCommand(deps)({}) - expect(deps.api.revokeToken).not.toHaveBeenCalled() - }) + config: { + read: vi.fn().mockReturnValue(null), + write: vi.fn(), + delete: vi.fn(), + }, + }); + await makeLogoutCommand(deps)({}); + expect(deps.api.revokeToken).not.toHaveBeenCalled(); + }); - it('config가 null이면 deps.config.delete 이 호출되지 않는다', async () => { + it("config가 null이면 deps.config.delete 이 호출되지 않는다", async () => { const deps = makeMockDeps({ - config: { read: vi.fn().mockReturnValue(null), write: vi.fn(), delete: vi.fn() }, - }) - await makeLogoutCommand(deps)({}) - expect(deps.config.delete).not.toHaveBeenCalled() - }) - }) + config: { + read: vi.fn().mockReturnValue(null), + write: vi.fn(), + delete: vi.fn(), + }, + }); + await makeLogoutCommand(deps)({}); + expect(deps.config.delete).not.toHaveBeenCalled(); + }); + }); - describe('서버 revokeToken 실패해도', () => { - it('deps.api.revokeToken 이 throw해도 deps.config.delete 는 호출된다', async () => { + describe("서버 revokeToken 실패해도", () => { + it("deps.api.revokeToken 이 throw해도 deps.config.delete 는 호출된다", async () => { const deps = makeMockDeps({ api: { exchange: vi.fn(), createProject: vi.fn(), joinOrg: vi.fn(), ensureMembership: vi.fn(), - revokeToken: vi.fn().mockRejectedValue(new Error('server error')), + revokeToken: vi.fn().mockRejectedValue(new Error("server error")), }, - }) - await makeLogoutCommand(deps)({}) - expect(deps.config.delete).toHaveBeenCalled() - }) - }) -}) + }); + await makeLogoutCommand(deps)({}); + expect(deps.config.delete).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/cli/src/__tests__/setup-command.test.ts b/packages/cli/src/__tests__/setup-command.test.ts index ed711ccc..4bf8f882 100644 --- a/packages/cli/src/__tests__/setup-command.test.ts +++ b/packages/cli/src/__tests__/setup-command.test.ts @@ -1,41 +1,41 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import { makeSetupCommand } from '../commands/setup.js' -import type { ExternalDeps } from '../deps.js' +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { makeSetupCommand } from "../commands/setup.js"; +import type { ExternalDeps } from "../deps.js"; const MOCK_CONFIG = { - token: 'test-token', - apiUrl: 'https://api.example.com', - userId: 'user-1', - email: 'test@example.com', -} + token: "test-token", + apiUrl: "https://api.example.com", + userId: "user-1", + email: "test@example.com", +}; const MOCK_PROJECT = { - projectId: 'proj-1', - orgId: 'org-1', - orgSlug: 'test-org', - orgName: 'Test Org', - projectName: 'test-project', - apiUrl: 'https://api.example.com', -} + projectId: "proj-1", + orgId: "org-1", + orgSlug: "test-org", + orgName: "Test Org", + projectName: "test-project", + apiUrl: "https://api.example.com", +}; const MOCK_EXCHANGE_RESPONSE = { - token: 'exchanged-token', + token: "exchanged-token", user: { - id: 'user-2', - email: 'joined@example.com', - name: 'Joined User', - createdAt: new Date('2026-01-01T00:00:00Z'), + id: "user-2", + email: "joined@example.com", + name: "Joined User", + createdAt: new Date("2026-01-01T00:00:00Z"), }, -} +}; const MOCK_CREATE_PROJECT_RESPONSE = { - projectId: 'proj-2', - orgId: 'org-2', - orgSlug: 'new-org', - orgName: 'New Org', - projectName: 'new-project', - projectSlug: 'new-project', -} + projectId: "proj-2", + orgId: "org-2", + orgSlug: "new-org", + orgName: "New Org", + projectName: "new-project", + projectSlug: "new-project", +}; function makeMockDeps(overrides: Partial = {}): ExternalDeps { return { @@ -60,7 +60,7 @@ function makeMockDeps(overrides: Partial = {}): ExternalDeps { revokeToken: vi.fn().mockResolvedValue(undefined), }, hooks: { - inject: vi.fn().mockReturnValue('already_present'), + inject: vi.fn().mockReturnValue("already_present"), fileExists: vi.fn().mockReturnValue(true), }, prompt: { @@ -76,49 +76,57 @@ function makeMockDeps(overrides: Partial = {}): ExternalDeps { events: { sendBackground: vi.fn(), }, - cwd: vi.fn().mockReturnValue('/test/cwd'), + cwd: vi.fn().mockReturnValue("/test/cwd"), ...overrides, - } as ExternalDeps + } as ExternalDeps; } -describe('makeSetupCommand', () => { +describe("makeSetupCommand", () => { beforeEach(() => { - vi.spyOn(process, 'exit').mockImplementation((() => {}) as never) - }) + vi.spyOn(process, "exit").mockImplementation((() => {}) as never); + }); afterEach(() => { - vi.clearAllMocks() - }) + vi.clearAllMocks(); + }); - it('기존 project.json 이 있으면 onboard token으로 로그인 후 프로젝트 생성 없이 org 합류만 수행한다', async () => { + it("기존 project.json 이 있으면 onboard token으로 로그인 후 프로젝트 생성 없이 org 합류만 수행한다", async () => { const deps = makeMockDeps({ project: { find: vi.fn().mockReturnValue(MOCK_PROJECT), - findWithPath: vi.fn().mockReturnValue({ config: MOCK_PROJECT, configPath: '/test/cwd/.argos/project.json' }), + findWithPath: vi + .fn() + .mockReturnValue({ + config: MOCK_PROJECT, + configPath: "/test/cwd/.argos/project.json", + }), write: vi.fn(), }, - }) + }); - await makeSetupCommand(deps)({ token: 'argos_onb_test' }) + await makeSetupCommand(deps)({ token: "argos_onb_test" }); - expect(deps.api.exchange).toHaveBeenCalledWith('argos_onb_test', MOCK_PROJECT.apiUrl) + expect(deps.api.exchange).toHaveBeenCalledWith( + "argos_onb_test", + MOCK_PROJECT.apiUrl, + ); expect(deps.config.write).toHaveBeenCalledWith({ token: MOCK_EXCHANGE_RESPONSE.token, userId: MOCK_EXCHANGE_RESPONSE.user.id, email: MOCK_EXCHANGE_RESPONSE.user.email, apiUrl: MOCK_PROJECT.apiUrl, - }) + }); expect(deps.api.joinOrg).toHaveBeenCalledWith( MOCK_PROJECT.orgSlug, MOCK_EXCHANGE_RESPONSE.token, - MOCK_PROJECT.apiUrl - ) - expect(deps.api.createProject).not.toHaveBeenCalled() - expect(deps.project.write).not.toHaveBeenCalled() - expect(deps.hooks.inject).toHaveBeenCalled() - }) - - it('이미 로그인과 project.json 이 모두 있으면 token 없이도 no-op 연결 확인만 수행한다', async () => { + MOCK_PROJECT.apiUrl, + ); + expect(deps.api.createProject).not.toHaveBeenCalled(); + expect(deps.project.write).not.toHaveBeenCalled(); + expect(deps.hooks.inject).toHaveBeenCalled(); + }); + + it("이미 로그인과 project.json 이 모두 있으면 token 없이도 no-op 연결 확인만 수행한다", async () => { const deps = makeMockDeps({ config: { read: vi.fn().mockReturnValue(MOCK_CONFIG), @@ -127,31 +135,36 @@ describe('makeSetupCommand', () => { }, project: { find: vi.fn().mockReturnValue(MOCK_PROJECT), - findWithPath: vi.fn().mockReturnValue({ config: MOCK_PROJECT, configPath: '/test/cwd/.argos/project.json' }), + findWithPath: vi + .fn() + .mockReturnValue({ + config: MOCK_PROJECT, + configPath: "/test/cwd/.argos/project.json", + }), write: vi.fn(), }, - }) + }); - await makeSetupCommand(deps)({}) + await makeSetupCommand(deps)({}); - expect(deps.api.exchange).not.toHaveBeenCalled() - expect(deps.config.write).not.toHaveBeenCalled() + expect(deps.api.exchange).not.toHaveBeenCalled(); + expect(deps.config.write).not.toHaveBeenCalled(); expect(deps.api.joinOrg).toHaveBeenCalledWith( MOCK_PROJECT.orgSlug, MOCK_CONFIG.token, - MOCK_PROJECT.apiUrl - ) - expect(deps.api.createProject).not.toHaveBeenCalled() - expect(deps.project.write).not.toHaveBeenCalled() - }) - - it('project.json 이 없으면 기존처럼 프로젝트를 생성한다', async () => { - const deps = makeMockDeps() - - await makeSetupCommand(deps)({ token: 'argos_onb_test' }) - - expect(deps.api.exchange).toHaveBeenCalled() - expect(deps.api.createProject).toHaveBeenCalled() - expect(deps.project.write).toHaveBeenCalled() - }) -}) + MOCK_PROJECT.apiUrl, + ); + expect(deps.api.createProject).not.toHaveBeenCalled(); + expect(deps.project.write).not.toHaveBeenCalled(); + }); + + it("project.json 이 없으면 기존처럼 프로젝트를 생성한다", async () => { + const deps = makeMockDeps(); + + await makeSetupCommand(deps)({ token: "argos_onb_test" }); + + expect(deps.api.exchange).toHaveBeenCalled(); + expect(deps.api.createProject).toHaveBeenCalled(); + expect(deps.project.write).toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/__tests__/status-command.test.ts b/packages/cli/src/__tests__/status-command.test.ts index a922c76b..95b7ec8e 100644 --- a/packages/cli/src/__tests__/status-command.test.ts +++ b/packages/cli/src/__tests__/status-command.test.ts @@ -1,22 +1,22 @@ -import { describe, it, expect, vi, afterEach } from 'vitest' -import { makeStatusCommand } from '../commands/status.js' -import type { ExternalDeps } from '../deps.js' +import { describe, it, expect, vi, afterEach } from "vitest"; +import { makeStatusCommand } from "../commands/status.js"; +import type { ExternalDeps } from "../deps.js"; const MOCK_CONFIG = { - token: 'test-token', - apiUrl: 'https://api.example.com', - userId: 'user-1', - email: 'test@example.com', -} + token: "test-token", + apiUrl: "https://api.example.com", + userId: "user-1", + email: "test@example.com", +}; const MOCK_PROJECT = { - projectId: 'proj-1', - orgId: 'org-1', - orgSlug: 'test-org', - orgName: 'Test Org', - projectName: 'test-project', - apiUrl: 'https://api.example.com', -} + projectId: "proj-1", + orgId: "org-1", + orgSlug: "test-org", + orgName: "Test Org", + projectName: "test-project", + apiUrl: "https://api.example.com", +}; function makeMockDeps(overrides: Partial = {}): ExternalDeps { return { @@ -40,7 +40,7 @@ function makeMockDeps(overrides: Partial = {}): ExternalDeps { revokeToken: vi.fn(), }, hooks: { - inject: vi.fn().mockReturnValue('already_present'), + inject: vi.fn().mockReturnValue("already_present"), fileExists: vi.fn().mockReturnValue(false), }, prompt: { @@ -54,44 +54,48 @@ function makeMockDeps(overrides: Partial = {}): ExternalDeps { events: { sendBackground: vi.fn(), }, - cwd: vi.fn().mockReturnValue('/test/cwd'), + cwd: vi.fn().mockReturnValue("/test/cwd"), ...overrides, - } as ExternalDeps + } as ExternalDeps; } -describe('makeStatusCommand', () => { +describe("makeStatusCommand", () => { afterEach(() => { - vi.clearAllMocks() - }) + vi.clearAllMocks(); + }); - describe('config 있음 + project 있음', () => { - it('deps.config.read 가 호출된다', async () => { - const deps = makeMockDeps() - await makeStatusCommand(deps)({}) - expect(deps.config.read).toHaveBeenCalled() - }) + describe("config 있음 + project 있음", () => { + it("deps.config.read 가 호출된다", async () => { + const deps = makeMockDeps(); + await makeStatusCommand(deps)({}); + expect(deps.config.read).toHaveBeenCalled(); + }); - it('deps.project.find 가 호출된다', async () => { - const deps = makeMockDeps() - await makeStatusCommand(deps)({}) - expect(deps.project.find).toHaveBeenCalled() - }) - }) + it("deps.project.find 가 호출된다", async () => { + const deps = makeMockDeps(); + await makeStatusCommand(deps)({}); + expect(deps.project.find).toHaveBeenCalled(); + }); + }); - describe('config 없음', () => { - it('deps.config.read 가 null을 반환해도 에러 없이 완료된다', async () => { + describe("config 없음", () => { + it("deps.config.read 가 null을 반환해도 에러 없이 완료된다", async () => { const deps = makeMockDeps({ - config: { read: vi.fn().mockReturnValue(null), write: vi.fn(), delete: vi.fn() }, - }) - await expect(makeStatusCommand(deps)({})).resolves.toBeUndefined() - }) - }) + config: { + read: vi.fn().mockReturnValue(null), + write: vi.fn(), + delete: vi.fn(), + }, + }); + await expect(makeStatusCommand(deps)({})).resolves.toBeUndefined(); + }); + }); - describe('hooks 파일 존재 여부', () => { - it('deps.hooks.fileExists 가 호출된다', async () => { - const deps = makeMockDeps() - await makeStatusCommand(deps)({}) - expect(deps.hooks.fileExists).toHaveBeenCalled() - }) - }) -}) + describe("hooks 파일 존재 여부", () => { + it("deps.hooks.fileExists 가 호출된다", async () => { + const deps = makeMockDeps(); + await makeStatusCommand(deps)({}); + expect(deps.hooks.fileExists).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/cli/src/__tests__/transcript.test.ts b/packages/cli/src/__tests__/transcript.test.ts index 42f57fc6..7164a433 100644 --- a/packages/cli/src/__tests__/transcript.test.ts +++ b/packages/cli/src/__tests__/transcript.test.ts @@ -1,384 +1,472 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest' -import { mkdtempSync, rmSync, writeFileSync } from 'fs' -import { join } from 'path' -import { tmpdir } from 'os' +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, rmSync, writeFileSync } from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; import { extractUsageFromTranscript, detectSlashCommand, extractMessages, -} from '../lib/transcript.js' +} from "../lib/transcript.js"; function writejsonl(dir: string, lines: object[]): string { - const path = join(dir, 'transcript.jsonl') - writeFileSync(path, lines.map((l) => JSON.stringify(l)).join('\n'), 'utf8') - return path + // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal + const path = join(dir, "transcript.jsonl"); + writeFileSync(path, lines.map((l) => JSON.stringify(l)).join("\n"), "utf8"); + return path; } -describe('extractUsageFromTranscript', () => { - let tempDir: string +describe("extractUsageFromTranscript", () => { + let tempDir: string; beforeEach(() => { - tempDir = mkdtempSync(join(tmpdir(), 'argos-test-')) - }) + tempDir = mkdtempSync(join(tmpdir(), "argos-test-")); + }); afterEach(() => { - rmSync(tempDir, { recursive: true, force: true }) - }) + rmSync(tempDir, { recursive: true, force: true }); + }); - it('returns null for a non-existent file', async () => { - const result = await extractUsageFromTranscript(join(tempDir, 'no-file.jsonl')) - expect(result).toBeNull() - }) + it("returns null for a non-existent file", async () => { + const result = await extractUsageFromTranscript( + join(tempDir, "no-file.jsonl"), + ); + expect(result).toBeNull(); + }); - it('sums tokens across multiple assistant messages', async () => { + it("sums tokens across multiple assistant messages", async () => { const path = writejsonl(tempDir, [ { - type: 'assistant', + type: "assistant", message: { - model: 'claude-sonnet', - usage: { input_tokens: 100, output_tokens: 50, cache_creation_input_tokens: 10, cache_read_input_tokens: 20 }, + model: "claude-sonnet", + usage: { + input_tokens: 100, + output_tokens: 50, + cache_creation_input_tokens: 10, + cache_read_input_tokens: 20, + }, }, }, - { type: 'human', message: { content: [{ type: 'text', text: 'hi' }] } }, + { type: "human", message: { content: [{ type: "text", text: "hi" }] } }, { - type: 'assistant', + type: "assistant", message: { - model: 'claude-sonnet', - usage: { input_tokens: 200, output_tokens: 80, cache_creation_input_tokens: 0, cache_read_input_tokens: 5 }, + model: "claude-sonnet", + usage: { + input_tokens: 200, + output_tokens: 80, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 5, + }, }, }, - ]) + ]); - const result = await extractUsageFromTranscript(path) + const result = await extractUsageFromTranscript(path); - expect(result).not.toBeNull() - expect(result!.inputTokens).toBe(300) - expect(result!.outputTokens).toBe(130) - expect(result!.cacheCreationTokens).toBe(10) - expect(result!.cacheReadTokens).toBe(25) - }) + expect(result).not.toBeNull(); + expect(result!.inputTokens).toBe(300); + expect(result!.outputTokens).toBe(130); + expect(result!.cacheCreationTokens).toBe(10); + expect(result!.cacheReadTokens).toBe(25); + }); - it('picks model from the first assistant message', async () => { + it("picks model from the first assistant message", async () => { const path = writejsonl(tempDir, [ - { type: 'assistant', message: { model: 'claude-opus', usage: { input_tokens: 10, output_tokens: 5 } } }, - { type: 'assistant', message: { model: 'claude-sonnet', usage: { input_tokens: 10, output_tokens: 5 } } }, - ]) + { + type: "assistant", + message: { + model: "claude-opus", + usage: { input_tokens: 10, output_tokens: 5 }, + }, + }, + { + type: "assistant", + message: { + model: "claude-sonnet", + usage: { input_tokens: 10, output_tokens: 5 }, + }, + }, + ]); - const result = await extractUsageFromTranscript(path) - expect(result!.model).toBe('claude-opus') - }) + const result = await extractUsageFromTranscript(path); + expect(result!.model).toBe("claude-opus"); + }); - it('returns null when all token counts are zero', async () => { + it("returns null when all token counts are zero", async () => { const path = writejsonl(tempDir, [ - { type: 'assistant', message: { usage: { input_tokens: 0, output_tokens: 0 } } }, - ]) + { + type: "assistant", + message: { usage: { input_tokens: 0, output_tokens: 0 } }, + }, + ]); - const result = await extractUsageFromTranscript(path) - expect(result).toBeNull() - }) + const result = await extractUsageFromTranscript(path); + expect(result).toBeNull(); + }); - it('ignores non-assistant lines for token counting', async () => { + it("ignores non-assistant lines for token counting", async () => { const path = writejsonl(tempDir, [ - { type: 'human', message: { usage: { input_tokens: 9999 } } }, - { type: 'system', message: { usage: { input_tokens: 8888 } } }, - { type: 'assistant', message: { usage: { input_tokens: 100, output_tokens: 50 } } }, - ]) - - const result = await extractUsageFromTranscript(path) - expect(result!.inputTokens).toBe(100) - expect(result!.outputTokens).toBe(50) - }) - - it('handles malformed lines without throwing', async () => { - const path = join(tempDir, 'transcript.jsonl') + { type: "human", message: { usage: { input_tokens: 9999 } } }, + { type: "system", message: { usage: { input_tokens: 8888 } } }, + { + type: "assistant", + message: { usage: { input_tokens: 100, output_tokens: 50 } }, + }, + ]); + + const result = await extractUsageFromTranscript(path); + expect(result!.inputTokens).toBe(100); + expect(result!.outputTokens).toBe(50); + }); + + it("handles malformed lines without throwing", async () => { + const path = join(tempDir, "transcript.jsonl"); writeFileSync( path, [ - '{ not valid json', - JSON.stringify({ type: 'assistant', message: { usage: { input_tokens: 50, output_tokens: 20 } } }), - ].join('\n'), - 'utf8' - ) - - const result = await extractUsageFromTranscript(path) - expect(result!.inputTokens).toBe(50) - }) -}) - -describe('detectSlashCommand', () => { - let tempDir: string + "{ not valid json", + JSON.stringify({ + type: "assistant", + message: { usage: { input_tokens: 50, output_tokens: 20 } }, + }), + ].join("\n"), + "utf8", + ); + + const result = await extractUsageFromTranscript(path); + expect(result!.inputTokens).toBe(50); + }); +}); + +describe("detectSlashCommand", () => { + let tempDir: string; beforeEach(() => { - tempDir = mkdtempSync(join(tmpdir(), 'argos-test-')) - }) + tempDir = mkdtempSync(join(tmpdir(), "argos-test-")); + }); afterEach(() => { - rmSync(tempDir, { recursive: true, force: true }) - }) + rmSync(tempDir, { recursive: true, force: true }); + }); - it('returns null when no slash command is present', async () => { - const path = writejsonl(tempDir, [{ type: 'human', content: 'regular message' }]) - expect(await detectSlashCommand(path)).toBeNull() - }) + it("returns null when no slash command is present", async () => { + const path = writejsonl(tempDir, [ + { type: "human", content: "regular message" }, + ]); + expect(await detectSlashCommand(path)).toBeNull(); + }); - it('returns null for non-existent file', async () => { - expect(await detectSlashCommand(join(tempDir, 'nope.jsonl'))).toBeNull() - }) + it("returns null for non-existent file", async () => { + expect(await detectSlashCommand(join(tempDir, "nope.jsonl"))).toBeNull(); + }); - it('returns skill name without the leading slash', async () => { - const path = writejsonl(tempDir, [{ type: 'queue-operation', content: '/commit' }]) - expect(await detectSlashCommand(path)).toBe('commit') - }) + it("returns skill name without the leading slash", async () => { + const path = writejsonl(tempDir, [ + { type: "queue-operation", content: "/commit" }, + ]); + expect(await detectSlashCommand(path)).toBe("commit"); + }); - it('detects slash command within a mixed transcript', async () => { + it("detects slash command within a mixed transcript", async () => { const path = writejsonl(tempDir, [ - { type: 'human', content: 'do something' }, - { type: 'queue-operation', content: '/review-pr' }, - { type: 'assistant', message: {} }, - ]) - expect(await detectSlashCommand(path)).toBe('review-pr') - }) - - it('ignores queue-operation entries that do not start with slash', async () => { + { type: "human", content: "do something" }, + { type: "queue-operation", content: "/review-pr" }, + { type: "assistant", message: {} }, + ]); + expect(await detectSlashCommand(path)).toBe("review-pr"); + }); + + it("ignores queue-operation entries that do not start with slash", async () => { const path = writejsonl(tempDir, [ - { type: 'queue-operation', content: 'not a slash command' }, - ]) - expect(await detectSlashCommand(path)).toBeNull() - }) + { type: "queue-operation", content: "not a slash command" }, + ]); + expect(await detectSlashCommand(path)).toBeNull(); + }); - it('returns only the first slash command when multiple exist', async () => { + it("returns only the first slash command when multiple exist", async () => { const path = writejsonl(tempDir, [ - { type: 'queue-operation', content: '/first' }, - { type: 'queue-operation', content: '/second' }, - ]) - expect(await detectSlashCommand(path)).toBe('first') - }) -}) + { type: "queue-operation", content: "/first" }, + { type: "queue-operation", content: "/second" }, + ]); + expect(await detectSlashCommand(path)).toBe("first"); + }); +}); -describe('extractMessages', () => { - let tempDir: string +describe("extractMessages", () => { + let tempDir: string; beforeEach(() => { - tempDir = mkdtempSync(join(tmpdir(), 'argos-test-')) - }) + tempDir = mkdtempSync(join(tmpdir(), "argos-test-")); + }); afterEach(() => { - rmSync(tempDir, { recursive: true, force: true }) - }) + rmSync(tempDir, { recursive: true, force: true }); + }); - it('returns empty array for non-existent file', async () => { - const result = await extractMessages(join(tempDir, 'nope.jsonl')) - expect(result).toEqual([]) - }) + it("returns empty array for non-existent file", async () => { + const result = await extractMessages(join(tempDir, "nope.jsonl")); + expect(result).toEqual([]); + }); it('extracts user and assistant messages with correct roles (type="user")', async () => { const path = writejsonl(tempDir, [ { - type: 'user', - message: { content: 'Hello' }, - timestamp: '2024-01-01T00:00:00Z', + type: "user", + message: { content: "Hello" }, + timestamp: "2024-01-01T00:00:00Z", }, { - type: 'assistant', - message: { content: [{ type: 'text', text: 'World' }] }, - timestamp: '2024-01-01T00:00:01Z', + type: "assistant", + message: { content: [{ type: "text", text: "World" }] }, + timestamp: "2024-01-01T00:00:01Z", }, - ]) + ]); - const result = await extractMessages(path) + const result = await extractMessages(path); - expect(result).toHaveLength(2) - expect(result[0].role).toBe('HUMAN') - expect(result[0].content).toBe('Hello') - expect(result[0].sequence).toBe(0) - expect(result[1].role).toBe('ASSISTANT') - expect(result[1].content).toBe('World') - expect(result[1].sequence).toBe(1) - }) + expect(result).toHaveLength(2); + expect(result[0].role).toBe("HUMAN"); + expect(result[0].content).toBe("Hello"); + expect(result[0].sequence).toBe(0); + expect(result[1].role).toBe("ASSISTANT"); + expect(result[1].content).toBe("World"); + expect(result[1].sequence).toBe(1); + }); it('supports legacy type="human" with array content', async () => { const path = writejsonl(tempDir, [ { - type: 'human', - message: { content: 'Legacy hello' }, - timestamp: '2024-01-01T00:00:00Z', + type: "human", + message: { content: "Legacy hello" }, + timestamp: "2024-01-01T00:00:00Z", }, - ]) + ]); - const result = await extractMessages(path) - expect(result).toHaveLength(1) - expect(result[0].role).toBe('HUMAN') - expect(result[0].content).toBe('Legacy hello') - }) + const result = await extractMessages(path); + expect(result).toHaveLength(1); + expect(result[0].role).toBe("HUMAN"); + expect(result[0].content).toBe("Legacy hello"); + }); - it('user array-content without matching tool_use yields no messages', async () => { + it("user array-content without matching tool_use yields no messages", async () => { const path = writejsonl(tempDir, [ { - type: 'user', - message: { content: [{ type: 'tool_result', tool_use_id: 'x', content: 'output' }] }, + type: "user", + message: { + content: [ + { type: "tool_result", tool_use_id: "x", content: "output" }, + ], + }, }, - ]) + ]); - const result = await extractMessages(path) - expect(result).toHaveLength(0) - }) + const result = await extractMessages(path); + expect(result).toHaveLength(0); + }); - it('emits separate TOOL row for each tool_use block', async () => { + it("emits separate TOOL row for each tool_use block", async () => { const path = writejsonl(tempDir, [ { - type: 'assistant', - timestamp: '2024-01-01T00:00:00.000Z', + type: "assistant", + timestamp: "2024-01-01T00:00:00.000Z", message: { content: [ - { type: 'text', text: 'Let me read the file.' }, - { type: 'tool_use', id: 'tu_1', name: 'Read', input: { file_path: '/tmp/test.ts' } }, + { type: "text", text: "Let me read the file." }, + { + type: "tool_use", + id: "tu_1", + name: "Read", + input: { file_path: "/tmp/test.ts" }, + }, ], }, }, - ]) - - const result = await extractMessages(path) - expect(result).toHaveLength(2) - expect(result[0].role).toBe('ASSISTANT') - expect(result[0].content).toBe('Let me read the file.') - expect(result[1].role).toBe('TOOL') - expect(result[1].toolName).toBe('Read') - expect(result[1].toolInput).toEqual({ file_path: '/tmp/test.ts' }) - expect(result[1].toolUseId).toBe('tu_1') - }) - - it('tool_use-only assistant entry produces just a TOOL row (no ASSISTANT row)', async () => { + ]); + + const result = await extractMessages(path); + expect(result).toHaveLength(2); + expect(result[0].role).toBe("ASSISTANT"); + expect(result[0].content).toBe("Let me read the file."); + expect(result[1].role).toBe("TOOL"); + expect(result[1].toolName).toBe("Read"); + expect(result[1].toolInput).toEqual({ file_path: "/tmp/test.ts" }); + expect(result[1].toolUseId).toBe("tu_1"); + }); + + it("tool_use-only assistant entry produces just a TOOL row (no ASSISTANT row)", async () => { const path = writejsonl(tempDir, [ { - type: 'assistant', - timestamp: '2024-01-01T00:00:00.000Z', + type: "assistant", + timestamp: "2024-01-01T00:00:00.000Z", message: { content: [ - { type: 'tool_use', id: 'tu_1', name: 'Bash', input: { command: 'ls -la' } }, + { + type: "tool_use", + id: "tu_1", + name: "Bash", + input: { command: "ls -la" }, + }, ], }, }, - ]) + ]); - const result = await extractMessages(path) - expect(result).toHaveLength(1) - expect(result[0].role).toBe('TOOL') - expect(result[0].toolName).toBe('Bash') - expect(result[0].toolInput).toEqual({ command: 'ls -la' }) - }) + const result = await extractMessages(path); + expect(result).toHaveLength(1); + expect(result[0].role).toBe("TOOL"); + expect(result[0].toolName).toBe("Bash"); + expect(result[0].toolInput).toEqual({ command: "ls -la" }); + }); - it('fills TOOL content + durationMs from matching tool_result', async () => { + it("fills TOOL content + durationMs from matching tool_result", async () => { const path = writejsonl(tempDir, [ { - type: 'assistant', - timestamp: '2024-01-01T00:00:00.000Z', + type: "assistant", + timestamp: "2024-01-01T00:00:00.000Z", message: { - content: [{ type: 'tool_use', id: 'tu_1', name: 'Bash', input: { command: 'ls' } }], + content: [ + { + type: "tool_use", + id: "tu_1", + name: "Bash", + input: { command: "ls" }, + }, + ], }, }, { - type: 'user', - timestamp: '2024-01-01T00:00:02.500Z', + type: "user", + timestamp: "2024-01-01T00:00:02.500Z", message: { - content: [{ type: 'tool_result', tool_use_id: 'tu_1', content: 'file-a\nfile-b' }], + content: [ + { + type: "tool_result", + tool_use_id: "tu_1", + content: "file-a\nfile-b", + }, + ], }, }, - ]) + ]); - const result = await extractMessages(path) - expect(result).toHaveLength(1) - expect(result[0].role).toBe('TOOL') - expect(result[0].content).toBe('file-a\nfile-b') - expect(result[0].durationMs).toBe(2500) - }) + const result = await extractMessages(path); + expect(result).toHaveLength(1); + expect(result[0].role).toBe("TOOL"); + expect(result[0].content).toBe("file-a\nfile-b"); + expect(result[0].durationMs).toBe(2500); + }); - it('tool_result with array content is flattened to joined text', async () => { + it("tool_result with array content is flattened to joined text", async () => { const path = writejsonl(tempDir, [ { - type: 'assistant', - timestamp: '2024-01-01T00:00:00.000Z', + type: "assistant", + timestamp: "2024-01-01T00:00:00.000Z", message: { - content: [{ type: 'tool_use', id: 'tu_1', name: 'Read', input: { file_path: '/a' } }], + content: [ + { + type: "tool_use", + id: "tu_1", + name: "Read", + input: { file_path: "/a" }, + }, + ], }, }, { - type: 'user', - timestamp: '2024-01-01T00:00:01.000Z', + type: "user", + timestamp: "2024-01-01T00:00:01.000Z", message: { content: [ { - type: 'tool_result', - tool_use_id: 'tu_1', + type: "tool_result", + tool_use_id: "tu_1", content: [ - { type: 'text', text: 'line1' }, - { type: 'text', text: 'line2' }, + { type: "text", text: "line1" }, + { type: "text", text: "line2" }, ], }, ], }, }, - ]) + ]); - const result = await extractMessages(path) - expect(result[0].content).toBe('line1\nline2') - }) + const result = await extractMessages(path); + expect(result[0].content).toBe("line1\nline2"); + }); - it('skips assistant entries with no text or tool_use blocks', async () => { + it("skips assistant entries with no text or tool_use blocks", async () => { const path = writejsonl(tempDir, [ - { type: 'assistant', message: { content: [{ type: 'thinking', thinking: 'hmm' }] } }, - ]) + { + type: "assistant", + message: { content: [{ type: "thinking", thinking: "hmm" }] }, + }, + ]); - const result = await extractMessages(path) - expect(result).toHaveLength(0) - }) + const result = await extractMessages(path); + expect(result).toHaveLength(0); + }); - it('truncates user string content to 50,000 characters', async () => { + it("truncates user string content to 50,000 characters", async () => { const path = writejsonl(tempDir, [ { - type: 'user', - message: { content: 'a'.repeat(60000) }, + type: "user", + message: { content: "a".repeat(60000) }, }, - ]) + ]); - const result = await extractMessages(path) - expect(result[0].content.length).toBe(50000) - }) + const result = await extractMessages(path); + expect(result[0].content.length).toBe(50000); + }); - it('assigns sequential sequence numbers including TOOL rows', async () => { + it("assigns sequential sequence numbers including TOOL rows", async () => { const path = writejsonl(tempDir, [ - { type: 'user', message: { content: 'msg1' }, timestamp: '2024-01-01T00:00:00Z' }, { - type: 'assistant', - timestamp: '2024-01-01T00:00:01Z', + type: "user", + message: { content: "msg1" }, + timestamp: "2024-01-01T00:00:00Z", + }, + { + type: "assistant", + timestamp: "2024-01-01T00:00:01Z", message: { content: [ - { type: 'text', text: 'msg2' }, - { type: 'tool_use', id: 'tu_1', name: 'Bash', input: {} }, + { type: "text", text: "msg2" }, + { type: "tool_use", id: "tu_1", name: "Bash", input: {} }, ], }, }, - { type: 'user', message: { content: 'msg3' }, timestamp: '2024-01-01T00:00:02Z' }, - ]) - - const result = await extractMessages(path) - expect(result.map((m) => m.sequence)).toEqual([0, 1, 2, 3]) - expect(result.map((m) => m.role)).toEqual(['HUMAN', 'ASSISTANT', 'TOOL', 'HUMAN']) - }) - - it('joins multiple text blocks within one assistant message', async () => { + { + type: "user", + message: { content: "msg3" }, + timestamp: "2024-01-01T00:00:02Z", + }, + ]); + + const result = await extractMessages(path); + expect(result.map((m) => m.sequence)).toEqual([0, 1, 2, 3]); + expect(result.map((m) => m.role)).toEqual([ + "HUMAN", + "ASSISTANT", + "TOOL", + "HUMAN", + ]); + }); + + it("joins multiple text blocks within one assistant message", async () => { const path = writejsonl(tempDir, [ { - type: 'assistant', + type: "assistant", message: { content: [ - { type: 'text', text: 'part one' }, - { type: 'text', text: 'part two' }, + { type: "text", text: "part one" }, + { type: "text", text: "part two" }, ], }, }, - ]) + ]); - const result = await extractMessages(path) - expect(result[0].content).toBe('part one\npart two') - }) -}) + const result = await extractMessages(path); + expect(result[0].content).toBe("part one\npart two"); + }); +}); diff --git a/packages/cli/src/adapters.ts b/packages/cli/src/adapters.ts index 451d8d47..2a5cb201 100644 --- a/packages/cli/src/adapters.ts +++ b/packages/cli/src/adapters.ts @@ -1,15 +1,29 @@ -import { existsSync } from 'fs' -import { input } from '@inquirer/prompts' -import { readConfig, writeConfig, deleteConfig } from './lib/config.js' -import { findProjectConfig, findProjectConfigWithPath, writeProjectConfig } from './lib/project.js' -import { runLoginFlow } from './lib/auth-flow.js' -import { apiRequest } from './lib/api-client.js' -import { injectHooks } from './lib/hooks-inject.js' -import { sendEventBackground } from './lib/event-sender.js' -import { extractUsageFromTranscript, extractUsagePerTurn, detectSlashCommand, extractMessages, extractSummary } from './lib/transcript.js' -import { extractUsageFromCodexTranscript, extractUsagePerTurnFromCodexTranscript, extractMessagesFromCodexTranscript } from './lib/transcript-codex.js' -import type { ExternalDeps } from './deps.js' -import type { CreateProjectResponse, ExchangeResponse } from '@argos/shared' +import { existsSync } from "fs"; +import { input } from "@inquirer/prompts"; +import { readConfig, writeConfig, deleteConfig } from "./lib/config.js"; +import { + findProjectConfig, + findProjectConfigWithPath, + writeProjectConfig, +} from "./lib/project.js"; +import { runLoginFlow } from "./lib/auth-flow.js"; +import { apiRequest } from "./lib/api-client.js"; +import { injectHooks } from "./lib/hooks-inject.js"; +import { sendEventBackground } from "./lib/event-sender.js"; +import { + extractUsageFromTranscript, + extractUsagePerTurn, + detectSlashCommand, + extractMessages, + extractSummary, +} from "./lib/transcript.js"; +import { + extractUsageFromCodexTranscript, + extractUsagePerTurnFromCodexTranscript, + extractMessagesFromCodexTranscript, +} from "./lib/transcript-codex.js"; +import type { ExternalDeps } from "./deps.js"; +import type { CreateProjectResponse, ExchangeResponse } from "@argos/shared"; export const realDeps: ExternalDeps = { config: { @@ -26,44 +40,49 @@ export const realDeps: ExternalDeps = { login: runLoginFlow, }, api: { - async exchange(onboardToken: string, apiUrl: string): Promise { + async exchange( + onboardToken: string, + apiUrl: string, + ): Promise { return apiRequest(`${apiUrl}/api/auth/exchange`, { - method: 'POST', + method: "POST", body: JSON.stringify({ onboardToken }), - baseUrl: '', - }) + baseUrl: "", + }); }, - async createProject(name: string, token: string, apiUrl: string): Promise { - const orgsRes = await apiRequest<{ orgs: Array<{ id: string; name: string; slug: string; role: string }> }>( - `${apiUrl}/api/orgs`, - { method: 'GET', token, baseUrl: '' } - ) + async createProject( + name: string, + token: string, + apiUrl: string, + ): Promise { + const orgsRes = await apiRequest<{ + orgs: Array<{ id: string; name: string; slug: string; role: string }>; + }>(`${apiUrl}/api/orgs`, { method: "GET", token, baseUrl: "" }); - let org: { id: string; name: string; slug: string } + let org: { id: string; name: string; slug: string }; if (!orgsRes.orgs || orgsRes.orgs.length === 0) { // 신규 유저: 첫 번째 프로젝트 생성 시 org를 프로젝트명과 동일하게 자동 생성 - const createOrgRes = await apiRequest<{ org: { id: string; name: string; slug: string } }>( - `${apiUrl}/api/orgs`, - { - method: 'POST', - body: JSON.stringify({ name }), - token, - baseUrl: '', - } - ) - org = createOrgRes.org + const createOrgRes = await apiRequest<{ + org: { id: string; name: string; slug: string }; + }>(`${apiUrl}/api/orgs`, { + method: "POST", + body: JSON.stringify({ name }), + token, + baseUrl: "", + }); + org = createOrgRes.org; } else { - org = orgsRes.orgs[0] + org = orgsRes.orgs[0]; } const createRes = await apiRequest<{ - project: { id: string; orgId: string; slug: string; name: string } + project: { id: string; orgId: string; slug: string; name: string }; }>(`${apiUrl}/api/orgs/${org.slug}/projects`, { - method: 'POST', + method: "POST", body: JSON.stringify({ name }), token, - baseUrl: '', - }) + baseUrl: "", + }); return { projectId: createRes.project.id, @@ -72,28 +91,36 @@ export const realDeps: ExternalDeps = { orgName: org.name, projectName: createRes.project.name, projectSlug: createRes.project.slug, - } + }; }, - async joinOrg(orgSlug: string, token: string, apiUrl: string): Promise { + async joinOrg( + orgSlug: string, + token: string, + apiUrl: string, + ): Promise { await apiRequest(`${apiUrl}/api/orgs/${orgSlug}/members`, { - method: 'POST', + method: "POST", token, - baseUrl: '', - }) + baseUrl: "", + }); }, - async ensureMembership(orgSlug: string, token: string, apiUrl: string): Promise { + async ensureMembership( + orgSlug: string, + token: string, + apiUrl: string, + ): Promise { await apiRequest(`${apiUrl}/api/orgs/${orgSlug}/members`, { - method: 'POST', + method: "POST", token, - baseUrl: '', - }) + baseUrl: "", + }); }, async revokeToken(token: string, apiUrl: string): Promise { await apiRequest(`${apiUrl}/api/auth/logout`, { - method: 'POST', + method: "POST", token, - baseUrl: '', - }) + baseUrl: "", + }); }, }, hooks: { @@ -102,7 +129,7 @@ export const realDeps: ExternalDeps = { }, prompt: { async input(message: string, defaultValue?: string): Promise { - return input({ message, default: defaultValue }) + return input({ message, default: defaultValue }); }, }, transcript: { @@ -119,6 +146,6 @@ export const realDeps: ExternalDeps = { sendBackground: (opts) => sendEventBackground(opts), }, cwd(): string { - return process.cwd() + return process.cwd(); }, -} +}; diff --git a/packages/cli/src/commands/default.ts b/packages/cli/src/commands/default.ts index a8676400..35f7e330 100644 --- a/packages/cli/src/commands/default.ts +++ b/packages/cli/src/commands/default.ts @@ -1,56 +1,67 @@ -import chalk from 'chalk' -import ora from 'ora' -import { DEFAULT_API_URL, normalizeApiUrl, type Config } from '../lib/config.js' -import { injectAgentHooks, printAgentHookResult, printCodexTrustNotice } from '../lib/inject-agent-hooks.js' -import type { ProjectConfig } from '../lib/project.js' -import type { CreateProjectResponse } from '@argos/shared' -import type { ExternalDeps, CommandFactory } from '../deps.js' +import chalk from "chalk"; +import ora from "ora"; +import { + DEFAULT_API_URL, + normalizeApiUrl, + type Config, +} from "../lib/config.js"; +import { + injectAgentHooks, + printAgentHookResult, + printCodexTrustNotice, +} from "../lib/inject-agent-hooks.js"; +import type { ProjectConfig } from "../lib/project.js"; +import type { CreateProjectResponse } from "@argos/shared"; +import type { ExternalDeps, CommandFactory } from "../deps.js"; interface DefaultCommandOptions { - apiUrl?: string + apiUrl?: string; } export const makeDefaultCommand: CommandFactory = (deps) => async (options) => { - const config = deps.config.read() - const project = deps.project.find() + const config = deps.config.read(); + const project = deps.project.find(); // customApiUrl is undefined unless the user passed a real self-hosted URL. // When undefined, the field is omitted from newly-written configs so they // track DEFAULT_API_URL automatically. - const customApiUrl = normalizeApiUrl(options.apiUrl) + const customApiUrl = normalizeApiUrl(options.apiUrl); // 4-way branch based on config and project presence if (!config && !project) { - await runFullSetup(deps, customApiUrl) + await runFullSetup(deps, customApiUrl); } else if (!config && project) { - await runLoginAndJoin(deps, project, customApiUrl) + await runLoginAndJoin(deps, project, customApiUrl); } else if (config && !project) { - await runProjectInit(deps, config, customApiUrl) + await runProjectInit(deps, config, customApiUrl); } else if (config && project) { - await ensureOrgMembershipAndShowStatus(deps, config, project) + await ensureOrgMembershipAndShowStatus(deps, config, project); } - } + }; /** * Flow 1: Full setup (login + create project + inject hooks) */ -async function runFullSetup(deps: ExternalDeps, customApiUrl: string | undefined): Promise { - console.log(chalk.bold('Argos 초기 설정')) - console.log() +async function runFullSetup( + deps: ExternalDeps, + customApiUrl: string | undefined, +): Promise { + console.log(chalk.bold("Argos 초기 설정")); + console.log(); - const effectiveApiUrl = customApiUrl ?? DEFAULT_API_URL + const effectiveApiUrl = customApiUrl ?? DEFAULT_API_URL; // Step 1: Login - console.log('→ 로그인') + console.log("→ 로그인"); - let loginResponse + let loginResponse; try { - loginResponse = await deps.auth.login(effectiveApiUrl) - console.log(chalk.green(`✓ 로그인 완료 (${loginResponse.user.email})`)) + loginResponse = await deps.auth.login(effectiveApiUrl); + console.log(chalk.green(`✓ 로그인 완료 (${loginResponse.user.email})`)); } catch (err) { - console.error(chalk.red('✗ 로그인 실패')) - console.error(err instanceof Error ? err.message : String(err)) - process.exit(1) + console.error(chalk.red("✗ 로그인 실패")); + console.error(err instanceof Error ? err.message : String(err)); + process.exit(1); } // Save config — omit apiUrl unless user provided a self-hosted override @@ -59,25 +70,31 @@ async function runFullSetup(deps: ExternalDeps, customApiUrl: string | undefined userId: loginResponse.user.id, email: loginResponse.user.email, ...(customApiUrl && { apiUrl: customApiUrl }), - }) + }); // Step 2: Create project - console.log() - console.log('→ 프로젝트 생성') + console.log(); + console.log("→ 프로젝트 생성"); - const projectName = deps.cwd().split('/').pop() || 'my-project' + const projectName = deps.cwd().split("/").pop() || "my-project"; - const spinner = ora('프로젝트 생성 중...').start() + const spinner = ora("프로젝트 생성 중...").start(); - let projectResponse: CreateProjectResponse + let projectResponse: CreateProjectResponse; try { - projectResponse = await deps.api.createProject(projectName, loginResponse.token, effectiveApiUrl) - spinner.succeed(chalk.green(`✓ 프로젝트 생성: ${projectResponse.projectName}`)) - console.log(` 조직: ${projectResponse.orgName}`) + projectResponse = await deps.api.createProject( + projectName, + loginResponse.token, + effectiveApiUrl, + ); + spinner.succeed( + chalk.green(`✓ 프로젝트 생성: ${projectResponse.projectName}`), + ); + console.log(` 조직: ${projectResponse.orgName}`); } catch (err) { - spinner.fail(chalk.red('✗ 프로젝트 생성 실패')) - console.error(err instanceof Error ? err.message : String(err)) - process.exit(1) + spinner.fail(chalk.red("✗ 프로젝트 생성 실패")); + console.error(err instanceof Error ? err.message : String(err)); + process.exit(1); } // Step 3: Write project config @@ -88,43 +105,51 @@ async function runFullSetup(deps: ExternalDeps, customApiUrl: string | undefined orgName: projectResponse.orgName, projectName: projectResponse.projectName, ...(customApiUrl && { apiUrl: customApiUrl }), - }) - console.log(chalk.green('✓ .argos/project.json 작성')) + }); + console.log(chalk.green("✓ .argos/project.json 작성")); // Step 4: Inject hooks (Claude Code + Codex) - printAgentHookResult(injectAgentHooks(deps, deps.cwd())) - printCodexTrustNotice() + printAgentHookResult(injectAgentHooks(deps, deps.cwd())); + printCodexTrustNotice(); // Success message - console.log() - console.log(chalk.bold.green('✓ 설정 완료!')) - console.log() - console.log('다음 단계:') - console.log(' git add .argos/project.json .claude/settings.json .codex/hooks.json') - console.log(' git commit -m "chore: add argos tracking"') - console.log() - console.log('팀원들이 이 저장소를 clone한 뒤 argos를 실행하면 자동으로 팀에 합류됩니다.') + console.log(); + console.log(chalk.bold.green("✓ 설정 완료!")); + console.log(); + console.log("다음 단계:"); + console.log( + " git add .argos/project.json .claude/settings.json .codex/hooks.json", + ); + console.log(' git commit -m "chore: add argos tracking"'); + console.log(); + console.log( + "팀원들이 이 저장소를 clone한 뒤 argos를 실행하면 자동으로 팀에 합류됩니다.", + ); } /** * Flow 2: Login and join existing org (project.json exists, but not logged in) */ -async function runLoginAndJoin(deps: ExternalDeps, project: ProjectConfig, customApiUrl: string | undefined): Promise { - console.log(chalk.bold('Argos 로그인')) - console.log(`프로젝트: ${project.projectName}`) - console.log() +async function runLoginAndJoin( + deps: ExternalDeps, + project: ProjectConfig, + customApiUrl: string | undefined, +): Promise { + console.log(chalk.bold("Argos 로그인")); + console.log(`프로젝트: ${project.projectName}`); + console.log(); - const inheritedApiUrl = customApiUrl ?? project.apiUrl - const effectiveApiUrl = inheritedApiUrl ?? DEFAULT_API_URL + const inheritedApiUrl = customApiUrl ?? project.apiUrl; + const effectiveApiUrl = inheritedApiUrl ?? DEFAULT_API_URL; - let loginResponse + let loginResponse; try { - loginResponse = await deps.auth.login(effectiveApiUrl) - console.log(chalk.green(`✓ 로그인 완료 (${loginResponse.user.email})`)) + loginResponse = await deps.auth.login(effectiveApiUrl); + console.log(chalk.green(`✓ 로그인 완료 (${loginResponse.user.email})`)); } catch (err) { - console.error(chalk.red('✗ 로그인 실패')) - console.error(err instanceof Error ? err.message : String(err)) - process.exit(1) + console.error(chalk.red("✗ 로그인 실패")); + console.error(err instanceof Error ? err.message : String(err)); + process.exit(1); } // Save config — inherit project's override if present, otherwise omit to track default @@ -133,51 +158,67 @@ async function runLoginAndJoin(deps: ExternalDeps, project: ProjectConfig, custo userId: loginResponse.user.id, email: loginResponse.user.email, ...(inheritedApiUrl && { apiUrl: inheritedApiUrl }), - }) + }); // Join org. Legacy project.json may lack orgSlug — fall back to orgId. - const orgIdentifier = project.orgSlug ?? project.orgId - const spinner = ora('조직 합류 중...').start() + const orgIdentifier = project.orgSlug ?? project.orgId; + const spinner = ora("조직 합류 중...").start(); try { - await deps.api.joinOrg(orgIdentifier, loginResponse.token, effectiveApiUrl) - spinner.succeed(chalk.green(`✓ 조직 합류: ${project.orgName}`)) + await deps.api.joinOrg(orgIdentifier, loginResponse.token, effectiveApiUrl); + spinner.succeed(chalk.green(`✓ 조직 합류: ${project.orgName}`)); } catch (err) { - spinner.fail(chalk.red('✗ 조직 합류 실패')) - console.error(err instanceof Error ? err.message : String(err)) - process.exit(1) + spinner.fail(chalk.red("✗ 조직 합류 실패")); + console.error(err instanceof Error ? err.message : String(err)); + process.exit(1); } - console.log() - console.log(chalk.bold.green('✓ 설정 완료!')) - console.log() - console.log('트래킹이 활성화되었습니다. Claude Code · Codex 를 사용하면 자동으로 기록됩니다.') - console.log(chalk.dim('(Codex 는 codex 실행 후 /hooks 에서 argos hook 들을 trust 해야 동작합니다.)')) + console.log(); + console.log(chalk.bold.green("✓ 설정 완료!")); + console.log(); + console.log( + "트래킹이 활성화되었습니다. Claude Code · Codex 를 사용하면 자동으로 기록됩니다.", + ); + console.log( + chalk.dim( + "(Codex 는 codex 실행 후 /hooks 에서 argos hook 들을 trust 해야 동작합니다.)", + ), + ); } /** * Flow 3: Create project (already logged in, but no project.json) */ -async function runProjectInit(deps: ExternalDeps, config: Config, customApiUrl: string | undefined): Promise { - console.log(chalk.green(`✓ 로그인됨: ${config.email}`)) - console.log('→ 이 디렉토리는 아직 Argos 프로젝트가 아닙니다.') - console.log() +async function runProjectInit( + deps: ExternalDeps, + config: Config, + customApiUrl: string | undefined, +): Promise { + console.log(chalk.green(`✓ 로그인됨: ${config.email}`)); + console.log("→ 이 디렉토리는 아직 Argos 프로젝트가 아닙니다."); + console.log(); - const inheritedApiUrl = customApiUrl ?? config.apiUrl - const effectiveApiUrl = inheritedApiUrl ?? DEFAULT_API_URL + const inheritedApiUrl = customApiUrl ?? config.apiUrl; + const effectiveApiUrl = inheritedApiUrl ?? DEFAULT_API_URL; - const projectName = deps.cwd().split('/').pop() || 'my-project' + const projectName = deps.cwd().split("/").pop() || "my-project"; - const spinner = ora('프로젝트 생성 중...').start() + const spinner = ora("프로젝트 생성 중...").start(); - let projectResponse: CreateProjectResponse + let projectResponse: CreateProjectResponse; try { - projectResponse = await deps.api.createProject(projectName, config.token, effectiveApiUrl) - spinner.succeed(chalk.green(`✓ 프로젝트 생성: ${projectResponse.projectName}`)) - console.log(` 조직: ${projectResponse.orgName}`) + projectResponse = await deps.api.createProject( + projectName, + config.token, + effectiveApiUrl, + ); + spinner.succeed( + chalk.green(`✓ 프로젝트 생성: ${projectResponse.projectName}`), + ); + console.log(` 조직: ${projectResponse.orgName}`); } catch (err) { - spinner.fail(chalk.red('✗ 프로젝트 생성 실패')) - console.error(err instanceof Error ? err.message : String(err)) - process.exit(1) + spinner.fail(chalk.red("✗ 프로젝트 생성 실패")); + console.error(err instanceof Error ? err.message : String(err)); + process.exit(1); } // Write project config — inherit user's override if present, otherwise omit @@ -188,15 +229,15 @@ async function runProjectInit(deps: ExternalDeps, config: Config, customApiUrl: orgName: projectResponse.orgName, projectName: projectResponse.projectName, ...(inheritedApiUrl && { apiUrl: inheritedApiUrl }), - }) - console.log(chalk.green('✓ .argos/project.json 작성')) + }); + console.log(chalk.green("✓ .argos/project.json 작성")); // Inject hooks (Claude Code + Codex) - printAgentHookResult(injectAgentHooks(deps, deps.cwd())) - printCodexTrustNotice() + printAgentHookResult(injectAgentHooks(deps, deps.cwd())); + printCodexTrustNotice(); - console.log() - console.log(chalk.bold.green('✓ 설정 완료!')) + console.log(); + console.log(chalk.bold.green("✓ 설정 완료!")); } /** @@ -205,28 +246,37 @@ async function runProjectInit(deps: ExternalDeps, config: Config, customApiUrl: async function ensureOrgMembershipAndShowStatus( deps: ExternalDeps, config: Config, - project: ProjectConfig + project: ProjectConfig, ): Promise { // Check if user is already a member - const spinner = ora('멤버십 확인 중...').start() + const spinner = ora("멤버십 확인 중...").start(); try { - await deps.api.ensureMembership(project.orgSlug ?? project.orgId, config.token, project.apiUrl ?? config.apiUrl ?? DEFAULT_API_URL) - spinner.stop() + await deps.api.ensureMembership( + project.orgSlug ?? project.orgId, + config.token, + project.apiUrl ?? config.apiUrl ?? DEFAULT_API_URL, + ); + spinner.stop(); } catch { - spinner.stop() + spinner.stop(); // Ignore error - user might already be a member } // Show status - console.log(chalk.bold.green('✓ 모두 준비되어 있습니다.')) - console.log() - console.log('사용자: ' + config.email) - console.log('프로젝트:' + ` ${project.projectName} (${project.projectId})`) - console.log('조직: ' + project.orgName) - console.log('API: ' + (project.apiUrl ?? config.apiUrl ?? DEFAULT_API_URL)) + console.log(chalk.bold.green("✓ 모두 준비되어 있습니다.")); + console.log(); + console.log("사용자: " + config.email); + console.log("프로젝트:" + ` ${project.projectName} (${project.projectId})`); + console.log("조직: " + project.orgName); + console.log( + "API: " + (project.apiUrl ?? config.apiUrl ?? DEFAULT_API_URL), + ); // Check hooks (self-heal: 누락 시 주입) - injectAgentHooks(deps, deps.cwd()) - console.log('Hooks: ' + chalk.green('✓ .claude/settings.json · .codex/hooks.json 설치됨')) + injectAgentHooks(deps, deps.cwd()); + console.log( + "Hooks: " + + chalk.green("✓ .claude/settings.json · .codex/hooks.json 설치됨"), + ); } diff --git a/packages/cli/src/commands/hook.ts b/packages/cli/src/commands/hook.ts index 4d3dae8f..13481e21 100644 --- a/packages/cli/src/commands/hook.ts +++ b/packages/cli/src/commands/hook.ts @@ -1,29 +1,29 @@ -import { homedir } from 'os' -import { appendFileSync, existsSync, mkdirSync } from 'fs' -import { join } from 'path' -import type { IngestEventPayload, EventType } from '@argos/shared' -import type { CommandFactory } from '../deps.js' -import { DEFAULT_API_URL } from '../lib/config.js' +import { homedir } from "os"; +import { appendFileSync, existsSync, mkdirSync } from "fs"; +import { join } from "path"; +import type { IngestEventPayload, EventType } from "@argos/shared"; +import type { CommandFactory } from "../deps.js"; +import { DEFAULT_API_URL } from "../lib/config.js"; interface HookStdinPayload { - hook_event_name?: string - session_id?: string - agent_id?: string - transcript_path?: string - agent_transcript_path?: string - tool_name?: string - tool_input?: Record - tool_response?: string - tool_use_id?: string - exit_code?: number - model?: string // Codex hook stdin 은 model 을 항상 제공 (Claude Code 엔 없음) + hook_event_name?: string; + session_id?: string; + agent_id?: string; + transcript_path?: string; + agent_transcript_path?: string; + tool_name?: string; + tool_input?: Record; + tool_response?: string; + tool_use_id?: string; + exit_code?: number; + model?: string; // Codex hook stdin 은 model 을 항상 제공 (Claude Code 엔 없음) } interface HookCommandOptions { - agent?: string // 'codex' | 'claude' — hooks.json 에서 `argos hook --agent codex` 로 전달 + agent?: string; // 'codex' | 'claude' — hooks.json 에서 `argos hook --agent codex` 로 전달 } -type Agent = 'claude' | 'codex' +type Agent = "claude" | "codex"; /** * 어느 에이전트의 hook 인지 판별. @@ -31,11 +31,15 @@ type Agent = 'claude' | 'codex' * 2) transcript_path 가 Codex 세션 경로(`/.codex/`)를 가리키는지 * 둘 다 아니면 Claude Code(기존 동작)로 간주. */ -export function detectAgent(options: HookCommandOptions, event: HookStdinPayload): Agent { - if (options.agent === 'codex' || options.agent === 'claude') return options.agent - const tp = event.transcript_path || event.agent_transcript_path || '' - if (tp.includes('/.codex/')) return 'codex' - return 'claude' +export function detectAgent( + options: HookCommandOptions, + event: HookStdinPayload, +): Agent { + if (options.agent === "codex" || options.agent === "claude") + return options.agent; + const tp = event.transcript_path || event.agent_transcript_path || ""; + if (tp.includes("/.codex/")) return "codex"; + return "claude"; } /** @@ -45,55 +49,55 @@ export function detectAgent(options: HookCommandOptions, event: HookStdinPayload async function readStdinWithTimeout(timeoutMs: number): Promise { // If stdin is a TTY, return immediately (user ran command manually) if (process.stdin.isTTY) { - return null + return null; } return new Promise((resolve) => { - let data = '' - let timeoutId: NodeJS.Timeout | null = null - let completed = false + let data = ""; + let timeoutId: NodeJS.Timeout | null = null; + let completed = false; const complete = (result: string | null) => { - if (completed) return - completed = true - if (timeoutId) clearTimeout(timeoutId) - process.stdin.removeAllListeners() - resolve(result) - } - - timeoutId = setTimeout(() => complete(null), timeoutMs) - - process.stdin.setEncoding('utf8') - process.stdin.on('data', (chunk) => { - data += chunk - }) - - process.stdin.on('end', () => { - complete(data || null) - }) - - process.stdin.on('error', () => { - complete(null) - }) - }) + if (completed) return; + completed = true; + if (timeoutId) clearTimeout(timeoutId); + process.stdin.removeAllListeners(); + resolve(result); + }; + + timeoutId = setTimeout(() => complete(null), timeoutMs); + + process.stdin.setEncoding("utf8"); + process.stdin.on("data", (chunk) => { + data += chunk; + }); + + process.stdin.on("end", () => { + complete(data || null); + }); + + process.stdin.on("error", () => { + complete(null); + }); + }); } /** * Debug log - only writes if ARGOS_DEBUG=1 */ function debugLog(message: unknown): void { - if (process.env.ARGOS_DEBUG !== '1') return + if (process.env.ARGOS_DEBUG !== "1") return; try { - const argosDir = join(homedir(), '.argos') + const argosDir = join(homedir(), ".argos"); if (!existsSync(argosDir)) { - mkdirSync(argosDir, { recursive: true }) + mkdirSync(argosDir, { recursive: true }); } - const logPath = join(argosDir, 'hook-debug.log') - const timestamp = new Date().toISOString() - const logMessage = `[${timestamp}] ${JSON.stringify(message, null, 2)}\n` - appendFileSync(logPath, logMessage, 'utf8') + const logPath = join(argosDir, "hook-debug.log"); + const timestamp = new Date().toISOString(); + const logMessage = `[${timestamp}] ${JSON.stringify(message, null, 2)}\n`; + appendFileSync(logPath, logMessage, "utf8"); } catch { // Ignore logging errors } @@ -108,7 +112,7 @@ export function convertEventType(hookEventName: string): string { // PostToolUse -> POST_TOOL_USE // Stop -> STOP // SubagentStop -> SUBAGENT_STOP - return hookEventName.replace(/([a-z])([A-Z])/g, '$1_$2').toUpperCase() + return hookEventName.replace(/([a-z])([A-Z])/g, "$1_$2").toUpperCase(); } /** @@ -116,41 +120,41 @@ export function convertEventType(hookEventName: string): string { */ export function buildPayload( event: HookStdinPayload, - project: { projectId: string; apiUrl?: string } + project: { projectId: string; apiUrl?: string }, ): IngestEventPayload { const payload: IngestEventPayload = { projectId: project.projectId, - sessionId: event.session_id || '', - hookEventName: convertEventType(event.hook_event_name || '') as EventType, - } + sessionId: event.session_id || "", + hookEventName: convertEventType(event.hook_event_name || "") as EventType, + }; // Add optional fields if (event.tool_name) { - payload.toolName = event.tool_name + payload.toolName = event.tool_name; } if (event.tool_input) { - payload.toolInput = event.tool_input + payload.toolInput = event.tool_input; } if (event.tool_response) { // Truncate to 2000 characters - payload.toolResponse = event.tool_response.slice(0, 2000) + payload.toolResponse = event.tool_response.slice(0, 2000); } if (event.tool_use_id) { - payload.toolUseId = event.tool_use_id + payload.toolUseId = event.tool_use_id; } if (event.exit_code !== undefined) { - payload.exitCode = event.exit_code + payload.exitCode = event.exit_code; } if (event.agent_id) { - payload.agentId = event.agent_id + payload.agentId = event.agent_id; } - return payload + return payload; } /** @@ -162,97 +166,113 @@ export const makeHookCommand: CommandFactory = (deps) => async (options) => { try { // Read stdin with 100ms timeout - const raw = await readStdinWithTimeout(100) + const raw = await readStdinWithTimeout(100); if (!raw) { - process.exit(0) - return + process.exit(0); + return; } // Parse hook event - const event: HookStdinPayload = JSON.parse(raw) - const agent = detectAgent(options ?? {}, event) + const event: HookStdinPayload = JSON.parse(raw); + const agent = detectAgent(options ?? {}, event); // Skip sub-agent events — we only track the user's main session. // Sub-agent events are identified by SubagentStop or by the presence of agent_id. - if (event.hook_event_name === 'SubagentStop' || event.agent_id) { - process.exit(0) - return + if (event.hook_event_name === "SubagentStop" || event.agent_id) { + process.exit(0); + return; } // Find project config (with its absolute path for self-heal) - const projectResult = deps.project.findWithPath(process.cwd()) + const projectResult = deps.project.findWithPath(process.cwd()); if (!projectResult) { - process.exit(0) - return + process.exit(0); + return; } - const { config: project, configPath: projectJsonPath } = projectResult + const { config: project, configPath: projectJsonPath } = projectResult; // Read user config - const config = deps.config.read() + const config = deps.config.read(); if (!config) { - process.exit(0) - return + process.exit(0); + return; } // Build base payload - const payload = buildPayload(event, project) + const payload = buildPayload(event, project); // 세션 출처를 서버로 전달 (대시보드 attribution) - payload.agent = agent === 'codex' ? 'CODEX' : 'CLAUDE' + payload.agent = agent === "codex" ? "CODEX" : "CLAUDE"; // SessionStart: detect slash command (Claude Code transcript only — Codex 엔 대응 개념이 없다) - if (agent === 'claude' && event.hook_event_name === 'SessionStart' && event.transcript_path) { - const slashSkill = await deps.transcript.detectSlashCommand(event.transcript_path) + if ( + agent === "claude" && + event.hook_event_name === "SessionStart" && + event.transcript_path + ) { + const slashSkill = await deps.transcript.detectSlashCommand( + event.transcript_path, + ); if (slashSkill) { - payload.isSlashCommand = true - payload.toolName = 'Skill' - payload.toolInput = { skill: slashSkill } + payload.isSlashCommand = true; + payload.toolName = "Skill"; + payload.toolInput = { skill: slashSkill }; } } // Stop/SubagentStop: extract usage and messages from transcript - if (event.hook_event_name === 'Stop' || event.hook_event_name === 'SubagentStop') { + if ( + event.hook_event_name === "Stop" || + event.hook_event_name === "SubagentStop" + ) { // SubagentStop: use agent transcript (not main session transcript) to avoid duplicates - const transcriptPath = event.hook_event_name === 'SubagentStop' - ? event.agent_transcript_path - : event.transcript_path + const transcriptPath = + event.hook_event_name === "SubagentStop" + ? event.agent_transcript_path + : event.transcript_path; // 에이전트별 transcript 파서 선택. Codex 는 rollout JSONL 포맷이 완전히 달라 별도 파서를 쓴다. - const tx = deps.transcript - const extractUsage = agent === 'codex' ? tx.extractUsageCodex : tx.extractUsage - const extractUsagePerTurn = agent === 'codex' ? tx.extractUsagePerTurnCodex : tx.extractUsagePerTurn - const extractMessages = agent === 'codex' ? tx.extractMessagesCodex : tx.extractMessages + const tx = deps.transcript; + const extractUsage = + agent === "codex" ? tx.extractUsageCodex : tx.extractUsage; + const extractUsagePerTurn = + agent === "codex" + ? tx.extractUsagePerTurnCodex + : tx.extractUsagePerTurn; + const extractMessages = + agent === "codex" ? tx.extractMessagesCodex : tx.extractMessages; if (transcriptPath) { - const usage = await extractUsage(transcriptPath) + const usage = await extractUsage(transcriptPath); if (usage) { // Codex: transcript 에서 model 을 못 뽑으면 hook stdin 의 model 로 보강 - if (!usage.model && event.model) usage.model = event.model - payload.usage = usage + if (!usage.model && event.model) usage.model = event.model; + payload.usage = usage; } // Extract per-turn usage for session timeline try { - const usagePerTurn = await extractUsagePerTurn(transcriptPath) + const usagePerTurn = await extractUsagePerTurn(transcriptPath); if (usagePerTurn.length > 0) { - payload.usagePerTurn = usagePerTurn + payload.usagePerTurn = usagePerTurn; } } catch { // Ignore errors - usagePerTurn is optional enhancement } - const messages = await extractMessages(transcriptPath) + const messages = await extractMessages(transcriptPath); if (messages.length > 0) { - payload.messages = messages + payload.messages = messages; } // Main session only: pick up transcript "summary" line (present after /compact or on resume). // Codex transcript 엔 summary 라인 개념이 없어 Claude 일 때만 시도한다. - if (agent === 'claude' && event.hook_event_name === 'Stop') { + if (agent === "claude" && event.hook_event_name === "Stop") { try { - const summary = await deps.transcript.extractSummary(transcriptPath) + const summary = + await deps.transcript.extractSummary(transcriptPath); if (summary) { - payload.summary = summary.slice(0, 10000) - payload.title = summary.slice(0, 500) + payload.summary = summary.slice(0, 10000); + payload.title = summary.slice(0, 500); } } catch { // Summary is optional — ignore parse errors @@ -265,7 +285,7 @@ export const makeHookCommand: CommandFactory = // The main process exits immediately (exit 0), so Claude Code is never blocked. // projectJsonPath is passed so the child can self-heal .argos/project.json if the // server indicates the project has been transferred to a different org (WU-5/WU-6). - const apiUrl = project.apiUrl ?? config.apiUrl ?? DEFAULT_API_URL + const apiUrl = project.apiUrl ?? config.apiUrl ?? DEFAULT_API_URL; deps.events.sendBackground({ url: `${apiUrl}/api/events`, token: config.token, @@ -276,11 +296,11 @@ export const makeHookCommand: CommandFactory = orgId: project.orgId, orgSlug: project.orgSlug ?? project.orgId, }, - }) + }); } catch (err) { - debugLog(err) + debugLog(err); } finally { // ALWAYS exit with 0 - never block Claude Code - process.exit(0) + process.exit(0); } - } + }; diff --git a/packages/cli/src/commands/logout.ts b/packages/cli/src/commands/logout.ts index cdf4a04b..cf642992 100644 --- a/packages/cli/src/commands/logout.ts +++ b/packages/cli/src/commands/logout.ts @@ -1,35 +1,38 @@ -import chalk from 'chalk' -import ora from 'ora' -import type { CommandFactory } from '../deps.js' -import { DEFAULT_API_URL } from '../lib/config.js' +import chalk from "chalk"; +import ora from "ora"; +import type { CommandFactory } from "../deps.js"; +import { DEFAULT_API_URL } from "../lib/config.js"; /** * Logout command - revoke token and delete local config */ -export const makeLogoutCommand: CommandFactory = - (deps) => async () => { - const config = deps.config.read() +export const makeLogoutCommand: CommandFactory = (deps) => async () => { + const config = deps.config.read(); - if (!config) { - console.log(chalk.yellow('⚠ 로그인 상태가 아닙니다.')) - return - } + if (!config) { + console.log(chalk.yellow("⚠ 로그인 상태가 아닙니다.")); + return; + } - const spinner = ora('로그아웃 중...').start() + const spinner = ora("로그아웃 중...").start(); - try { - // Try to revoke token on server - await deps.api.revokeToken(config.token, config.apiUrl ?? DEFAULT_API_URL) - } catch { - // Ignore API errors - still delete local config - spinner.warn(chalk.yellow('서버에서 토큰을 취소하는데 실패했지만 로컬 설정은 삭제됩니다.')) - } + try { + // Try to revoke token on server + await deps.api.revokeToken(config.token, config.apiUrl ?? DEFAULT_API_URL); + } catch { + // Ignore API errors - still delete local config + spinner.warn( + chalk.yellow( + "서버에서 토큰을 취소하는데 실패했지만 로컬 설정은 삭제됩니다.", + ), + ); + } - // Delete local config - deps.config.delete() - spinner.succeed(chalk.green('✓ 로그아웃 완료')) + // Delete local config + deps.config.delete(); + spinner.succeed(chalk.green("✓ 로그아웃 완료")); - console.log() - console.log('로컬 인증 정보가 삭제되었습니다.') - console.log('다시 로그인하려면 argos를 실행하세요.') - } + console.log(); + console.log("로컬 인증 정보가 삭제되었습니다."); + console.log("다시 로그인하려면 argos를 실행하세요."); +}; diff --git a/packages/cli/src/commands/setup.ts b/packages/cli/src/commands/setup.ts index c72a8ae8..4418a037 100644 --- a/packages/cli/src/commands/setup.ts +++ b/packages/cli/src/commands/setup.ts @@ -1,15 +1,19 @@ -import chalk from 'chalk' -import ora from 'ora' -import { DEFAULT_API_URL, normalizeApiUrl } from '../lib/config.js' -import { injectAgentHooks, printAgentHookResult, printCodexTrustNotice } from '../lib/inject-agent-hooks.js' -import type { CreateProjectResponse } from '@argos/shared' -import type { CommandFactory, ExternalDeps } from '../deps.js' -import type { Config } from '../lib/config.js' -import type { ProjectConfig } from '../lib/project.js' +import chalk from "chalk"; +import ora from "ora"; +import { DEFAULT_API_URL, normalizeApiUrl } from "../lib/config.js"; +import { + injectAgentHooks, + printAgentHookResult, + printCodexTrustNotice, +} from "../lib/inject-agent-hooks.js"; +import type { CreateProjectResponse } from "@argos/shared"; +import type { CommandFactory, ExternalDeps } from "../deps.js"; +import type { Config } from "../lib/config.js"; +import type { ProjectConfig } from "../lib/project.js"; interface SetupCommandOptions { - token?: string - apiUrl?: string + token?: string; + apiUrl?: string; } /** @@ -19,47 +23,72 @@ interface SetupCommandOptions { */ export const makeSetupCommand: CommandFactory = (deps) => async (options) => { - const existingConfig = deps.config.read() - const existingProject = deps.project.find() - const inheritedApiUrl = normalizeApiUrl(options.apiUrl) ?? existingProject?.apiUrl ?? existingConfig?.apiUrl - const effectiveApiUrl = inheritedApiUrl ?? DEFAULT_API_URL + const existingConfig = deps.config.read(); + const existingProject = deps.project.find(); + const inheritedApiUrl = + normalizeApiUrl(options.apiUrl) ?? + existingProject?.apiUrl ?? + existingConfig?.apiUrl; + const effectiveApiUrl = inheritedApiUrl ?? DEFAULT_API_URL; if (!options.token) { if (existingConfig && existingProject) { - console.log(chalk.bold('Argos 초기 설정')) - console.log() - await connectExistingProject(deps, existingConfig, existingProject, effectiveApiUrl) - return + console.log(chalk.bold("Argos 초기 설정")); + console.log(); + await connectExistingProject( + deps, + existingConfig, + existingProject, + effectiveApiUrl, + ); + return; } - console.error(chalk.red('✗ --token 인자가 필요합니다.')) - console.error('예: argos setup --token=argos_onb_XXXX') - console.error() - console.error('이미 .argos/project.json 이 있는 저장소에 합류하는 경우에는 repo 루트에서 argos 를 실행하세요.') - process.exit(1) + console.error(chalk.red("✗ --token 인자가 필요합니다.")); + console.error("예: argos setup --token=argos_onb_XXXX"); + console.error(); + console.error( + "이미 .argos/project.json 이 있는 저장소에 합류하는 경우에는 repo 루트에서 argos 를 실행하세요.", + ); + process.exit(1); } - console.log(chalk.bold('Argos 초기 설정')) - console.log() + console.log(chalk.bold("Argos 초기 설정")); + console.log(); if (existingConfig && existingProject) { - console.log(chalk.yellow('이미 Argos 프로젝트와 로그인 설정이 있습니다. 프로젝트 생성은 건너뜁니다.')) - console.log() - await connectExistingProject(deps, existingConfig, existingProject, effectiveApiUrl) - return + console.log( + chalk.yellow( + "이미 Argos 프로젝트와 로그인 설정이 있습니다. 프로젝트 생성은 건너뜁니다.", + ), + ); + console.log(); + await connectExistingProject( + deps, + existingConfig, + existingProject, + effectiveApiUrl, + ); + return; } // Step 1: onboard token 교환 - const loginSpinner = ora('로그인 중...').start() - let exchange + const loginSpinner = ora("로그인 중...").start(); + let exchange; try { - exchange = await deps.api.exchange(options.token, effectiveApiUrl) - loginSpinner.succeed(chalk.green(`✓ 로그인 완료 (${exchange.user.email})`)) + exchange = await deps.api.exchange(options.token, effectiveApiUrl); + loginSpinner.succeed( + chalk.green(`✓ 로그인 완료 (${exchange.user.email})`), + ); } catch (err) { - loginSpinner.fail(chalk.red('✗ 로그인 실패')) - console.error(err instanceof Error ? err.message : String(err)) - console.error(chalk.yellow('토큰이 만료되었거나 이미 사용되었을 수 있습니다. 웹에서 새 프롬프트를 발급받으세요.')) - process.exit(1) + loginSpinner.fail(chalk.red("✗ 로그인 실패")); + console.error(err instanceof Error ? err.message : String(err)); + console.error( + chalk.yellow( + "토큰이 만료되었거나 이미 사용되었을 수 있습니다. 웹에서 새 프롬프트를 발급받으세요.", + ), + ); + process.exit(1); } deps.config.write({ @@ -67,7 +96,7 @@ export const makeSetupCommand: CommandFactory = userId: exchange.user.id, email: exchange.user.email, ...(inheritedApiUrl && { apiUrl: inheritedApiUrl }), - }) + }); if (existingProject) { await connectExistingProject( @@ -79,30 +108,46 @@ export const makeSetupCommand: CommandFactory = ...(inheritedApiUrl && { apiUrl: inheritedApiUrl }), }, existingProject, - effectiveApiUrl - ) - return + effectiveApiUrl, + ); + return; } // Step 2: 프로젝트 생성 (cwd 디렉터리명 기반). org가 없으면 adapter에서 자동 생성. - const projectName = deps.cwd().split('/').pop() || 'my-project' - const projectSpinner = ora('프로젝트 생성 중...').start() + const projectName = deps.cwd().split("/").pop() || "my-project"; + const projectSpinner = ora("프로젝트 생성 중...").start(); - let projectResponse: CreateProjectResponse + let projectResponse: CreateProjectResponse; try { - projectResponse = await deps.api.createProject(projectName, exchange.token, effectiveApiUrl) - projectSpinner.succeed(chalk.green(`✓ 프로젝트 생성: ${projectResponse.projectName}`)) - console.log(` 조직: ${projectResponse.orgName}`) + projectResponse = await deps.api.createProject( + projectName, + exchange.token, + effectiveApiUrl, + ); + projectSpinner.succeed( + chalk.green(`✓ 프로젝트 생성: ${projectResponse.projectName}`), + ); + console.log(` 조직: ${projectResponse.orgName}`); } catch (err) { - projectSpinner.fail(chalk.red('✗ 프로젝트 생성 실패')) - console.error(err instanceof Error ? err.message : String(err)) + projectSpinner.fail(chalk.red("✗ 프로젝트 생성 실패")); + console.error(err instanceof Error ? err.message : String(err)); if (isProjectCreationForbidden(err)) { - console.error() - console.error(chalk.yellow('프로젝트 생성은 MANAGER 이상만 할 수 있습니다.')) - console.error(chalk.yellow('이미 .argos/project.json 이 커밋된 저장소라면 repo 루트에서 argos setup --token=... 을 다시 실행하면 기존 프로젝트에 연결됩니다.')) - console.error(chalk.yellow('아직 .argos/project.json 이 없다면 관리자에게 프로젝트 생성을 요청하세요.')) + console.error(); + console.error( + chalk.yellow("프로젝트 생성은 MANAGER 이상만 할 수 있습니다."), + ); + console.error( + chalk.yellow( + "이미 .argos/project.json 이 커밋된 저장소라면 repo 루트에서 argos setup --token=... 을 다시 실행하면 기존 프로젝트에 연결됩니다.", + ), + ); + console.error( + chalk.yellow( + "아직 .argos/project.json 이 없다면 관리자에게 프로젝트 생성을 요청하세요.", + ), + ); } - process.exit(1) + process.exit(1); } // Step 3: project config 기록 @@ -113,52 +158,58 @@ export const makeSetupCommand: CommandFactory = orgName: projectResponse.orgName, projectName: projectResponse.projectName, ...(inheritedApiUrl && { apiUrl: inheritedApiUrl }), - }) - console.log(chalk.green('✓ .argos/project.json 작성')) + }); + console.log(chalk.green("✓ .argos/project.json 작성")); // Step 4: hook 설치 (Claude Code + Codex) - printAgentHookResult(injectAgentHooks(deps, deps.cwd())) - printCodexTrustNotice() - - console.log() - console.log(chalk.bold.green('✓ 설정 완료!')) - console.log() - console.log('다음 단계:') - console.log(' git add .argos/project.json .claude/settings.json .codex/hooks.json') - console.log(' git commit -m "chore: add argos tracking"') - } + printAgentHookResult(injectAgentHooks(deps, deps.cwd())); + printCodexTrustNotice(); + + console.log(); + console.log(chalk.bold.green("✓ 설정 완료!")); + console.log(); + console.log("다음 단계:"); + console.log( + " git add .argos/project.json .claude/settings.json .codex/hooks.json", + ); + console.log(' git commit -m "chore: add argos tracking"'); + }; async function connectExistingProject( deps: ExternalDeps, config: Config, project: ProjectConfig, - effectiveApiUrl: string + effectiveApiUrl: string, ): Promise { - console.log(chalk.green(`✓ 로그인됨: ${config.email}`)) - console.log(`프로젝트: ${project.projectName}`) - console.log() + console.log(chalk.green(`✓ 로그인됨: ${config.email}`)); + console.log(`프로젝트: ${project.projectName}`); + console.log(); - const orgIdentifier = project.orgSlug ?? project.orgId - const joinSpinner = ora('기존 프로젝트 연결 중...').start() + const orgIdentifier = project.orgSlug ?? project.orgId; + const joinSpinner = ora("기존 프로젝트 연결 중...").start(); try { - await deps.api.joinOrg(orgIdentifier, config.token, effectiveApiUrl) - joinSpinner.succeed(chalk.green(`✓ 조직 합류 확인: ${project.orgName}`)) + await deps.api.joinOrg(orgIdentifier, config.token, effectiveApiUrl); + joinSpinner.succeed(chalk.green(`✓ 조직 합류 확인: ${project.orgName}`)); } catch (err) { - joinSpinner.fail(chalk.red('✗ 기존 프로젝트 연결 실패')) - console.error(err instanceof Error ? err.message : String(err)) - process.exit(1) + joinSpinner.fail(chalk.red("✗ 기존 프로젝트 연결 실패")); + console.error(err instanceof Error ? err.message : String(err)); + process.exit(1); } - printAgentHookResult(injectAgentHooks(deps, deps.cwd())) - printCodexTrustNotice() + printAgentHookResult(injectAgentHooks(deps, deps.cwd())); + printCodexTrustNotice(); - console.log() - console.log(chalk.bold.green('✓ 설정 완료!')) - console.log() - console.log('기존 프로젝트에 연결되었습니다. Claude Code · Codex 를 사용하면 자동으로 기록됩니다.') + console.log(); + console.log(chalk.bold.green("✓ 설정 완료!")); + console.log(); + console.log( + "기존 프로젝트에 연결되었습니다. Claude Code · Codex 를 사용하면 자동으로 기록됩니다.", + ); } function isProjectCreationForbidden(err: unknown): boolean { - const message = err instanceof Error ? err.message : String(err) - return message.includes('API Error (403)') || message.includes('MANAGER 이상') + const message = err instanceof Error ? err.message : String(err); + return ( + message.includes("API Error (403)") || message.includes("MANAGER 이상") + ); } diff --git a/packages/cli/src/commands/status.ts b/packages/cli/src/commands/status.ts index c833382e..ee75c889 100644 --- a/packages/cli/src/commands/status.ts +++ b/packages/cli/src/commands/status.ts @@ -1,67 +1,70 @@ -import { join } from 'path' -import chalk from 'chalk' -import type { CommandFactory } from '../deps.js' -import { DEFAULT_API_URL } from '../lib/config.js' +import { join } from "path"; +import chalk from "chalk"; +import type { CommandFactory } from "../deps.js"; +import { DEFAULT_API_URL } from "../lib/config.js"; /** * Status command - show current configuration */ -export const makeStatusCommand: CommandFactory = - (deps) => async () => { - const config = deps.config.read() - const project = deps.project.find() +export const makeStatusCommand: CommandFactory = (deps) => async () => { + const config = deps.config.read(); + const project = deps.project.find(); - console.log(chalk.bold('Argos 상태')) - console.log() + console.log(chalk.bold("Argos 상태")); + console.log(); - // Login status - if (config) { - console.log(chalk.green('✓ 로그인됨')) - console.log(' 이메일: ' + config.email) - console.log(' 사용자: ' + config.userId) - console.log(' API URL: ' + (config.apiUrl ?? DEFAULT_API_URL)) - } else { - console.log(chalk.red('✗ 로그인 안 됨')) - console.log(' argos를 실행하여 로그인하세요.') - } + // Login status + if (config) { + console.log(chalk.green("✓ 로그인됨")); + console.log(" 이메일: " + config.email); + console.log(" 사용자: " + config.userId); + console.log(" API URL: " + (config.apiUrl ?? DEFAULT_API_URL)); + } else { + console.log(chalk.red("✗ 로그인 안 됨")); + console.log(" argos를 실행하여 로그인하세요."); + } - console.log() + console.log(); - // Project status - if (project) { - console.log(chalk.green('✓ 프로젝트 설정됨')) - console.log(' 프로젝트: ' + project.projectName) - console.log(' 조직: ' + project.orgName) - console.log(' ID: ' + project.projectId) - console.log(' API URL: ' + (project.apiUrl ?? config?.apiUrl ?? DEFAULT_API_URL)) - } else { - console.log(chalk.red('✗ 프로젝트 없음')) - console.log(' 이 디렉토리는 Argos 프로젝트가 아닙니다.') - console.log(' argos를 실행하여 프로젝트를 생성하세요.') - } + // Project status + if (project) { + console.log(chalk.green("✓ 프로젝트 설정됨")); + console.log(" 프로젝트: " + project.projectName); + console.log(" 조직: " + project.orgName); + console.log(" ID: " + project.projectId); + console.log( + " API URL: " + (project.apiUrl ?? config?.apiUrl ?? DEFAULT_API_URL), + ); + } else { + console.log(chalk.red("✗ 프로젝트 없음")); + console.log(" 이 디렉토리는 Argos 프로젝트가 아닙니다."); + console.log(" argos를 실행하여 프로젝트를 생성하세요."); + } - console.log() + console.log(); - // Hooks status (Claude Code + Codex) - const claudePath = join(deps.cwd(), '.claude', 'settings.json') - const codexPath = join(deps.cwd(), '.codex', 'hooks.json') - const hasClaude = deps.hooks.fileExists(claudePath) - const hasCodex = deps.hooks.fileExists(codexPath) + // Hooks status (Claude Code + Codex) + // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal + const claudePath = join(deps.cwd(), ".claude", "settings.json"); + // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal + const codexPath = join(deps.cwd(), ".codex", "hooks.json"); + const hasClaude = deps.hooks.fileExists(claudePath); + const hasCodex = deps.hooks.fileExists(codexPath); - if (hasClaude) { - console.log(chalk.green('✓ Claude Code hooks 설정 파일 존재')) - console.log(' 경로: ' + claudePath) - } else { - console.log(chalk.yellow('⚠ Claude Code hooks 설정 파일 없음')) - } - if (hasCodex) { - console.log(chalk.green('✓ Codex hooks 설정 파일 존재')) - console.log(' 경로: ' + codexPath) - console.log(chalk.dim(' (Codex 는 /hooks 에서 trust 후에 동작합니다)')) - } else { - console.log(chalk.yellow('⚠ Codex hooks 설정 파일 없음')) - } - if (!hasClaude && !hasCodex) { - console.log(' argos를 실행하여 hooks를 설치하세요.') - } + if (hasClaude) { + console.log(chalk.green("✓ Claude Code hooks 설정 파일 존재")); + console.log(" 경로: " + claudePath); + } else { + console.log(chalk.yellow("⚠ Claude Code hooks 설정 파일 없음")); + } + if (hasCodex) { + console.log(chalk.green("✓ Codex hooks 설정 파일 존재")); + console.log(" 경로: " + codexPath); + console.log(chalk.dim(" (Codex 는 /hooks 에서 trust 후에 동작합니다)")); + } else { + console.log(chalk.yellow("⚠ Codex hooks 설정 파일 없음")); + } + if (!hasClaude && !hasCodex) { + console.log(" argos를 실행하여 hooks를 설치하세요."); } +}; diff --git a/packages/cli/src/deps.ts b/packages/cli/src/deps.ts index 6d12abfb..0ce4a418 100644 --- a/packages/cli/src/deps.ts +++ b/packages/cli/src/deps.ts @@ -1,52 +1,73 @@ -import type { Config } from './lib/config.js' -import type { ProjectConfig } from './lib/project.js' -import type { SendEventBackgroundOpts } from './lib/event-sender.js' -import type { LoginResponse, ExchangeResponse, CreateProjectResponse, UsagePayload, UsagePerTurnPayload, MessagePayload } from '@argos/shared' +import type { Config } from "./lib/config.js"; +import type { ProjectConfig } from "./lib/project.js"; +import type { SendEventBackgroundOpts } from "./lib/event-sender.js"; +import type { + LoginResponse, + ExchangeResponse, + CreateProjectResponse, + UsagePayload, + UsagePerTurnPayload, + MessagePayload, +} from "@argos/shared"; export interface ExternalDeps { config: { - read(): Config | null - write(config: Config): void - delete(): void - } + read(): Config | null; + write(config: Config): void; + delete(): void; + }; project: { - find(cwd?: string): ProjectConfig | null - findWithPath(cwd?: string): { config: ProjectConfig; configPath: string } | null - write(config: ProjectConfig): void - } + find(cwd?: string): ProjectConfig | null; + findWithPath( + cwd?: string, + ): { config: ProjectConfig; configPath: string } | null; + write(config: ProjectConfig): void; + }; auth: { - login(apiUrl: string): Promise - } + login(apiUrl: string): Promise; + }; api: { - exchange(onboardToken: string, apiUrl: string): Promise - createProject(name: string, token: string, apiUrl: string): Promise - joinOrg(orgSlug: string, token: string, apiUrl: string): Promise - ensureMembership(orgSlug: string, token: string, apiUrl: string): Promise - revokeToken(token: string, apiUrl: string): Promise - } + exchange(onboardToken: string, apiUrl: string): Promise; + createProject( + name: string, + token: string, + apiUrl: string, + ): Promise; + joinOrg(orgSlug: string, token: string, apiUrl: string): Promise; + ensureMembership( + orgSlug: string, + token: string, + apiUrl: string, + ): Promise; + revokeToken(token: string, apiUrl: string): Promise; + }; hooks: { - inject(settingsPath: string, agent?: 'claude' | 'codex'): 'injected' | 'already_present' - fileExists(path: string): boolean - } + inject( + settingsPath: string, + agent?: "claude" | "codex", + ): "injected" | "already_present"; + fileExists(path: string): boolean; + }; prompt: { - input(message: string, defaultValue?: string): Promise - } + input(message: string, defaultValue?: string): Promise; + }; transcript: { - extractUsage(path: string): Promise - extractUsagePerTurn(path: string): Promise - detectSlashCommand(path: string): Promise - extractMessages(path: string): Promise - extractSummary(path: string): Promise + extractUsage(path: string): Promise; + extractUsagePerTurn(path: string): Promise; + detectSlashCommand(path: string): Promise; + extractMessages(path: string): Promise; + extractSummary(path: string): Promise; // Codex rollout 파서 (agent === 'codex' 일 때 사용) - extractUsageCodex(path: string): Promise - extractUsagePerTurnCodex(path: string): Promise - extractMessagesCodex(path: string): Promise - } + extractUsageCodex(path: string): Promise; + extractUsagePerTurnCodex(path: string): Promise; + extractMessagesCodex(path: string): Promise; + }; events: { - sendBackground(opts: SendEventBackgroundOpts): void - } - cwd(): string + sendBackground(opts: SendEventBackgroundOpts): void; + }; + cwd(): string; } -export type CommandFactory> = - (deps: ExternalDeps) => (opts: TOpts) => Promise +export type CommandFactory> = ( + deps: ExternalDeps, +) => (opts: TOpts) => Promise; diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index bb7d5dea..b107ea27 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,60 +1,62 @@ #!/usr/bin/env node -import { Command } from 'commander' -import { readFileSync } from 'fs' -import { join, dirname } from 'path' -import { fileURLToPath } from 'url' -import { realDeps } from './adapters.js' -import { makeDefaultCommand } from './commands/default.js' -import { makeHookCommand } from './commands/hook.js' -import { makeSetupCommand } from './commands/setup.js' -import { makeStatusCommand } from './commands/status.js' -import { makeLogoutCommand } from './commands/logout.js' - -const __filename = fileURLToPath(import.meta.url) -const __dirname = dirname(__filename) +import { Command } from "commander"; +import { readFileSync } from "fs"; +import { join, dirname } from "path"; +import { fileURLToPath } from "url"; +import { realDeps } from "./adapters.js"; +import { makeDefaultCommand } from "./commands/default.js"; +import { makeHookCommand } from "./commands/hook.js"; +import { makeSetupCommand } from "./commands/setup.js"; +import { makeStatusCommand } from "./commands/status.js"; +import { makeLogoutCommand } from "./commands/logout.js"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); // Read package.json for version -const pkgPath = join(__dirname, '..', 'package.json') -const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) +const pkgPath = join(__dirname, "..", "package.json"); +const pkg = JSON.parse(readFileSync(pkgPath, "utf8")); const program = new Command() - .name('argos') - .description('Claude Code observability for AI-native teams') + .name("argos") + .description("Claude Code observability for AI-native teams") .version(pkg.version) - .option('--api-url ', 'API URL override (for self-hosting)') + .option("--api-url ", "API URL override (for self-hosting)"); // Default command (argos without subcommand) -program.action(makeDefaultCommand(realDeps)) +program.action(makeDefaultCommand(realDeps)); // Setup command (called with onboard token from web signup prompt) program - .command('setup') - .description('non-interactive setup or existing project connection using onboard token') - .option('--token ', 'onboard token issued by the Argos web app') - .option('--api-url ', 'API URL override (for self-hosting)') - .action(makeSetupCommand(realDeps)) + .command("setup") + .description( + "non-interactive setup or existing project connection using onboard token", + ) + .option("--token ", "onboard token issued by the Argos web app") + .option("--api-url ", "API URL override (for self-hosting)") + .action(makeSetupCommand(realDeps)); // Hook command (internal - called by Claude Code) program - .command('hook') - .description('[internal] process hook event from stdin') - .option('--agent ', 'source agent: claude (default) or codex') - .action(makeHookCommand(realDeps)) + .command("hook") + .description("[internal] process hook event from stdin") + .option("--agent ", "source agent: claude (default) or codex") + .action(makeHookCommand(realDeps)); // Status command program - .command('status') - .description('show current setup status') - .action(makeStatusCommand(realDeps)) + .command("status") + .description("show current setup status") + .action(makeStatusCommand(realDeps)); // Logout command program - .command('logout') - .description('log out and remove local credentials') - .action(makeLogoutCommand(realDeps)) + .command("logout") + .description("log out and remove local credentials") + .action(makeLogoutCommand(realDeps)); // Parse and execute program.parseAsync(process.argv).catch((err) => { - console.error('Error:', err.message) - process.exit(1) -}) + console.error("Error:", err.message); + process.exit(1); +}); diff --git a/packages/cli/src/lib/api-client.ts b/packages/cli/src/lib/api-client.ts index e30f2629..caae2b3c 100644 --- a/packages/cli/src/lib/api-client.ts +++ b/packages/cli/src/lib/api-client.ts @@ -3,68 +3,69 @@ */ export interface ApiRequestOptions extends RequestInit { - token?: string - baseUrl?: string + token?: string; + baseUrl?: string; } export async function apiRequest( path: string, - options: ApiRequestOptions + options: ApiRequestOptions, ): Promise { - const { token, baseUrl, ...fetchOptions } = options + const { token, baseUrl, ...fetchOptions } = options; - const url = `${baseUrl || ''}${path}` + const url = `${baseUrl || ""}${path}`; const headers: Record = { - 'Content-Type': 'application/json', - } + "Content-Type": "application/json", + }; // Merge existing headers if (fetchOptions.headers) { - const existingHeaders = new Headers(fetchOptions.headers) + const existingHeaders = new Headers(fetchOptions.headers); existingHeaders.forEach((value, key) => { - headers[key] = value - }) + headers[key] = value; + }); } if (token) { - headers['Authorization'] = `Bearer ${token}` + headers["Authorization"] = `Bearer ${token}`; } - const controller = new AbortController() - const timeoutId = setTimeout(() => controller.abort(), 10000) // 10 second timeout + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout try { const response = await fetch(url, { ...fetchOptions, headers, signal: fetchOptions.signal || controller.signal, - }) + }); - clearTimeout(timeoutId) + clearTimeout(timeoutId); if (!response.ok) { - const errorText = await response.text().catch(() => 'Unknown error') - let errorMessage: string + const errorText = await response.text().catch(() => "Unknown error"); + let errorMessage: string; try { - const errorJson = JSON.parse(errorText) - errorMessage = errorJson.error?.message || errorJson.message || response.statusText + const errorJson = JSON.parse(errorText); + errorMessage = + errorJson.error?.message || errorJson.message || response.statusText; } catch { - errorMessage = errorText || response.statusText + errorMessage = errorText || response.statusText; } - throw new Error(`API Error (${response.status}): ${errorMessage}`) + throw new Error(`API Error (${response.status}): ${errorMessage}`); } - return await response.json() + return await response.json(); } catch (err) { - clearTimeout(timeoutId) + clearTimeout(timeoutId); if (err instanceof Error) { - if (err.name === 'AbortError') { - throw new Error('API request timed out') + if (err.name === "AbortError") { + throw new Error("API request timed out"); } - throw err + throw err; } - throw new Error('Unknown API error') + throw new Error("Unknown API error"); } } diff --git a/packages/cli/src/lib/auth-flow.test.ts b/packages/cli/src/lib/auth-flow.test.ts index 020b0cd3..276f0a6d 100644 --- a/packages/cli/src/lib/auth-flow.test.ts +++ b/packages/cli/src/lib/auth-flow.test.ts @@ -1,96 +1,115 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import { runLoginFlow } from './auth-flow.js' -import { apiRequest } from './api-client.js' -import * as childProcess from 'child_process' +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { runLoginFlow } from "./auth-flow.js"; +import { apiRequest } from "./api-client.js"; +import * as childProcess from "child_process"; // Mock dependencies -vi.mock('./api-client', () => ({ +vi.mock("./api-client", () => ({ apiRequest: vi.fn(), -})) +})); -vi.mock('child_process', () => ({ +vi.mock("child_process", () => ({ spawn: vi.fn(() => ({ unref: vi.fn(), })), -})) +})); // Mock ora and console -vi.mock('ora', () => ({ +vi.mock("ora", () => ({ default: vi.fn(() => ({ start: vi.fn(() => ({ succeed: vi.fn(), fail: vi.fn(), })), })), -})) -console.log = vi.fn() +})); +console.log = vi.fn(); -describe('auth-flow', () => { - const originalPlatform = process.platform +describe("auth-flow", () => { + const originalPlatform = process.platform; beforeEach(() => { - vi.clearAllMocks() - }) + vi.clearAllMocks(); + }); afterEach(() => { - Object.defineProperty(process, 'platform', { + Object.defineProperty(process, "platform", { value: originalPlatform, - }) - }) + }); + }); - it('opens browser using start on win32 safely with spawn', async () => { - Object.defineProperty(process, 'platform', { - value: 'win32', - }) + it("opens browser using start on win32 safely with spawn", async () => { + Object.defineProperty(process, "platform", { + value: "win32", + }); - const mockApiRequest = vi.mocked(apiRequest) - mockApiRequest.mockResolvedValueOnce({ state: 'state123', authUrl: 'http://example.com/&calc' }) // Step 1 - mockApiRequest.mockResolvedValueOnce({ token: 'token123' }) // Step 3 - mockApiRequest.mockResolvedValueOnce({ user: { id: 'u1', name: 'User1' } }) // Step 5 + const mockApiRequest = vi.mocked(apiRequest); + mockApiRequest.mockResolvedValueOnce({ + state: "state123", + authUrl: "http://example.com/&calc", + }); // Step 1 + mockApiRequest.mockResolvedValueOnce({ token: "token123" }); // Step 3 + mockApiRequest.mockResolvedValueOnce({ user: { id: "u1", name: "User1" } }); // Step 5 - await runLoginFlow('http://api') + await runLoginFlow("http://api"); expect(childProcess.spawn).toHaveBeenCalledWith( - 'cmd.exe', - ['/c', 'start', '""', 'http://example.com/^&calc'], - { windowsVerbatimArguments: true, detached: true, stdio: 'ignore' } - ) - }) + "cmd.exe", + ["/c", "start", '""', "http://example.com/^&calc"], + { windowsVerbatimArguments: true, detached: true, stdio: "ignore" }, + ); + }); + + it("opens browser using open on darwin safely with spawn", async () => { + Object.defineProperty(process, "platform", { + value: "darwin", + }); + + const mockApiRequest = vi.mocked(apiRequest); + mockApiRequest.mockResolvedValueOnce({ + state: "state123", + authUrl: "http://example.com/url", + }); // Step 1 + mockApiRequest.mockResolvedValueOnce({ token: "token123" }); // Step 3 + mockApiRequest.mockResolvedValueOnce({ user: { id: "u1", name: "User1" } }); // Step 5 + + await runLoginFlow("http://api"); - it('opens browser using open on darwin safely with spawn', async () => { - Object.defineProperty(process, 'platform', { - value: 'darwin', - }) - - const mockApiRequest = vi.mocked(apiRequest) - mockApiRequest.mockResolvedValueOnce({ state: 'state123', authUrl: 'http://example.com/url' }) // Step 1 - mockApiRequest.mockResolvedValueOnce({ token: 'token123' }) // Step 3 - mockApiRequest.mockResolvedValueOnce({ user: { id: 'u1', name: 'User1' } }) // Step 5 - - await runLoginFlow('http://api') - - expect(childProcess.spawn).toHaveBeenCalledWith('open', ['http://example.com/url'], { detached: true, stdio: 'ignore' }) - }) - - it('opens browser using xdg-open on linux safely with spawn', async () => { - Object.defineProperty(process, 'platform', { - value: 'linux', - }) - - const mockApiRequest = vi.mocked(apiRequest) - mockApiRequest.mockResolvedValueOnce({ state: 'state123', authUrl: 'http://example.com/url' }) // Step 1 - mockApiRequest.mockResolvedValueOnce({ token: 'token123' }) // Step 3 - mockApiRequest.mockResolvedValueOnce({ user: { id: 'u1', name: 'User1' } }) // Step 5 - - await runLoginFlow('http://api') - - expect(childProcess.spawn).toHaveBeenCalledWith('xdg-open', ['http://example.com/url'], { detached: true, stdio: 'ignore' }) - }) - - it('throws an error if step 1 fails', async () => { - const mockApiRequest = vi.mocked(apiRequest) - mockApiRequest.mockRejectedValueOnce(new Error('Network error')) + expect(childProcess.spawn).toHaveBeenCalledWith( + "open", + ["http://example.com/url"], + { detached: true, stdio: "ignore" }, + ); + }); + + it("opens browser using xdg-open on linux safely with spawn", async () => { + Object.defineProperty(process, "platform", { + value: "linux", + }); + + const mockApiRequest = vi.mocked(apiRequest); + mockApiRequest.mockResolvedValueOnce({ + state: "state123", + authUrl: "http://example.com/url", + }); // Step 1 + mockApiRequest.mockResolvedValueOnce({ token: "token123" }); // Step 3 + mockApiRequest.mockResolvedValueOnce({ user: { id: "u1", name: "User1" } }); // Step 5 + + await runLoginFlow("http://api"); - await expect(runLoginFlow('http://api')).rejects.toThrow('인증 요청 실패: Network error') - }) -}) + expect(childProcess.spawn).toHaveBeenCalledWith( + "xdg-open", + ["http://example.com/url"], + { detached: true, stdio: "ignore" }, + ); + }); + + it("throws an error if step 1 fails", async () => { + const mockApiRequest = vi.mocked(apiRequest); + mockApiRequest.mockRejectedValueOnce(new Error("Network error")); + + await expect(runLoginFlow("http://api")).rejects.toThrow( + "인증 요청 실패: Network error", + ); + }); +}); diff --git a/packages/cli/src/lib/auth-flow.ts b/packages/cli/src/lib/auth-flow.ts index 1274609a..38b5439b 100644 --- a/packages/cli/src/lib/auth-flow.ts +++ b/packages/cli/src/lib/auth-flow.ts @@ -1,27 +1,31 @@ -import { spawn } from 'child_process' -import chalk from 'chalk' -import ora from 'ora' -import type { User, LoginResponse } from '@argos/shared' -import { apiRequest } from './api-client.js' +import { spawn } from "child_process"; +import chalk from "chalk"; +import ora from "ora"; +import type { User, LoginResponse } from "@argos/shared"; +import { apiRequest } from "./api-client.js"; function openBrowser(url: string): void { // Command Injection 방지를 위해 exec 대신 spawn 사용 - if (process.platform === 'win32') { + if (process.platform === "win32") { // Windows: cmd.exe 빌트인 start 명령어 사용 - const child = spawn('cmd.exe', ['/c', 'start', '""', url.replace(/&/g, '^&')], { - windowsVerbatimArguments: true, - detached: true, - stdio: 'ignore' - }) - child.unref() - } else if (process.platform === 'darwin') { + const child = spawn( + "cmd.exe", + ["/c", "start", '""', url.replace(/&/g, "^&")], + { + windowsVerbatimArguments: true, + detached: true, + stdio: "ignore", + }, + ); + child.unref(); + } else if (process.platform === "darwin") { // macOS - const child = spawn('open', [url], { detached: true, stdio: 'ignore' }) - child.unref() + const child = spawn("open", [url], { detached: true, stdio: "ignore" }); + child.unref(); } else { // Linux 등 - const child = spawn('xdg-open', [url], { detached: true, stdio: 'ignore' }) - child.unref() + const child = spawn("xdg-open", [url], { detached: true, stdio: "ignore" }); + child.unref(); } } @@ -33,66 +37,72 @@ function openBrowser(url: string): void { */ export async function runLoginFlow(apiUrl: string): Promise { // Step 1: state 발급 - let state: string, authUrl: string + let state: string, authUrl: string; try { const res = await apiRequest<{ state: string; authUrl: string }>( `${apiUrl}/api/auth/cli-request`, - { method: 'POST', baseUrl: '' } - ) - state = res.state - authUrl = res.authUrl + { method: "POST", baseUrl: "" }, + ); + state = res.state; + authUrl = res.authUrl; } catch (err) { - throw new Error(`인증 요청 실패: ${err instanceof Error ? err.message : String(err)}`) + throw new Error( + `인증 요청 실패: ${err instanceof Error ? err.message : String(err)}`, + ); } // Step 2: 브라우저 즉시 열기 - openBrowser(authUrl) - console.log() - console.log(`브라우저에서 허용해 주세요: ${authUrl}`) - console.log() + openBrowser(authUrl); + console.log(); + console.log(`브라우저에서 허용해 주세요: ${authUrl}`); + console.log(); // Step 3: 승인 polling - const spinner = ora('브라우저 로그인 대기 중...').start() + const spinner = ora("브라우저 로그인 대기 중...").start(); const token = await new Promise((resolve, reject) => { - let attempts = 0 - const maxAttempts = 450 // 15분 (2초 간격) + let attempts = 0; + const maxAttempts = 450; // 15분 (2초 간격) const interval = setInterval(async () => { - attempts++ + attempts++; if (attempts > maxAttempts) { - clearInterval(interval) - reject(new Error('로그인 시간이 초과되었습니다.')) - return + clearInterval(interval); + reject(new Error("로그인 시간이 초과되었습니다.")); + return; } try { - const result = await apiRequest<{ pending?: boolean; denied?: boolean; token?: string }>( - `${apiUrl}/api/auth/cli-poll?state=${state}`, - { method: 'GET', baseUrl: '' } - ) + const result = await apiRequest<{ + pending?: boolean; + denied?: boolean; + token?: string; + }>(`${apiUrl}/api/auth/cli-poll?state=${state}`, { + method: "GET", + baseUrl: "", + }); if (result.denied) { - clearInterval(interval) - reject(new Error('로그인이 거부되었습니다.')) + clearInterval(interval); + reject(new Error("로그인이 거부되었습니다.")); } else if (result.token) { - clearInterval(interval) - resolve(result.token) + clearInterval(interval); + resolve(result.token); } } catch { // 일시적 오류는 무시하고 계속 polling } - }, 2000) - }) + }, 2000); + }); - spinner.succeed(chalk.green('✓ 로그인 완료')) + spinner.succeed(chalk.green("✓ 로그인 완료")); // Step 5: 사용자 정보 조회 const { user } = await apiRequest<{ user: User }>(`${apiUrl}/api/auth/me`, { - method: 'GET', + method: "GET", token, - baseUrl: '', - }) + baseUrl: "", + }); - return { token, user } + return { token, user }; } diff --git a/packages/cli/src/lib/config.test.ts b/packages/cli/src/lib/config.test.ts index 0b82ec0a..3ba43aca 100644 --- a/packages/cli/src/lib/config.test.ts +++ b/packages/cli/src/lib/config.test.ts @@ -1,41 +1,45 @@ -import { describe, it, expect } from 'vitest' -import { normalizeApiUrl, DEFAULT_API_URL } from './config.js' +import { describe, it, expect } from "vitest"; +import { normalizeApiUrl, DEFAULT_API_URL } from "./config.js"; -describe('normalizeApiUrl', () => { +describe("normalizeApiUrl", () => { it('빈 값(undefined/null/"")은 undefined 를 반환한다', () => { - expect(normalizeApiUrl(undefined)).toBeUndefined() - expect(normalizeApiUrl(null)).toBeUndefined() - expect(normalizeApiUrl('')).toBeUndefined() - }) + expect(normalizeApiUrl(undefined)).toBeUndefined(); + expect(normalizeApiUrl(null)).toBeUndefined(); + expect(normalizeApiUrl("")).toBeUndefined(); + }); - it('커스텀 호스트 URL 은 입력 문자열 그대로 반환한다 (정규화 없음)', () => { - expect(normalizeApiUrl('http://localhost:3000')).toBe('http://localhost:3000') - expect(normalizeApiUrl('https://my-argos.example.com/base/')).toBe( - 'https://my-argos.example.com/base/', - ) - }) + it("커스텀 호스트 URL 은 입력 문자열 그대로 반환한다 (정규화 없음)", () => { + expect(normalizeApiUrl("http://localhost:3000")).toBe( + "http://localhost:3000", + ); + expect(normalizeApiUrl("https://my-argos.example.com/base/")).toBe( + "https://my-argos.example.com/base/", + ); + }); - it('기본 서비스 호스트(argos-ai.xyz)는 override 로 취급하지 않고 undefined 를 반환한다', () => { - expect(normalizeApiUrl('https://argos-ai.xyz')).toBeUndefined() - expect(normalizeApiUrl(DEFAULT_API_URL)).toBeUndefined() // https://www.argos-ai.xyz - }) + it("기본 서비스 호스트(argos-ai.xyz)는 override 로 취급하지 않고 undefined 를 반환한다", () => { + expect(normalizeApiUrl("https://argos-ai.xyz")).toBeUndefined(); + expect(normalizeApiUrl(DEFAULT_API_URL)).toBeUndefined(); // https://www.argos-ai.xyz + }); - it('argos-ai.xyz 의 모든 서브도메인을 기본 서비스로 취급한다', () => { - expect(normalizeApiUrl('https://api.argos-ai.xyz/v2')).toBeUndefined() - expect(normalizeApiUrl('https://staging.api.argos-ai.xyz')).toBeUndefined() - }) + it("argos-ai.xyz 의 모든 서브도메인을 기본 서비스로 취급한다", () => { + expect(normalizeApiUrl("https://api.argos-ai.xyz/v2")).toBeUndefined(); + expect(normalizeApiUrl("https://staging.api.argos-ai.xyz")).toBeUndefined(); + }); - it('유사 도메인(evil-argos-ai.xyz)은 서브도메인이 아니므로 커스텀 URL 로 통과시킨다', () => { - expect(normalizeApiUrl('https://evil-argos-ai.xyz')).toBe('https://evil-argos-ai.xyz') - }) + it("유사 도메인(evil-argos-ai.xyz)은 서브도메인이 아니므로 커스텀 URL 로 통과시킨다", () => { + expect(normalizeApiUrl("https://evil-argos-ai.xyz")).toBe( + "https://evil-argos-ai.xyz", + ); + }); - it('URL 로 파싱할 수 없는 문자열은 undefined 를 반환한다', () => { - expect(normalizeApiUrl('not a url')).toBeUndefined() - expect(normalizeApiUrl('//missing-scheme.com')).toBeUndefined() - }) + it("URL 로 파싱할 수 없는 문자열은 undefined 를 반환한다", () => { + expect(normalizeApiUrl("not a url")).toBeUndefined(); + expect(normalizeApiUrl("//missing-scheme.com")).toBeUndefined(); + }); - it('같은 입력으로 반복 호출해도 결과가 같다 (순수성)', () => { - const input = 'https://self-hosted.corp.internal' - expect(normalizeApiUrl(input)).toBe(normalizeApiUrl(input)) - }) -}) + it("같은 입력으로 반복 호출해도 결과가 같다 (순수성)", () => { + const input = "https://self-hosted.corp.internal"; + expect(normalizeApiUrl(input)).toBe(normalizeApiUrl(input)); + }); +}); diff --git a/packages/cli/src/lib/config.ts b/packages/cli/src/lib/config.ts index b7d295cb..0c459642 100644 --- a/packages/cli/src/lib/config.ts +++ b/packages/cli/src/lib/config.ts @@ -1,18 +1,24 @@ -import { homedir } from 'os' -import { join } from 'path' -import { readFileSync, writeFileSync, mkdirSync, existsSync, unlinkSync } from 'fs' +import { homedir } from "os"; +import { join } from "path"; +import { + readFileSync, + writeFileSync, + mkdirSync, + existsSync, + unlinkSync, +} from "fs"; -export const DEFAULT_API_URL = 'https://www.argos-ai.xyz' +export const DEFAULT_API_URL = "https://www.argos-ai.xyz"; export interface Config { - token: string - apiUrl?: string - userId: string - email: string + token: string; + apiUrl?: string; + userId: string; + email: string; } export function getConfigPath(): string { - return join(homedir(), '.argos', 'config.json') + return join(homedir(), ".argos", "config.json"); } /** @@ -20,53 +26,56 @@ export function getConfigPath(): string { * or points at the default Argos service (any *argos-ai.xyz host). Callers that * need a guaranteed URL should fall back to DEFAULT_API_URL with `?? DEFAULT_API_URL`. */ -export function normalizeApiUrl(url: string | undefined | null): string | undefined { - if (!url) return undefined +export function normalizeApiUrl( + url: string | undefined | null, +): string | undefined { + if (!url) return undefined; try { - const host = new URL(url).hostname - if (host === 'argos-ai.xyz' || host.endsWith('.argos-ai.xyz')) return undefined - return url + const host = new URL(url).hostname; + if (host === "argos-ai.xyz" || host.endsWith(".argos-ai.xyz")) + return undefined; + return url; } catch { - return undefined + return undefined; } } export function readConfig(): Config | null { try { - const configPath = getConfigPath() + const configPath = getConfigPath(); if (!existsSync(configPath)) { - return null + return null; } - const content = readFileSync(configPath, 'utf8') - const parsed = JSON.parse(content) as Config - const normalized = normalizeApiUrl(parsed.apiUrl) + const content = readFileSync(configPath, "utf8"); + const parsed = JSON.parse(content) as Config; + const normalized = normalizeApiUrl(parsed.apiUrl); if (normalized) { - parsed.apiUrl = normalized + parsed.apiUrl = normalized; } else { - delete parsed.apiUrl + delete parsed.apiUrl; } - return parsed + return parsed; } catch { - return null + return null; } } export function writeConfig(config: Config): void { - const configPath = getConfigPath() - const configDir = join(homedir(), '.argos') + const configPath = getConfigPath(); + const configDir = join(homedir(), ".argos"); if (!existsSync(configDir)) { - mkdirSync(configDir, { recursive: true }) + mkdirSync(configDir, { recursive: true }); } - writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf8') + writeFileSync(configPath, JSON.stringify(config, null, 2), "utf8"); } export function deleteConfig(): void { try { - const configPath = getConfigPath() + const configPath = getConfigPath(); if (existsSync(configPath)) { - unlinkSync(configPath) + unlinkSync(configPath); } } catch { // Ignore errors @@ -74,11 +83,11 @@ export function deleteConfig(): void { } export function requireAuth(): Config { - const config = readConfig() + const config = readConfig(); if (!config) { - console.error('✗ 로그인이 필요합니다.') - console.error(' argos를 실행하여 로그인하세요.') - process.exit(1) + console.error("✗ 로그인이 필요합니다."); + console.error(" argos를 실행하여 로그인하세요."); + process.exit(1); } - return config + return config; } diff --git a/packages/cli/src/lib/event-sender.test.ts b/packages/cli/src/lib/event-sender.test.ts index d9ccebbc..fe313aab 100644 --- a/packages/cli/src/lib/event-sender.test.ts +++ b/packages/cli/src/lib/event-sender.test.ts @@ -1,122 +1,128 @@ -import { describe, it, expect, beforeAll } from 'vitest' -import { buildSelfHealScript } from './event-sender.js' +import { describe, it, expect, beforeAll } from "vitest"; +import { buildSelfHealScript } from "./event-sender.js"; -const TMP_FILE = '/tmp/argos-test-payload.json' -const TMP_DIR = '/tmp/argos-test-dir' -const PROJECT_JSON_PATH = '/repo/.argos/project.json' +const TMP_FILE = "/tmp/argos-test-payload.json"; +const TMP_DIR = "/tmp/argos-test-dir"; +const PROJECT_JSON_PATH = "/repo/.argos/project.json"; -describe('buildSelfHealScript', () => { - let script: string +describe("buildSelfHealScript", () => { + let script: string; beforeAll(() => { script = buildSelfHealScript({ tmpFile: TMP_FILE, tmpDir: TMP_DIR, projectJsonPath: PROJECT_JSON_PATH, - }) - }) - - it('returns a non-empty string', () => { - expect(typeof script).toBe('string') - expect(script.length).toBeGreaterThan(0) - }) - - it('(a) embeds projectJsonPath literal', () => { - expect(script).toContain(PROJECT_JSON_PATH) - }) - - it('(b) contains body.project.id, orgId, orgSlug shape validation', () => { - expect(script).toContain('body.project.id') - expect(script).toContain('body.project.orgId') - expect(script).toContain('body.project.orgSlug') + }); + }); + + it("returns a non-empty string", () => { + expect(typeof script).toBe("string"); + expect(script.length).toBeGreaterThan(0); + }); + + it("(a) embeds projectJsonPath literal", () => { + expect(script).toContain(PROJECT_JSON_PATH); + }); + + it("(b) contains body.project.id, orgId, orgSlug shape validation", () => { + expect(script).toContain("body.project.id"); + expect(script).toContain("body.project.orgId"); + expect(script).toContain("body.project.orgSlug"); // Shape guard: all three must be string checks - expect(script).toContain("typeof body.project.id!=='string'") - expect(script).toContain("typeof body.project.orgId!=='string'") - expect(script).toContain("typeof body.project.orgSlug!=='string'") - }) - - it('(c) contains renameSync call for atomic write', () => { - expect(script).toContain('renameSync') - }) - - it('holds an inter-process lock while rewriting project.json', () => { - expect(script).toContain(`const lockDir=${JSON.stringify(PROJECT_JSON_PATH)}+'.lock'`) - const mkdirIdx = script.indexOf('fs.mkdirSync(lockDir)') - const readIdx = script.indexOf(`JSON.parse(fs.readFileSync(${JSON.stringify(PROJECT_JSON_PATH)},'utf8'))`) - const renameIdx = script.indexOf(`fs.renameSync(atomicTmp,${JSON.stringify(PROJECT_JSON_PATH)})`) - const releaseIdx = script.indexOf('fs.rmdirSync(lockDir)') - - expect(mkdirIdx).toBeGreaterThanOrEqual(0) - expect(readIdx).toBeGreaterThan(mkdirIdx) - expect(renameIdx).toBeGreaterThan(readIdx) - expect(releaseIdx).toBeGreaterThan(renameIdx) - }) - - it('(d) contains res.status !== 202 guard', () => { - expect(script).toContain('res.status!==202') - }) - - it('embeds the tmpFile path for reading payload', () => { - expect(script).toContain(TMP_FILE) - }) - - it('guards against cross-project contamination (body.project.id vs currentConfig.projectId)', () => { - expect(script).toContain('currentConfig.projectId') - expect(script).toContain('body.project.id!==currentConfig.projectId') - }) - - it('re-reads the project.json file for race protection', () => { + expect(script).toContain("typeof body.project.id!=='string'"); + expect(script).toContain("typeof body.project.orgId!=='string'"); + expect(script).toContain("typeof body.project.orgSlug!=='string'"); + }); + + it("(c) contains renameSync call for atomic write", () => { + expect(script).toContain("renameSync"); + }); + + it("holds an inter-process lock while rewriting project.json", () => { + expect(script).toContain( + `const lockDir=${JSON.stringify(PROJECT_JSON_PATH)}+'.lock'`, + ); + const mkdirIdx = script.indexOf("fs.mkdirSync(lockDir)"); + const readIdx = script.indexOf( + `JSON.parse(fs.readFileSync(${JSON.stringify(PROJECT_JSON_PATH)},'utf8'))`, + ); + const renameIdx = script.indexOf( + `fs.renameSync(atomicTmp,${JSON.stringify(PROJECT_JSON_PATH)})`, + ); + const releaseIdx = script.indexOf("fs.rmdirSync(lockDir)"); + + expect(mkdirIdx).toBeGreaterThanOrEqual(0); + expect(readIdx).toBeGreaterThan(mkdirIdx); + expect(renameIdx).toBeGreaterThan(readIdx); + expect(releaseIdx).toBeGreaterThan(renameIdx); + }); + + it("(d) contains res.status !== 202 guard", () => { + expect(script).toContain("res.status!==202"); + }); + + it("embeds the tmpFile path for reading payload", () => { + expect(script).toContain(TMP_FILE); + }); + + it("guards against cross-project contamination (body.project.id vs currentConfig.projectId)", () => { + expect(script).toContain("currentConfig.projectId"); + expect(script).toContain("body.project.id!==currentConfig.projectId"); + }); + + it("re-reads the project.json file for race protection", () => { // Should read the file a second time (re-read step) // The script must contain two references to the projectJsonPath for readFileSync - const readMatches = script.match(/readFileSync/g) - expect(readMatches).not.toBeNull() - expect(readMatches!.length).toBeGreaterThanOrEqual(2) - }) - - it('checks latest.projectId after re-read (race protection step 7)', () => { - expect(script).toContain('latest.projectId') - }) - - it('contains no-op check for already up-to-date orgId and orgSlug (step 8)', () => { - expect(script).toContain('latest.orgId===body.project.orgId') - expect(script).toContain('latest.orgSlug===body.project.orgSlug') - }) - - it('spreads latest to preserve all existing fields (step 9)', () => { - expect(script).toContain('...latest') - }) - - it('cleans up tmpFile in finally block', () => { - expect(script).toContain('finally') + const readMatches = script.match(/readFileSync/g); + expect(readMatches).not.toBeNull(); + expect(readMatches!.length).toBeGreaterThanOrEqual(2); + }); + + it("checks latest.projectId after re-read (race protection step 7)", () => { + expect(script).toContain("latest.projectId"); + }); + + it("contains no-op check for already up-to-date orgId and orgSlug (step 8)", () => { + expect(script).toContain("latest.orgId===body.project.orgId"); + expect(script).toContain("latest.orgSlug===body.project.orgSlug"); + }); + + it("spreads latest to preserve all existing fields (step 9)", () => { + expect(script).toContain("...latest"); + }); + + it("cleans up tmpFile in finally block", () => { + expect(script).toContain("finally"); // The tmp file unlink should happen in the finally block - const finallyIdx = script.indexOf('finally') - const afterFinally = script.slice(finallyIdx) - expect(afterFinally).toContain('unlinkSync') - }) - - it('cleans up the private tmp directory in finally block', () => { - const finallyIdx = script.indexOf('finally') - const afterFinally = script.slice(finallyIdx) - expect(afterFinally).toContain(TMP_DIR) - expect(afterFinally).toContain('rmSync') - expect(afterFinally).toContain('recursive:true') - expect(afterFinally).toContain('force:true') - }) - - it('is wrapped in an async IIFE', () => { - expect(script).toContain('async()') - }) - - it('uses AbortSignal.timeout(10000) for fetch', () => { - expect(script).toContain('AbortSignal.timeout(10000)') - }) - - it('produces different scripts for different paths', () => { + const finallyIdx = script.indexOf("finally"); + const afterFinally = script.slice(finallyIdx); + expect(afterFinally).toContain("unlinkSync"); + }); + + it("cleans up the private tmp directory in finally block", () => { + const finallyIdx = script.indexOf("finally"); + const afterFinally = script.slice(finallyIdx); + expect(afterFinally).toContain(TMP_DIR); + expect(afterFinally).toContain("rmSync"); + expect(afterFinally).toContain("recursive:true"); + expect(afterFinally).toContain("force:true"); + }); + + it("is wrapped in an async IIFE", () => { + expect(script).toContain("async()"); + }); + + it("uses AbortSignal.timeout(10000) for fetch", () => { + expect(script).toContain("AbortSignal.timeout(10000)"); + }); + + it("produces different scripts for different paths", () => { const script2 = buildSelfHealScript({ - tmpFile: '/tmp/other.json', - projectJsonPath: '/other/project.json', - }) - expect(script2).toContain('/other/project.json') - expect(script2).not.toContain(PROJECT_JSON_PATH) - }) -}) + tmpFile: "/tmp/other.json", + projectJsonPath: "/other/project.json", + }); + expect(script2).toContain("/other/project.json"); + expect(script2).not.toContain(PROJECT_JSON_PATH); + }); +}); diff --git a/packages/cli/src/lib/event-sender.ts b/packages/cli/src/lib/event-sender.ts index 01a74d05..345e52d8 100644 --- a/packages/cli/src/lib/event-sender.ts +++ b/packages/cli/src/lib/event-sender.ts @@ -1,18 +1,18 @@ -import { mkdtempSync, rmSync, writeFileSync } from 'fs' -import { join } from 'path' -import { tmpdir } from 'os' -import { spawn } from 'child_process' -import type { IngestEventPayload } from '@argos/shared' +import { mkdtempSync, rmSync, writeFileSync } from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; +import { spawn } from "child_process"; +import type { IngestEventPayload } from "@argos/shared"; /** * Config snapshot captured at hook invocation time. * Only the fields needed for self-heal comparison. */ export interface CurrentConfig { - projectId: string - orgId: string - orgSlug: string - [key: string]: unknown + projectId: string; + orgId: string; + orgSlug: string; + [key: string]: unknown; } /** @@ -20,13 +20,13 @@ export interface CurrentConfig { * projectJsonPath must be the absolute path discovered by findProjectConfig traversal. */ export interface SendEventBackgroundOpts { - url: string - token: string - payload: IngestEventPayload + url: string; + token: string; + payload: IngestEventPayload; /** Absolute path to .argos/project.json as found by findProjectConfig traversal. */ - projectJsonPath: string + projectJsonPath: string; /** Snapshot of the config at hook invocation time. */ - currentConfig: CurrentConfig + currentConfig: CurrentConfig; } /** @@ -51,14 +51,14 @@ export function buildSelfHealScript({ tmpDir, projectJsonPath, }: { - tmpFile: string - tmpDir?: string - projectJsonPath: string + tmpFile: string; + tmpDir?: string; + projectJsonPath: string; }): string { // Serialize paths as JSON so they are safely embedded in the script string. - const tmpFileJson = JSON.stringify(tmpFile) - const tmpDirJson = tmpDir ? JSON.stringify(tmpDir) : 'null' - const projectJsonPathJson = JSON.stringify(projectJsonPath) + const tmpFileJson = JSON.stringify(tmpFile); + const tmpDirJson = tmpDir ? JSON.stringify(tmpDir) : "null"; + const projectJsonPathJson = JSON.stringify(projectJsonPath); return [ `const fs=require('fs');`, @@ -105,7 +105,7 @@ export function buildSelfHealScript({ // Cleanup tmp file/dir in finally (runs whether self-heal succeeded or any early return) `finally{try{fs.unlinkSync(${tmpFileJson});}catch{};if(${tmpDirJson})try{fs.rmSync(${tmpDirJson},{recursive:true,force:true});}catch{}}`, `})()`, - ].join('') + ].join(""); } /** @@ -118,28 +118,28 @@ export function buildSelfHealScript({ * A temp JSON file is used to pass the payload safely (avoids shell-escaping issues). */ export function sendEventBackground(opts: SendEventBackgroundOpts): void { - const { url, token, payload, projectJsonPath, currentConfig } = opts + const { url, token, payload, projectJsonPath, currentConfig } = opts; - let tmpDir: string | undefined + let tmpDir: string | undefined; try { - tmpDir = mkdtempSync(join(tmpdir(), 'argos-')) - const tmpFile = join(tmpDir, 'payload.json') + tmpDir = mkdtempSync(join(tmpdir(), "argos-")); + const tmpFile = join(tmpDir, "payload.json"); writeFileSync( tmpFile, JSON.stringify({ url, token, payload, projectJsonPath, currentConfig }), - 'utf8', - ) + "utf8", + ); - const script = buildSelfHealScript({ tmpFile, tmpDir, projectJsonPath }) + const script = buildSelfHealScript({ tmpFile, tmpDir, projectJsonPath }); - const child = spawn(process.execPath, ['-e', script], { + const child = spawn(process.execPath, ["-e", script], { detached: true, - stdio: 'ignore', - }) - child.unref() + stdio: "ignore", + }); + child.unref(); } catch { try { - if (tmpDir) rmSync(tmpDir, { recursive: true, force: true }) + if (tmpDir) rmSync(tmpDir, { recursive: true, force: true }); } catch {} } } diff --git a/packages/cli/src/lib/hooks-inject.ts b/packages/cli/src/lib/hooks-inject.ts index 2855ca43..ad666f81 100644 --- a/packages/cli/src/lib/hooks-inject.ts +++ b/packages/cli/src/lib/hooks-inject.ts @@ -1,46 +1,56 @@ -import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs' -import { dirname } from 'path' +import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs"; +import { dirname } from "path"; // 트래킹 대상 에이전트. Claude Code 와 Codex 는 hook 설정 파일 모양이 동일(`{ hooks: { : [...] } }`)해서 // 동일한 주입 로직을 쓰되, hook command 에 `--agent codex` 를 붙여 `argos hook` 이 transcript 파서를 분기하게 한다. -export type HookAgent = 'claude' | 'codex' +export type HookAgent = "claude" | "codex"; -const ARGOS_HOOK_COMMAND = 'argos hook' -const ARGOS_HOOK_COMMAND_CODEX = 'argos hook --agent codex' +const ARGOS_HOOK_COMMAND = "argos hook"; +const ARGOS_HOOK_COMMAND_CODEX = "argos hook --agent codex"; // SessionStart에서만 사용. argos CLI가 PATH에 없으면 자동 전역 설치 후 hook 실행. // 신규 팀원이 저장소를 clone한 직후 에이전트를 열었을 때 설치가 끊김없이 이어지도록 하는 부트스트랩. // POSIX 셸 기준(command, ||, ;). Windows는 shell 차이로 동작하지 않을 수 있다. const ARGOS_SESSION_START_COMMAND = - 'command -v argos >/dev/null 2>&1 || npm install -g argos-ai@latest; argos hook' + "command -v argos >/dev/null 2>&1 || npm install -g argos-ai@latest; argos hook"; const ARGOS_SESSION_START_COMMAND_CODEX = - 'command -v argos >/dev/null 2>&1 || npm install -g argos-ai@latest; argos hook --agent codex' + "command -v argos >/dev/null 2>&1 || npm install -g argos-ai@latest; argos hook --agent codex"; -const HOOK_EVENTS = ['SessionStart', 'PreToolUse', 'PostToolUse', 'Stop', 'SubagentStop'] +const HOOK_EVENTS = [ + "SessionStart", + "PreToolUse", + "PostToolUse", + "Stop", + "SubagentStop", +]; function commandForEvent(event: string, agent: HookAgent): string { - if (agent === 'codex') { - return event === 'SessionStart' ? ARGOS_SESSION_START_COMMAND_CODEX : ARGOS_HOOK_COMMAND_CODEX + if (agent === "codex") { + return event === "SessionStart" + ? ARGOS_SESSION_START_COMMAND_CODEX + : ARGOS_HOOK_COMMAND_CODEX; } - return event === 'SessionStart' ? ARGOS_SESSION_START_COMMAND : ARGOS_HOOK_COMMAND + return event === "SessionStart" + ? ARGOS_SESSION_START_COMMAND + : ARGOS_HOOK_COMMAND; } // 기존 argos 훅(구버전 `argos hook`, bootstrap, 또는 `--agent codex` 변형)이 있으면 "이미 존재"로 본다. function isArgosCommand(cmd: string): boolean { - return cmd.includes('argos hook') + return cmd.includes("argos hook"); } interface HookConfig { - type: string - command: string + type: string; + command: string; } interface HookEntry { - matcher: string - hooks: HookConfig[] + matcher: string; + hooks: HookConfig[]; } interface SettingsJson { - hooks?: Record + hooks?: Record; } /** @@ -55,52 +65,52 @@ interface SettingsJson { */ export function injectHooks( settingsPath: string, - agent: HookAgent = 'claude' -): 'injected' | 'already_present' { + agent: HookAgent = "claude", +): "injected" | "already_present" { // Ensure directory exists - const settingsDir = dirname(settingsPath) + const settingsDir = dirname(settingsPath); if (!existsSync(settingsDir)) { - mkdirSync(settingsDir, { recursive: true }) + mkdirSync(settingsDir, { recursive: true }); } // Read existing settings or create empty object - let settings: SettingsJson = {} + let settings: SettingsJson = {}; if (existsSync(settingsPath)) { try { - const content = readFileSync(settingsPath, 'utf8') - settings = JSON.parse(content) + const content = readFileSync(settingsPath, "utf8"); + settings = JSON.parse(content); } catch { // If file is corrupted, start fresh - settings = {} + settings = {}; } } - settings.hooks = settings.hooks || {} + settings.hooks = settings.hooks || {}; - let changed = false + let changed = false; for (const event of HOOK_EVENTS) { - const hooks: HookEntry[] = settings.hooks[event] || [] + const hooks: HookEntry[] = settings.hooks[event] || []; // Check if any argos-related hook already exists for this event const alreadyExists = hooks.some((entry) => - entry.hooks?.some((hook) => isArgosCommand(hook.command)) - ) + entry.hooks?.some((hook) => isArgosCommand(hook.command)), + ); if (!alreadyExists) { hooks.push({ - matcher: '', - hooks: [{ type: 'command', command: commandForEvent(event, agent) }], - }) - settings.hooks[event] = hooks - changed = true + matcher: "", + hooks: [{ type: "command", command: commandForEvent(event, agent) }], + }); + settings.hooks[event] = hooks; + changed = true; } } if (changed) { - writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf8') - return 'injected' + writeFileSync(settingsPath, JSON.stringify(settings, null, 2), "utf8"); + return "injected"; } - return 'already_present' + return "already_present"; } diff --git a/packages/cli/src/lib/inject-agent-hooks.ts b/packages/cli/src/lib/inject-agent-hooks.ts index 994334ed..06563eaf 100644 --- a/packages/cli/src/lib/inject-agent-hooks.ts +++ b/packages/cli/src/lib/inject-agent-hooks.ts @@ -1,33 +1,38 @@ -import { join } from 'path' -import chalk from 'chalk' -import type { ExternalDeps } from '../deps.js' +import { join } from "path"; +import chalk from "chalk"; +import type { ExternalDeps } from "../deps.js"; -type InjectResult = 'injected' | 'already_present' +type InjectResult = "injected" | "already_present"; export interface AgentHookResult { - claude: InjectResult - codex: InjectResult + claude: InjectResult; + codex: InjectResult; } /** * Claude Code(.claude/settings.json) 와 Codex(.codex/hooks.json) hook 을 모두 주입한다. * 두 에이전트 중 무엇을 쓰든 argos 가 추적하도록 기본적으로 둘 다 설치한다(미사용 에이전트의 파일은 무해). */ -export function injectAgentHooks(deps: ExternalDeps, cwd: string): AgentHookResult { +export function injectAgentHooks( + deps: ExternalDeps, + cwd: string, +): AgentHookResult { return { - claude: deps.hooks.inject(join(cwd, '.claude', 'settings.json'), 'claude'), - codex: deps.hooks.inject(join(cwd, '.codex', 'hooks.json'), 'codex'), - } + // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal + claude: deps.hooks.inject(join(cwd, ".claude", "settings.json"), "claude"), + // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal + codex: deps.hooks.inject(join(cwd, ".codex", "hooks.json"), "codex"), + }; } /** 주입 결과를 사람이 읽는 메시지로 출력한다. */ export function printAgentHookResult(result: AgentHookResult): void { const line = (label: string, r: InjectResult) => - r === 'injected' + r === "injected" ? chalk.green(`✓ ${label} hooks 설치 완료`) - : chalk.yellow(`✓ ${label} hooks 이미 설치됨`) - console.log(line('Claude Code (.claude/settings.json)', result.claude)) - console.log(line('Codex (.codex/hooks.json)', result.codex)) + : chalk.yellow(`✓ ${label} hooks 이미 설치됨`); + console.log(line("Claude Code (.claude/settings.json)", result.claude)); + console.log(line("Codex (.codex/hooks.json)", result.codex)); } /** @@ -35,8 +40,12 @@ export function printAgentHookResult(result: AgentHookResult): void { * 세팅 직후 사용자가 한 번은 거쳐야 하는 단계이므로 명시적으로 안내한다. */ export function printCodexTrustNotice(): void { - console.log() - console.log(chalk.bold('Codex 사용자 추가 단계 (1회):')) - console.log(' Codex 는 보안상 새 hook 을 자동 실행하지 않습니다.') - console.log(' codex 를 실행한 뒤 ' + chalk.cyan('/hooks') + ' 에서 argos hook 들을 trust 하세요.') + console.log(); + console.log(chalk.bold("Codex 사용자 추가 단계 (1회):")); + console.log(" Codex 는 보안상 새 hook 을 자동 실행하지 않습니다."); + console.log( + " codex 를 실행한 뒤 " + + chalk.cyan("/hooks") + + " 에서 argos hook 들을 trust 하세요.", + ); } diff --git a/packages/cli/src/lib/project.test.ts b/packages/cli/src/lib/project.test.ts index 695f8b1e..f9d9bd6a 100644 --- a/packages/cli/src/lib/project.test.ts +++ b/packages/cli/src/lib/project.test.ts @@ -1,39 +1,44 @@ -import { afterEach, describe, expect, it } from 'vitest' -import { mkdtempSync, mkdirSync, realpathSync, rmSync } from 'fs' -import { join, resolve } from 'path' -import { tmpdir } from 'os' -import { findProjectConfigWithPath, writeProjectConfig } from './project.js' +import { afterEach, describe, expect, it } from "vitest"; +import { mkdtempSync, mkdirSync, realpathSync, rmSync } from "fs"; +import { join, resolve } from "path"; +import { tmpdir } from "os"; +import { findProjectConfigWithPath, writeProjectConfig } from "./project.js"; -const originalCwd = process.cwd() +const originalCwd = process.cwd(); afterEach(() => { - process.chdir(originalCwd) -}) + process.chdir(originalCwd); +}); -describe('findProjectConfigWithPath', () => { - it('resolves relative startDir segments before walking parent directories', () => { - const tmpRoot = mkdtempSync(join(tmpdir(), 'argos-project-test-')) +describe("findProjectConfigWithPath", () => { + it("resolves relative startDir segments before walking parent directories", () => { + const tmpRoot = mkdtempSync(join(tmpdir(), "argos-project-test-")); try { - const repoRoot = join(tmpRoot, 'repo') - const nestedDir = join(repoRoot, 'packages', 'cli') - mkdirSync(nestedDir, { recursive: true }) - writeProjectConfig({ - projectId: 'project-1', - orgId: 'org-1', - orgName: 'Org', - projectName: 'Project', - }, repoRoot) + const repoRoot = join(tmpRoot, "repo"); + const nestedDir = join(repoRoot, "packages", "cli"); + mkdirSync(nestedDir, { recursive: true }); + writeProjectConfig( + { + projectId: "project-1", + orgId: "org-1", + orgName: "Org", + projectName: "Project", + }, + repoRoot, + ); - process.chdir(repoRoot) + process.chdir(repoRoot); - const result = findProjectConfigWithPath('packages/../packages/cli') + const result = findProjectConfigWithPath("packages/../packages/cli"); - expect(result?.config.projectId).toBe('project-1') - expect(result?.configPath).toBe(realpathSync(resolve(repoRoot, '.argos', 'project.json'))) + expect(result?.config.projectId).toBe("project-1"); + expect(result?.configPath).toBe( + realpathSync(resolve(repoRoot, ".argos", "project.json")), + ); } finally { - process.chdir(originalCwd) - rmSync(tmpRoot, { recursive: true, force: true }) + process.chdir(originalCwd); + rmSync(tmpRoot, { recursive: true, force: true }); } - }) -}) + }); +}); diff --git a/packages/cli/src/lib/project.ts b/packages/cli/src/lib/project.ts index bbbeb5c6..3cceaff4 100644 --- a/packages/cli/src/lib/project.ts +++ b/packages/cli/src/lib/project.ts @@ -1,16 +1,16 @@ -import { dirname, join, resolve } from 'path' -import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs' -import { normalizeApiUrl } from './config.js' +import { dirname, join, resolve } from "path"; +import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs"; +import { normalizeApiUrl } from "./config.js"; export interface ProjectConfig { - projectId: string - orgId: string + projectId: string; + orgId: string; // v0.1.13 미만에서 만들어진 project.json 에는 orgSlug 가 없을 수 있다. // 누락 시 CLI 는 orgId 로 대체해 서버를 호출한다. - orgSlug?: string - orgName: string - projectName: string - apiUrl?: string + orgSlug?: string; + orgName: string; + projectName: string; + apiUrl?: string; } /** @@ -22,38 +22,40 @@ export interface ProjectConfig { export function findProjectConfigWithPath( startDir?: string, ): { config: ProjectConfig; configPath: string } | null { - let currentDir = resolve(startDir || process.cwd()) - let depth = 0 - const maxDepth = 10 + // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal + let currentDir = resolve(startDir || process.cwd()); + let depth = 0; + const maxDepth = 10; while (depth < maxDepth) { - const configPath = join(currentDir, '.argos', 'project.json') + // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal + const configPath = join(currentDir, ".argos", "project.json"); if (existsSync(configPath)) { try { - const content = readFileSync(configPath, 'utf8') - const parsed = JSON.parse(content) as ProjectConfig - const normalized = normalizeApiUrl(parsed.apiUrl) + const content = readFileSync(configPath, "utf8"); + const parsed = JSON.parse(content) as ProjectConfig; + const normalized = normalizeApiUrl(parsed.apiUrl); if (normalized) { - parsed.apiUrl = normalized + parsed.apiUrl = normalized; } else { - delete parsed.apiUrl + delete parsed.apiUrl; } - return { config: parsed, configPath } + return { config: parsed, configPath }; } catch { - return null + return null; } } - const parentDir = dirname(currentDir) + const parentDir = dirname(currentDir); if (parentDir === currentDir) { // Reached root directory - break + break; } - currentDir = parentDir - depth++ + currentDir = parentDir; + depth++; } - return null + return null; } /** @@ -62,8 +64,8 @@ export function findProjectConfigWithPath( * @returns ProjectConfig or null if not found */ export function findProjectConfig(startDir?: string): ProjectConfig | null { - const result = findProjectConfigWithPath(startDir) - return result ? result.config : null + const result = findProjectConfigWithPath(startDir); + return result ? result.config : null; } /** @@ -73,18 +75,21 @@ export function findProjectConfig(startDir?: string): ProjectConfig | null { * @param dir Target directory (defaults to process.cwd()) */ export function writeProjectConfig(config: ProjectConfig, dir?: string): void { - const targetDir = dir || process.cwd() - const argosDir = join(targetDir, '.argos') + const targetDir = dir || process.cwd(); + // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal + const argosDir = join(targetDir, ".argos"); if (!existsSync(argosDir)) { - mkdirSync(argosDir, { recursive: true }) + mkdirSync(argosDir, { recursive: true }); } - const configPath = join(argosDir, 'project.json') - writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf8') + // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal + const configPath = join(argosDir, "project.json"); + writeFileSync(configPath, JSON.stringify(config, null, 2), "utf8"); // Create .gitignore with comment (but don't actually ignore anything) - const gitignorePath = join(argosDir, '.gitignore') - const gitignoreComment = '# argos 설정 (gitignore 하지 않음)\n' - writeFileSync(gitignorePath, gitignoreComment, 'utf8') + // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal + const gitignorePath = join(argosDir, ".gitignore"); + const gitignoreComment = "# argos 설정 (gitignore 하지 않음)\n"; + writeFileSync(gitignorePath, gitignoreComment, "utf8"); } diff --git a/packages/cli/src/lib/transcript-codex.test.ts b/packages/cli/src/lib/transcript-codex.test.ts index 886f2230..ab0ca4a0 100644 --- a/packages/cli/src/lib/transcript-codex.test.ts +++ b/packages/cli/src/lib/transcript-codex.test.ts @@ -1,86 +1,200 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest' -import { mkdtempSync, rmSync, writeFileSync } from 'fs' -import { join } from 'path' -import { tmpdir } from 'os' +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, rmSync, writeFileSync } from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; import { extractUsageFromCodexTranscript, extractUsagePerTurnFromCodexTranscript, extractMessagesFromCodexTranscript, -} from './transcript-codex.js' +} from "./transcript-codex.js"; // 실제 Codex rollout 라인 구조를 본뜬 합성 fixture (docs/codex-integration.md §3 기준). const FIXTURE = [ - { timestamp: '2026-05-26T00:00:00.000Z', type: 'session_meta', payload: { id: 'sess', cwd: '/x', cli_version: '0.133.0', model_provider: 'openai' } }, - { timestamp: '2026-05-26T00:00:01.000Z', type: 'turn_context', payload: { turn_id: 't1', cwd: '/x', model: 'gpt-5.5' } }, - { timestamp: '2026-05-26T00:00:02.000Z', type: 'event_msg', payload: { type: 'user_message', message: '적절히 commit push한거 맞아?' } }, - { timestamp: '2026-05-26T00:00:03.000Z', type: 'event_msg', payload: { type: 'agent_message', message: 'Checking git status first.' } }, - { timestamp: '2026-05-26T00:00:04.000Z', type: 'response_item', payload: { type: 'function_call', name: 'exec_command', arguments: '{"cmd":"git status"}', call_id: 'call_1' } }, - { timestamp: '2026-05-26T00:00:05.500Z', type: 'response_item', payload: { type: 'function_call_output', call_id: 'call_1', output: 'clean' } }, - { timestamp: '2026-05-26T00:00:06.000Z', type: 'response_item', payload: { type: 'custom_tool_call', name: 'apply_patch', input: '*** Begin Patch', call_id: 'call_2' } }, - { timestamp: '2026-05-26T00:00:06.200Z', type: 'response_item', payload: { type: 'custom_tool_call_output', call_id: 'call_2', output: 'Success.' } }, + { + timestamp: "2026-05-26T00:00:00.000Z", + type: "session_meta", + payload: { + id: "sess", + cwd: "/x", + cli_version: "0.133.0", + model_provider: "openai", + }, + }, + { + timestamp: "2026-05-26T00:00:01.000Z", + type: "turn_context", + payload: { turn_id: "t1", cwd: "/x", model: "gpt-5.5" }, + }, + { + timestamp: "2026-05-26T00:00:02.000Z", + type: "event_msg", + payload: { type: "user_message", message: "적절히 commit push한거 맞아?" }, + }, + { + timestamp: "2026-05-26T00:00:03.000Z", + type: "event_msg", + payload: { type: "agent_message", message: "Checking git status first." }, + }, + { + timestamp: "2026-05-26T00:00:04.000Z", + type: "response_item", + payload: { + type: "function_call", + name: "exec_command", + arguments: '{"cmd":"git status"}', + call_id: "call_1", + }, + }, + { + timestamp: "2026-05-26T00:00:05.500Z", + type: "response_item", + payload: { + type: "function_call_output", + call_id: "call_1", + output: "clean", + }, + }, + { + timestamp: "2026-05-26T00:00:06.000Z", + type: "response_item", + payload: { + type: "custom_tool_call", + name: "apply_patch", + input: "*** Begin Patch", + call_id: "call_2", + }, + }, + { + timestamp: "2026-05-26T00:00:06.200Z", + type: "response_item", + payload: { + type: "custom_tool_call_output", + call_id: "call_2", + output: "Success.", + }, + }, // 누적 token_count 2개: total 은 누적, last 는 턴 델타 - { timestamp: '2026-05-26T00:00:03.500Z', type: 'event_msg', payload: { type: 'token_count', info: { total_token_usage: { input_tokens: 14839, cached_input_tokens: 4480, output_tokens: 348, reasoning_output_tokens: 95, total_tokens: 15187 }, last_token_usage: { input_tokens: 14839, cached_input_tokens: 4480, output_tokens: 348, reasoning_output_tokens: 95, total_tokens: 15187 } } } }, - { timestamp: '2026-05-26T00:00:07.000Z', type: 'event_msg', payload: { type: 'token_count', info: { total_token_usage: { input_tokens: 33123, cached_input_tokens: 19200, output_tokens: 477, reasoning_output_tokens: 95, total_tokens: 33600 }, last_token_usage: { input_tokens: 18284, cached_input_tokens: 14720, output_tokens: 129, reasoning_output_tokens: 0, total_tokens: 18413 } } } }, -] + { + timestamp: "2026-05-26T00:00:03.500Z", + type: "event_msg", + payload: { + type: "token_count", + info: { + total_token_usage: { + input_tokens: 14839, + cached_input_tokens: 4480, + output_tokens: 348, + reasoning_output_tokens: 95, + total_tokens: 15187, + }, + last_token_usage: { + input_tokens: 14839, + cached_input_tokens: 4480, + output_tokens: 348, + reasoning_output_tokens: 95, + total_tokens: 15187, + }, + }, + }, + }, + { + timestamp: "2026-05-26T00:00:07.000Z", + type: "event_msg", + payload: { + type: "token_count", + info: { + total_token_usage: { + input_tokens: 33123, + cached_input_tokens: 19200, + output_tokens: 477, + reasoning_output_tokens: 95, + total_tokens: 33600, + }, + last_token_usage: { + input_tokens: 18284, + cached_input_tokens: 14720, + output_tokens: 129, + reasoning_output_tokens: 0, + total_tokens: 18413, + }, + }, + }, + }, +]; -describe('transcript-codex', () => { - let dir: string - let path: string +describe("transcript-codex", () => { + let dir: string; + let path: string; beforeEach(() => { - dir = mkdtempSync(join(tmpdir(), 'argos-codex-tr-')) - path = join(dir, 'rollout.jsonl') - writeFileSync(path, FIXTURE.map((l) => JSON.stringify(l)).join('\n'), 'utf8') - }) - afterEach(() => rmSync(dir, { recursive: true, force: true })) + dir = mkdtempSync(join(tmpdir(), "argos-codex-tr-")); + path = join(dir, "rollout.jsonl"); + writeFileSync( + path, + FIXTURE.map((l) => JSON.stringify(l)).join("\n"), + "utf8", + ); + }); + afterEach(() => rmSync(dir, { recursive: true, force: true })); - it('extractUsage: 마지막 token_count 의 total 을 Claude 컨벤션으로 매핑', async () => { - const u = await extractUsageFromCodexTranscript(path) - expect(u).not.toBeNull() + it("extractUsage: 마지막 token_count 의 total 을 Claude 컨벤션으로 매핑", async () => { + const u = await extractUsageFromCodexTranscript(path); + expect(u).not.toBeNull(); // input(33123) - cached(19200) = 13923 - expect(u!.inputTokens).toBe(13923) - expect(u!.cacheReadTokens).toBe(19200) - expect(u!.cacheCreationTokens).toBe(0) - expect(u!.outputTokens).toBe(477) - expect(u!.model).toBe('gpt-5.5') - }) + expect(u!.inputTokens).toBe(13923); + expect(u!.cacheReadTokens).toBe(19200); + expect(u!.cacheCreationTokens).toBe(0); + expect(u!.outputTokens).toBe(477); + expect(u!.model).toBe("gpt-5.5"); + }); - it('extractUsage: token_count 가 없으면 null', async () => { - const empty = join(dir, 'empty.jsonl') - writeFileSync(empty, JSON.stringify({ type: 'turn_context', payload: { model: 'gpt-5.5' } }), 'utf8') - expect(await extractUsageFromCodexTranscript(empty)).toBeNull() - }) + it("extractUsage: token_count 가 없으면 null", async () => { + const empty = join(dir, "empty.jsonl"); + writeFileSync( + empty, + JSON.stringify({ type: "turn_context", payload: { model: "gpt-5.5" } }), + "utf8", + ); + expect(await extractUsageFromCodexTranscript(empty)).toBeNull(); + }); - it('extractUsagePerTurn: token_count 마다 last_token_usage 1개', async () => { - const turns = await extractUsagePerTurnFromCodexTranscript(path) - expect(turns).toHaveLength(2) - expect(turns[0].inputTokens).toBe(14839 - 4480) - expect(turns[1].inputTokens).toBe(18284 - 14720) - expect(turns[1].cacheReadTokens).toBe(14720) - expect(turns[0].model).toBe('gpt-5.5') - expect(turns[0].timestamp).toBe('2026-05-26T00:00:03.500Z') - }) + it("extractUsagePerTurn: token_count 마다 last_token_usage 1개", async () => { + const turns = await extractUsagePerTurnFromCodexTranscript(path); + expect(turns).toHaveLength(2); + expect(turns[0].inputTokens).toBe(14839 - 4480); + expect(turns[1].inputTokens).toBe(18284 - 14720); + expect(turns[1].cacheReadTokens).toBe(14720); + expect(turns[0].model).toBe("gpt-5.5"); + expect(turns[0].timestamp).toBe("2026-05-26T00:00:03.500Z"); + }); - it('extractMessages: HUMAN/ASSISTANT/TOOL 매핑 + 결과 backfill', async () => { - const msgs = await extractMessagesFromCodexTranscript(path) - const byRole = msgs.reduce>((a, m) => { a[m.role] = (a[m.role] || 0) + 1; return a }, {}) - expect(byRole).toEqual({ HUMAN: 1, ASSISTANT: 1, TOOL: 2 }) + it("extractMessages: HUMAN/ASSISTANT/TOOL 매핑 + 결과 backfill", async () => { + const msgs = await extractMessagesFromCodexTranscript(path); + const byRole = msgs.reduce>((a, m) => { + a[m.role] = (a[m.role] || 0) + 1; + return a; + }, {}); + expect(byRole).toEqual({ HUMAN: 1, ASSISTANT: 1, TOOL: 2 }); - const human = msgs.find((m) => m.role === 'HUMAN')! - expect(human.content).toBe('적절히 commit push한거 맞아?') + const human = msgs.find((m) => m.role === "HUMAN")!; + expect(human.content).toBe("적절히 commit push한거 맞아?"); - const exec = msgs.find((m) => m.toolName === 'exec_command')! - expect(exec.toolInput).toEqual({ cmd: 'git status' }) // arguments JSON 파싱 - expect(exec.content).toBe('clean') // function_call_output backfill - expect(exec.durationMs).toBe(1500) // 00:05.500 - 00:04.000 + const exec = msgs.find((m) => m.toolName === "exec_command")!; + expect(exec.toolInput).toEqual({ cmd: "git status" }); // arguments JSON 파싱 + expect(exec.content).toBe("clean"); // function_call_output backfill + expect(exec.durationMs).toBe(1500); // 00:05.500 - 00:04.000 - const patch = msgs.find((m) => m.toolName === 'apply_patch')! - expect(patch.toolInput).toEqual({ input: '*** Begin Patch' }) // raw input - expect(patch.content).toBe('Success.') - }) + const patch = msgs.find((m) => m.toolName === "apply_patch")!; + expect(patch.toolInput).toEqual({ input: "*** Begin Patch" }); // raw input + expect(patch.content).toBe("Success."); + }); - it('존재하지 않는 파일 → null / []', async () => { - expect(await extractUsageFromCodexTranscript('/no/such/file.jsonl')).toBeNull() - expect(await extractMessagesFromCodexTranscript('/no/such/file.jsonl')).toEqual([]) - }) -}) + it("존재하지 않는 파일 → null / []", async () => { + expect( + await extractUsageFromCodexTranscript("/no/such/file.jsonl"), + ).toBeNull(); + expect( + await extractMessagesFromCodexTranscript("/no/such/file.jsonl"), + ).toEqual([]); + }); +}); diff --git a/packages/cli/src/lib/transcript-codex.ts b/packages/cli/src/lib/transcript-codex.ts index 599cf241..daedbd31 100644 --- a/packages/cli/src/lib/transcript-codex.ts +++ b/packages/cli/src/lib/transcript-codex.ts @@ -1,5 +1,9 @@ -import { readFileSync, existsSync } from 'fs' -import type { UsagePayload, UsagePerTurnPayload, MessagePayload } from '@argos/shared' +import { readFileSync, existsSync } from "fs"; +import type { + UsagePayload, + UsagePerTurnPayload, + MessagePayload, +} from "@argos/shared"; /** * Codex rollout(transcript) 파서. @@ -20,49 +24,52 @@ import type { UsagePayload, UsagePerTurnPayload, MessagePayload } from '@argos/s */ interface CodexTokenUsage { - input_tokens?: number - cached_input_tokens?: number - output_tokens?: number - reasoning_output_tokens?: number - total_tokens?: number + input_tokens?: number; + cached_input_tokens?: number; + output_tokens?: number; + reasoning_output_tokens?: number; + total_tokens?: number; } interface RolloutLine { - timestamp?: string - type?: string + timestamp?: string; + type?: string; payload?: { - type?: string - model?: string - message?: string // event_msg user_message/agent_message - role?: string // response_item message - content?: Array<{ type?: string; text?: string }> + type?: string; + model?: string; + message?: string; // event_msg user_message/agent_message + role?: string; // response_item message + content?: Array<{ type?: string; text?: string }>; // token_count - info?: { total_token_usage?: CodexTokenUsage; last_token_usage?: CodexTokenUsage } + info?: { + total_token_usage?: CodexTokenUsage; + last_token_usage?: CodexTokenUsage; + }; // function_call / custom_tool_call - name?: string - arguments?: string // function_call: JSON string - input?: string // custom_tool_call: raw string (e.g. apply_patch) - call_id?: string - output?: string // *_output - } + name?: string; + arguments?: string; // function_call: JSON string + input?: string; // custom_tool_call: raw string (e.g. apply_patch) + call_id?: string; + output?: string; // *_output + }; } function readRolloutLines(path: string): RolloutLine[] { - if (!existsSync(path)) return [] + if (!existsSync(path)) return []; try { - const content = readFileSync(path, 'utf8') + const content = readFileSync(path, "utf8"); return content - .split('\n') + .split("\n") .filter((l) => l.trim()) .map((l) => { try { - return JSON.parse(l) as RolloutLine + return JSON.parse(l) as RolloutLine; } catch { - return {} + return {}; } - }) + }); } catch { - return [] + return []; } } @@ -76,59 +83,72 @@ function readRolloutLines(path: string): RolloutLine[] { * outputTokens = output_tokens (reasoning 토큰 이미 포함) * total_token_usage 는 세션 누적이므로 **마지막 token_count** 를 세션 총합으로 사용한다. */ -export async function extractUsageFromCodexTranscript(path: string): Promise { - const lines = readRolloutLines(path) - let lastTotal: CodexTokenUsage | undefined - let model: string | undefined +export async function extractUsageFromCodexTranscript( + path: string, +): Promise { + const lines = readRolloutLines(path); + let lastTotal: CodexTokenUsage | undefined; + let model: string | undefined; for (const line of lines) { - const p = line.payload - if (!p) continue - if (!model && line.type === 'turn_context' && p.model) model = p.model - if (!model && line.type === 'session_meta' && p.model) model = p.model - if (line.type === 'event_msg' && p.type === 'token_count' && p.info?.total_token_usage) { - lastTotal = p.info.total_token_usage + const p = line.payload; + if (!p) continue; + if (!model && line.type === "turn_context" && p.model) model = p.model; + if (!model && line.type === "session_meta" && p.model) model = p.model; + if ( + line.type === "event_msg" && + p.type === "token_count" && + p.info?.total_token_usage + ) { + lastTotal = p.info.total_token_usage; } } - if (!lastTotal) return null + if (!lastTotal) return null; - return toUsagePayload(lastTotal, model) + return toUsagePayload(lastTotal, model); } /** * 턴별 사용량. token_count 이벤트마다 `last_token_usage`(해당 턴 델타)를 한 항목으로. */ export async function extractUsagePerTurnFromCodexTranscript( - path: string + path: string, ): Promise { - const lines = readRolloutLines(path) - const results: UsagePerTurnPayload[] = [] - let model: string | undefined + const lines = readRolloutLines(path); + const results: UsagePerTurnPayload[] = []; + let model: string | undefined; for (const line of lines) { - const p = line.payload - if (!p) continue - if (!model && line.type === 'turn_context' && p.model) model = p.model - if (line.type === 'event_msg' && p.type === 'token_count' && p.info?.last_token_usage) { - const u = toUsagePayload(p.info.last_token_usage, model) - results.push({ ...u, timestamp: line.timestamp || new Date().toISOString() }) + const p = line.payload; + if (!p) continue; + if (!model && line.type === "turn_context" && p.model) model = p.model; + if ( + line.type === "event_msg" && + p.type === "token_count" && + p.info?.last_token_usage + ) { + const u = toUsagePayload(p.info.last_token_usage, model); + results.push({ + ...u, + timestamp: line.timestamp || new Date().toISOString(), + }); } } - return results + return results; } function toUsagePayload(u: CodexTokenUsage, model?: string): UsagePayload { - const input = u.input_tokens || 0 - const cached = u.cached_input_tokens || 0 + const input = u.input_tokens || 0; + const cached = u.cached_input_tokens || 0; return { inputTokens: Math.max(0, input - cached), outputTokens: u.output_tokens || 0, cacheCreationTokens: 0, cacheReadTokens: cached, model, - } + }; } /** @@ -140,72 +160,102 @@ function toUsagePayload(u: CodexTokenUsage, model?: string): UsagePayload { * * sequence 는 rollout 순서대로 부여(best-effort) — API 측에서 재정렬될 수 있다. */ -export async function extractMessagesFromCodexTranscript(path: string): Promise { - const lines = readRolloutLines(path) - const messages: MessagePayload[] = [] - const toolByCallId = new Map() - let sequence = 0 +export async function extractMessagesFromCodexTranscript( + path: string, +): Promise { + const lines = readRolloutLines(path); + const messages: MessagePayload[] = []; + const toolByCallId = new Map(); + let sequence = 0; for (const line of lines) { - const p = line.payload - if (!p) continue - const timestamp = line.timestamp || new Date().toISOString() - - if (line.type === 'event_msg') { - if (p.type === 'user_message' && typeof p.message === 'string' && p.message.length > 0) { - messages.push({ role: 'HUMAN', content: p.message.slice(0, 50000), sequence: sequence++, timestamp }) - } else if (p.type === 'agent_message' && typeof p.message === 'string' && p.message.length > 0) { - messages.push({ role: 'ASSISTANT', content: p.message.slice(0, 50000), sequence: sequence++, timestamp }) + const p = line.payload; + if (!p) continue; + const timestamp = line.timestamp || new Date().toISOString(); + + if (line.type === "event_msg") { + if ( + p.type === "user_message" && + typeof p.message === "string" && + p.message.length > 0 + ) { + messages.push({ + role: "HUMAN", + content: p.message.slice(0, 50000), + sequence: sequence++, + timestamp, + }); + } else if ( + p.type === "agent_message" && + typeof p.message === "string" && + p.message.length > 0 + ) { + messages.push({ + role: "ASSISTANT", + content: p.message.slice(0, 50000), + sequence: sequence++, + timestamp, + }); } - continue + continue; } - if (line.type === 'response_item') { + if (line.type === "response_item") { // 툴 호출 (function_call = JSON arguments, custom_tool_call = raw input) - if ((p.type === 'function_call' || p.type === 'custom_tool_call') && p.name) { + if ( + (p.type === "function_call" || p.type === "custom_tool_call") && + p.name + ) { const tool: MessagePayload = { - role: 'TOOL', - content: '', + role: "TOOL", + content: "", sequence: sequence++, timestamp, toolName: p.name, toolInput: parseToolInput(p), toolUseId: p.call_id, - } - messages.push(tool) - if (p.call_id) toolByCallId.set(p.call_id, tool) - continue + }; + messages.push(tool); + if (p.call_id) toolByCallId.set(p.call_id, tool); + continue; } // 툴 결과 → 매칭 TOOL backfill - if ((p.type === 'function_call_output' || p.type === 'custom_tool_call_output') && p.call_id) { - const tool = toolByCallId.get(p.call_id) - if (tool && typeof p.output === 'string') { - tool.content = p.output.slice(0, 50000) - const startMs = Date.parse(tool.timestamp) - const endMs = Date.parse(timestamp) + if ( + (p.type === "function_call_output" || + p.type === "custom_tool_call_output") && + p.call_id + ) { + const tool = toolByCallId.get(p.call_id); + if (tool && typeof p.output === "string") { + tool.content = p.output.slice(0, 50000); + const startMs = Date.parse(tool.timestamp); + const endMs = Date.parse(timestamp); if (!Number.isNaN(startMs) && !Number.isNaN(endMs)) { - tool.durationMs = Math.max(0, endMs - startMs) + tool.durationMs = Math.max(0, endMs - startMs); } } } } } - return messages + return messages; } -function parseToolInput(p: NonNullable): Record { +function parseToolInput( + p: NonNullable, +): Record { // function_call: arguments 는 JSON 문자열 - if (typeof p.arguments === 'string') { + if (typeof p.arguments === "string") { try { - const parsed = JSON.parse(p.arguments) - if (parsed && typeof parsed === 'object') return parsed as Record + const parsed = JSON.parse(p.arguments); + if (parsed && typeof parsed === "object") + return parsed as Record; } catch { // fall through } - return { arguments: p.arguments } + return { arguments: p.arguments }; } // custom_tool_call: input 은 raw 문자열(apply_patch 등) - if (typeof p.input === 'string') return { input: p.input } - return {} + if (typeof p.input === "string") return { input: p.input }; + return {}; } diff --git a/packages/cli/src/lib/transcript.test.ts b/packages/cli/src/lib/transcript.test.ts index 4d624afc..66da72cb 100644 --- a/packages/cli/src/lib/transcript.test.ts +++ b/packages/cli/src/lib/transcript.test.ts @@ -1,160 +1,173 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest' -import { mkdtempSync, rmSync, writeFileSync } from 'fs' -import { join } from 'path' -import { tmpdir } from 'os' +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, rmSync, writeFileSync } from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; import { readTranscriptLines, extractUsageFromTranscript, detectSlashCommand, extractMessages, -} from './transcript.js' +} from "./transcript.js"; /** Write an array of objects as JSONL to a temp file and return the path. */ function writeJsonl(dir: string, lines: object[]): string { - const path = join(dir, 'transcript.jsonl') - writeFileSync(path, lines.map((l) => JSON.stringify(l)).join('\n'), 'utf8') - return path + // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal + const path = join(dir, "transcript.jsonl"); + writeFileSync(path, lines.map((l) => JSON.stringify(l)).join("\n"), "utf8"); + return path; } // --------------------------------------------------------------------------- // readTranscriptLines // --------------------------------------------------------------------------- -describe('readTranscriptLines', () => { - let tempDir: string +describe("readTranscriptLines", () => { + let tempDir: string; beforeEach(() => { - tempDir = mkdtempSync(join(tmpdir(), 'argos-rtl-')) - }) + tempDir = mkdtempSync(join(tmpdir(), "argos-rtl-")); + }); afterEach(() => { - rmSync(tempDir, { recursive: true, force: true }) - }) + rmSync(tempDir, { recursive: true, force: true }); + }); - it('파일이 없으면 빈 배열을 반환한다', async () => { - const result = await readTranscriptLines(join(tempDir, 'nonexistent.jsonl')) - expect(result).toEqual([]) - }) + it("파일이 없으면 빈 배열을 반환한다", async () => { + const result = await readTranscriptLines( + join(tempDir, "nonexistent.jsonl"), + ); + expect(result).toEqual([]); + }); - it('각 줄을 JSON.parse하여 반환한다', async () => { + it("각 줄을 JSON.parse하여 반환한다", async () => { const path = writeJsonl(tempDir, [ - { type: 'human', message: { content: [] } }, - { type: 'assistant', message: { usage: { input_tokens: 10 } } }, - ]) - - const lines = await readTranscriptLines(path) - expect(lines).toHaveLength(2) - expect(lines[0].type).toBe('human') - expect(lines[1].type).toBe('assistant') - }) - - it('파싱 실패한 줄은 {} 로 반환한다', async () => { - const path = join(tempDir, 'bad.jsonl') - writeFileSync(path, '{ invalid json\n{"type":"human"}', 'utf8') - - const lines = await readTranscriptLines(path) - expect(lines).toHaveLength(2) - expect(lines[0]).toEqual({}) - expect(lines[1].type).toBe('human') - }) - - it('빈 줄은 제거한다', async () => { - const path = join(tempDir, 'empty-lines.jsonl') - writeFileSync( - path, - '{"type":"human"}\n\n{"type":"assistant"}\n', - 'utf8' - ) - - const lines = await readTranscriptLines(path) - expect(lines).toHaveLength(2) - }) -}) + { type: "human", message: { content: [] } }, + { type: "assistant", message: { usage: { input_tokens: 10 } } }, + ]); + + const lines = await readTranscriptLines(path); + expect(lines).toHaveLength(2); + expect(lines[0].type).toBe("human"); + expect(lines[1].type).toBe("assistant"); + }); + + it("파싱 실패한 줄은 {} 로 반환한다", async () => { + const path = join(tempDir, "bad.jsonl"); + writeFileSync(path, '{ invalid json\n{"type":"human"}', "utf8"); + + const lines = await readTranscriptLines(path); + expect(lines).toHaveLength(2); + expect(lines[0]).toEqual({}); + expect(lines[1].type).toBe("human"); + }); + + it("빈 줄은 제거한다", async () => { + const path = join(tempDir, "empty-lines.jsonl"); + writeFileSync(path, '{"type":"human"}\n\n{"type":"assistant"}\n', "utf8"); + + const lines = await readTranscriptLines(path); + expect(lines).toHaveLength(2); + }); +}); // --------------------------------------------------------------------------- // extractUsageFromTranscript // --------------------------------------------------------------------------- -describe('extractUsageFromTranscript', () => { - let tempDir: string +describe("extractUsageFromTranscript", () => { + let tempDir: string; beforeEach(() => { - tempDir = mkdtempSync(join(tmpdir(), 'argos-usage-')) - }) + tempDir = mkdtempSync(join(tmpdir(), "argos-usage-")); + }); afterEach(() => { - rmSync(tempDir, { recursive: true, force: true }) - }) + rmSync(tempDir, { recursive: true, force: true }); + }); - it('assistant 라인 여러 개의 토큰을 합산한다', async () => { + it("assistant 라인 여러 개의 토큰을 합산한다", async () => { const path = writeJsonl(tempDir, [ { - type: 'assistant', + type: "assistant", message: { - model: 'claude-3-5-sonnet', + model: "claude-3-5-sonnet", usage: { input_tokens: 100, output_tokens: 50 }, }, }, { - type: 'assistant', + type: "assistant", message: { - model: 'claude-3-5-sonnet', + model: "claude-3-5-sonnet", usage: { input_tokens: 200, output_tokens: 80 }, }, }, - ]) + ]); - const result = await extractUsageFromTranscript(path) - expect(result).not.toBeNull() - expect(result!.inputTokens).toBe(300) - expect(result!.outputTokens).toBe(130) - }) + const result = await extractUsageFromTranscript(path); + expect(result).not.toBeNull(); + expect(result!.inputTokens).toBe(300); + expect(result!.outputTokens).toBe(130); + }); - it('assistant 라인이 없으면 null을 반환한다', async () => { + it("assistant 라인이 없으면 null을 반환한다", async () => { const path = writeJsonl(tempDir, [ - { type: 'human', message: { content: [{ type: 'text', text: 'hello' }] } }, - ]) + { + type: "human", + message: { content: [{ type: "text", text: "hello" }] }, + }, + ]); - const result = await extractUsageFromTranscript(path) - expect(result).toBeNull() - }) + const result = await extractUsageFromTranscript(path); + expect(result).toBeNull(); + }); - it('모든 토큰이 0이면 null을 반환한다', async () => { + it("모든 토큰이 0이면 null을 반환한다", async () => { const path = writeJsonl(tempDir, [ { - type: 'assistant', + type: "assistant", message: { - model: 'claude-3-5-sonnet', - usage: { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 }, + model: "claude-3-5-sonnet", + usage: { + input_tokens: 0, + output_tokens: 0, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, }, }, - ]) + ]); - const result = await extractUsageFromTranscript(path) - expect(result).toBeNull() - }) + const result = await extractUsageFromTranscript(path); + expect(result).toBeNull(); + }); - it('첫 번째 assistant 라인의 model을 사용한다', async () => { + it("첫 번째 assistant 라인의 model을 사용한다", async () => { const path = writeJsonl(tempDir, [ { - type: 'assistant', - message: { model: 'claude-3-opus', usage: { input_tokens: 10, output_tokens: 5 } }, + type: "assistant", + message: { + model: "claude-3-opus", + usage: { input_tokens: 10, output_tokens: 5 }, + }, }, { - type: 'assistant', - message: { model: 'claude-3-5-sonnet', usage: { input_tokens: 20, output_tokens: 10 } }, + type: "assistant", + message: { + model: "claude-3-5-sonnet", + usage: { input_tokens: 20, output_tokens: 10 }, + }, }, - ]) + ]); - const result = await extractUsageFromTranscript(path) - expect(result).not.toBeNull() - expect(result!.model).toBe('claude-3-opus') - }) + const result = await extractUsageFromTranscript(path); + expect(result).not.toBeNull(); + expect(result!.model).toBe("claude-3-opus"); + }); - it('cache 토큰(cache_creation_input_tokens, cache_read_input_tokens)을 올바르게 집계한다', async () => { + it("cache 토큰(cache_creation_input_tokens, cache_read_input_tokens)을 올바르게 집계한다", async () => { const path = writeJsonl(tempDir, [ { - type: 'assistant', + type: "assistant", message: { - model: 'claude-3-5-sonnet', + model: "claude-3-5-sonnet", usage: { input_tokens: 50, output_tokens: 20, @@ -164,9 +177,9 @@ describe('extractUsageFromTranscript', () => { }, }, { - type: 'assistant', + type: "assistant", message: { - model: 'claude-3-5-sonnet', + model: "claude-3-5-sonnet", usage: { input_tokens: 50, output_tokens: 20, @@ -175,265 +188,288 @@ describe('extractUsageFromTranscript', () => { }, }, }, - ]) + ]); - const result = await extractUsageFromTranscript(path) - expect(result).not.toBeNull() - expect(result!.cacheCreationTokens).toBe(500) - expect(result!.cacheReadTokens).toBe(500) - }) -}) + const result = await extractUsageFromTranscript(path); + expect(result).not.toBeNull(); + expect(result!.cacheCreationTokens).toBe(500); + expect(result!.cacheReadTokens).toBe(500); + }); +}); // --------------------------------------------------------------------------- // detectSlashCommand // --------------------------------------------------------------------------- -describe('detectSlashCommand', () => { - let tempDir: string +describe("detectSlashCommand", () => { + let tempDir: string; beforeEach(() => { - tempDir = mkdtempSync(join(tmpdir(), 'argos-slash-')) - }) + tempDir = mkdtempSync(join(tmpdir(), "argos-slash-")); + }); afterEach(() => { - rmSync(tempDir, { recursive: true, force: true }) - }) + rmSync(tempDir, { recursive: true, force: true }); + }); - it('queue-operation 라인이 /로 시작하면 / 없이 반환한다', async () => { + it("queue-operation 라인이 /로 시작하면 / 없이 반환한다", async () => { const path = writeJsonl(tempDir, [ - { type: 'queue-operation', content: '/review' }, - ]) + { type: "queue-operation", content: "/review" }, + ]); - const result = await detectSlashCommand(path) - expect(result).toBe('review') - }) + const result = await detectSlashCommand(path); + expect(result).toBe("review"); + }); - it('queue-operation 라인이 없으면 null을 반환한다', async () => { + it("queue-operation 라인이 없으면 null을 반환한다", async () => { const path = writeJsonl(tempDir, [ - { type: 'human', message: { content: [{ type: 'text', text: 'hi' }] } }, - ]) + { type: "human", message: { content: [{ type: "text", text: "hi" }] } }, + ]); - const result = await detectSlashCommand(path) - expect(result).toBeNull() - }) + const result = await detectSlashCommand(path); + expect(result).toBeNull(); + }); - it('/로 시작하지 않는 queue-operation은 무시한다', async () => { + it("/로 시작하지 않는 queue-operation은 무시한다", async () => { const path = writeJsonl(tempDir, [ - { type: 'queue-operation', content: 'some-tool' }, - ]) + { type: "queue-operation", content: "some-tool" }, + ]); - const result = await detectSlashCommand(path) - expect(result).toBeNull() - }) -}) + const result = await detectSlashCommand(path); + expect(result).toBeNull(); + }); +}); // --------------------------------------------------------------------------- // extractMessages // --------------------------------------------------------------------------- -describe('extractMessages', () => { - let tempDir: string +describe("extractMessages", () => { + let tempDir: string; beforeEach(() => { - tempDir = mkdtempSync(join(tmpdir(), 'argos-msg-')) - }) + tempDir = mkdtempSync(join(tmpdir(), "argos-msg-")); + }); afterEach(() => { - rmSync(tempDir, { recursive: true, force: true }) - }) + rmSync(tempDir, { recursive: true, force: true }); + }); it('type="user" 라인에서 string content를 추출한다', async () => { const path = writeJsonl(tempDir, [ { - type: 'user', - timestamp: '2024-01-01T00:00:00.000Z', - message: { content: 'Hello' }, + type: "user", + timestamp: "2024-01-01T00:00:00.000Z", + message: { content: "Hello" }, }, { - type: 'assistant', - timestamp: '2024-01-01T00:01:00.000Z', - message: { content: [{ type: 'text', text: 'Hi there' }] }, + type: "assistant", + timestamp: "2024-01-01T00:01:00.000Z", + message: { content: [{ type: "text", text: "Hi there" }] }, }, - ]) + ]); - const result = await extractMessages(path) - expect(result).toHaveLength(2) - expect(result[0].role).toBe('HUMAN') - expect(result[0].content).toBe('Hello') - expect(result[1].role).toBe('ASSISTANT') - expect(result[1].content).toBe('Hi there') - }) + const result = await extractMessages(path); + expect(result).toHaveLength(2); + expect(result[0].role).toBe("HUMAN"); + expect(result[0].content).toBe("Hello"); + expect(result[1].role).toBe("ASSISTANT"); + expect(result[1].content).toBe("Hi there"); + }); it('레거시 type="human"도 지원한다', async () => { const path = writeJsonl(tempDir, [ { - type: 'human', - timestamp: '2024-01-01T00:00:00.000Z', - message: { content: 'Legacy message' }, + type: "human", + timestamp: "2024-01-01T00:00:00.000Z", + message: { content: "Legacy message" }, }, - ]) + ]); - const result = await extractMessages(path) - expect(result).toHaveLength(1) - expect(result[0].role).toBe('HUMAN') - expect(result[0].content).toBe('Legacy message') - }) + const result = await extractMessages(path); + expect(result).toHaveLength(1); + expect(result[0].role).toBe("HUMAN"); + expect(result[0].content).toBe("Legacy message"); + }); - it('tool_result가 아직 본 적 없는 tool_use_id면 무시하고 user 라인은 HUMAN으로 변환되지 않는다', async () => { + it("tool_result가 아직 본 적 없는 tool_use_id면 무시하고 user 라인은 HUMAN으로 변환되지 않는다", async () => { const path = writeJsonl(tempDir, [ { - type: 'user', - timestamp: '2024-01-01T00:00:00.000Z', - message: { content: [{ type: 'tool_result', tool_use_id: 'x', content: 'output' }] }, + type: "user", + timestamp: "2024-01-01T00:00:00.000Z", + message: { + content: [ + { type: "tool_result", tool_use_id: "x", content: "output" }, + ], + }, }, { - type: 'assistant', - timestamp: '2024-01-01T00:01:00.000Z', - message: { content: [{ type: 'text', text: 'response' }] }, + type: "assistant", + timestamp: "2024-01-01T00:01:00.000Z", + message: { content: [{ type: "text", text: "response" }] }, }, - ]) + ]); - const result = await extractMessages(path) - expect(result).toHaveLength(1) - expect(result[0].role).toBe('ASSISTANT') - expect(result[0].content).toBe('response') - }) + const result = await extractMessages(path); + expect(result).toHaveLength(1); + expect(result[0].role).toBe("ASSISTANT"); + expect(result[0].content).toBe("response"); + }); - it('assistant의 tool_use 블록은 별도 TOOL row로 분리된다', async () => { + it("assistant의 tool_use 블록은 별도 TOOL row로 분리된다", async () => { const path = writeJsonl(tempDir, [ { - type: 'assistant', - timestamp: '2024-01-01T00:00:00.000Z', + type: "assistant", + timestamp: "2024-01-01T00:00:00.000Z", message: { content: [ - { type: 'text', text: 'Reading the file.' }, - { type: 'tool_use', id: 'tu_1', name: 'Read', input: { file_path: '/tmp/a.ts' } }, + { type: "text", text: "Reading the file." }, + { + type: "tool_use", + id: "tu_1", + name: "Read", + input: { file_path: "/tmp/a.ts" }, + }, ], }, }, - ]) - - const result = await extractMessages(path) - expect(result).toHaveLength(2) - expect(result[0].role).toBe('ASSISTANT') - expect(result[0].content).toBe('Reading the file.') - expect(result[1].role).toBe('TOOL') - expect(result[1].toolName).toBe('Read') - expect(result[1].toolInput).toEqual({ file_path: '/tmp/a.ts' }) - expect(result[1].toolUseId).toBe('tu_1') - }) - - it('tool_use만 있는 assistant 라인은 TOOL row만 남긴다', async () => { + ]); + + const result = await extractMessages(path); + expect(result).toHaveLength(2); + expect(result[0].role).toBe("ASSISTANT"); + expect(result[0].content).toBe("Reading the file."); + expect(result[1].role).toBe("TOOL"); + expect(result[1].toolName).toBe("Read"); + expect(result[1].toolInput).toEqual({ file_path: "/tmp/a.ts" }); + expect(result[1].toolUseId).toBe("tu_1"); + }); + + it("tool_use만 있는 assistant 라인은 TOOL row만 남긴다", async () => { const path = writeJsonl(tempDir, [ { - type: 'assistant', - timestamp: '2024-01-01T00:00:00.000Z', + type: "assistant", + timestamp: "2024-01-01T00:00:00.000Z", message: { content: [ - { type: 'tool_use', id: 'tu_1', name: 'Bash', input: { command: 'npm test' } }, + { + type: "tool_use", + id: "tu_1", + name: "Bash", + input: { command: "npm test" }, + }, ], }, }, - ]) + ]); - const result = await extractMessages(path) - expect(result).toHaveLength(1) - expect(result[0].role).toBe('TOOL') - expect(result[0].toolName).toBe('Bash') - expect(result[0].toolInput).toEqual({ command: 'npm test' }) - }) + const result = await extractMessages(path); + expect(result).toHaveLength(1); + expect(result[0].role).toBe("TOOL"); + expect(result[0].toolName).toBe("Bash"); + expect(result[0].toolInput).toEqual({ command: "npm test" }); + }); - it('tool_result가 매칭되는 TOOL row의 content/durationMs를 채운다', async () => { + it("tool_result가 매칭되는 TOOL row의 content/durationMs를 채운다", async () => { const path = writeJsonl(tempDir, [ { - type: 'assistant', - timestamp: '2024-01-01T00:00:00.000Z', + type: "assistant", + timestamp: "2024-01-01T00:00:00.000Z", message: { - content: [{ type: 'tool_use', id: 'tu_1', name: 'Bash', input: { command: 'ls' } }], + content: [ + { + type: "tool_use", + id: "tu_1", + name: "Bash", + input: { command: "ls" }, + }, + ], }, }, { - type: 'user', - timestamp: '2024-01-01T00:00:01.500Z', + type: "user", + timestamp: "2024-01-01T00:00:01.500Z", message: { - content: [{ type: 'tool_result', tool_use_id: 'tu_1', content: 'output' }], + content: [ + { type: "tool_result", tool_use_id: "tu_1", content: "output" }, + ], }, }, - ]) + ]); - const result = await extractMessages(path) - const tool = result.find((m) => m.role === 'TOOL')! - expect(tool.content).toBe('output') - expect(tool.durationMs).toBe(1500) - }) + const result = await extractMessages(path); + const tool = result.find((m) => m.role === "TOOL")!; + expect(tool.content).toBe("output"); + expect(tool.durationMs).toBe(1500); + }); - it('text/tool_use 외의 블록(thinking 등)만 있으면 건너뛴다', async () => { + it("text/tool_use 외의 블록(thinking 등)만 있으면 건너뛴다", async () => { const path = writeJsonl(tempDir, [ { - type: 'assistant', - timestamp: '2024-01-01T00:00:00.000Z', - message: { content: [{ type: 'thinking', thinking: 'hmm' }] }, + type: "assistant", + timestamp: "2024-01-01T00:00:00.000Z", + message: { content: [{ type: "thinking", thinking: "hmm" }] }, }, - ]) + ]); - const result = await extractMessages(path) - expect(result).toHaveLength(0) - }) + const result = await extractMessages(path); + expect(result).toHaveLength(0); + }); - it('50000자를 초과하는 user 텍스트는 잘린다', async () => { - const longText = 'a'.repeat(60000) + it("50000자를 초과하는 user 텍스트는 잘린다", async () => { + const longText = "a".repeat(60000); const path = writeJsonl(tempDir, [ { - type: 'user', - timestamp: '2024-01-01T00:00:00.000Z', + type: "user", + timestamp: "2024-01-01T00:00:00.000Z", message: { content: longText }, }, - ]) + ]); - const result = await extractMessages(path) - expect(result).toHaveLength(1) - expect(result[0].content).toHaveLength(50000) - }) + const result = await extractMessages(path); + expect(result).toHaveLength(1); + expect(result[0].content).toHaveLength(50000); + }); - it('sequence가 0부터 순서대로 증가한다', async () => { + it("sequence가 0부터 순서대로 증가한다", async () => { const path = writeJsonl(tempDir, [ { - type: 'user', - timestamp: '2024-01-01T00:00:00.000Z', - message: { content: 'msg1' }, + type: "user", + timestamp: "2024-01-01T00:00:00.000Z", + message: { content: "msg1" }, }, { - type: 'assistant', - timestamp: '2024-01-01T00:01:00.000Z', - message: { content: [{ type: 'text', text: 'msg2' }] }, + type: "assistant", + timestamp: "2024-01-01T00:01:00.000Z", + message: { content: [{ type: "text", text: "msg2" }] }, }, { - type: 'user', - timestamp: '2024-01-01T00:02:00.000Z', - message: { content: 'msg3' }, + type: "user", + timestamp: "2024-01-01T00:02:00.000Z", + message: { content: "msg3" }, }, - ]) + ]); - const result = await extractMessages(path) - expect(result[0].sequence).toBe(0) - expect(result[1].sequence).toBe(1) - expect(result[2].sequence).toBe(2) - }) + const result = await extractMessages(path); + expect(result[0].sequence).toBe(0); + expect(result[1].sequence).toBe(1); + expect(result[2].sequence).toBe(2); + }); - it('role이 올바르게 HUMAN/ASSISTANT로 매핑된다', async () => { + it("role이 올바르게 HUMAN/ASSISTANT로 매핑된다", async () => { const path = writeJsonl(tempDir, [ { - type: 'user', - timestamp: '2024-01-01T00:00:00.000Z', - message: { content: 'user message' }, + type: "user", + timestamp: "2024-01-01T00:00:00.000Z", + message: { content: "user message" }, }, { - type: 'assistant', - timestamp: '2024-01-01T00:01:00.000Z', - message: { content: [{ type: 'text', text: 'assistant message' }] }, + type: "assistant", + timestamp: "2024-01-01T00:01:00.000Z", + message: { content: [{ type: "text", text: "assistant message" }] }, }, - ]) + ]); - const result = await extractMessages(path) - expect(result[0].role).toBe('HUMAN') - expect(result[1].role).toBe('ASSISTANT') - }) -}) + const result = await extractMessages(path); + expect(result[0].role).toBe("HUMAN"); + expect(result[1].role).toBe("ASSISTANT"); + }); +}); diff --git a/packages/cli/src/lib/transcript.ts b/packages/cli/src/lib/transcript.ts index b62f86e8..4bd551b2 100644 --- a/packages/cli/src/lib/transcript.ts +++ b/packages/cli/src/lib/transcript.ts @@ -1,56 +1,62 @@ -import { readFileSync, existsSync } from 'fs' -import type { UsagePayload, UsagePerTurnPayload, MessagePayload } from '@argos/shared' +import { readFileSync, existsSync } from "fs"; +import type { + UsagePayload, + UsagePerTurnPayload, + MessagePayload, +} from "@argos/shared"; interface ContentBlock { - type?: string - text?: string - name?: string - input?: Record - id?: string // tool_use.id - tool_use_id?: string // tool_result.tool_use_id - content?: string | Array<{ type?: string; text?: string }> // tool_result content + type?: string; + text?: string; + name?: string; + input?: Record; + id?: string; // tool_use.id + tool_use_id?: string; // tool_result.tool_use_id + content?: string | Array<{ type?: string; text?: string }>; // tool_result content } interface TranscriptLine { - type?: string + type?: string; message?: { usage?: { - input_tokens?: number - output_tokens?: number - cache_creation_input_tokens?: number - cache_read_input_tokens?: number - } - model?: string - content?: string | ContentBlock[] - } - content?: string - timestamp?: string + input_tokens?: number; + output_tokens?: number; + cache_creation_input_tokens?: number; + cache_read_input_tokens?: number; + }; + model?: string; + content?: string | ContentBlock[]; + }; + content?: string; + timestamp?: string; // type === 'summary' entries (Claude Code writes these on /compact or session resume) - summary?: string - leafUuid?: string + summary?: string; + leafUuid?: string; } /** * Read transcript.jsonl file and parse each line */ -export async function readTranscriptLines(path: string): Promise { +export async function readTranscriptLines( + path: string, +): Promise { if (!existsSync(path)) { - return [] + return []; } try { - const content = readFileSync(path, 'utf8') - const lines = content.split('\n').filter((line) => line.trim()) + const content = readFileSync(path, "utf8"); + const lines = content.split("\n").filter((line) => line.trim()); return lines.map((line) => { try { - return JSON.parse(line) as TranscriptLine + return JSON.parse(line) as TranscriptLine; } catch { - return {} + return {}; } - }) + }); } catch { - return [] + return []; } } @@ -59,27 +65,27 @@ export async function readTranscriptLines(path: string): Promise { - const lines = await readTranscriptLines(transcriptPath) + const lines = await readTranscriptLines(transcriptPath); - let totalInputTokens = 0 - let totalOutputTokens = 0 - let totalCacheCreationTokens = 0 - let totalCacheReadTokens = 0 - let model: string | undefined + let totalInputTokens = 0; + let totalOutputTokens = 0; + let totalCacheCreationTokens = 0; + let totalCacheReadTokens = 0; + let model: string | undefined; for (const line of lines) { - if (line.type === 'assistant' && line.message?.usage) { - const usage = line.message.usage - totalInputTokens += usage.input_tokens || 0 - totalOutputTokens += usage.output_tokens || 0 - totalCacheCreationTokens += usage.cache_creation_input_tokens || 0 - totalCacheReadTokens += usage.cache_read_input_tokens || 0 + if (line.type === "assistant" && line.message?.usage) { + const usage = line.message.usage; + totalInputTokens += usage.input_tokens || 0; + totalOutputTokens += usage.output_tokens || 0; + totalCacheCreationTokens += usage.cache_creation_input_tokens || 0; + totalCacheReadTokens += usage.cache_read_input_tokens || 0; // Get model from first assistant message if (!model && line.message.model) { - model = line.message.model + model = line.message.model; } } } @@ -90,7 +96,7 @@ export async function extractUsageFromTranscript( totalCacheCreationTokens === 0 && totalCacheReadTokens === 0 ) { - return null + return null; } return { @@ -99,7 +105,7 @@ export async function extractUsageFromTranscript( cacheCreationTokens: totalCacheCreationTokens, cacheReadTokens: totalCacheReadTokens, model, - } + }; } /** @@ -108,14 +114,14 @@ export async function extractUsageFromTranscript( * Each entry's timestamp comes from the transcript line's timestamp field. */ export async function extractUsagePerTurn( - transcriptPath: string + transcriptPath: string, ): Promise { - const lines = await readTranscriptLines(transcriptPath) - const results: UsagePerTurnPayload[] = [] + const lines = await readTranscriptLines(transcriptPath); + const results: UsagePerTurnPayload[] = []; for (const line of lines) { - if (line.type === 'assistant' && line.message?.usage) { - const usage = line.message.usage + if (line.type === "assistant" && line.message?.usage) { + const usage = line.message.usage; results.push({ inputTokens: usage.input_tokens || 0, outputTokens: usage.output_tokens || 0, @@ -123,11 +129,11 @@ export async function extractUsagePerTurn( cacheReadTokens: usage.cache_read_input_tokens || 0, model: line.message.model, timestamp: line.timestamp || new Date().toISOString(), - }) + }); } } - return results + return results; } /** @@ -136,15 +142,21 @@ export async function extractUsagePerTurn( * resume. When multiple summary lines exist, the last one wins (most recent compaction). * Returns null when no summary line is present (typical for short, non-compacted sessions). */ -export async function extractSummary(transcriptPath: string): Promise { - const lines = await readTranscriptLines(transcriptPath) - let last: string | null = null +export async function extractSummary( + transcriptPath: string, +): Promise { + const lines = await readTranscriptLines(transcriptPath); + let last: string | null = null; for (const line of lines) { - if (line.type === 'summary' && typeof line.summary === 'string' && line.summary.length > 0) { - last = line.summary + if ( + line.type === "summary" && + typeof line.summary === "string" && + line.summary.length > 0 + ) { + last = line.summary; } } - return last + return last; } /** @@ -152,34 +164,36 @@ export async function extractSummary(transcriptPath: string): Promise { - const lines = await readTranscriptLines(transcriptPath) +export async function detectSlashCommand( + transcriptPath: string, +): Promise { + const lines = await readTranscriptLines(transcriptPath); const queueOp = lines.find( (l) => - l.type === 'queue-operation' && - typeof l.content === 'string' && - l.content.startsWith('/') - ) + l.type === "queue-operation" && + typeof l.content === "string" && + l.content.startsWith("/"), + ); - if (!queueOp || typeof queueOp.content !== 'string') { - return null + if (!queueOp || typeof queueOp.content !== "string") { + return null; } // Remove leading '/' and return skill name - return queueOp.content.slice(1) + return queueOp.content.slice(1); } /** * Flatten tool_result.content — either string or array of {type:'text', text}. */ -function toolResultText(raw: ContentBlock['content']): string { - if (typeof raw === 'string') return raw - if (!Array.isArray(raw)) return '' +function toolResultText(raw: ContentBlock["content"]): string { + if (typeof raw === "string") return raw; + if (!Array.isArray(raw)) return ""; return raw - .map((b) => (b.type === 'text' && b.text ? b.text : '')) + .map((b) => (b.type === "text" && b.text ? b.text : "")) .filter(Boolean) - .join('\n') + .join("\n"); } /** @@ -193,86 +207,88 @@ function toolResultText(raw: ContentBlock['content']): string { * sequence is assigned in transcript order and is best-effort — the API may re-sequence TOOL rows * that were inserted in realtime via PreToolUse/PostToolUse hooks. */ -export async function extractMessages(transcriptPath: string): Promise { - const lines = await readTranscriptLines(transcriptPath) - const messages: MessagePayload[] = [] +export async function extractMessages( + transcriptPath: string, +): Promise { + const lines = await readTranscriptLines(transcriptPath); + const messages: MessagePayload[] = []; // TOOL lookup by tool_use_id — so tool_result can fill in content/duration - const toolById = new Map() - let sequence = 0 + const toolById = new Map(); + let sequence = 0; for (const line of lines) { - const isUser = line.type === 'user' || line.type === 'human' - const isAssistant = line.type === 'assistant' - if (!isUser && !isAssistant) continue + const isUser = line.type === "user" || line.type === "human"; + const isAssistant = line.type === "assistant"; + if (!isUser && !isAssistant) continue; - const content = line.message?.content - const timestamp = line.timestamp || new Date().toISOString() + const content = line.message?.content; + const timestamp = line.timestamp || new Date().toISOString(); if (isUser) { // Plain string → HUMAN message - if (typeof content === 'string' && content.length > 0) { + if (typeof content === "string" && content.length > 0) { messages.push({ - role: 'HUMAN', + role: "HUMAN", content: content.slice(0, 50000), sequence: sequence++, timestamp, - }) - continue + }); + continue; } // Array → look for tool_result blocks and backfill matching TOOL messages if (Array.isArray(content)) { for (const block of content) { - if (block.type !== 'tool_result' || !block.tool_use_id) continue - const tool = toolById.get(block.tool_use_id) - if (!tool) continue - tool.content = toolResultText(block.content).slice(0, 50000) - const startMs = Date.parse(tool.timestamp) - const endMs = Date.parse(timestamp) + if (block.type !== "tool_result" || !block.tool_use_id) continue; + const tool = toolById.get(block.tool_use_id); + if (!tool) continue; + tool.content = toolResultText(block.content).slice(0, 50000); + const startMs = Date.parse(tool.timestamp); + const endMs = Date.parse(timestamp); if (!Number.isNaN(startMs) && !Number.isNaN(endMs)) { - tool.durationMs = Math.max(0, endMs - startMs) + tool.durationMs = Math.max(0, endMs - startMs); } } } - continue + continue; } // Assistant - if (!Array.isArray(content)) continue + if (!Array.isArray(content)) continue; - const textParts: string[] = [] - const toolRows: MessagePayload[] = [] + const textParts: string[] = []; + const toolRows: MessagePayload[] = []; for (const block of content) { - if (block.type === 'text' && block.text) { - textParts.push(block.text) - } else if (block.type === 'tool_use' && block.name) { + if (block.type === "text" && block.text) { + textParts.push(block.text); + } else if (block.type === "tool_use" && block.name) { toolRows.push({ - role: 'TOOL', - content: '', + role: "TOOL", + content: "", sequence: 0, // assigned below timestamp, toolName: block.name, toolInput: block.input || {}, toolUseId: block.id, - }) + }); } } if (textParts.length > 0) { messages.push({ - role: 'ASSISTANT', - content: textParts.join('\n').slice(0, 50000), + role: "ASSISTANT", + content: textParts.join("\n").slice(0, 50000), sequence: sequence++, timestamp, - }) + }); } for (const tool of toolRows) { - tool.sequence = sequence++ - messages.push(tool) - if (tool.toolUseId) toolById.set(tool.toolUseId, tool) + tool.sequence = sequence++; + messages.push(tool); + if (tool.toolUseId) toolById.set(tool.toolUseId, tool); } } - return messages + return messages; }