@1a35e1/sonar-cli 0.2.1 → 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +151 -166
  2. package/dist/commands/{inbox/archive.js → archive.js} +2 -2
  3. package/dist/commands/config/data/download.js +2 -2
  4. package/dist/commands/config/data/sync.js +2 -2
  5. package/dist/commands/config/nuke.js +20 -2
  6. package/dist/commands/feed.js +105 -155
  7. package/dist/commands/index.js +172 -4
  8. package/dist/commands/{inbox/later.js → later.js} +2 -2
  9. package/dist/commands/refresh.js +41 -0
  10. package/dist/commands/{inbox/skip.js → skip.js} +2 -2
  11. package/dist/commands/status.js +128 -0
  12. package/dist/commands/sync/bookmarks.js +35 -0
  13. package/dist/commands/topics/add.js +71 -0
  14. package/dist/commands/topics/delete.js +42 -0
  15. package/dist/commands/topics/edit.js +97 -0
  16. package/dist/commands/topics/index.js +54 -0
  17. package/dist/commands/topics/suggest.js +125 -0
  18. package/dist/commands/topics/view.js +48 -0
  19. package/dist/components/AccountCard.js +1 -1
  20. package/dist/components/Banner.js +11 -0
  21. package/dist/components/InteractiveSession.js +95 -210
  22. package/dist/components/Spinner.js +5 -4
  23. package/dist/components/TopicCard.js +15 -0
  24. package/dist/components/TweetCard.js +76 -0
  25. package/dist/lib/ai.js +85 -0
  26. package/dist/lib/client.js +66 -40
  27. package/dist/lib/config.js +3 -2
  28. package/dist/lib/data-queries.js +1 -3
  29. package/dist/lib/skill.js +66 -226
  30. package/package.json +13 -3
  31. package/dist/commands/account.js +0 -75
  32. package/dist/commands/inbox/index.js +0 -103
  33. package/dist/commands/inbox/read.js +0 -41
  34. package/dist/commands/ingest/bookmarks.js +0 -55
  35. package/dist/commands/ingest/index.js +0 -5
  36. package/dist/commands/ingest/tweets.js +0 -55
  37. package/dist/commands/interests/create.js +0 -107
  38. package/dist/commands/interests/index.js +0 -56
  39. package/dist/commands/interests/match.js +0 -33
  40. package/dist/commands/interests/update.js +0 -153
  41. package/dist/commands/monitor.js +0 -93
  42. package/dist/commands/quickstart.js +0 -231
  43. package/dist/components/InterestCard.js +0 -10
