feat: improve selfChatMode messaging in onboarding and startup (#99)

- Reorder options to show "personal number" first (recommended/safe)
- Add warnings when user selects dedicated number mode
- Skip dmPolicy question when selfChatMode is on (irrelevant)
- Add startup warnings when selfChatMode is off
- Add Linear skill for issue management

Addresses LET-7273 - users were confused about WhatsApp configuration.
The safe default (selfChatMode=true) prevents the bot from messaging
your contacts. Only disable this for dedicated bot numbers.

Written by Cameron ◯ Letta Code

"Make the right thing easy and the wrong thing hard." - Kathy Sierra
This commit is contained in:
Cameron
2026-02-03 12:16:10 -08:00
committed by GitHub
parent 6d246d6e58
commit b8968a8732
4 changed files with 486 additions and 47 deletions

81
.skills/linear/SKILL.md Normal file
View File

@@ -0,0 +1,81 @@
---
name: linear
description: Manage Linear issues via GraphQL API. List, filter, update, prioritize, comment, and search issues. Use when the user asks about Linear, issues, project management, or backlog.
---
# Linear
Interact with Linear's GraphQL API to manage issues.
## When to Use
- User asks about Linear issues, tickets, or project management
- Need to triage, prioritize, or update issue status
- Want to search or comment on issues
- Managing a backlog or sprint
## Setup
Requires `LINEAR_API_KEY` environment variable. Get one from Linear Settings > API > Personal API keys.
## Quick Operations
### List issues by status
```bash
npx tsx scripts/linear.ts list --state "Triage"
npx tsx scripts/linear.ts list --state "In Progress"
npx tsx scripts/linear.ts list --state "Backlog"
```
### List issues assigned to someone
```bash
npx tsx scripts/linear.ts list --assignee "cameron"
```
### Get issue details
```bash
npx tsx scripts/linear.ts get <issue-id>
```
### Update issue priority (0=none, 1=urgent, 2=high, 3=medium, 4=low)
```bash
npx tsx scripts/linear.ts update <issue-id> --priority 2
```
### Update issue state
```bash
npx tsx scripts/linear.ts update <issue-id> --state "In Progress"
```
### Add comment
```bash
npx tsx scripts/linear.ts comment <issue-id> "Your comment here"
```
### Search issues
```bash
npx tsx scripts/linear.ts search "search query"
```
## Triage Workflow
1. List triage issues: `list --state "Triage"`
2. Review each issue, decide priority
3. Update priority and move to appropriate state
4. Add comments for context if needed
## Output Format
All commands output JSON for easy parsing. Use `jq` for filtering if needed.
## Battle-Tested Insights
### GraphQL Query Patterns
- Linear uses GraphQL with nested filtering. State filters need the exact format: `state: { name: { eq: "State Name" } }`
- Assignee filters are case-insensitive with `containsIgnoreCase`
- When updating state, you need to fetch the state ID first - state names alone won't work in mutations
### Common Pitfalls
- Issue IDs vs Identifiers: The API accepts both UUID-style IDs and human-readable identifiers (e.g., "ENG-123"), but some endpoints prefer one over the other
- Priority is numeric (0-4), not a string
- Rate limits are generous but exist - batch operations if doing bulk updates

View File

@@ -0,0 +1,334 @@
#!/usr/bin/env npx tsx
/**
* Linear CLI for triage workflows
* Usage: npx tsx linear.ts <command> [options]
*/
const LINEAR_API = "https://api.linear.app/graphql";
const API_KEY = process.env.LINEAR_API_KEY;
// Type definitions
interface GraphQLError {
message: string;
}
interface WorkflowState {
id: string;
name: string;
}
interface IssueUpdateInput {
priority?: number;
stateId?: string;
}
if (!API_KEY) {
console.error("Error: LINEAR_API_KEY environment variable not set");
process.exit(1);
}
async function graphql(query: string, variables: Record<string, unknown> = {}) {
const res = await fetch(LINEAR_API, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": API_KEY!,
},
body: JSON.stringify({ query, variables }),
});
const data = await res.json();
if (data.errors) {
throw new Error(data.errors.map((e: GraphQLError) => e.message).join(", "));
}
return data.data;
}
async function listIssues(filters: { state?: string; assignee?: string; limit?: number }) {
const limit = filters.limit || 50;
let filterStr = "";
const filterParts: string[] = [];
if (filters.state) {
filterParts.push(`state: { name: { eq: "${filters.state}" } }`);
}
if (filters.assignee) {
filterParts.push(`assignee: { name: { containsIgnoreCase: "${filters.assignee}" } }`);
}
if (filterParts.length > 0) {
filterStr = `filter: { ${filterParts.join(", ")} }`;
}
const query = `
query {
issues(first: ${limit}, ${filterStr}) {
nodes {
id
identifier
title
priority
state { name }
assignee { name }
createdAt
url
}
}
}
`;
const data = await graphql(query);
return data.issues.nodes;
}
async function getIssue(idOrIdentifier: string) {
// Try by identifier first (e.g., "ENG-123")
const query = `
query($id: String!) {
issue(id: $id) {
id
identifier
title
description
priority
state { name }
assignee { name }
labels { nodes { name } }
createdAt
updatedAt
url
comments {
nodes {
body
user { name }
createdAt
}
}
}
}
`;
try {
const data = await graphql(query, { id: idOrIdentifier });
return data.issue;
} catch {
// Try searching by identifier
const searchQuery = `
query($filter: IssueFilter) {
issues(filter: $filter, first: 1) {
nodes {
id
identifier
title
description
priority
state { name }
assignee { name }
labels { nodes { name } }
createdAt
updatedAt
url
}
}
}
`;
const data = await graphql(searchQuery, {
filter: { number: { eq: parseInt(idOrIdentifier.replace(/\D/g, "")) } }
});
return data.issues.nodes[0];
}
}
async function updateIssue(id: string, updates: { priority?: number; state?: string }) {
// First get the issue to find its ID
const issue = await getIssue(id);
if (!issue) {
throw new Error(`Issue not found: ${id}`);
}
const input: IssueUpdateInput = {};
if (updates.priority !== undefined) {
input.priority = updates.priority;
}
if (updates.state) {
// Need to find the state ID
const statesQuery = `
query {
workflowStates {
nodes {
id
name
}
}
}
`;
const statesData = await graphql(statesQuery);
const state = statesData.workflowStates.nodes.find(
(s: WorkflowState) => s.name.toLowerCase() === updates.state!.toLowerCase()
);
if (!state) {
throw new Error(`State not found: ${updates.state}`);
}
input.stateId = state.id;
}
const mutation = `
mutation($id: String!, $input: IssueUpdateInput!) {
issueUpdate(id: $id, input: $input) {
issue {
id
identifier
title
priority
state { name }
}
}
}
`;
const data = await graphql(mutation, { id: issue.id, input });
return data.issueUpdate.issue;
}
async function addComment(id: string, body: string) {
const issue = await getIssue(id);
if (!issue) {
throw new Error(`Issue not found: ${id}`);
}
const mutation = `
mutation($issueId: String!, $body: String!) {
commentCreate(input: { issueId: $issueId, body: $body }) {
comment {
id
body
createdAt
}
}
}
`;
const data = await graphql(mutation, { issueId: issue.id, body });
return data.commentCreate.comment;
}
async function searchIssues(query: string) {
const searchQuery = `
query($query: String!) {
searchIssues(query: $query, first: 20) {
nodes {
id
identifier
title
priority
state { name }
assignee { name }
url
}
}
}
`;
const data = await graphql(searchQuery, { query });
return data.searchIssues.nodes;
}
// CLI parsing
const args = process.argv.slice(2);
const command = args[0];
async function main() {
try {
switch (command) {
case "list": {
const filters: { state?: string; assignee?: string; limit?: number } = {};
for (let i = 1; i < args.length; i++) {
if (args[i] === "--state" && args[i + 1]) {
filters.state = args[++i];
} else if (args[i] === "--assignee" && args[i + 1]) {
filters.assignee = args[++i];
} else if (args[i] === "--limit" && args[i + 1]) {
filters.limit = parseInt(args[++i]);
}
}
const issues = await listIssues(filters);
console.log(JSON.stringify(issues, null, 2));
break;
}
case "get": {
const id = args[1];
if (!id) {
console.error("Usage: get <issue-id>");
process.exit(1);
}
const issue = await getIssue(id);
console.log(JSON.stringify(issue, null, 2));
break;
}
case "update": {
const id = args[1];
if (!id) {
console.error("Usage: update <issue-id> [--priority N] [--state STATE]");
process.exit(1);
}
const updates: { priority?: number; state?: string } = {};
for (let i = 2; i < args.length; i++) {
if (args[i] === "--priority" && args[i + 1]) {
updates.priority = parseInt(args[++i]);
} else if (args[i] === "--state" && args[i + 1]) {
updates.state = args[++i];
}
}
const issue = await updateIssue(id, updates);
console.log(JSON.stringify(issue, null, 2));
break;
}
case "comment": {
const id = args[1];
const body = args[2];
if (!id || !body) {
console.error("Usage: comment <issue-id> <body>");
process.exit(1);
}
const comment = await addComment(id, body);
console.log(JSON.stringify(comment, null, 2));
break;
}
case "search": {
const query = args.slice(1).join(" ");
if (!query) {
console.error("Usage: search <query>");
process.exit(1);
}
const issues = await searchIssues(query);
console.log(JSON.stringify(issues, null, 2));
break;
}
default:
console.log(`
Linear Triage CLI
Commands:
list [--state STATE] [--assignee NAME] [--limit N]
get <issue-id>
update <issue-id> [--priority N] [--state STATE]
comment <issue-id> <body>
search <query>
Priority levels: 0=none, 1=urgent, 2=high, 3=medium, 4=low
`);
}
} catch (error) {
console.error("Error:", error instanceof Error ? error.message : error);
process.exit(1);
}
}
main();