Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@ Validate your Linear API key without touching any of your data:
LINEAR_API_KEY=lin_api_… npx human-handoff-linear doctor
```

Or provision the workspace-level `Human Handoff` issue template from the versioned, public-safe markdown body. Idempotent: creates the template the first time, updates it when the bundled body drifts, reports no-change when already in sync.

```bash
LINEAR_API_KEY=lin_api_… npx human-handoff-linear sync-template
```

### [`llm-cost-attribution`](packages/llm-cost-attribution)

Per-issue token, turn, and quota analytics for Claude Code and Codex CLI sessions. Reads the CLIs' own session JSONLs — no custom telemetry pipeline required.
Expand Down
63 changes: 51 additions & 12 deletions packages/human-handoff-linear/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,15 @@
Linear workflow primitives for installing and maintaining the Human Handoff
issue template used by autonomous project workflows.

This package is still a contract shell — most commands (`setup`,
`sync-template`, `bootstrap-project`) are dry-run/no-op scaffolds awaiting
later issues. The `doctor` command, however, performs a real Linear auth check
through the GraphQL adapter, and the underlying `LinearWorkspace` adapter
implements the full surface those later commands will use.
Today the package ships two real Linear commands:

- `doctor` — read-only auth and viewer/organization check.
- `sync-template` — provision the workspace-level `Human Handoff` issue
template idempotently: create it if missing, update its body when the
bundled markdown drifts, report no-change when already in sync.

`setup` and `bootstrap-project` remain scaffold-only and perform no Linear
mutations; later issues will wire them to the same `LinearWorkspace` adapter.

## Requirements

Expand Down Expand Up @@ -39,8 +43,35 @@ Subcommands:
- `doctor` — validate the Linear API token by fetching the current viewer and
workspace. Read-only: never creates or updates templates, labels, issues, or
relations.
- `setup`, `sync-template`, `bootstrap-project` — scaffold-only today; reserved
command surfaces that later issues will wire to real Linear mutations.
- `sync-template` — create or update the workspace-level `Human Handoff` issue
template idempotently. Pass `--dry-run` to plan without writing.
- `setup`, `bootstrap-project` — scaffold-only today; reserved command surfaces
that later issues will wire to real Linear mutations.

### `sync-template`

```text
$ human-handoff-linear sync-template
human-handoff-linear sync-template - syncing "Human Handoff" workspace template
Created workspace template "Human Handoff" (id: tpl_…)
human-handoff-linear sync-template complete - create performed (id: tpl_…)
```

Run it once to install the template; run it again after editing
`templates/human-handoff-issue-body.md` to push the new body. When the body
already matches what is in Linear, `sync-template` reports `no change` and
performs no write. It is safe to run from CI on every push.

```text
$ human-handoff-linear sync-template --dry-run # plan-only, no writes
human-handoff-linear sync-template - syncing "Human Handoff" workspace template
[dry-run] Would update workspace template "Human Handoff" (id: tpl_…)
human-handoff-linear sync-template complete - update planned
```

The same Linear error codes that `doctor` returns also apply to
`sync-template` (auth → 3, permission → 4, rate-limit → 5, network → 6, other
API errors → 7).

### `doctor`

Expand Down Expand Up @@ -70,22 +101,30 @@ The CLI adapter is deliberately thin. Core behavior is available as use-case
modules with injected ports:

```js
import { readFile } from 'node:fs/promises';
import {
createBootstrapProjectUseCase,
createDoctorUseCase,
createLinearGraphqlWorkspace,
createSetupUseCase,
createSyncTemplateUseCase,
} from 'human-handoff-linear';

const workspace = createLinearGraphqlWorkspace({ apiKey: process.env.LINEAR_API_KEY });
const reporter = { info: console.log, error: console.error };

// Read-only auth check
const doctor = createDoctorUseCase({
reporter: { info: console.log, error: console.error },
reporter,
secretReader: { read: (name) => process.env[name] },
workspace,
});
const result = await doctor();
if (!result.ok) process.exit(1);
const auth = await doctor();
if (!auth.ok) process.exit(1);