@@ -0,0 +1,128 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ import { useEffect, useState } from 'react';
3
+ import zod from 'zod';
4
+ import { Box, Text, useApp, useInput } from 'ink';
5
+ import { formatDistanceToNow } from 'date-fns';
6
+ import { getToken, getApiUrl } from '../lib/config.js';
7
+ import { gql } from '../lib/client.js';
8
+ import { Spinner } from '../components/Spinner.js';
9
+ export const options = zod.object({
10
+ watch: zod.boolean().default(false).describe('Poll and refresh every 2 seconds'),
11
+ json: zod.boolean().default(false).describe('Raw JSON output'),
12
+ });
13
+ const POLL_INTERVAL = 2000;
14
+ const QUEUE_LABELS = {
15
+ tweets: 'Tweets',
16
+ bookmarks: 'Bookmarks',
17
+ social_graph: 'Social graph',
18
+ suggestions: 'Suggestions',
19
+ default: 'Pipeline',
20
+ topics: 'Topics',
21
+ };
22
+ const GQL_QUERY = `
23
+ query Status {
24
+ me {
25
+ accountId email xHandle xid isPayingCustomer
26
+ indexingAccounts indexedTweets pendingEmbeddings
27
+ twitterIndexedAt refreshedSuggestionsAt
28
+ }
29
+ suggestionCounts {
30
+ inbox later archived total
31
+ }
32
+ usage {
33
+ plan
34
+ interests { used limit atLimit }
35
+ bookmarksEnabled
36
+ suggestionRefreshes { used limit atLimit resetsAt }
37
+ }
38
+ }
39
+ `;
40
+ function timeAgo(iso) {
41
+ if (!iso)
42
+ return 'never';
43
+ return formatDistanceToNow(new Date(iso), { addSuffix: true });
44
+ }
45
+ export default function Status({ options: flags }) {
46
+ const { exit } = useApp();
47
+ const [data, setData] = useState(null);
48
+ const [error, setError] = useState(null);
49
+ useEffect(() => {
50
+ const token = getToken();
51
+ const baseUrl = getApiUrl().replace(/\/graphql$/, '');
52
+ async function fetchStatus() {
53
+ const controller = new AbortController();
54
+ const timer = setTimeout(() => controller.abort(), 10_000);
55
+ try {
56
+ const [statusRes, gqlRes] = await Promise.all([
57
+ fetch(`${baseUrl}/indexing/status`, {
58
+ signal: controller.signal,
59
+ headers: { Authorization: `Bearer ${token}` },
60
+ }),
61
+ gql(GQL_QUERY),
62
+ ]);
63
+ clearTimeout(timer);
64
+ if (!statusRes.ok)
65
+ throw new Error(`HTTP ${statusRes.status}`);
66
+ const status = await statusRes.json();
67
+ if (flags.json) {
68
+ process.stdout.write(JSON.stringify({ ...gqlRes, queues: status.queues }, null, 2) + '\n');
69
+ process.exit(0);
70
+ }
71
+ setData({ me: gqlRes.me, queues: status.queues, pipeline: status.pipeline ?? null, usage: gqlRes.usage, suggestionCounts: gqlRes.suggestionCounts });
72
+ setError(null);
73
+ }
74
+ catch (err) {
75
+ clearTimeout(timer);
76
+ if (err instanceof DOMException && err.name === 'AbortError') {
77
+ setError('Request timed out (10s)');
78
+ }
79
+ else {
80
+ setError(err instanceof Error ? err.message : String(err));
81
+ }
82
+ }
83
+ }
84
+ fetchStatus();
85
+ if (!flags.watch)
86
+ return;
87
+ const timer = setInterval(fetchStatus, POLL_INTERVAL);
88
+ return () => clearInterval(timer);
89
+ }, []);
90
+ useEffect(() => { if (!flags.watch && data !== null)
91
+ exit(); }, [data]);
92
+ useEffect(() => { if (!flags.watch && error !== null)
93
+ exit(new Error(error)); }, [error]);
94
+ const [refreshing, setRefreshing] = useState(false);
95
+ const [refreshMsg, setRefreshMsg] = useState(null);
96
+ useInput((input, key) => {
97
+ if (!flags.watch)
98
+ return;
99
+ if (input === 'r' && !refreshing) {
100
+ setRefreshing(true);
101
+ setRefreshMsg(null);
102
+ gql('mutation Refresh { refresh(days: 1) }')
103
+ .then(() => {
104
+ setRefreshMsg('pipeline queued');
105
+ setRefreshing(false);
106
+ })
107
+ .catch((err) => {
108
+ setRefreshMsg(err instanceof Error ? err.message : String(err));
109
+ setRefreshing(false);
110
+ });
111
+ }
112
+ if (input === 'q')
113
+ exit();
114
+ }, { isActive: flags.watch });
115
+ if (error)
116
+ return _jsxs(Text, { color: "red", children: ["Error: ", error] });
117
+ if (!data)
118
+ return _jsx(Spinner, { label: "Loading..." });
119
+ const { me, queues, pipeline, usage, suggestionCounts } = data;
120
+ const entries = Object.entries(queues);
121
+ const hasActivity = entries.length > 0 || me.pendingEmbeddings > 0 || (pipeline !== null && pipeline.status === 'running');
122
+ const embedded = me.indexedTweets - me.pendingEmbeddings;
123
+ const embedPct = me.indexedTweets > 0 ? Math.round((embedded / me.indexedTweets) * 100) : 100;
124
+ const BAR_WIDTH = 20;
125
+ const filledCount = Math.round((embedPct / 100) * BAR_WIDTH);
126
+ const progressBar = '█'.repeat(filledCount) + '░'.repeat(BAR_WIDTH - filledCount);
127
+ return (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { bold: true, color: "cyan", children: ["@", me.xHandle] }), _jsxs(Text, { dimColor: true, children: [me.indexedTweets.toLocaleString(), " tweets", ' · ', "indexed ", timeAgo(me.twitterIndexedAt), ' · ', "refreshed ", timeAgo(me.refreshedSuggestionsAt)] })] }), me.pendingEmbeddings > 0 && (_jsx(Box, { flexDirection: "column", children: _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "embeddings " }), _jsx(Text, { color: embedPct === 100 ? 'green' : 'yellow', children: progressBar }), _jsxs(Text, { dimColor: true, children: [" ", embedPct, "% "] }), _jsxs(Text, { dimColor: true, children: ["(", embedded.toLocaleString(), "/", me.indexedTweets.toLocaleString(), ")"] })] }) })), _jsxs(Box, { borderStyle: "round", borderColor: "gray", flexDirection: "column", paddingX: 1, children: [usage && (_jsxs(Box, { gap: 2, children: [_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "plan " }), _jsx(Text, { color: usage.plan === 'trial' ? 'yellow' : 'green', children: usage.plan })] }), _jsx(Text, { dimColor: true, children: "\u2502" }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "topics " }), _jsxs(Text, { color: usage.interests.atLimit ? 'red' : undefined, children: [usage.interests.used, usage.interests.limit !== null ? `/${usage.interests.limit}` : ''] })] }), _jsx(Text, { dimColor: true, children: "\u2502" }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "refreshes " }), usage.suggestionRefreshes.limit !== null ? (_jsxs(Text, { color: usage.suggestionRefreshes.atLimit ? 'red' : undefined, children: [usage.suggestionRefreshes.used, "/", usage.suggestionRefreshes.limit] })) : (_jsx(Text, { color: "green", children: "unlimited" }))] })] })), _jsxs(Box, { gap: 2, children: [_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "inbox " }), _jsx(Text, { color: suggestionCounts.inbox > 0 ? 'green' : undefined, children: suggestionCounts.inbox })] }), _jsx(Text, { dimColor: true, children: "\u2502" }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "later " }), suggestionCounts.later] }), _jsx(Text, { dimColor: true, children: "\u2502" }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "archived " }), suggestionCounts.archived] })] })] }), pipeline && pipeline.status === 'running' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, dimColor: true, children: "PIPELINE" }), pipeline.steps.map((step, i) => (_jsxs(Text, { children: [_jsx(Text, { color: "green", children: " \u2713 " }), _jsx(Text, { children: step.label }), _jsxs(Text, { dimColor: true, children: [" (", step.duration, "s)"] })] }, i))), pipeline.current !== '' && (_jsxs(Box, { children: [_jsx(Text, { children: " " }), _jsx(Spinner, { label: pipeline.current })] }))] })), pipeline && pipeline.status === 'complete' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, dimColor: true, children: "PIPELINE" }), pipeline.steps.map((step, i) => (_jsxs(Text, { children: [_jsx(Text, { color: "green", children: " \u2713 " }), _jsx(Text, { children: step.label }), _jsxs(Text, { dimColor: true, children: [" (", step.duration, "s)"] })] }, i))), _jsxs(Text, { color: "green", children: [" \u2713 Complete (", pipeline.total_duration, "s)"] })] })), pipeline && pipeline.status === 'failed' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, dimColor: true, children: "PIPELINE" }), pipeline.steps.map((step, i) => (_jsxs(Text, { children: [_jsx(Text, { color: "green", children: " \u2713 " }), _jsx(Text, { children: step.label }), _jsxs(Text, { dimColor: true, children: [" (", step.duration, "s)"] })] }, i))), _jsx(Text, { color: "red", children: " \u2717 Failed" })] })), me.pendingEmbeddings > 0 && !(pipeline && pipeline.status === 'running') && (_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: 'Embeddings'.padEnd(16) }), _jsxs(Text, { color: "yellow", children: ["\u25CF ", me.pendingEmbeddings.toLocaleString(), " pending"] })] })), entries.filter(([name]) => name !== 'default').length > 0 && !(pipeline && pipeline.status === 'running') && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, dimColor: true, children: "QUEUES" }), entries.filter(([name]) => name !== 'default').map(([name, counts]) => (_jsxs(Text, { children: [_jsxs(Text, { dimColor: true, children: [" ", (QUEUE_LABELS[name] ?? name).padEnd(16)] }), counts.running > 0 && _jsxs(Text, { color: "green", children: ["\u25B6 ", counts.running, " running "] }), counts.queued > 0 && _jsxs(Text, { color: "yellow", children: ["\u25CF ", counts.queued, " queued "] }), (counts.deferred ?? 0) > 0 && _jsxs(Text, { color: "blue", children: ["\u25C6 ", counts.deferred, " pending "] })] }, name)))] })), !hasActivity && (_jsxs(Text, { dimColor: true, children: ["idle \u2014 run ", _jsx(Text, { color: "cyan", children: "sonar refresh" }), " to trigger pipeline"] })), flags.watch && (_jsxs(Box, { gap: 2, children: [refreshing && _jsx(Text, { color: "yellow", children: "refreshing..." }), refreshMsg && _jsx(Text, { color: "green", children: refreshMsg }), _jsxs(Text, { dimColor: true, children: ["press ", _jsx(Text, { color: "cyan", children: "r" }), " to refresh \u00B7 ", _jsx(Text, { color: "cyan", children: "q" }), " to quit"] })] }))] }));
128
+ }
@@ -0,0 +1,35 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useState } from 'react';
3
+ import { Box, Text, useApp } from 'ink';
4
+ import { gql } from '../../lib/client.js';
5
+ import { Spinner } from '../../components/Spinner.js';
6
+ export default function SyncBookmarks() {
7
+ const { exit } = useApp();
8
+ const [status, setStatus] = useState('pending');
9
+ const [error, setError] = useState(null);
10
+ useEffect(() => {
11
+ async function run() {
12
+ setStatus('running');
13
+ try {
14
+ await gql('mutation SyncBookmarks { syncBookmarks }');
15
+ setStatus('ok');
16
+ }
17
+ catch (err) {
18
+ setStatus('failed');
19
+ setError(err instanceof Error ? err.message : String(err));
20
+ }
21
+ }
22
+ run();
23
+ }, []);
24
+ useEffect(() => {
25
+ if (status === 'ok' || status === 'failed')
26
+ exit();
27
+ }, [status]);
28
+ if (status === 'running') {
29
+ return _jsx(Spinner, { label: "Syncing bookmarks..." });
30
+ }
31
+ if (status === 'failed') {
32
+ return _jsxs(Text, { color: "red", children: ["Error: ", error] });
33
+ }
34
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "green", children: "\u2713 Bookmark sync queued" }), _jsxs(Text, { dimColor: true, children: ["Run ", _jsx(Text, { color: "cyan", children: "sonar status --watch" }), " to monitor progress."] })] }));
35
+ }
@@ -0,0 +1,71 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useState } from 'react';
3
+ import zod from 'zod';
4
+ import { Box, Text } from 'ink';
5
+ import { gql } from '../../lib/client.js';
6
+ import { Spinner } from '../../components/Spinner.js';
7
+ export const args = zod.tuple([
8
+ zod.string().describe('Topic name or phrase'),
9
+ ]);
10
+ export const options = zod.object({
11
+ description: zod.string().optional().describe('Optional description (auto-generated if omitted)'),
12
+ json: zod.boolean().default(false).describe('Raw JSON output'),
13
+ });
14
+ const CREATE_MUTATION = `
15
+ mutation CreateOrUpdateTopic(
16
+ $name: String!
17
+ $description: String
18
+ ) {
19
+ createOrUpdateTopic(input: {
20
+ name: $name
21
+ description: $description
22
+ }) {
23
+ id: nanoId
24
+ name
25
+ description
26
+ version
27
+ createdAt
28
+ updatedAt
29
+ }
30
+ }
31
+ `;
32
+ export default function TopicsAdd({ args: [name], options: flags }) {
33
+ const [data, setData] = useState(null);
34
+ const [error, setError] = useState(null);
35
+ useEffect(() => {
36
+ if (!error || !flags.json)
37
+ return;
38
+ process.stderr.write(`${error}\n`);
39
+ process.exit(1);
40
+ }, [error, flags.json]);
41
+ useEffect(() => {
42
+ async function run() {
43
+ try {
44
+ const result = await gql(CREATE_MUTATION, {
45
+ name,
46
+ description: flags.description ?? null,
47
+ });
48
+ if (flags.json) {
49
+ process.stdout.write(JSON.stringify(result.createOrUpdateTopic, null, 2) + '\n');
50
+ process.exit(0);
51
+ }
52
+ setData(result.createOrUpdateTopic);
53
+ }
54
+ catch (err) {
55
+ setError(err instanceof Error ? err.message : String(err));
56
+ }
57
+ }
58
+ run();
59
+ }, []);
60
+ if (error) {
61
+ if (flags.json)
62
+ return _jsx(_Fragment, {});
63
+ return _jsxs(Text, { color: "red", children: ["Error: ", error] });
64
+ }
65
+ if (!data) {
66
+ if (flags.json)
67
+ return _jsx(_Fragment, {});
68
+ return _jsx(Spinner, { label: "Creating topic (expanding via AI)..." });
69
+ }
70
+ return (_jsxs(Box, { flexDirection: "column", gap: 0, children: [_jsxs(Box, { gap: 2, children: [_jsx(Text, { bold: true, color: "cyan", children: data.name }), _jsxs(Text, { dimColor: true, children: ["v", data.version, " \u00B7 ", data.id, " \u00B7 created"] })] }), data.description && _jsx(Text, { dimColor: true, children: data.description }), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { dimColor: true, children: "tip run " }), _jsx(Text, { color: "cyan", children: "sonar refresh" }), _jsx(Text, { dimColor: true, children: " to match this topic against recent tweets" })] })] }));
71
+ }
@@ -0,0 +1,42 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ import { useEffect, useState } from 'react';
3
+ import zod from 'zod';
4
+ import { Box, Text } from 'ink';
5
+ import { gql } from '../../lib/client.js';
6
+ import { Spinner } from '../../components/Spinner.js';
7
+ export const args = zod.tuple([
8
+ zod.string().describe('Topic ID'),
9
+ ]);
10
+ export const options = zod.object({
11
+ json: zod.boolean().default(false).describe('Raw JSON output'),
12
+ });
13
+ const DELETE_MUTATION = `
14
+ mutation DeleteTopic($nanoId: String!) {
15
+ deleteTopic(nanoId: $nanoId)
16
+ }
17
+ `;
18
+ export default function TopicDelete({ args: [id], options: flags }) {
19
+ const [done, setDone] = useState(false);
20
+ const [error, setError] = useState(null);
21
+ useEffect(() => {
22
+ async function run() {
23
+ try {
24
+ await gql(DELETE_MUTATION, { nanoId: id });
25
+ if (flags.json) {
26
+ process.stdout.write(JSON.stringify({ deleted: id }) + '\n');
27
+ process.exit(0);
28
+ }
29
+ setDone(true);
30
+ }
31
+ catch (err) {
32
+ setError(err instanceof Error ? err.message : String(err));
33
+ }
34
+ }
35
+ run();
36
+ }, []);
37
+ if (error)
38
+ return _jsxs(Text, { color: "red", children: ["Error: ", error] });
39
+ if (!done)
40
+ return _jsx(Spinner, { label: "Deleting topic..." });
41
+ return (_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: "green", children: "Deleted" }), _jsx(Text, { dimColor: true, children: id })] }));
42
+ }
@@ -0,0 +1,97 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useState } from 'react';
3
+ import zod from 'zod';
4
+ import { Box, Text } from 'ink';
5
+ import { gql } from '../../lib/client.js';
6
+ import { Spinner } from '../../components/Spinner.js';
7
+ export const args = zod.tuple([
8
+ zod.string().describe('Topic ID'),
9
+ ]);
10
+ export const options = zod.object({
11
+ name: zod.string().optional().describe('New name'),
12
+ description: zod.string().optional().describe('New description'),
13
+ json: zod.boolean().default(false).describe('Raw JSON output'),
14
+ });
15
+ const QUERY = `
16
+ query Topics {
17
+ topics {
18
+ id: nanoId
19
+ name
20
+ description
21
+ version
22
+ createdAt
23
+ updatedAt
24
+ }
25
+ }
26
+ `;
27
+ const UPDATE_MUTATION = `
28
+ mutation CreateOrUpdateTopic(
29
+ $nanoId: String
30
+ $name: String!
31
+ $description: String
32
+ ) {
33
+ createOrUpdateTopic(input: {
34
+ nanoId: $nanoId
35
+ name: $name
36
+ description: $description
37
+ }) {
38
+ id: nanoId
39
+ name
40
+ description
41
+ version
42
+ createdAt
43
+ updatedAt
44
+ }
45
+ }
46
+ `;
47
+ async function fetchById(id) {
48
+ const result = await gql(QUERY);
49
+ const found = result.topics.find((p) => p.id === id);
50
+ if (!found)
51
+ throw new Error(`Topic "${id}" not found. Run: sonar topics`);
52
+ return found;
53
+ }
54
+ export default function TopicEdit({ args: [id], options: flags }) {
55
+ const [data, setData] = useState(null);
56
+ const [error, setError] = useState(null);
57
+ useEffect(() => {
58
+ if (!error || !flags.json)
59
+ return;
60
+ process.stderr.write(`${error}\n`);
61
+ process.exit(1);
62
+ }, [error, flags.json]);
63
+ useEffect(() => {
64
+ async function run() {
65
+ try {
66
+ const existing = await fetchById(id);
67
+ const name = flags.name ?? existing.name;
68
+ const description = flags.description ?? existing.description ?? null;
69
+ const result = await gql(UPDATE_MUTATION, {
70
+ nanoId: id,
71
+ name,
72
+ description,
73
+ });
74
+ if (flags.json) {
75
+ process.stdout.write(JSON.stringify(result.createOrUpdateTopic, null, 2) + '\n');
76
+ process.exit(0);
77
+ }
78
+ setData(result.createOrUpdateTopic);
79
+ }
80
+ catch (err) {
81
+ setError(err instanceof Error ? err.message : String(err));
82
+ }
83
+ }
84
+ run();
85
+ }, []);
86
+ if (error) {
87
+ if (flags.json)
88
+ return _jsx(_Fragment, {});
89
+ return _jsxs(Text, { color: "red", children: ["Error: ", error] });
90
+ }
91
+ if (!data) {
92
+ if (flags.json)
93
+ return _jsx(_Fragment, {});
94
+ return _jsx(Spinner, { label: "Updating topic..." });
95
+ }
96
+ return (_jsxs(Box, { flexDirection: "column", gap: 0, children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: data.name }), _jsxs(Text, { dimColor: true, children: ["v", data.version, " \u00B7 ", data.id, " \u00B7 updated"] })] }), data.description && _jsxs(Text, { dimColor: true, children: [data.description.slice(0, 160), "..."] })] }));
97
+ }
@@ -0,0 +1,54 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ import { useEffect, useState } from 'react';
3
+ import zod from 'zod';
4
+ import { Box, Text, useStdout } from 'ink';
5
+ import { gql } from '../../lib/client.js';
6
+ import { Spinner } from '../../components/Spinner.js';
7
+ import { TopicCard } from '../../components/TopicCard.js';
8
+ export const options = zod.object({
9
+ json: zod.boolean().default(false).describe('Raw JSON output'),
10
+ });
11
+ const QUERY = `
12
+ query Topics {
13
+ topics {
14
+ id: nanoId
15
+ name
16
+ description
17
+ version
18
+ createdAt
19
+ updatedAt
20
+ }
21
+ }
22
+ `;
23
+ export default function Topics({ options: flags }) {
24
+ const [data, setData] = useState(null);
25
+ const [error, setError] = useState(null);
26
+ const { stdout } = useStdout();
27
+ const termWidth = stdout.columns ?? 100;
28
+ useEffect(() => {
29
+ async function run() {
30
+ try {
31
+ const result = await gql(QUERY);
32
+ if (flags.json) {
33
+ process.stdout.write(JSON.stringify(result.topics, null, 2) + '\n');
34
+ process.exit(0);
35
+ }
36
+ setData(result.topics);
37
+ }
38
+ catch (err) {
39
+ setError(err instanceof Error ? err.message : String(err));
40
+ }
41
+ }
42
+ run();
43
+ }, []);
44
+ if (error) {
45
+ return _jsxs(Text, { color: "red", children: ["Error: ", error] });
46
+ }
47
+ if (!data) {
48
+ return _jsx(Spinner, { label: "Fetching topics..." });
49
+ }
50
+ if (data.length === 0) {
51
+ return (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsx(Text, { children: "No topics found. Add one:" }), _jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: " sonar topics add \"AI agents\"" }), _jsx(Text, { dimColor: true, children: " sonar topics add \"Rust and systems programming\"" }), _jsx(Text, { dimColor: true, children: " sonar topics add \"DeFi protocols\"" })] })] }));
52
+ }
53
+ return (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, children: "Topics" }), _jsxs(Text, { dimColor: true, children: [" (", data.length, ")"] })] }), data.map((p, i) => (_jsx(TopicCard, { topic: p, termWidth: termWidth, isLast: i === data.length - 1 }, p.id))), _jsx(Text, { dimColor: true, children: "view: sonar topics view <id> \u00B7 edit: sonar topics edit <id> --name \"new name\"" })] }));
54
+ }
@@ -0,0 +1,125 @@
1
+ import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useCallback, useEffect, useState } from 'react';
3
+ import zod from 'zod';
4
+ import { Box, Text, useInput } from 'ink';
5
+ import { gql } from '../../lib/client.js';
6
+ import { getVendor } from '../../lib/config.js';
7
+ import { generateTopicSuggestions } from '../../lib/ai.js';
8
+ import { Spinner } from '../../components/Spinner.js';
9
+ export const options = zod.object({
10
+ vendor: zod.string().optional().describe('AI vendor: openai|anthropic'),
11
+ count: zod.number().optional().describe('Number of suggestions (default: 5)'),
12
+ json: zod.boolean().default(false).describe('Raw JSON output'),
13
+ });
14
+ const TOPICS_QUERY = `
15
+ query Topics {
16
+ topics { id: nanoId name description }
17
+ }
18
+ `;
19
+ const FEED_QUERY = `
20
+ query Feed($hours: Int, $limit: Int) {
21
+ feed(hours: $hours, limit: $limit) {
22
+ tweet { text }
23
+ }
24
+ }
25
+ `;
26
+ const CREATE_MUTATION = `
27
+ mutation CreateOrUpdateTopic($name: String!, $description: String) {
28
+ createOrUpdateTopic(input: { name: $name, description: $description }) {
29
+ id: nanoId name description version createdAt updatedAt
30
+ }
31
+ }
32
+ `;
33
+ export default function TopicsSuggest({ options: flags }) {
34
+ const vendor = getVendor(flags.vendor);
35
+ const count = flags.count ?? 5;
36
+ const [phase, setPhase] = useState('loading');
37
+ const [suggestions, setSuggestions] = useState([]);
38
+ const [index, setIndex] = useState(0);
39
+ const [accepted, setAccepted] = useState([]);
40
+ const [error, setError] = useState(null);
41
+ const [saving, setSaving] = useState(false);
42
+ // Phase 1: Fetch context, Phase 2: Generate suggestions
43
+ useEffect(() => {
44
+ async function run() {
45
+ try {
46
+ const [topicsRes, feedRes] = await Promise.all([
47
+ gql(TOPICS_QUERY),
48
+ gql(FEED_QUERY, { hours: 24, limit: 15 }),
49
+ ]);
50
+ const existingNames = topicsRes.topics.map(t => t.name);
51
+ const tweetTexts = feedRes.feed.map(f => f.tweet.text);
52
+ setPhase('suggesting');
53
+ const results = await generateTopicSuggestions(existingNames, tweetTexts, count, vendor);
54
+ if (flags.json) {
55
+ process.stdout.write(JSON.stringify(results, null, 2) + '\n');
56
+ process.exit(0);
57
+ }
58
+ setSuggestions(results);
59
+ setPhase('reviewing');
60
+ }
61
+ catch (err) {
62
+ setError(err instanceof Error ? err.message : String(err));
63
+ }
64
+ }
65
+ run();
66
+ }, []);
67
+ const current = suggestions[index];
68
+ const acceptCurrent = useCallback(async () => {
69
+ if (!current || saving)
70
+ return;
71
+ setSaving(true);
72
+ try {
73
+ await gql(CREATE_MUTATION, {
74
+ name: current.name,
75
+ description: current.description,
76
+ });
77
+ setAccepted(prev => [...prev, current.name]);
78
+ }
79
+ catch (err) {
80
+ process.stderr.write(`Failed to save "${current.name}": ${err instanceof Error ? err.message : String(err)}\n`);
81
+ }
82
+ setSaving(false);
83
+ if (index + 1 >= suggestions.length) {
84
+ setPhase('done');
85
+ }
86
+ else {
87
+ setIndex(i => i + 1);
88
+ }
89
+ }, [current, index, suggestions.length, saving]);
90
+ const skipCurrent = useCallback(() => {
91
+ if (saving)
92
+ return;
93
+ if (index + 1 >= suggestions.length) {
94
+ setPhase('done');
95
+ }
96
+ else {
97
+ setIndex(i => i + 1);
98
+ }
99
+ }, [index, suggestions.length, saving]);
100
+ useInput((input) => {
101
+ if (phase !== 'reviewing')
102
+ return;
103
+ if (input === 'y')
104
+ acceptCurrent();
105
+ else if (input === 'n')
106
+ skipCurrent();
107
+ else if (input === 'q')
108
+ setPhase('done');
109
+ }, { isActive: phase === 'reviewing' && !saving });
110
+ if (error)
111
+ return _jsxs(Text, { color: "red", children: ["Error: ", error] });
112
+ if (phase === 'loading') {
113
+ return _jsx(Spinner, { label: "Analyzing your interests and feed..." });
114
+ }
115
+ if (phase === 'suggesting') {
116
+ return _jsx(Spinner, { label: `Generating ${count} topic suggestions via ${vendor}...` });
117
+ }
118
+ if (phase === 'done') {
119
+ return (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsx(Text, { color: "green", children: accepted.length > 0
120
+ ? `Added ${accepted.length} topic${accepted.length === 1 ? '' : 's'}`
121
+ : 'No topics added' }), accepted.map(name => (_jsxs(Text, { dimColor: true, children: [" + ", name] }, name))), accepted.length > 0 && (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { dimColor: true, children: "tip run " }), _jsx(Text, { color: "cyan", children: "sonar refresh" }), _jsx(Text, { dimColor: true, children: " to match new topics against recent tweets" })] }))] }));
122
+ }
123
+ // Phase: reviewing
124
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { marginBottom: 1, children: [_jsxs(Text, { dimColor: true, children: ["[", index + 1, "/", suggestions.length, "]"] }), accepted.length > 0 && _jsxs(Text, { color: "green", children: [" ", accepted.length, " accepted"] })] }), _jsxs(Box, { flexDirection: "column", gap: 0, children: [_jsx(Text, { bold: true, color: "cyan", children: current.name }), current.description && (_jsx(Box, { marginTop: 1, paddingLeft: 2, children: _jsxs(Text, { wrap: "wrap", children: [current.description.slice(0, 300), current.description.length > 300 ? '...' : ''] }) })), current.keywords.length > 0 && (_jsxs(Box, { marginTop: 1, paddingLeft: 2, children: [_jsx(Text, { dimColor: true, children: "keywords " }), _jsx(Text, { color: "yellow", children: current.keywords.slice(0, 10).join(' ') })] }))] }), _jsx(Box, { marginTop: 1, gap: 3, children: saving ? (_jsx(Spinner, { label: "Saving..." })) : (_jsxs(_Fragment, { children: [_jsxs(Text, { dimColor: true, children: [_jsx(Text, { color: "white", children: "y" }), " accept"] }), _jsxs(Text, { dimColor: true, children: [_jsx(Text, { color: "white", children: "n" }), " skip"] }), _jsxs(Text, { dimColor: true, children: [_jsx(Text, { color: "white", children: "q" }), " quit"] })] })) })] }));
125
+ }
@@ -0,0 +1,48 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ import { useEffect, useState } from 'react';
3
+ import zod from 'zod';
4
+ import { Box, Text } from 'ink';
5
+ import { gql } from '../../lib/client.js';
6
+ import { Spinner } from '../../components/Spinner.js';
7
+ export const args = zod.tuple([
8
+ zod.string().describe('Topic ID'),
9
+ ]);
10
+ const QUERY = `
11
+ query Topics {
12
+ topics {
13
+ id: nanoId
14
+ name
15
+ description
16
+ version
17
+ createdAt
18
+ updatedAt
19
+ }
20
+ }
21
+ `;
22
+ export default function TopicView({ args: [id] }) {
23
+ const [data, setData] = useState(null);
24
+ const [error, setError] = useState(null);
25
+ useEffect(() => {
26
+ async function run() {
27
+ try {
28
+ const result = await gql(QUERY);
29
+ const found = result.topics.find((p) => p.id === id);
30
+ if (!found)
31
+ throw new Error(`Topic "${id}" not found. Run: sonar topics`);
32
+ setData(found);
33
+ }
34
+ catch (err) {
35
+ setError(err instanceof Error ? err.message : String(err));
36
+ }
37
+ }
38
+ run();
39
+ }, []);
40
+ if (error)
41
+ return _jsxs(Text, { color: "red", children: ["Error: ", error] });
42
+ if (!data)
43
+ return _jsx(Spinner, { label: "Loading topic..." });
44
+ const updatedAt = new Date(data.updatedAt).toLocaleDateString('en-US', {
45
+ month: 'short', day: 'numeric', year: 'numeric',
46
+ });
47
+ return (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: data.name }), _jsxs(Text, { dimColor: true, children: ["v", data.version, " \u00B7 ", data.id, " \u00B7 ", updatedAt] })] }), data.description && (_jsx(Box, { paddingLeft: 2, children: _jsx(Text, { wrap: "wrap", children: data.description }) })), _jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { dimColor: true, children: ["edit: sonar topics edit ", data.id, " --name \"new name\""] }), _jsxs(Text, { dimColor: true, children: ["delete: sonar topics delete ", data.id] })] })] }));
48
+ }
@@ -2,5 +2,5 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
3
  import { formatDistanceToNow } from 'date-fns';
