diff --git a/TODO.md b/TODO.md
index 43ad853..2d4dcdb 100644
--- a/TODO.md
+++ b/TODO.md
@@ -39,7 +39,7 @@ This document outlines the development tasks for the Tangled CLI, based on the `
- [x] Implement `tangled issue create "
" [--body "" | --body-file | -F -]` command.
- [x] Implement `tangled issue list [--json "id,title"]` command.
- - [ ] Support `--json` output with field filtering.
+ - [x] Support `--json` output with field filtering.
- [ ] Migrate this TODO list into Tangled issues once issue creation is implemented. (note defects and address blocking features as needed).
- [ ] Create phases in this todo list, and then use `- [ ]` tasks in the issue descriptions.
- [ ] Remove TODO.md once all tasks are migrated to issues.
diff --git a/src/commands/issue.ts b/src/commands/issue.ts
index e7d8ff4..167811e 100644
--- a/src/commands/issue.ts
+++ b/src/commands/issue.ts
@@ -16,7 +16,7 @@ import {
import { buildRepoAtUri } from '../utils/at-uri.js';
import { requireAuth } from '../utils/auth-helpers.js';
import { readBodyInput } from '../utils/body-input.js';
-import { formatDate, formatIssueState } from '../utils/formatting.js';
+import { formatDate, formatIssueState, outputJson } from '../utils/formatting.js';
import { validateIssueBody, validateIssueTitle } from '../utils/validation.js';
/**
@@ -93,7 +93,11 @@ function createViewCommand(): Command {
return new Command('view')
.description('View details of a specific issue')
.argument('', 'Issue number (e.g., 1, #2) or rkey')
- .action(async (issueId: string) => {
+ .option(
+ '--json [fields]',
+ 'Output JSON; optionally specify comma-separated fields (title, body, state, author, createdAt, uri, cid)'
+ )
+ .action(async (issueId: string, options: { json?: string | true }) => {
try {
// 1. Validate auth
const client = createApiClient();
@@ -123,7 +127,21 @@ function createViewCommand(): Command {
// 6. Fetch issue state
const state = await getIssueState({ client, issueUri: issue.uri });
- // 7. Display issue details
+ // 7. Output result
+ if (options.json !== undefined) {
+ const issueData = {
+ title: issue.title,
+ body: issue.body,
+ state,
+ author: issue.author,
+ createdAt: issue.createdAt,
+ uri: issue.uri,
+ cid: issue.cid,
+ };
+ outputJson(issueData, typeof options.json === 'string' ? options.json : undefined);
+ return;
+ }
+
console.log(`\nIssue ${displayId} ${formatIssueState(state)}`);
console.log(`Title: ${issue.title}`);
console.log(`Author: ${issue.author}`);
@@ -156,8 +174,15 @@ function createEditCommand(): Command {
.option('-t, --title ', 'New issue title')
.option('-b, --body ', 'New issue body text')
.option('-F, --body-file ', 'Read body from file (- for stdin)')
+ .option(
+ '--json [fields]',
+ 'Output JSON of the updated issue; optionally specify comma-separated fields (title, body, author, createdAt, uri, cid)'
+ )
.action(
- async (issueId: string, options: { title?: string; body?: string; bodyFile?: string }) => {
+ async (
+ issueId: string,
+ options: { title?: string; body?: string; bodyFile?: string; json?: string | true }
+ ) => {
try {
// 1. Validate at least one option provided
if (!options.title && !options.body && !options.bodyFile) {
@@ -195,14 +220,27 @@ function createEditCommand(): Command {
const validBody = body !== undefined ? validateIssueBody(body) : undefined;
// 8. Update issue
- await updateIssue({
+ const updatedIssue = await updateIssue({
client,
issueUri,
title: validTitle,
body: validBody,
});
- // 9. Display success
+ // 9. Output result
+ if (options.json !== undefined) {
+ const issueData = {
+ title: updatedIssue.title,
+ body: updatedIssue.body,
+ author: updatedIssue.author,
+ createdAt: updatedIssue.createdAt,
+ uri: updatedIssue.uri,
+ cid: updatedIssue.cid,
+ };
+ outputJson(issueData, typeof options.json === 'string' ? options.json : undefined);
+ return;
+ }
+
const updated: string[] = [];
if (validTitle !== undefined) updated.push('title');
if (validBody !== undefined) updated.push('body');
@@ -400,57 +438,81 @@ function createCreateCommand(): Command {
.argument('', 'Issue title')
.option('-b, --body ', 'Issue body text')
.option('-F, --body-file ', 'Read body from file (- for stdin)')
- .action(async (title: string, options: { body?: string; bodyFile?: string }) => {
- try {
- // 1. Validate auth
- const client = createApiClient();
- if (!(await client.resumeSession())) {
- console.error('✗ Not authenticated. Run "tangled auth login" first.');
- process.exit(1);
- }
+ .option(
+ '--json [fields]',
+ 'Output JSON; optionally specify comma-separated fields (title, body, author, createdAt, uri, cid)'
+ )
+ .action(
+ async (
+ title: string,
+ options: { body?: string; bodyFile?: string; json?: string | true }
+ ) => {
+ try {
+ // 1. Validate auth
+ const client = createApiClient();
+ if (!(await client.resumeSession())) {
+ console.error('✗ Not authenticated. Run "tangled auth login" first.');
+ process.exit(1);
+ }
- // 2. Get repo context
- const context = await getCurrentRepoContext();
- if (!context) {
- console.error('✗ Not in a Tangled repository');
- console.error('\nTo use this repository with Tangled, add a remote:');
- console.error(' git remote add origin git@tangled.org:/.git');
- process.exit(1);
- }
+ // 2. Get repo context
+ const context = await getCurrentRepoContext();
+ if (!context) {
+ console.error('✗ Not in a Tangled repository');
+ console.error('\nTo use this repository with Tangled, add a remote:');
+ console.error(' git remote add origin git@tangled.org:/.git');
+ process.exit(1);
+ }
- // 3. Validate title
- const validTitle = validateIssueTitle(title);
+ // 3. Validate title
+ const validTitle = validateIssueTitle(title);
- // 4. Handle body input
- const body = await readBodyInput(options.body, options.bodyFile);
- if (body !== undefined) {
- validateIssueBody(body);
- }
+ // 4. Handle body input
+ const body = await readBodyInput(options.body, options.bodyFile);
+ if (body !== undefined) {
+ validateIssueBody(body);
+ }
- // 5. Build repo AT-URI
- const repoAtUri = await buildRepoAtUri(context.owner, context.name, client);
+ // 5. Build repo AT-URI
+ const repoAtUri = await buildRepoAtUri(context.owner, context.name, client);
- // 6. Create issue
- console.log('Creating issue...');
- const issue = await createIssue({
- client,
- repoAtUri,
- title: validTitle,
- body,
- });
+ // 6. Create issue (suppress progress message in JSON mode)
+ if (options.json === undefined) {
+ console.log('Creating issue...');
+ }
+ const issue = await createIssue({
+ client,
+ repoAtUri,
+ title: validTitle,
+ body,
+ });
- // 7. Display success
- const rkey = extractRkey(issue.uri);
- console.log(`\n✓ Issue created: #${rkey}`);
- console.log(` Title: ${issue.title}`);
- console.log(` URI: ${issue.uri}`);
- } catch (error) {
- console.error(
- `✗ Failed to create issue: ${error instanceof Error ? error.message : 'Unknown error'}`
- );
- process.exit(1);
+ // 7. Output result
+ if (options.json !== undefined) {
+ const issueData = {
+ title: issue.title,
+ body: issue.body,
+ author: issue.author,
+ createdAt: issue.createdAt,
+ uri: issue.uri,
+ cid: issue.cid,
+ };
+ outputJson(issueData, typeof options.json === 'string' ? options.json : undefined);
+ return;
+ }
+
+ const rkey = extractRkey(issue.uri);
+ console.log(`\n✓ Issue created: #${rkey}`);
+ console.log(` Title: ${issue.title}`);
+ console.log(` URI: ${issue.uri}`);
+ } catch (error) {
+ console.error(
+ `✗ Failed to create issue: ${error instanceof Error ? error.message : 'Unknown error'}`
+ );
+ process.exit(1);
+ }
}
- });
+ );
}
/**
@@ -460,7 +522,11 @@ function createListCommand(): Command {
return new Command('list')
.description('List issues for the current repository')
.option('-l, --limit ', 'Maximum number of issues to fetch', '50')
- .action(async (options: { limit: string }) => {
+ .option(
+ '--json [fields]',
+ 'Output JSON; optionally specify comma-separated fields (number, title, body, state, author, createdAt, uri, cid)'
+ )
+ .action(async (options: { limit: string; json?: string | true }) => {
try {
// 1. Validate auth
const client = createApiClient();
@@ -494,9 +560,13 @@ function createListCommand(): Command {
limit,
});
- // 5. Display results
+ // 5. Handle empty results
if (issues.length === 0) {
- console.log('No issues found for this repository.');
+ if (options.json !== undefined) {
+ console.log('[]');
+ } else {
+ console.log('No issues found for this repository.');
+ }
return;
}
@@ -505,21 +575,35 @@ function createListCommand(): Command {
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
);
- console.log(
- `\nFound ${sortedIssues.length} issue${sortedIssues.length === 1 ? '' : 's'}:\n`
+ // Build issue data with states (in parallel for performance)
+ const issueData = await Promise.all(
+ sortedIssues.map(async (issue, i) => {
+ const state = await getIssueState({ client, issueUri: issue.uri });
+ return {
+ number: i + 1,
+ title: issue.title,
+ body: issue.body,
+ state,
+ author: issue.author,
+ createdAt: issue.createdAt,
+ uri: issue.uri,
+ cid: issue.cid,
+ };
+ })
);
- // Fetch and display each issue with number and state
- for (let i = 0; i < sortedIssues.length; i++) {
- const issue = sortedIssues[i];
- const num = i + 1;
- const date = formatDate(issue.createdAt);
+ // 6. Output results
+ if (options.json !== undefined) {
+ outputJson(issueData, typeof options.json === 'string' ? options.json : undefined);
+ return;
+ }
- // Get issue state
- const state = await getIssueState({ client, issueUri: issue.uri });
- const stateBadge = formatIssueState(state);
+ console.log(`\nFound ${issueData.length} issue${issueData.length === 1 ? '' : 's'}:\n`);
- console.log(` #${num} ${stateBadge} ${issue.title}`);
+ for (const item of issueData) {
+ const stateBadge = formatIssueState(item.state);
+ const date = formatDate(item.createdAt);
+ console.log(` #${item.number} ${stateBadge} ${item.title}`);
console.log(` Created ${date}`);
console.log();
}
diff --git a/src/utils/formatting.ts b/src/utils/formatting.ts
index 14db2c1..769949e 100644
--- a/src/utils/formatting.ts
+++ b/src/utils/formatting.ts
@@ -25,3 +25,47 @@ export function formatDate(dateString: string): string {
export function formatIssueState(state: 'open' | 'closed'): string {
return state === 'open' ? '[OPEN]' : '[CLOSED]';
}
+
+/**
+ * Pick specific fields from an object, omitting fields not present in the object
+ */
+function pickFields(obj: Record, fields: string[]): Record {
+ const result: Record = {};
+ for (const field of fields) {
+ if (field in obj) {
+ result[field] = obj[field];
+ }
+ }
+ return result;
+}
+
+/**
+ * Output data as JSON to stdout, following GitHub CLI conventions.
+ *
+ * @param data - The data to output (object or array of objects)
+ * @param fields - Comma-separated field names to include; omit for all fields
+ */
+export function outputJson(
+ data: Record | Record[],
+ fields?: string
+): void {
+ if (fields) {
+ const fieldList = fields
+ .split(',')
+ .map((f) => f.trim())
+ .filter(Boolean);
+ if (Array.isArray(data)) {
+ console.log(
+ JSON.stringify(
+ data.map((item) => pickFields(item, fieldList)),
+ null,
+ 2
+ )
+ );
+ } else {
+ console.log(JSON.stringify(pickFields(data, fieldList), null, 2));
+ }
+ } else {
+ console.log(JSON.stringify(data, null, 2));
+ }
+}
diff --git a/tests/commands/issue.test.ts b/tests/commands/issue.test.ts
index 254816e..29c4826 100644
--- a/tests/commands/issue.test.ts
+++ b/tests/commands/issue.test.ts
@@ -248,6 +248,53 @@ describe('issue create command', () => {
expect(processExitSpy).toHaveBeenCalledWith(1);
});
});
+
+ describe('JSON output', () => {
+ const mockIssue: IssueWithMetadata = {
+ $type: 'sh.tangled.repo.issue',
+ repo: 'at://did:plc:abc123/sh.tangled.repo/test-repo',
+ title: 'Test Issue',
+ body: 'Test body',
+ createdAt: '2024-01-01T00:00:00.000Z',
+ uri: 'at://did:plc:abc123/sh.tangled.repo.issue/abc123',
+ cid: 'bafyreiabc123',
+ author: 'did:plc:abc123',
+ };
+
+ it('should output JSON of created issue when --json is passed', async () => {
+ vi.mocked(issuesApi.createIssue).mockResolvedValue(mockIssue);
+
+ const command = createIssueCommand();
+ await command.parseAsync(['node', 'test', 'create', 'Test Issue', '--json']);
+
+ // Should NOT print human-readable messages
+ expect(consoleLogSpy).not.toHaveBeenCalledWith('Creating issue...');
+
+ const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0] as string);
+ expect(jsonOutput).toMatchObject({
+ title: 'Test Issue',
+ body: 'Test body',
+ author: 'did:plc:abc123',
+ uri: 'at://did:plc:abc123/sh.tangled.repo.issue/abc123',
+ cid: 'bafyreiabc123',
+ });
+ });
+
+ it('should output filtered JSON when --json with fields is passed', async () => {
+ vi.mocked(issuesApi.createIssue).mockResolvedValue(mockIssue);
+
+ const command = createIssueCommand();
+ await command.parseAsync(['node', 'test', 'create', 'Test Issue', '--json', 'title,uri']);
+
+ const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0] as string);
+ expect(jsonOutput).toEqual({
+ title: 'Test Issue',
+ uri: 'at://did:plc:abc123/sh.tangled.repo.issue/abc123',
+ });
+ expect(jsonOutput).not.toHaveProperty('body');
+ expect(jsonOutput).not.toHaveProperty('author');
+ });
+ });
});
describe('issue list command', () => {
@@ -435,6 +482,73 @@ describe('issue list command', () => {
expect(processExitSpy).toHaveBeenCalledWith(1);
});
});
+
+ describe('JSON output', () => {
+ const mockIssues: IssueWithMetadata[] = [
+ {
+ $type: 'sh.tangled.repo.issue',
+ repo: 'at://did:plc:abc123/sh.tangled.repo/xyz789',
+ title: 'First Issue',
+ body: 'First body',
+ createdAt: new Date('2024-01-01').toISOString(),
+ uri: 'at://did:plc:abc123/sh.tangled.repo.issue/issue1',
+ cid: 'bafyrei1',
+ author: 'did:plc:abc123',
+ },
+ {
+ $type: 'sh.tangled.repo.issue',
+ repo: 'at://did:plc:abc123/sh.tangled.repo/xyz789',
+ title: 'Second Issue',
+ createdAt: new Date('2024-01-02').toISOString(),
+ uri: 'at://did:plc:abc123/sh.tangled.repo.issue/issue2',
+ cid: 'bafyrei2',
+ author: 'did:plc:abc123',
+ },
+ ];
+
+ beforeEach(() => {
+ vi.mocked(issuesApi.listIssues).mockResolvedValue({
+ issues: mockIssues,
+ cursor: undefined,
+ });
+ vi.mocked(issuesApi.getIssueState).mockResolvedValue('open');
+ });
+
+ it('should output JSON array when --json is passed', async () => {
+ const command = createIssueCommand();
+ await command.parseAsync(['node', 'test', 'list', '--json']);
+
+ const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0] as string);
+ expect(Array.isArray(jsonOutput)).toBe(true);
+ expect(jsonOutput).toHaveLength(2);
+ expect(jsonOutput[0]).toMatchObject({
+ number: 1,
+ title: 'First Issue',
+ state: 'open',
+ author: 'did:plc:abc123',
+ });
+ expect(jsonOutput[1]).toMatchObject({ number: 2, title: 'Second Issue' });
+ });
+
+ it('should output filtered JSON when --json with fields is passed', async () => {
+ const command = createIssueCommand();
+ await command.parseAsync(['node', 'test', 'list', '--json', 'number,title,state']);
+
+ const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0] as string);
+ expect(jsonOutput[0]).toEqual({ number: 1, title: 'First Issue', state: 'open' });
+ expect(jsonOutput[0]).not.toHaveProperty('author');
+ expect(jsonOutput[0]).not.toHaveProperty('uri');
+ });
+
+ it('should output empty JSON array when no issues exist', async () => {
+ vi.mocked(issuesApi.listIssues).mockResolvedValue({ issues: [], cursor: undefined });
+
+ const command = createIssueCommand();
+ await command.parseAsync(['node', 'test', 'list', '--json']);
+
+ expect(consoleLogSpy).toHaveBeenCalledWith('[]');
+ });
+ });
});
describe('issue view command', () => {
@@ -578,6 +692,47 @@ describe('issue view command', () => {
expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('Issue #99 not found'));
});
+
+ describe('JSON output', () => {
+ it('should output JSON when --json is passed', async () => {
+ vi.mocked(issuesApi.listIssues).mockResolvedValue({
+ issues: [mockIssue],
+ cursor: undefined,
+ });
+ vi.mocked(issuesApi.getIssue).mockResolvedValue(mockIssue);
+ vi.mocked(issuesApi.getIssueState).mockResolvedValue('open');
+
+ const command = createIssueCommand();
+ await command.parseAsync(['node', 'test', 'view', '1', '--json']);
+
+ const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0] as string);
+ expect(jsonOutput).toMatchObject({
+ title: 'Test Issue',
+ body: 'Issue body',
+ state: 'open',
+ author: 'did:plc:abc123',
+ uri: mockIssue.uri,
+ cid: mockIssue.cid,
+ });
+ });
+
+ it('should output filtered JSON when --json with fields is passed', async () => {
+ vi.mocked(issuesApi.listIssues).mockResolvedValue({
+ issues: [mockIssue],
+ cursor: undefined,
+ });
+ vi.mocked(issuesApi.getIssue).mockResolvedValue(mockIssue);
+ vi.mocked(issuesApi.getIssueState).mockResolvedValue('closed');
+
+ const command = createIssueCommand();
+ await command.parseAsync(['node', 'test', 'view', '1', '--json', 'title,state']);
+
+ const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0] as string);
+ expect(jsonOutput).toEqual({ title: 'Test Issue', state: 'closed' });
+ expect(jsonOutput).not.toHaveProperty('body');
+ expect(jsonOutput).not.toHaveProperty('author');
+ });
+ });
});
describe('issue edit command', () => {
@@ -692,6 +847,58 @@ describe('issue edit command', () => {
'✗ Not authenticated. Run "tangled auth login" first.'
);
});
+
+ describe('JSON output', () => {
+ it('should output JSON of updated issue when --json is passed', async () => {
+ const updatedIssue = { ...mockIssue, title: 'New Title' };
+ vi.mocked(issuesApi.listIssues).mockResolvedValue({
+ issues: [mockIssue],
+ cursor: undefined,
+ });
+ vi.mocked(issuesApi.updateIssue).mockResolvedValue(updatedIssue);
+
+ const command = createIssueCommand();
+ await command.parseAsync(['node', 'test', 'edit', '1', '--title', 'New Title', '--json']);
+
+ const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0] as string);
+ expect(jsonOutput).toMatchObject({
+ title: 'New Title',
+ author: 'did:plc:abc123',
+ uri: mockIssue.uri,
+ cid: mockIssue.cid,
+ });
+ // Human-readable messages should NOT appear
+ expect(consoleLogSpy).not.toHaveBeenCalledWith('✓ Issue #1 updated');
+ });
+
+ it('should output filtered JSON when --json with fields is passed', async () => {
+ const updatedIssue = { ...mockIssue, title: 'New Title' };
+ vi.mocked(issuesApi.listIssues).mockResolvedValue({
+ issues: [mockIssue],
+ cursor: undefined,
+ });
+ vi.mocked(issuesApi.updateIssue).mockResolvedValue(updatedIssue);
+
+ const command = createIssueCommand();
+ await command.parseAsync([
+ 'node',
+ 'test',
+ 'edit',
+ '1',
+ '--title',
+ 'New Title',
+ '--json',
+ 'title,uri',
+ ]);
+
+ const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0] as string);
+ expect(jsonOutput).toEqual({
+ title: 'New Title',
+ uri: mockIssue.uri,
+ });
+ expect(jsonOutput).not.toHaveProperty('author');
+ });
+ });
});
describe('issue close command', () => {
diff --git a/tests/utils/formatting.test.ts b/tests/utils/formatting.test.ts
index fdad7be..d9844fa 100644
--- a/tests/utils/formatting.test.ts
+++ b/tests/utils/formatting.test.ts
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-import { formatDate } from '../../src/utils/formatting.js';
+import { formatDate, outputJson } from '../../src/utils/formatting.js';
describe('formatDate', () => {
beforeEach(() => {
@@ -81,3 +81,66 @@ describe('formatDate', () => {
expect(formatted).toMatch(/\d{1,2}\/\d{1,2}\/\d{4}/);
});
});
+
+describe('outputJson', () => {
+ let consoleLogSpy: ReturnType;
+
+ beforeEach(() => {
+ consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('should output all fields of an object when no fields specified', () => {
+ const data = { title: 'Test', state: 'open', author: 'did:plc:abc' };
+ outputJson(data);
+ expect(consoleLogSpy).toHaveBeenCalledWith(JSON.stringify(data, null, 2));
+ });
+
+ it('should output only specified fields of an object', () => {
+ const data = { title: 'Test', state: 'open', author: 'did:plc:abc', cid: 'bafyrei1' };
+ outputJson(data, 'title,state');
+ const output = JSON.parse(consoleLogSpy.mock.calls[0][0] as string);
+ expect(output).toEqual({ title: 'Test', state: 'open' });
+ expect(output).not.toHaveProperty('author');
+ expect(output).not.toHaveProperty('cid');
+ });
+
+ it('should output all fields of an array when no fields specified', () => {
+ const data = [
+ { title: 'First', state: 'open' },
+ { title: 'Second', state: 'closed' },
+ ];
+ outputJson(data);
+ expect(consoleLogSpy).toHaveBeenCalledWith(JSON.stringify(data, null, 2));
+ });
+
+ it('should output only specified fields of an array', () => {
+ const data = [
+ { title: 'First', state: 'open', author: 'did:plc:abc' },
+ { title: 'Second', state: 'closed', author: 'did:plc:xyz' },
+ ];
+ outputJson(data, 'title,state');
+ const output = JSON.parse(consoleLogSpy.mock.calls[0][0] as string);
+ expect(output).toEqual([
+ { title: 'First', state: 'open' },
+ { title: 'Second', state: 'closed' },
+ ]);
+ });
+
+ it('should silently omit fields not present in the object', () => {
+ const data = { title: 'Test', state: 'open' };
+ outputJson(data, 'title,nonexistent');
+ const output = JSON.parse(consoleLogSpy.mock.calls[0][0] as string);
+ expect(output).toEqual({ title: 'Test' });
+ });
+
+ it('should trim whitespace from field names', () => {
+ const data = { title: 'Test', state: 'open' };
+ outputJson(data, ' title , state ');
+ const output = JSON.parse(consoleLogSpy.mock.calls[0][0] as string);
+ expect(output).toEqual({ title: 'Test', state: 'open' });
+ });
+});