// Idempotent template sync
const templateBody = await readFile('./templates/human-handoff-issue-body.md', 'utf8');
const sync = createSyncTemplateUseCase({ reporter, templateBody, workspace });
const result = await sync({ dryRun: false });
// → { action: 'create' | 'update' | 'no-change', templateId, mutationsPerformed, ... }
```

Ports are plain objects:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -179,33 +179,47 @@ export function createLinearGraphqlWorkspace({
);
return data?.template ? normalizeTemplate(data.template) : null;
}
if (!teamId || !name) {
throw new TypeError('getTemplate requires { id } or { teamId, name }.');
if (!name) {
throw new TypeError('getTemplate requires { id } or { name } (optionally scoped with { teamId }).');
}
if (teamId) {
const data = await graphql(
`query TeamTemplates($teamId: String!) {
team(id: $teamId) {
templates { nodes { id name description type teamId } }
}
}`,
{ teamId },
);
const nodes = data?.team?.templates?.nodes ?? [];
const match = nodes.find((n) => n.name === name);
return match ? normalizeTemplate(match) : null;
}
// Workspace-level: list every template the viewer can see and filter by
// name + type === 'issue', dropping any team-scoped templates so a
// same-named team template does not shadow a missing workspace one.
const data = await graphql(
`query TeamTemplates($teamId: String!) {
team(id: $teamId) {
templates { nodes { id name description type teamId } }
}
`query WorkspaceTemplates {
templates { id name description type teamId }
}`,
{ teamId },
);
const nodes = data?.team?.templates?.nodes ?? [];
const match = nodes.find((n) => n.name === name);
const nodes = Array.isArray(data?.templates) ? data.templates : [];
const match = nodes.find((n) => n.name === name && n.type === 'issue' && !n.teamId);
return match ? normalizeTemplate(match) : null;
},

