diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 067b8d4..fafc7ec 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "timelog", - "version": "0.2.0", + "version": "0.3.0", "description": "Automatic time tracking for Claude Code sessions. Logs project, ticket, and prompt data as JSONL for timesheet reconstruction.", "license": "Apache-2.0", "keywords": [ diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..3c544fe --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,42 @@ +--- +name: Bug Report +about: Report a bug in claude-code-timelog +labels: bug +--- + +## Describe the Bug + +A clear description of what the bug is. + +## Steps to Reproduce + +1. Step one +2. Step two +3. Step three + +## Expected Behaviour + +What you expected to happen. + +## Environment + +- OS: [e.g., macOS 14.2, Ubuntu 22.04] +- Node.js version: [e.g., 18.19.0, 22.0.0] +- Claude Code version: [e.g., 1.0.33] +- Plugin version: [e.g., 0.3.0] + +## Logs + +If applicable, include: + +- Report output or error messages +- Contents of `~/.claude/timelog/` (redact + any sensitive prompt text) + +``` +Paste logs here +``` + +## Additional Context + +Any other context about the problem. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..49cbb26 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,24 @@ +--- +name: Feature Request +about: Suggest a new feature for claude-code-timelog +labels: enhancement +--- + +## Problem Description + +Describe the problem this feature would solve. +What are you trying to accomplish? + +## Proposed Solution + +Describe your proposed solution. How would +this feature work? + +## Alternatives Considered + +What alternative solutions or workarounds +have you considered? + +## Additional Context + +Any other context, screenshots, or examples. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..7d4f559 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,16 @@ +## Description + +Describe the changes in this pull request. + +## Checklist + +- [ ] Description of changes provided above +- [ ] Tests added or updated for new behaviour +- [ ] `npm test` passes +- [ ] `npm run lint` passes +- [ ] `CHANGELOG.md` updated under `[Unreleased]` +- [ ] Documentation updated if needed (README) + +## Related Issues + +Fixes #(issue number) diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..a6238ab --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,59 @@ +# Changelog + +All notable changes to claude-code-timelog are +documented here. Format follows +[Keep a Changelog][kac]. + +[kac]: https://keepachangelog.com/en/1.1.0/ + +## [Unreleased] + +### Added + +- Default report: `claudelog` with no arguments + runs a weekly report instead of printing usage. + Configurable via `defaultReport` in config.json. +- `CONTRIBUTING.md` with setup, code style, + testing, and submission guidelines. +- `SECURITY.md` with vulnerability reporting + instructions. +- Issue templates for bug reports and feature + requests. +- Pull request template with checklist. +- `CHANGELOG.md` (this file). + +## [0.2.0] — 2026-02-12 + +### Added + +- `claudelog` CLI command for running reports + and backfills from any terminal without an + active Claude Code session. +- `bin` field in package.json. + +## [0.1.0] — 2026-02-11 + +Initial release. + +### Added + +- Automatic time tracking via Claude Code hooks + (SessionStart, UserPromptSubmit, Stop). +- JSONL log files (one per day) with session, + project, ticket, and prompt data. +- Report generation with multiple views: + default (day x project x ticket), timesheet, + by-project, by-ticket, by-model, by-day. +- Date range filters: `--week`, `--month`, + `--from`/`--to`. +- Project and ticket filters. +- JSON output mode (`--json`). +- Backfill from existing Claude Code session + transcripts. +- Configurable project detection via + `projectPattern` regex. +- Configurable ticket patterns with + Jira/Linear/GitHub support. +- Break detection with configurable threshold. +- Event-level aggregation for accurate + mid-session project/ticket switching. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..4ccba70 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,105 @@ +# Contributing to claude-code-timelog + +## Prerequisites + +- Node.js 18 or later +- Claude Code (for testing hooks) + +## Setup + +```bash +git clone https://github.com/RemoteCTO/claude-code-timelog.git +cd claude-code-timelog +npm install +npm test +npm run lint +``` + +## Project structure + +``` +hooks/ Claude Code hook handlers +scripts/ CLI scripts (report, backfill) +lib/ Shared library code +bin/ CLI entry point (claudelog) +commands/ Plugin command definitions +test/ Tests (mirrors source layout) +``` + +**Hooks** run inside Claude Code sessions. +**Scripts** run standalone from the terminal. +**lib/** is shared between both. + +## Code style + +Code style is enforced by ESLint and +editorconfig: + +- 80-character line limit +- ESM modules (`.mjs` extension) +- `prefer-const`, `no-var` +- 2-space indentation +- LF line endings + +Run `npm run lint` before submitting changes. + +## Testing + +Tests use Node's native test runner +(`node --test`). + +- Write tests first (TDD) +- Test behaviour, not implementation +- Use real objects, not mocks +- Keep tests focused and independent + +Run tests with `npm test`. Run a single file: + +```bash +node --test test/lib/config.test.mjs +``` + +Use `CLAUDE_TIMELOG_DIR` to point at a temp +directory during development to avoid polluting +your real timelog data. + +## Pull request process + +1. Fork the repository +2. Create a feature branch from `main` +3. Make your changes +4. Write or update tests +5. Update `CHANGELOG.md` under an `[Unreleased]` + heading (see [Keep a Changelog][kac]) +6. Run `npm test` and `npm run lint` +7. Submit a pull request + +[kac]: https://keepachangelog.com/en/1.1.0/ + +Keep PRs focused on a single change. Include +clear descriptions of what changed and why. + +## Commit messages + +Follow conventional commit format where +appropriate: + +- `feat:` new features +- `fix:` bug fixes +- `docs:` documentation changes +- `test:` test additions or changes +- `refactor:` code changes without behaviour + changes + +Keep commit messages concise and descriptive. + +## Questions + +Open an issue for questions or clarifications +before starting significant changes. + +## Licence + +By contributing, you agree that your +contributions will be licensed under the +Apache 2.0 licence. diff --git a/README.md b/README.md index 7cc07d4..88120eb 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ external services. ## Quick start -### From marketplace (recommended) +### From [marketplace][mkt] (recommended) ``` /plugin marketplace add RemoteCTO/claude-plugins-marketplace @@ -45,6 +45,8 @@ git clone https://github.com/RemoteCTO/claude-code-timelog.git claude --plugin-dir ./claude-code-timelog ``` +[mkt]: https://github.com/RemoteCTO/claude-plugins-marketplace + That's it. The plugin starts logging immediately with sensible defaults: @@ -98,12 +100,24 @@ and backfills from any terminal — no active Claude Code session needed. ```bash -claudelog report --week --by-project -claudelog report --month --timesheet --json -claudelog backfill -claudelog --help +claudelog # default report (--week) +claudelog report --month --timesheet # explicit flags +claudelog backfill # import history +claudelog --help # show usage ``` +Running `claudelog` with no arguments runs +a default report. Configure the default via +`defaultReport` in `config.json`: + +```json +{ + "defaultReport": ["--month", "--timesheet"] +} +``` + +Falls back to `["--week"]` if not set. + ### Adding to PATH The plugin installs to a versioned cache @@ -204,7 +218,8 @@ settings are optional. ], "projectSource": "git-root", "projectPattern": null, - "breakThreshold": 1800 + "breakThreshold": 1800, + "defaultReport": ["--week"] } ``` diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..9e6e29a --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,34 @@ +# Security Policy + +## Reporting a Vulnerability + +If you discover a security vulnerability in +claude-code-timelog, please report it via +GitHub Security Advisories: + +https://github.com/RemoteCTO/claude-code-timelog/security/advisories/new + +Do **not** open a public issue for security +vulnerabilities. + +Alternatively, email security reports to: +edward@bannermedia.ltd + +## What to Include + +- Description of the vulnerability +- Steps to reproduce +- Potential impact +- Suggested fix (if you have one) + +## Response Time + +We aim to respond to security reports within +48 hours and provide a fix or mitigation plan +within one week for critical vulnerabilities. + +## Disclosure Policy + +Please allow us reasonable time to address the +vulnerability before public disclosure. We will +coordinate disclosure timing with you. diff --git a/bin/claudelog b/bin/claudelog index 2d58504..d6a8dd1 100755 --- a/bin/claudelog +++ b/bin/claudelog @@ -1,53 +1,90 @@ #!/usr/bin/env node import { execFileSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; +import { homedir } from 'node:os'; const ROOT = dirname( dirname(fileURLToPath(import.meta.url)) ); -const COMMANDS = new Set(['report', 'backfill']); +const COMMANDS = new Set([ + 'report', 'backfill', +]); -const USAGE = `Usage: claudelog [options] +const DEFAULT_ARGS = ['--week']; + +const USAGE = `Usage: claudelog [command] [options] Commands: report Generate time reports backfill Import historical transcripts +With no arguments, runs a default report +(configurable via defaultReport in +config.json). + Run claudelog --help for details. Examples: + claudelog claudelog report --week --by-project claudelog report --month --timesheet claudelog backfill`; +function loadDefaultReport() { + const dir = + process.env.CLAUDE_TIMELOG_DIR || + join(homedir(), '.claude', 'timelog'); + try { + const raw = readFileSync( + join(dir, 'config.json'), 'utf8' + ); + const cfg = JSON.parse(raw); + if (Array.isArray(cfg.defaultReport)) { + return cfg.defaultReport; + } + } catch { + // No config or invalid JSON — use default + } + return DEFAULT_ARGS; +} + +function dispatch(script, args) { + try { + execFileSync( + process.execPath, + [script, ...args], + { stdio: 'inherit' } + ); + } catch (err) { + process.exit(err.status ?? 1); + } +} + const cmd = process.argv[2]; -if (!cmd || cmd === '--help' || cmd === 'help') { - const out = cmd ? process.stdout : process.stderr; - out.write(USAGE + '\n'); - process.exit(cmd ? 0 : 1); +if (cmd === '--help' || cmd === 'help') { + process.stdout.write(USAGE + '\n'); + process.exit(0); } -if (!COMMANDS.has(cmd)) { +if (!cmd) { + const args = loadDefaultReport(); + dispatch( + join(ROOT, 'scripts', 'report.mjs'), + args + ); +} else if (COMMANDS.has(cmd)) { + dispatch( + join(ROOT, 'scripts', `${cmd}.mjs`), + process.argv.slice(3) + ); +} else { process.stderr.write( `Unknown command: ${cmd}\n\n${USAGE}\n` ); process.exit(1); } - -const script = join( - ROOT, 'scripts', `${cmd}.mjs` -); - -try { - execFileSync( - process.execPath, - [script, ...process.argv.slice(3)], - { stdio: 'inherit' } - ); -} catch (err) { - process.exit(err.status ?? 1); -} diff --git a/config.example.json b/config.example.json index 82538e7..3301d5c 100644 --- a/config.example.json +++ b/config.example.json @@ -4,5 +4,6 @@ "#(\\d+)" ], "projectSource": "git-root", - "projectPattern": "projects/(?:active/)?([^/]+)" + "projectPattern": "projects/(?:active/)?([^/]+)", + "defaultReport": ["--week"] } diff --git a/lib/config.mjs b/lib/config.mjs index 384066e..9893c83 100644 --- a/lib/config.mjs +++ b/lib/config.mjs @@ -75,6 +75,24 @@ function validateConfig(cfg) { DEFAULT_CONFIG.projectSource; } + // defaultReport: array of strings + if ('defaultReport' in result) { + if ( + !Array.isArray(result.defaultReport) + ) { + console.error( + 'timelog: defaultReport must be ' + + 'an array. Ignoring.' + ); + delete result.defaultReport; + } else { + result.defaultReport = + result.defaultReport.filter( + (a) => typeof a === 'string' + ); + } + } + // projectPattern: reject ReDoS patterns if ( result.projectPattern && diff --git a/package-lock.json b/package-lock.json index 4da0e5d..8f057d0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,13 +1,16 @@ { "name": "claude-code-timelog", - "version": "0.2.0", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "claude-code-timelog", - "version": "0.2.0", + "version": "0.3.0", "license": "Apache-2.0", + "bin": { + "claudelog": "bin/claudelog" + }, "devDependencies": { "eslint": "^9" }, diff --git a/package.json b/package.json index 6979e07..3c64379 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "claude-code-timelog", - "version": "0.2.0", + "version": "0.3.0", "type": "module", "bin": { "claudelog": "bin/claudelog" diff --git a/test/bin/claudelog.test.mjs b/test/bin/claudelog.test.mjs index 82ad325..4839eee 100644 --- a/test/bin/claudelog.test.mjs +++ b/test/bin/claudelog.test.mjs @@ -1,6 +1,11 @@ -import { describe, it } from 'node:test'; +import { + describe, it, before, after, +} from 'node:test'; import assert from 'node:assert/strict'; import { spawnSync } from 'node:child_process'; +import { + mkdirSync, writeFileSync, rmSync, +} from 'node:fs'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -9,70 +14,171 @@ const BIN = join( '..', '..', '..', 'bin', 'claudelog' ); -const ENV = { - ...process.env, - CLAUDE_TIMELOG_DIR: '/tmp/claudelog-test', -}; +const TEST_DIR = join( + '/tmp', 'claudelog-test-' + process.pid +); -function run(...args) { - return spawnSync('node', [BIN, ...args], { - encoding: 'utf8', - env: ENV, - timeout: 10000, - }); +function makeEnv(dir) { + return { + ...process.env, + CLAUDE_TIMELOG_DIR: dir || TEST_DIR, + }; +} + +function run(args, env) { + return spawnSync( + 'node', [BIN, ...args], + { + encoding: 'utf8', + env: env || makeEnv(), + timeout: 10000, + } + ); } describe('bin/claudelog', () => { - it('prints usage with no arguments', () => { - const r = run(); - assert.equal(r.status, 1); - assert.match(r.stderr, /usage:/i); - assert.match(r.stderr, /claudelog/); - assert.match(r.stderr, /report/); - assert.match(r.stderr, /backfill/); + before(() => { + mkdirSync(TEST_DIR, { recursive: true }); }); - it('prints usage with --help', () => { - const r = run('--help'); - assert.equal(r.status, 0); - assert.match(r.stdout, /usage:/i); - assert.match(r.stdout, /report/); - assert.match(r.stdout, /backfill/); + after(() => { + rmSync(TEST_DIR, { + recursive: true, force: true, + }); }); - it('prints usage with help subcommand', () => { - const r = run('help'); - assert.equal(r.status, 0); - assert.match(r.stdout, /usage:/i); - }); + describe('help', () => { + it('prints usage with --help', () => { + const r = run(['--help']); + assert.equal(r.status, 0); + assert.match(r.stdout, /usage:/i); + assert.match(r.stdout, /report/); + assert.match(r.stdout, /backfill/); + }); - it('rejects unknown subcommands', () => { - const r = run('nonsense'); - assert.equal(r.status, 1); - assert.match(r.stderr, /unknown command/i); - }); + it('prints usage with help subcommand', () => { + const r = run(['help']); + assert.equal(r.status, 0); + assert.match(r.stdout, /usage:/i); + }); - it('runs report --help without error', () => { - const r = run('report', '--help'); - assert.equal(r.status, 0); - assert.match(r.stdout, /usage:/i); + it('mentions default report in usage', () => { + const r = run(['--help']); + assert.match( + r.stdout, /no arguments/i + ); + }); }); - it('forwards exit code from subcommand', () => { - // Far-future range guarantees no data - const r = run( - 'report', - '--from', '2099-01-01', - '--to', '2099-01-07' - ); - assert.notEqual(r.status, 0); - assert.match( - r.stderr, /no timelog data/i - ); + describe('default report (no arguments)', () => { + it('runs report instead of usage', () => { + // With no data, report exits 1 with + // "no timelog data" — NOT usage text + const dir = join(TEST_DIR, 'empty'); + mkdirSync(dir, { recursive: true }); + const r = run([], makeEnv(dir)); + assert.match( + r.stderr, /no timelog data/i + ); + assert.doesNotMatch( + r.stderr, /usage:/i + ); + }); + + it('uses --week by default', () => { + // report.mjs mentions the date range + // in its "no data" message + const dir = join(TEST_DIR, 'week'); + mkdirSync(dir, { recursive: true }); + const r = run([], makeEnv(dir)); + // Default --week shows date range + assert.match( + r.stderr, /no timelog data/i + ); + }); + + it('uses defaultReport from config', () => { + const dir = join(TEST_DIR, 'custom'); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'config.json'), + JSON.stringify({ + defaultReport: [ + '--from', '2099-01-01', + '--to', '2099-01-07', + ], + }) + ); + const r = run([], makeEnv(dir)); + // The custom date range appears in + // the "no data" error message + assert.match( + r.stderr, + /2099/ + ); + }); + + it('ignores invalid defaultReport', () => { + const dir = join( + TEST_DIR, 'bad-config' + ); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'config.json'), + JSON.stringify({ + defaultReport: 'not-an-array', + }) + ); + // Falls back to --week, still runs + const r = run([], makeEnv(dir)); + assert.match( + r.stderr, /no timelog data/i + ); + assert.doesNotMatch( + r.stderr, /usage:/i + ); + }); }); - it('runs backfill without error', () => { - const r = run('backfill'); - assert.equal(r.status, 0); + describe('explicit subcommands', () => { + it('rejects unknown subcommands', () => { + const r = run(['nonsense']); + assert.equal(r.status, 1); + assert.match( + r.stderr, /unknown command/i + ); + }); + + it('runs report --help', () => { + const r = run(['report', '--help']); + assert.equal(r.status, 0); + assert.match(r.stdout, /usage:/i); + }); + + it('forwards flags to report', () => { + const r = run([ + 'report', + '--from', '2099-01-01', + '--to', '2099-01-07', + ]); + assert.notEqual(r.status, 0); + assert.match( + r.stderr, /no timelog data/i + ); + }); + + it('forwards exit code', () => { + const r = run([ + 'report', + '--from', '2099-01-01', + '--to', '2099-01-07', + ]); + assert.notEqual(r.status, 0); + }); + + it('runs backfill without error', () => { + const r = run(['backfill']); + assert.equal(r.status, 0); + }); }); }); diff --git a/test/lib/config.test.mjs b/test/lib/config.test.mjs index b6c0b50..827558e 100644 --- a/test/lib/config.test.mjs +++ b/test/lib/config.test.mjs @@ -557,6 +557,44 @@ describe('lib/config', () => { }); }); + describe('defaultReport', () => { + it('accepts valid array', () => { + const cfg = validateConfig({ + ...DEFAULT_CONFIG, + defaultReport: [ + '--week', '--timesheet', + ], + }); + assert.deepEqual( + cfg.defaultReport, + ['--week', '--timesheet'] + ); + }); + + it('rejects non-array', () => { + const cfg = validateConfig({ + ...DEFAULT_CONFIG, + defaultReport: '--week', + }); + assert.strictEqual( + cfg.defaultReport, undefined + ); + }); + + it('filters non-string entries', + () => { + const cfg = validateConfig({ + ...DEFAULT_CONFIG, + defaultReport: [ + '--week', 42, null, + ], + }); + assert.deepEqual( + cfg.defaultReport, ['--week'] + ); + }); + }); + describe('projectPattern', () => { it('rejects nested quantifiers', () => {