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
85 changes: 85 additions & 0 deletions default/skills/openalice-cli/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
---
name: openalice-cli
description: >
How to reach OpenAlice's market data from your shell via the `alice` CLI —
news, market symbol search, equity fundamentals, macro/economy series, and
technical indicators, as plain `alice <group> <verb> --flags` commands that
print JSON. Use this whenever you need a number, a headline, a fundamental, or
an indicator and this workspace exposes the `alice` command instead of (or
alongside) the OpenAlice MCP tools: "look up AAPL", "find the symbol for
bitcoin", "what's Apple's revenue", "search the news for the Fed", "compute
RSI", "pull unemployment from FRED". Discover everything live with
`alice --help` — do NOT guess flags.
---

# Using the `alice` CLI

`alice` is OpenAlice's market-data layer on your shell PATH. It talks to the
same backend the `openalice` MCP tools do — it's just the CLI front-end, handy
for piping, grepping, and quick scripted lookups. **Prefer it for data reads**
in this workspace.

## Discover, don't guess

The command tree and every flag are served live. Always start here rather than
guessing — the surface can change without this skill changing:

```bash
alice --help # list command groups
alice <group> --help # list verbs in a group
alice <group> <verb> --help # show a verb's flags (which are required)
```

## Shape

```
alice <group> <verb> [--flag value] [--flag=value]
```