async createTemplate({ teamId, name, description, type = 'issue' } = {}) {
if (!teamId) throw new TypeError('createTemplate requires { teamId }.');
if (!name) throw new TypeError('createTemplate requires { name }.');
const input = { name, description, type };
if (teamId) input.teamId = teamId;
const data = await graphql(
`mutation CreateTemplate($input: TemplateCreateInput!) {
templateCreate(input: $input) {
success
template { id name description type teamId }
}
}`,
{ input: { teamId, name, description, type } },
{ input },
);
const result = data?.templateCreate;
if (!result?.success || !result.template) {
Expand Down
75 changes: 68 additions & 7 deletions packages/human-handoff-linear/src/cli/run-cli.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,19 @@ export async function runCli({ argv, env, stdout, stderr, stdin = null, fetch: f
});
}

if (command === 'sync-template') {
return runSyncTemplateCommand({
options: parsed.options,
env,
stdin,
stdout,
stderr,
reporter,
fetchImpl,
workspaceFactory,
});
}

const secretReader = createEnvironmentSecretReader(env);
const workspace = createNoopLinearWorkspace();

Expand All @@ -70,6 +83,39 @@ export async function runCli({ argv, env, stdout, stderr, stdin = null, fetch: f
}
}

async function runSyncTemplateCommand({ options, env, stdin, stdout, stderr, reporter, fetchImpl, workspaceFactory }) {
const secretReader = createInteractiveSecretReader({ env, stdin, stdout });

let apiKey = null;
try {
apiKey = await secretReader.readLinearApiKey({ interactive: !options.noPrompt });
} catch (err) {
reporter.error(`human-handoff-linear sync-template failed - ${err.message}`);
return 1;
}
if (!apiKey) {
reporter.error('human-handoff-linear sync-template failed - LINEAR_API_KEY is not set.');
reporter.error('Export LINEAR_API_KEY or rerun without --no-prompt to be prompted.');
return exitCodeFor('missing_token');
}

const factory = workspaceFactory ?? (({ apiKey: key }) => createLinearGraphqlWorkspace({ apiKey: key, fetch: fetchImpl }));
const workspace = factory({ apiKey });

const templateBody = await readFile(TEMPLATE_PATH, 'utf8');
try {
const result = await createSyncTemplateUseCase({ reporter, templateBody, workspace })(options);
const suffix = result.mutationsPerformed === 1
? `${result.action} performed (id: ${result.templateId})`
: (result.action === 'no-change' ? `no change (id: ${result.templateId})` : `${result.action} planned`);
stdout.write(`human-handoff-linear sync-template complete - ${suffix}\n`);
return 0;
} catch (err) {
reporter.error(`human-handoff-linear sync-template failed - ${err.message}`);
return exitCodeFor(err);
}
}

async function runDoctorCommand({ options, env, stdin, stdout, stderr, reporter, fetchImpl, workspaceFactory }) {
const secretReader = createInteractiveSecretReader({ env, stdin, stdout });

Expand Down Expand Up @@ -129,7 +175,7 @@ async function dispatchCommand({ command, options, reporter, secretReader, works

function parseArgs(argv) {
const args = [...argv];
const options = { dryRun: true, quiet: false, verbose: false, team: null, noPrompt: false };
const options = { dryRun: false, quiet: false, verbose: false, team: null, noPrompt: false };
let help = false;
let command = null;

Expand All @@ -139,6 +185,7 @@ function parseArgs(argv) {
if (arg === '--quiet' || arg === '-q') { options.quiet = true; continue; }
if (arg === '--verbose' || arg === '-v') { options.verbose = true; continue; }
if (arg === '--no-prompt') { options.noPrompt = true; continue; }
if (arg === '--dry-run') { options.dryRun = true; continue; }
if (arg === '--team') {
if (args[i + 1] === undefined) return { options, error: '--team requires a value' };
options.team = args[i + 1];
Expand All @@ -151,6 +198,13 @@ function parseArgs(argv) {
return { options, error: `Unexpected argument: ${arg}` };
}

// Scaffold commands (setup, bootstrap-project) default to dry-run / no-op
// because their use cases have not yet been implemented. sync-template
// performs real Linear writes by default unless the caller passes --dry-run.
if (command === 'setup' || command === 'bootstrap-project') {
options.dryRun = true;
}

return { command, help, options };
}

Expand All @@ -169,21 +223,28 @@ Commands:
${rows}

Options:
--dry-run Plan only — print the action sync-template would take
without calling any Linear write mutation.
--team <key> Linear team key for future setup/bootstrap operations
--no-prompt Disable interactive prompts (for CI / non-TTY environments)
-q, --quiet Print only final outcome and errors
-v, --verbose Print additional diagnostic detail
-h, --help Show this help

Auth:
doctor reads the Linear API key from LINEAR_API_KEY. When unset and stdin is
a TTY, it prompts for the key without echoing. Pass --no-prompt to skip the
prompt and exit with a missing-token error instead.
doctor and sync-template read the Linear API key from LINEAR_API_KEY. When
unset and stdin is a TTY, the CLI prompts for the key without echoing. Pass
--no-prompt to skip the prompt and exit with a missing-token error instead.

Create a personal API key at https://linear.app/settings/api.

Current scaffold behavior:
setup, sync-template, and bootstrap-project validate routing and contracts
only. No Linear mutations are performed by those commands.
Mutations:
sync-template performs real Linear writes — it creates the "Human Handoff"
workspace issue template if missing, updates it if the body drifted, and
reports no change when already in sync. Pass --dry-run to plan without
writing.

No Linear mutations are performed by the setup or bootstrap-project scaffold
commands; they remain dry-run placeholders awaiting later issues.
`;
}
2 changes: 1 addition & 1 deletion packages/human-handoff-linear/src/ports.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@
* @property {(input?: { teamId?: string }) => Promise<LinearLabel[]>=} listLabels
* @property {(input: { teamId: string, name: string, color?: string, description?: string }) => Promise<LinearLabel>=} createLabel
* @property {(input: { id?: string, teamId?: string, name?: string }) => Promise<LinearTemplate | null>=} getTemplate
* @property {(input: { teamId: string, name: string, description?: string, type?: string }) => Promise<LinearTemplate>=} createTemplate
* @property {(input: { teamId?: string, name: string, description?: string, type?: string }) => Promise<LinearTemplate>=} createTemplate
* @property {(input: { id: string, name?: string, description?: string }) => Promise<LinearTemplate>=} updateTemplate
* @property {(input: { teamId: string, title: string, description?: string, labelIds?: string[], templateId?: string, projectId?: string }) => Promise<LinearIssue>=} createIssue
* @property {(input: { issueId: string, relatedIssueId: string, type?: string }) => Promise<LinearRelation>=} createRelation
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ export const HUMAN_HANDOFF_LINEAR_COMMANDS = Object.freeze([
}),
Object.freeze({
name: 'sync-template',
summary: 'Report the checked-in Human Handoff template that future work will sync.',
summary: 'Create or update the Human Handoff workspace template idempotently (--dry-run plans without writing).',
}),
Object.freeze({
name: 'doctor',
Expand Down
Loading
Loading