4
4
  export function AccountCard({ me }) {
5
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, color: "cyan", children: "Account" }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "handle: " }), "@", me.xHandle] }), me.email && _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "email: " }), me.email] }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "plan: " }), me.isPayingCustomer ? 'Pro' : 'Free'] }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "indexing accounts: " }), me.indexingAccounts] }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "indexed tweets: " }), me.indexedTweets.toLocaleString()] }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "embeddings queue: " }), _jsx(Text, { color: me.pendingEmbeddings > 0 ? 'yellow' : 'green', children: me.pendingEmbeddings })] }), me.twitterIndexedAt && (_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "last indexed: " }), formatDistanceToNow(new Date(me.twitterIndexedAt), { addSuffix: true })] })), me.refreshedSuggestionsAt && (_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "suggestions refreshed: " }), formatDistanceToNow(new Date(me.refreshedSuggestionsAt), { addSuffix: true })] }))] }));
5
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, color: "cyan", children: "Account" }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "handle: " }), "@", me.xHandle] }), me.email && _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "email: " }), me.email] }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "plan: " }), me.isPayingCustomer ? 'Pro' : 'Free'] }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "indexing accounts: " }), me.indexingAccounts] }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "indexed tweets: " }), me.indexedTweets.toLocaleString()] }), me.twitterIndexedAt && (_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "last indexed: " }), formatDistanceToNow(new Date(me.twitterIndexedAt), { addSuffix: true })] })), me.refreshedSuggestionsAt && (_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "suggestions refreshed: " }), formatDistanceToNow(new Date(me.refreshedSuggestionsAt), { addSuffix: true })] }))] }));
6
6
  }
@@ -0,0 +1,11 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Text } from 'ink';
3
+ const LOGO = `
4
+ _|_|_| _|_| _| _| _|_| _|_|_|
5
+ _| _| _| _|_| _| _| _| _| _|
6
+ _|_| _| _| _| _| _| _|_|_|_| _|_|_|
7
+ _| _| _| _| _|_| _| _| _| _|
8
+ _|_|_| _|_| _| _| _| _| _| _|`.trimStart();
9
+ export function Banner() {
10
+ return _jsx(Text, { dimColor: true, children: LOGO });
11
+ }