- **Output is JSON on stdout.** Pipe it: `alice market search --query AAPL | jq '.results[0]'`.
- **A non-zero exit means it failed**; the error goes to stderr. Check it.
- Groups you'll typically see: `news`, `market`, `equity`, `economy`,
`analysis`, `think` (confirm with `alice --help` — that's authoritative).

## Common workflows

**Find a symbol, then pull fundamentals:**

```bash
alice market search --query "apple"
alice equity profile --symbol AAPL
alice equity financials --symbol AAPL --type income --period annual --limit 5
```

**Scan news, then read one article by its stable id:**

```bash
alice news grep --pattern "interest rate" --lookback 2d
alice news read --id <id-from-the-results>
```

The `id` is stable — you do **not** need to repeat `--lookback` to read it.

**Macro and indicators:**

```bash
alice economy fred-search --query unemployment # find the series id
alice economy fred-series --symbol UNRATE --limit 12
alice analysis indicator --asset equity --formula "RSI(CLOSE('AAPL','1d'),14)"
```

**Filter news by metadata** — `--meta` is repeatable and maps to the
metadata filter:

```bash
alice news grep --pattern BTC --meta source=coindesk --meta category=crypto
```

## What `alice` is NOT for

- **Trading and scheduling are not on the CLI** — placing/closing orders,
cron, etc. stay on the OpenAlice MCP tools by design. If you need those and
they aren't available here, say so rather than improvising.
- **Handing finished work back to the user** goes through the **inbox**
(`inbox_push` MCP tool), not the CLI. `alice` is for reading data; the inbox
is the outbound channel. Use each for its job.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@
"dist/electron/**",
"ui/dist/**",
"default/**",
"src/workspaces/cli/**",
"package.json"
],
"mac": {
Expand Down
14 changes: 14 additions & 0 deletions src/core/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,20 @@ export function templatesPath(): string {
return resolve(APP_RESOURCES_HOME, 'src', 'workspaces', 'templates')
}

/**
* Dir holding the workspace-local `alice` CLI shim, prepended to each PTY's
* PATH so a native agent can run `alice ...` from its shell. A single shared,
* env-driven script (it reads OPENALICE_MCP_URL + AQ_WS_ID at runtime), so it
* is NOT written into individual workspaces and never enters their git repos.
*
* Rides APP_RESOURCES_HOME exactly like templatesPath(): repo source in dev,
* the bundler-copied location in a packaged .app. The same packaging caveat
* applies — build.files in package.json must ship `src/workspaces/cli/**`.
*/
export function cliBinPath(): string {
return resolve(APP_RESOURCES_HOME, 'src', 'workspaces', 'cli', 'bin')
}

/** Effective USER_DATA_HOME — exported for diagnostics / migration logic. */
export const userDataHome = USER_DATA_HOME

Expand Down
36 changes: 23 additions & 13 deletions src/domain/news/query/archive.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,29 +4,35 @@ import type { NewsItem } from '../types'

describe('news tools (pure functions)', () => {
// Mock news data
// Non-sequential ids on purpose: proves addressing is by stable id, not
// by position in the returned list.
const mockNews: NewsItem[] = [
{
id: 10,
time: new Date('2025-01-01T08:00:00Z'),
title: 'BTC breaks 50k resistance',
content:
'Bitcoin has broken the 50k resistance level after weeks of consolidation. Analysts predict further upside.',
metadata: { source: 'official', category: 'crypto' },
},
{
id: 20,
time: new Date('2025-01-01T10:00:00Z'),
title: 'ETH upgrade announcement',
content:
'Ethereum announces new upgrade scheduled for Q2. Gas fees expected to decrease significantly.',
metadata: { source: 'official', category: 'crypto' },
},
{
id: 30,
time: new Date('2025-01-01T12:00:00Z'),
title: 'Market analysis report',
content:
'Analysts predict bullish trend for Bitcoin and altcoins. Interest rate decisions may impact crypto.',
metadata: { source: 'analyst', category: 'analysis' },
},
{
id: 40,
time: new Date('2025-01-02T06:00:00Z'),
title: '', // Empty title - simulates untitled news
content:
Expand All @@ -44,7 +50,7 @@ describe('news tools (pure functions)', () => {
const results = await globNews(createContext(), { pattern: 'BTC' })

expect(results).toHaveLength(1)
expect(results[0].index).toBe(0)
expect(results[0].id).toBe(10)
expect(results[0].title).toBe('BTC breaks 50k resistance')
})

Expand Down Expand Up @@ -99,6 +105,7 @@ describe('news tools (pure functions)', () => {
it('should truncate long metadata', async () => {
const newsWithLongMetadata: NewsItem[] = [
{
id: 1,
time: new Date(),
title: 'Test',
content: 'Content',
Expand Down Expand Up @@ -132,7 +139,7 @@ describe('news tools (pure functions)', () => {
})

expect(results).toHaveLength(1)
expect(results[0].index).toBe(2)
expect(results[0].id).toBe(30)
expect(results[0].matchedText).toContain('Interest rate')
})

Expand Down Expand Up @@ -176,7 +183,7 @@ describe('news tools (pure functions)', () => {
})

expect(results).toHaveLength(1)
expect(results[0].index).toBe(2)
expect(results[0].id).toBe(30)
})

it('should respect limit', async () => {
Expand All @@ -192,7 +199,7 @@ describe('news tools (pure functions)', () => {
const results = await grepNews(createContext(), { pattern: 'Korea' })

expect(results).toHaveLength(1)
expect(results[0].index).toBe(3)
expect(results[0].id).toBe(40)
expect(results[0].title).toBe('')
})

Expand All @@ -205,28 +212,31 @@ describe('news tools (pure functions)', () => {
})

describe('readNews', () => {
it('should read news by index', async () => {
const result = await readNews(createContext(), { index: 1 })
it('should read news by stable id', async () => {
const result = await readNews(createContext(), { id: 20 })

expect(result).not.toBeNull()
expect(result!.title).toBe('ETH upgrade announcement')
expect(result!.content).toContain('Ethereum')
})

it('should return null for invalid index', async () => {
const result = await readNews(createContext(), { index: 100 })
it('should resolve by id regardless of position in the list', async () => {
// id 40 is the last item; a positional index of 40 would be out of range,
// proving addressing is by id, not by position.
const result = await readNews(createContext(), { id: 40 })

expect(result).toBeNull()
expect(result).not.toBeNull()
expect(result!.content).toContain('Korea')
})

it('should return null for negative index', async () => {
const result = await readNews(createContext(), { index: -1 })
it('should return null for unknown id', async () => {
const result = await readNews(createContext(), { id: 99999 })

expect(result).toBeNull()
})

it('should return full news item with all fields', async () => {
const result = await readNews(createContext(), { index: 0 })
const result = await readNews(createContext(), { id: 10 })

expect(result).toEqual(mockNews[0])
expect(result!.time).toBeInstanceOf(Date)
Expand All @@ -248,7 +258,7 @@ describe('news tools (pure functions)', () => {
})

it('readNews should return null', async () => {
const result = await readNews(emptyContext, { index: 0 })
const result = await readNews(emptyContext, { id: 0 })
expect(result).toBeNull()
})
})
Expand Down
44 changes: 21 additions & 23 deletions src/domain/news/query/archive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,14 @@ export interface NewsToolContext {
}

export interface GlobNewsResult {
index: number
id: number
title: string
contentLength: number
metadata: string
}

export interface GrepNewsResult {
index: number
id: number
title: string
matchedText: string
contentLength: number
Expand Down Expand Up @@ -59,13 +59,12 @@ export async function globNews(
const regex = new RegExp(options.pattern, 'i')
const results: GlobNewsResult[] = []

for (let i = 0; i < news.length; i++) {
const item = news[i]
for (const item of news) {
if (options.metadataFilter && !matchesMetadataFilter(item.metadata, options.metadataFilter)) continue
if (!regex.test(item.title)) continue

results.push({
index: i,
id: item.id,
title: item.title,
contentLength: item.content.length,
metadata: truncateMetadata(item.metadata),
Expand All @@ -92,8 +91,7 @@ export async function grepNews(
const contextChars = options.contextChars ?? 50
const results: GrepNewsResult[] = []

for (let i = 0; i < news.length; i++) {
const item = news[i]
for (const item of news) {
if (options.metadataFilter && !matchesMetadataFilter(item.metadata, options.metadataFilter)) continue

const searchText = `${item.title}\n${item.content}`
Expand All @@ -111,7 +109,7 @@ export async function grepNews(
if (contextEnd < searchText.length) matchedText += '...'

results.push({
index: i,
id: item.id,
title: item.title,
matchedText,
contentLength: item.content.length,
Expand All @@ -126,14 +124,13 @@ export async function grepNews(
return results
}

/** Read full news content by index (like "cat") */
/** Read full news content by stable id (like "cat") */
export async function readNews(
context: NewsToolContext,
options: { index: number },
options: { id: number },
): Promise<NewsItem | null> {
const news = await context.getNews()
if (options.index < 0 || options.index >= news.length) return null
return news[options.index]
return news.find((item) => item.id === options.id) ?? null
}

// ==================== AI Tool factory ====================
Expand All @@ -143,7 +140,9 @@ export function createNewsArchiveTools(provider: INewsProvider) {
globNews: tool({
description: `Search collected news archive by title pattern (like "ls" / "glob").

Returns matching headlines with index, title, content length, and metadata preview.
Returns matching headlines with a stable \`id\`, title, content length, and metadata preview.
Pass an \`id\` to readNews to read the full article — the id is stable across calls,
so you do NOT need to repeat your \`lookback\`.
Use this to quickly scan what's been happening in the market.

Search pool: the most recent ${NEWS_LIMIT} items within \`lookback\` (or the
Expand Down Expand Up @@ -193,21 +192,20 @@ Example: grepNews({ pattern: "interest rate", lookback: "2d" })`,
}),

readNews: tool({
description: `Read full content of a collected news item by index (like "cat").
description: `Read full content of a collected news item by stable id (like "cat").

Use after globNews/grepNews to read a specific article. The index addresses the
same ${NEWS_LIMIT}-item search pool used by globNews/grepNews — pass the SAME
\`lookback\` you used in the prior call, otherwise the indices will not align.`,
Use after globNews/grepNews to read a specific article — pass the \`id\` from their
results. The id is stable, so it resolves regardless of what \`lookback\` you used
to find the item (no need to repeat it).`,
inputSchema: z.object({
index: z.number().int().nonnegative().describe('News index from globNews/grepNews results'),
lookback: z.string().optional().describe(`Match the lookback from your prior globNews/grepNews call (addresses the same ${NEWS_LIMIT}-item pool)`),
id: z.number().int().nonnegative().describe('Stable news id from globNews/grepNews results'),
}),
execute: async ({ index, lookback }) => {
execute: async ({ id }) => {
const result = await readNews(
{ getNews: () => provider.getNewsV2({ endTime: new Date(), lookback, limit: NEWS_LIMIT }) },
{ index },
{ getNews: () => provider.getNewsV2({ endTime: new Date(), limit: NEWS_LIMIT }) },
{ id },
)
return result ?? { error: `News index ${index} not found` }
return result ?? { error: `News id ${id} not found` }
},
}),
}
Expand Down
1 change: 1 addition & 0 deletions src/domain/news/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,7 @@ export class NewsCollectorStore implements INewsProvider {

function recordToNewsItem(record: NewsRecord): NewsItem {
return {
id: record.seq,
time: new Date(record.pubTs),
title: record.title,
content: record.content,
Expand Down
4 changes: 4 additions & 0 deletions src/domain/news/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ export type IngestSource = 'rss'

/** A single news article (in-memory representation) */
export interface NewsItem {
/** Stable monotonic id (mirrors NewsRecord.seq). Survives across lookback
* windows, so it — not a positional index — is the addressing key the
* archive tools hand back and readNews resolves against. */
id: number
time: Date
title: string
content: string
Expand Down
Loading
Loading