@taurgis/bonsai 1.0.5 → 1.0.6
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.
- package/README.md +53 -0
- package/agents/agents/official-docs-researcher.agent.md +10 -1
- package/agents/agents/salesforce-docs-researcher.agent.md +10 -1
- package/agents/instructions/salesforce-research.instructions.md +4 -2
- package/agents/instructions/web-research.instructions.md +4 -2
- package/agents/skills/web-research/SKILL.md +8 -3
- package/bin/cli.mjs +30 -13
- package/dist/base-command.d.ts +3 -0
- package/dist/base-command.js +40 -7
- package/dist/commands/config/base.d.ts +2 -0
- package/dist/commands/config/base.js +15 -1
- package/dist/commands/config/get.d.ts +1 -1
- package/dist/commands/config/get.js +4 -5
- package/dist/commands/config/index.d.ts +12 -0
- package/dist/commands/config/index.js +34 -0
- package/dist/commands/config/list.js +2 -2
- package/dist/commands/config/set.js +18 -12
- package/dist/commands/config/unset.d.ts +1 -1
- package/dist/commands/config/unset.js +9 -6
- package/dist/commands/fetch.js +44 -34
- package/dist/commands/import.d.ts +3 -0
- package/dist/commands/import.js +82 -52
- package/dist/commands/inspect.d.ts +4 -0
- package/dist/commands/inspect.js +24 -19
- package/dist/commands/list.d.ts +2 -2
- package/dist/commands/list.js +23 -15
- package/dist/commands/prune.d.ts +1 -1
- package/dist/commands/prune.js +30 -13
- package/dist/commands/search.d.ts +4 -0
- package/dist/commands/search.js +83 -21
- package/dist/commands/status.d.ts +6 -0
- package/dist/commands/status.js +50 -24
- package/dist/commands.d.ts +2 -0
- package/dist/commands.js +2 -0
- package/dist/hooks/command-not-found/suggest.js +37 -8
- package/dist/lib/argv.d.ts +10 -0
- package/dist/lib/argv.js +53 -0
- package/dist/lib/envelope.d.ts +36 -0
- package/dist/lib/envelope.js +76 -0
- package/dist/lib/json-meta.d.ts +10 -0
- package/dist/lib/json-meta.js +98 -0
- package/dist/lib/research/revalidate.js +3 -1
- package/dist/lib/research/schema.d.ts +5 -2
- package/dist/lib/research/schema.js +12 -1
- package/dist/lib/text.d.ts +2 -0
- package/dist/lib/text.js +2 -0
- package/dist/sites/algolia.d.ts +9 -0
- package/dist/sites/algolia.js +26 -0
- package/dist/sites/auto.d.ts +8 -0
- package/dist/sites/auto.js +30 -0
- package/dist/sites/index.js +17 -1
- package/dist/sites/nextjs/index.d.ts +2 -0
- package/dist/sites/nextjs/index.js +52 -0
- package/dist/sites/react/index.d.ts +2 -0
- package/dist/sites/react/index.js +17 -0
- package/dist/sites/tailwind/index.d.ts +2 -0
- package/dist/sites/tailwind/index.js +17 -0
- package/dist/sites/vue/index.d.ts +2 -0
- package/dist/sites/vue/index.js +17 -0
- package/oclif.manifest.json +149 -76
- package/package.json +8 -2
package/dist/commands/list.js
CHANGED
|
@@ -3,51 +3,55 @@ import { BaseCommand } from '../base-command.js';
|
|
|
3
3
|
import { scanCacheDirs } from '../lib/research/storage.js';
|
|
4
4
|
import { loadStoreRoots } from '../lib/research/store-roots.js';
|
|
5
5
|
import { evaluateFreshness } from '../lib/research/freshness.js';
|
|
6
|
-
import {
|
|
6
|
+
import { ARTIFACT_TYPES, CAPTURE_METHODS } from '../lib/research/schema.js';
|
|
7
|
+
import { NO_TOPIC_LABEL, resultListHeading, truncationNotice, } from '../lib/text.js';
|
|
7
8
|
// Listings are ordered newest-first, so the truncation word is "first"; --limit caps at 100.
|
|
8
9
|
const LIST_LABELS = { noun: 'cached research', order: 'first', maxLimit: 100 };
|
|
10
|
+
// `list` answers "what pages/notes do I have?" and deliberately omits section children (see
|
|
11
|
+
// scanCacheDirForList), so `section` is not an offered filter — every other artifact type can appear.
|
|
12
|
+
const LISTABLE_ARTIFACT_TYPES = ARTIFACT_TYPES.filter((type) => type !== 'section');
|
|
9
13
|
export default class ResearchList extends BaseCommand {
|
|
10
14
|
static id = 'list';
|
|
11
15
|
static summary = 'List cached research artifacts by metadata filters.';
|
|
12
16
|
static description = 'Lists cached research artifacts, including metadata details like path, source count, freshness, token estimates, and quality metrics without printing full content.';
|
|
13
17
|
static examples = [
|
|
14
18
|
{
|
|
15
|
-
description: '
|
|
19
|
+
description: 'list all cached entries',
|
|
16
20
|
command: '<%= config.bin %> list',
|
|
17
21
|
},
|
|
18
22
|
{
|
|
19
|
-
description: '
|
|
23
|
+
description: 'list cached entries for a specific topic with JSON output',
|
|
20
24
|
command: '<%= config.bin %> list --topic "React Suspense" --json',
|
|
21
25
|
},
|
|
22
26
|
{
|
|
23
|
-
description: '
|
|
27
|
+
description: 'list only fresh entries filtered by tags',
|
|
24
28
|
command: '<%= config.bin %> list --freshness fresh --tags node --tags url',
|
|
25
29
|
},
|
|
26
30
|
];
|
|
27
31
|
static flags = {
|
|
28
32
|
topic: Flags.string({
|
|
29
33
|
char: 't',
|
|
30
|
-
description: '
|
|
34
|
+
description: 'filter by exact topic (case-insensitive)',
|
|
31
35
|
}),
|
|
32
36
|
tags: Flags.string({
|
|
33
37
|
char: 'g',
|
|
34
|
-
description: '
|
|
38
|
+
description: 'filter by tags (must match all tags specified)',
|
|
35
39
|
multiple: true,
|
|
36
40
|
}),
|
|
37
41
|
freshness: Flags.option({
|
|
38
|
-
description: '
|
|
42
|
+
description: 'filter by freshness state',
|
|
39
43
|
options: ['fresh', 'stale_grace', 'stale_expired'],
|
|
40
44
|
})(),
|
|
41
45
|
'artifact-type': Flags.option({
|
|
42
|
-
description: '
|
|
43
|
-
options:
|
|
46
|
+
description: 'filter by artifact type',
|
|
47
|
+
options: LISTABLE_ARTIFACT_TYPES,
|
|
44
48
|
})(),
|
|
45
49
|
'capture-method': Flags.option({
|
|
46
|
-
description: '
|
|
47
|
-
options:
|
|
50
|
+
description: 'filter by capture method',
|
|
51
|
+
options: CAPTURE_METHODS,
|
|
48
52
|
})(),
|
|
49
53
|
limit: Flags.integer({
|
|
50
|
-
description: '
|
|
54
|
+
description: 'maximum number of results to return (default 50, max 100)',
|
|
51
55
|
default: 50,
|
|
52
56
|
}),
|
|
53
57
|
};
|
|
@@ -55,7 +59,7 @@ export default class ResearchList extends BaseCommand {
|
|
|
55
59
|
validateListFlags() {
|
|
56
60
|
const limit = this.flags.limit;
|
|
57
61
|
if (limit !== undefined && (limit < 1 || limit > 100)) {
|
|
58
|
-
this.error('Limit must be between 1 and 100.', { exit: 2 });
|
|
62
|
+
this.error('Limit must be between 1 and 100.', { exit: 2, code: 'INVALID_LIMIT' });
|
|
59
63
|
}
|
|
60
64
|
}
|
|
61
65
|
matchesFilters(meta, freshness) {
|
|
@@ -88,7 +92,10 @@ export default class ResearchList extends BaseCommand {
|
|
|
88
92
|
// Section children are sub-chunks of a page, not artifacts a user "has" — they would flood the
|
|
89
93
|
// listing (one page yields dozens) and aren't in the documented source/research_note contract.
|
|
90
94
|
// They stay discoverable through `search` (which ranks them) and `inspect` (which lists a
|
|
91
|
-
// page's sections). `list` answers "what pages/notes do I have?", so keep it page-level.
|
|
95
|
+
// page's sections). `list` answers "what pages/notes do I have?", so keep it page-level. This
|
|
96
|
+
// unconditional guard owns the exclusion (the default no-filter case relies on it);
|
|
97
|
+
// LISTABLE_ARTIFACT_TYPES just hides `section` from --artifact-type so no one filters for a
|
|
98
|
+
// type list can never return. Keep both in sync if section handling ever changes.
|
|
92
99
|
if (artifact.metadata.artifact_type === 'section')
|
|
93
100
|
return null;
|
|
94
101
|
const freshness = evaluateFreshness(artifact.metadata, currentTime, null);
|
|
@@ -115,11 +122,12 @@ export default class ResearchList extends BaseCommand {
|
|
|
115
122
|
return;
|
|
116
123
|
if (finalResults.length === 0) {
|
|
117
124
|
this.log('No cached research entries found matching filters.');
|
|
125
|
+
this.log(`\nTip: populate the cache first: ${this.config.bin} <url>`);
|
|
118
126
|
return;
|
|
119
127
|
}
|
|
120
128
|
this.log(`${resultListHeading(totalMatched, finalResults.length, LIST_LABELS)}\n`);
|
|
121
129
|
finalResults.forEach((res, index) => {
|
|
122
|
-
this.log(`${index + 1}. [${res.topic ||
|
|
130
|
+
this.log(`${index + 1}. [${res.topic || NO_TOPIC_LABEL}] Key: ${res.cacheKey}`);
|
|
123
131
|
this.log(` Type: ${res.artifactType} | Freshness: ${res.freshness}`);
|
|
124
132
|
this.log(` Tokens: compressed=${res.tokenEstimate?.compressed || 0}, detailed=${res.tokenEstimate?.detailed || 0}`);
|
|
125
133
|
this.log(` Source URLs: ${res.sourceUrls.join(', ')}\n`);
|
package/dist/commands/prune.d.ts
CHANGED
|
@@ -10,7 +10,7 @@ export default class ResearchPrune extends BaseCommand<typeof ResearchPrune> {
|
|
|
10
10
|
static flags: {
|
|
11
11
|
'older-than': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
12
12
|
inactive: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
13
|
-
'artifact-type': import("@oclif/core/interfaces").OptionFlag<"source" | "research_note" | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
13
|
+
'artifact-type': import("@oclif/core/interfaces").OptionFlag<"source" | "research_note" | "index" | "section" | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
14
14
|
'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
15
15
|
yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
16
16
|
};
|
package/dist/commands/prune.js
CHANGED
|
@@ -5,49 +5,66 @@ import { BaseCommand } from '../base-command.js';
|
|
|
5
5
|
import { scanCacheDir } from '../lib/research/storage.js';
|
|
6
6
|
import { loadStoreRoots } from '../lib/research/store-roots.js';
|
|
7
7
|
import { parseTtlToMs, durationFlagError } from '../lib/research/freshness.js';
|
|
8
|
-
import {
|
|
8
|
+
import { ARTIFACT_TYPES } from '../lib/research/schema.js';
|
|
9
|
+
import { NO_TOPIC_LABEL, pluralize } from '../lib/text.js';
|
|
9
10
|
export default class ResearchPrune extends BaseCommand {
|
|
10
11
|
static id = 'prune';
|
|
11
12
|
static summary = 'Clean up old or inactive research cache entries.';
|
|
12
13
|
static description = 'Prunes cached research entries based on age, inactivity, or artifact type to free up disk space.';
|
|
13
14
|
static examples = [
|
|
14
15
|
{
|
|
15
|
-
description: '
|
|
16
|
+
description: 'perform a dry run of pruning entries older than 90 days',
|
|
16
17
|
command: '<%= config.bin %> prune --older-than 90d --dry-run',
|
|
17
18
|
},
|
|
18
19
|
{
|
|
19
|
-
description: '
|
|
20
|
+
description: 'actually prune entries older than 30 days that are source scrapes',
|
|
20
21
|
command: '<%= config.bin %> prune --older-than 30d --artifact-type source --yes',
|
|
21
22
|
},
|
|
22
23
|
];
|
|
23
24
|
static flags = {
|
|
24
25
|
'older-than': Flags.string({
|
|
25
|
-
description: '
|
|
26
|
+
description: 'prune entries older than duration (e.g. "30d", "90d")',
|
|
26
27
|
}),
|
|
27
28
|
inactive: Flags.string({
|
|
28
|
-
description: '
|
|
29
|
+
description: 'prune entries inactive (unvalidated/unfetched) for duration (e.g. "14d", "30d")',
|
|
29
30
|
}),
|
|
30
31
|
'artifact-type': Flags.option({
|
|
31
|
-
|
|
32
|
-
|
|
32
|
+
// Prune operates on every cached file, so it can target any artifact type — including the
|
|
33
|
+
// `section`/`index` children a page generates (e.g. to clear orphans left after a source is
|
|
34
|
+
// pruned with `--artifact-type source`).
|
|
35
|
+
description: 'filter pruning to specific artifact type',
|
|
36
|
+
options: ARTIFACT_TYPES,
|
|
33
37
|
})(),
|
|
34
38
|
'dry-run': Flags.boolean({
|
|
35
|
-
description: '
|
|
39
|
+
description: 'list files that would be deleted without actually deleting them',
|
|
36
40
|
default: false,
|
|
37
41
|
}),
|
|
38
42
|
yes: Flags.boolean({
|
|
39
43
|
char: 'y',
|
|
40
|
-
description: '
|
|
44
|
+
description: 'confirm deletion and prune matched entries (required unless --dry-run)',
|
|
41
45
|
default: false,
|
|
42
46
|
}),
|
|
43
47
|
};
|
|
44
48
|
static stdoutIsPrimaryData = true;
|
|
45
49
|
validatePruneFlags() {
|
|
46
50
|
if (!this.flags['older-than'] && !this.flags.inactive && !this.flags['artifact-type']) {
|
|
47
|
-
this.error('Must specify at least one pruning filter: --older-than, --inactive, or --artifact-type.', {
|
|
51
|
+
this.error('Must specify at least one pruning filter: --older-than, --inactive, or --artifact-type.', {
|
|
52
|
+
exit: 2,
|
|
53
|
+
code: 'MISSING_FILTER',
|
|
54
|
+
suggestions: [
|
|
55
|
+
`Preview age-based pruning: ${this.config.bin} prune --older-than 90d --dry-run`,
|
|
56
|
+
],
|
|
57
|
+
});
|
|
48
58
|
}
|
|
49
59
|
if (!this.flags['dry-run'] && !this.flags.yes) {
|
|
50
|
-
|
|
60
|
+
const olderThanPart = this.flags['older-than']
|
|
61
|
+
? ` --older-than ${this.flags['older-than']}`
|
|
62
|
+
: '';
|
|
63
|
+
this.error('Safety check: use --yes to confirm pruning, or --dry-run to preview files that would be deleted.', {
|
|
64
|
+
exit: 2,
|
|
65
|
+
code: 'SAFETY_CHECK_REQUIRED',
|
|
66
|
+
suggestions: [`Preview first: ${this.config.bin} prune --dry-run${olderThanPart}`],
|
|
67
|
+
});
|
|
51
68
|
}
|
|
52
69
|
// Validate durations up front: scanCacheDir swallows per-file errors, so a malformed
|
|
53
70
|
// --older-than/--inactive would otherwise be silently ignored and report "0 entries".
|
|
@@ -56,7 +73,7 @@ export default class ResearchPrune extends BaseCommand {
|
|
|
56
73
|
durationFlagError('--inactive', this.flags.inactive),
|
|
57
74
|
]) {
|
|
58
75
|
if (msg)
|
|
59
|
-
this.error(msg, { exit: 2 });
|
|
76
|
+
this.error(msg, { exit: 2, code: 'INVALID_DURATION' });
|
|
60
77
|
}
|
|
61
78
|
}
|
|
62
79
|
shouldPrune(meta, currentTime) {
|
|
@@ -128,7 +145,7 @@ export default class ResearchPrune extends BaseCommand {
|
|
|
128
145
|
const noun = pluralize(count, 'entry', 'entries');
|
|
129
146
|
this.log(`[Dry Run] Found ${count} research cache ${noun} that would be pruned:\n`);
|
|
130
147
|
filesToPrune.forEach((f) => {
|
|
131
|
-
this.log(`- [${f.topic ||
|
|
148
|
+
this.log(`- [${f.topic || NO_TOPIC_LABEL}] Key: ${f.cacheKey} (${f.url || 'Imported note'})`);
|
|
132
149
|
});
|
|
133
150
|
}
|
|
134
151
|
}
|
|
@@ -3,6 +3,10 @@ export default class ResearchSearch extends BaseCommand<typeof ResearchSearch> {
|
|
|
3
3
|
static id: string;
|
|
4
4
|
static summary: string;
|
|
5
5
|
static description: string;
|
|
6
|
+
static examples: {
|
|
7
|
+
description: string;
|
|
8
|
+
command: string;
|
|
9
|
+
}[];
|
|
6
10
|
static args: {
|
|
7
11
|
query: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
|
|
8
12
|
};
|
package/dist/commands/search.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
import { Args, Flags } from '@oclif/core';
|
|
1
|
+
import { Args, Flags, ux } from '@oclif/core';
|
|
2
2
|
import { BaseCommand } from '../base-command.js';
|
|
3
3
|
import { loadSearchableArtifacts } from '../lib/research/search-index.js';
|
|
4
|
-
import { levenshtein, resultListHeading, truncationNotice, } from '../lib/text.js';
|
|
4
|
+
import { levenshtein, NO_TOPIC_LABEL, resultListHeading, truncationNotice, } from '../lib/text.js';
|
|
5
5
|
import { loadStoreRoots } from '../lib/research/store-roots.js';
|
|
6
6
|
import { evaluateFreshness } from '../lib/research/freshness.js';
|
|
7
|
+
import { ARTIFACT_TYPES } from '../lib/research/schema.js';
|
|
7
8
|
import { detectSite } from '../sites/index.js';
|
|
8
9
|
import { fetchStaticHtml, fetchText, postJson } from '../lib/research/fetcher.js';
|
|
10
|
+
import { normalizeUrl } from '../lib/research/url.js';
|
|
9
11
|
import { runRemoteDocsSearch, } from '../lib/research/docs/remote-search-runner.js';
|
|
10
12
|
// Results are ranked, so the truncation word is "top"; --limit caps at 50.
|
|
11
13
|
const SEARCH_LABELS = {
|
|
@@ -29,52 +31,66 @@ export default class ResearchSearch extends BaseCommand {
|
|
|
29
31
|
static id = 'search';
|
|
30
32
|
static summary = 'Search locally cached research artifacts by keywords.';
|
|
31
33
|
static description = 'Scans the local research cache database and ranks matching entries based on topic, tags, source URLs, summary, and content.';
|
|
34
|
+
static examples = [
|
|
35
|
+
{
|
|
36
|
+
description: 'search for cached notes containing specific keywords',
|
|
37
|
+
command: '<%= config.bin %> <%= command.id %> "authentication flow"',
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
description: 'search with remote docs fallback',
|
|
41
|
+
command: '<%= config.bin %> <%= command.id %> "router" --remote https://react.dev',
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
description: 'search local cache with tag filtering',
|
|
45
|
+
command: '<%= config.bin %> <%= command.id %> "setup" --tags node --tags tutorial',
|
|
46
|
+
},
|
|
47
|
+
];
|
|
32
48
|
static args = {
|
|
33
49
|
query: Args.string({
|
|
34
50
|
required: true,
|
|
35
|
-
description: '
|
|
51
|
+
description: 'the search query containing terms to match',
|
|
36
52
|
}),
|
|
37
53
|
};
|
|
38
54
|
static flags = {
|
|
39
55
|
topic: Flags.string({
|
|
40
56
|
char: 't',
|
|
41
|
-
description: '
|
|
57
|
+
description: 'filter results by exact topic (case-insensitive)',
|
|
42
58
|
}),
|
|
43
59
|
tags: Flags.string({
|
|
44
60
|
char: 'g',
|
|
45
|
-
description: '
|
|
61
|
+
description: 'filter results by tags (must match all tags specified)',
|
|
46
62
|
multiple: true,
|
|
47
63
|
}),
|
|
48
64
|
'artifact-type': Flags.option({
|
|
49
|
-
description: '
|
|
50
|
-
options:
|
|
65
|
+
description: 'filter results by artifact type',
|
|
66
|
+
options: ARTIFACT_TYPES,
|
|
51
67
|
})(),
|
|
52
68
|
limit: Flags.integer({
|
|
53
|
-
description: '
|
|
69
|
+
description: 'maximum number of results to return (default 10, max 50)',
|
|
54
70
|
default: 10,
|
|
55
71
|
}),
|
|
56
72
|
'include-stale': Flags.boolean({
|
|
57
|
-
description: '
|
|
73
|
+
description: 'include stale expired cache entries in the search results',
|
|
58
74
|
default: false,
|
|
59
75
|
}),
|
|
60
76
|
domain: Flags.string({
|
|
61
|
-
description: '
|
|
77
|
+
description: 'search a documentation site directly (requires compatible site module)',
|
|
62
78
|
}),
|
|
63
79
|
remote: Flags.string({
|
|
64
|
-
description: '
|
|
80
|
+
description: 'discover uncached docs via public search or index (falls back to local cache)',
|
|
65
81
|
}),
|
|
66
82
|
};
|
|
67
83
|
static stdoutIsPrimaryData = true;
|
|
68
84
|
validateSearchFlags() {
|
|
69
85
|
const limit = this.flags.limit;
|
|
70
86
|
if (limit !== undefined && (limit < 1 || limit > 50)) {
|
|
71
|
-
this.error('Limit must be between 1 and 50.', { exit: 2 });
|
|
87
|
+
this.error('Limit must be between 1 and 50.', { exit: 2, code: 'INVALID_LIMIT' });
|
|
72
88
|
}
|
|
73
89
|
}
|
|
74
90
|
getSearchQueryTerms(query) {
|
|
75
91
|
const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
|
|
76
92
|
if (terms.length === 0) {
|
|
77
|
-
this.error('Query string cannot be empty.', { exit: 2 });
|
|
93
|
+
this.error('Query string cannot be empty.', { exit: 2, code: 'EMPTY_QUERY' });
|
|
78
94
|
}
|
|
79
95
|
return terms;
|
|
80
96
|
}
|
|
@@ -194,11 +210,12 @@ export default class ResearchSearch extends BaseCommand {
|
|
|
194
210
|
return;
|
|
195
211
|
if (finalResults.length === 0) {
|
|
196
212
|
this.log('No matching cached research entries found.');
|
|
213
|
+
this.log(`\nTip: populate the cache first: ${this.config.bin} <url>`);
|
|
197
214
|
return;
|
|
198
215
|
}
|
|
199
216
|
this.log(`${resultListHeading(totalMatched, finalResults.length, SEARCH_LABELS)}\n`);
|
|
200
217
|
finalResults.forEach((res, index) => {
|
|
201
|
-
this.log(`${index + 1}. [${res.topic ||
|
|
218
|
+
this.log(`${index + 1}. [${res.topic || NO_TOPIC_LABEL}] Score: ${res.score}`);
|
|
202
219
|
this.log(` Cache Key: ${res.cacheKey}`);
|
|
203
220
|
this.log(` Snippet: ${res.snippet}`);
|
|
204
221
|
this.log(` Source URLs: ${res.sourceUrls.join(', ')}\n`);
|
|
@@ -220,24 +237,47 @@ export default class ResearchSearch extends BaseCommand {
|
|
|
220
237
|
async executeSiteSearch(query, domain) {
|
|
221
238
|
const siteModule = detectSite(`https://${domain}`);
|
|
222
239
|
if (!siteModule) {
|
|
223
|
-
this.error(`No site module registered for domain: ${domain}`, {
|
|
240
|
+
this.error(`No site module registered for domain: ${domain}`, {
|
|
241
|
+
exit: 2,
|
|
242
|
+
code: 'UNSUPPORTED_DOMAIN',
|
|
243
|
+
});
|
|
224
244
|
}
|
|
225
245
|
if (!siteModule.search) {
|
|
226
|
-
this.error(`Site module '${siteModule.id}' does not implement search.`, {
|
|
246
|
+
this.error(`Site module '${siteModule.id}' does not implement search.`, {
|
|
247
|
+
exit: 2,
|
|
248
|
+
code: 'SEARCH_NOT_SUPPORTED',
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
try {
|
|
252
|
+
if (!this.jsonEnabled())
|
|
253
|
+
ux.action.start(`Searching ${domain}`);
|
|
254
|
+
const results = await siteModule.search(query);
|
|
255
|
+
if (!this.jsonEnabled())
|
|
256
|
+
ux.action.stop();
|
|
257
|
+
this.printResults(`Found ${results.length} results from ${siteModule.name}:\n`, results);
|
|
258
|
+
return results.map((r) => ({ ...r, site_module_id: siteModule.id }));
|
|
259
|
+
}
|
|
260
|
+
catch (err) {
|
|
261
|
+
if (!this.jsonEnabled())
|
|
262
|
+
ux.action.stop('failed');
|
|
263
|
+
throw err;
|
|
227
264
|
}
|
|
228
|
-
const results = await siteModule.search(query);
|
|
229
|
-
this.printResults(`Found ${results.length} results from ${siteModule.name}:\n`, results);
|
|
230
|
-
return results.map((r) => ({ ...r, site_module_id: siteModule.id }));
|
|
231
265
|
}
|
|
232
266
|
// Remote docs discovery. On any connector failure, degrade to local cache search with a warning
|
|
233
267
|
// (T-20). Returns discovery results tagged remote: true so callers can tell them from cache hits.
|
|
234
268
|
async executeRemoteSearch(query, docsUrl) {
|
|
235
269
|
try {
|
|
270
|
+
if (!this.jsonEnabled())
|
|
271
|
+
ux.action.start(`Searching ${docsUrl}`);
|
|
236
272
|
const { provider, results } = await runRemoteDocsSearch(docsUrl, query, REMOTE_SEARCH_DEPS);
|
|
273
|
+
if (!this.jsonEnabled())
|
|
274
|
+
ux.action.stop();
|
|
237
275
|
this.printResults(`Found ${results.length} remote results via ${provider}:\n`, results);
|
|
238
276
|
return results.map((r) => ({ ...r, remote: true }));
|
|
239
277
|
}
|
|
240
278
|
catch (err) {
|
|
279
|
+
if (!this.jsonEnabled())
|
|
280
|
+
ux.action.stop('failed');
|
|
241
281
|
this.warn(`Remote docs search unavailable, using local cache: ${err.message}`);
|
|
242
282
|
return undefined;
|
|
243
283
|
}
|
|
@@ -245,14 +285,36 @@ export default class ResearchSearch extends BaseCommand {
|
|
|
245
285
|
async run() {
|
|
246
286
|
const { query } = this.args;
|
|
247
287
|
const { domain, remote } = this.flags;
|
|
288
|
+
// Reject whitespace-only queries on every path (local, --domain, --remote).
|
|
289
|
+
this.getSearchQueryTerms(query);
|
|
290
|
+
if (domain && remote) {
|
|
291
|
+
this.error('--domain and --remote are mutually exclusive. Use one discovery mode at a time.', {
|
|
292
|
+
exit: 2,
|
|
293
|
+
code: 'CONFLICTING_FLAGS',
|
|
294
|
+
suggestions: [
|
|
295
|
+
`Local site API: ${this.config.bin} search "${query}" --domain ${domain}`,
|
|
296
|
+
`Remote index: ${this.config.bin} search "${query}" --remote ${remote}`,
|
|
297
|
+
],
|
|
298
|
+
});
|
|
299
|
+
}
|
|
248
300
|
if (domain) {
|
|
249
301
|
return this.executeSiteSearch(query, domain);
|
|
250
302
|
}
|
|
251
303
|
if (remote) {
|
|
252
|
-
|
|
304
|
+
let normalizedRemote;
|
|
305
|
+
try {
|
|
306
|
+
normalizedRemote = normalizeUrl(remote);
|
|
307
|
+
}
|
|
308
|
+
catch (err) {
|
|
309
|
+
this.error(`Invalid --remote URL: ${err.message}`, {
|
|
310
|
+
exit: 2,
|
|
311
|
+
code: 'INVALID_URL',
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
const remoteResults = await this.executeRemoteSearch(query, normalizedRemote);
|
|
253
315
|
if (remoteResults !== undefined)
|
|
254
316
|
return remoteResults;
|
|
255
|
-
// else: fall through to local cache search
|
|
317
|
+
// else: fall through to local cache search (connector/network failure only)
|
|
256
318
|
}
|
|
257
319
|
this.validateSearchFlags();
|
|
258
320
|
const queryTerms = this.getSearchQueryTerms(query);
|
|
@@ -3,6 +3,10 @@ export default class ResearchStatus extends BaseCommand<typeof ResearchStatus> {
|
|
|
3
3
|
static id: string;
|
|
4
4
|
static summary: string;
|
|
5
5
|
static description: string;
|
|
6
|
+
static examples: {
|
|
7
|
+
description: string;
|
|
8
|
+
command: string;
|
|
9
|
+
}[];
|
|
6
10
|
static args: {
|
|
7
11
|
url: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
|
|
8
12
|
};
|
|
@@ -12,5 +16,7 @@ export default class ResearchStatus extends BaseCommand<typeof ResearchStatus> {
|
|
|
12
16
|
'max-age': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
13
17
|
};
|
|
14
18
|
static stdoutIsPrimaryData: boolean;
|
|
19
|
+
/** Enrich cache-miss envelopes with the same CACHE_MISS code and suggestions as inspect. */
|
|
20
|
+
protected toSuccessJson(data: unknown): Record<string, unknown>;
|
|
15
21
|
run(): Promise<unknown>;
|
|
16
22
|
}
|
package/dist/commands/status.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { Args, Flags } from '@oclif/core';
|
|
2
2
|
import { BaseCommand } from '../base-command.js';
|
|
3
|
+
import { formatErrorForJson } from '../lib/envelope.js';
|
|
3
4
|
import { getArtifactPath } from '../lib/research/storage.js';
|
|
4
5
|
import { evaluateFreshness, checkMaxAgeExpired, durationFlagError, } from '../lib/research/freshness.js';
|
|
5
|
-
import { resolveResearchTarget } from '../lib/research/resolve-target.js';
|
|
6
6
|
function resolveFreshness(cached, currentTime, ttl, maxAge) {
|
|
7
7
|
const isMaxAgeExpired = maxAge ? checkMaxAgeExpired(cached, currentTime, maxAge) : false;
|
|
8
8
|
return isMaxAgeExpired
|
|
@@ -22,27 +22,52 @@ export default class ResearchStatus extends BaseCommand {
|
|
|
22
22
|
static id = 'status';
|
|
23
23
|
static summary = 'Check the cache status of a URL without fetching or writing.';
|
|
24
24
|
static description = 'Reports whether the URL is cached, its freshness state, and what action (fetch, revalidate, or cached return) the root fetch command would take.';
|
|
25
|
+
static examples = [
|
|
26
|
+
{
|
|
27
|
+
description: 'check if a URL is already in the cache',
|
|
28
|
+
command: '<%= config.bin %> <%= command.id %> https://example.com/docs',
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
description: 'check status against a specific freshness tier and TTL',
|
|
32
|
+
command: '<%= config.bin %> <%= command.id %> https://example.com/docs --tier volatile --ttl 2h',
|
|
33
|
+
},
|
|
34
|
+
];
|
|
25
35
|
static args = {
|
|
26
36
|
url: Args.string({
|
|
27
37
|
required: true,
|
|
28
|
-
description: '
|
|
38
|
+
description: 'the URL to check',
|
|
29
39
|
}),
|
|
30
40
|
};
|
|
31
41
|
static flags = {
|
|
32
42
|
tier: Flags.option({
|
|
33
|
-
description: '
|
|
43
|
+
description: 'freshness tier policy to evaluate against',
|
|
34
44
|
options: ['stable', 'standard', 'volatile'],
|
|
35
45
|
default: 'standard',
|
|
36
46
|
})(),
|
|
37
47
|
ttl: Flags.string({
|
|
38
48
|
char: 'l',
|
|
39
|
-
description: '
|
|
49
|
+
description: 'custom TTL duration to evaluate against (e.g. "2h", "7d")',
|
|
40
50
|
}),
|
|
41
51
|
'max-age': Flags.string({
|
|
42
|
-
description: '
|
|
52
|
+
description: 'maximum age of cache to accept (e.g. "1d", "30d")',
|
|
43
53
|
}),
|
|
44
54
|
};
|
|
45
55
|
static stdoutIsPrimaryData = true;
|
|
56
|
+
/** Enrich cache-miss envelopes with the same CACHE_MISS code and suggestions as inspect. */
|
|
57
|
+
toSuccessJson(data) {
|
|
58
|
+
const envelope = super.toSuccessJson(data);
|
|
59
|
+
const d = data;
|
|
60
|
+
if (d?.status !== 'miss')
|
|
61
|
+
return envelope;
|
|
62
|
+
const normalizedUrl = d.normalizedUrl ?? '';
|
|
63
|
+
const suggestions = [`Fetch and cache it first: ${this.config.bin} ${normalizedUrl}`];
|
|
64
|
+
const stderr = formatErrorForJson({
|
|
65
|
+
message: `Cache miss for ${normalizedUrl}`,
|
|
66
|
+
code: 'CACHE_MISS',
|
|
67
|
+
suggestions,
|
|
68
|
+
});
|
|
69
|
+
return { ...envelope, ok: false, exitCode: 1, stderr, code: 'CACHE_MISS', suggestions };
|
|
70
|
+
}
|
|
46
71
|
async run() {
|
|
47
72
|
const { url } = this.args;
|
|
48
73
|
const { ttl, tier, 'max-age': maxAge } = this.flags;
|
|
@@ -50,20 +75,9 @@ export default class ResearchStatus extends BaseCommand {
|
|
|
50
75
|
// wrong, rather than later surfacing as a misattributed parse failure.
|
|
51
76
|
for (const msg of [durationFlagError('--ttl', ttl), durationFlagError('--max-age', maxAge)]) {
|
|
52
77
|
if (msg)
|
|
53
|
-
this.error(msg, { exit: 2 });
|
|
54
|
-
}
|
|
55
|
-
let target;
|
|
56
|
-
try {
|
|
57
|
-
target = resolveResearchTarget({
|
|
58
|
-
configDir: this.config.configDir,
|
|
59
|
-
cwd: process.cwd(),
|
|
60
|
-
dataDir: this.config.dataDir,
|
|
61
|
-
url,
|
|
62
|
-
});
|
|
63
|
-
}
|
|
64
|
-
catch (err) {
|
|
65
|
-
this.error(`Invalid URL: ${err.message}`, { exit: 2 });
|
|
78
|
+
this.error(msg, { exit: 2, code: 'INVALID_DURATION' });
|
|
66
79
|
}
|
|
80
|
+
const target = this.resolveResearchTargetOrFail(url);
|
|
67
81
|
const { cacheKey, located, normalizedUrl, roots } = target;
|
|
68
82
|
const cached = located?.artifact ?? null;
|
|
69
83
|
const currentTime = new Date();
|
|
@@ -72,16 +86,28 @@ export default class ResearchStatus extends BaseCommand {
|
|
|
72
86
|
// On a hit, report where it actually lives; on a miss, where a fetch would write it.
|
|
73
87
|
const artifactPath = located?.path ?? getArtifactPath(roots.writeRoot, cacheKey);
|
|
74
88
|
if (!this.jsonEnabled()) {
|
|
75
|
-
this.log(
|
|
76
|
-
this.log(
|
|
77
|
-
this.log(
|
|
78
|
-
this.log(
|
|
79
|
-
this.log(
|
|
80
|
-
this.log(
|
|
89
|
+
this.log(`${'URL:'.padEnd(25)} ${normalizedUrl}`);
|
|
90
|
+
this.log(`${'Cache Key:'.padEnd(25)} ${cacheKey}`);
|
|
91
|
+
this.log(`${'Cache Path:'.padEnd(25)} ${artifactPath}`);
|
|
92
|
+
this.log(`${'Status:'.padEnd(25)} ${result.status}`);
|
|
93
|
+
this.log(`${'Freshness:'.padEnd(25)} ${result.freshness}`);
|
|
94
|
+
this.log(`${'Action:'.padEnd(25)} ${result.action}`);
|
|
95
|
+
}
|
|
96
|
+
// Align with inspect's CACHE_MISS contract: a miss is actionable, not a usage error. Exit 1
|
|
97
|
+
// lets scripts branch on $? while JSON still returns the structured status payload. Unlike
|
|
98
|
+
// inspect, we do not throw here — status must always return structured data even on a miss.
|
|
99
|
+
if (result.status === 'miss') {
|
|
100
|
+
// Human mode: actionable hint on stderr. JSON mode: same info lives in the envelope (see
|
|
101
|
+
// toSuccessJson) so agents get code/suggestions without parsing warn() line wraps.
|
|
102
|
+
if (!this.jsonEnabled()) {
|
|
103
|
+
this.warn(`Cache miss — run: ${this.config.bin} ${normalizedUrl}`);
|
|
104
|
+
}
|
|
105
|
+
process.exitCode = 1;
|
|
81
106
|
}
|
|
82
107
|
return {
|
|
83
108
|
cacheKey,
|
|
84
109
|
cachePath: artifactPath,
|
|
110
|
+
normalizedUrl,
|
|
85
111
|
status: result.status,
|
|
86
112
|
freshness: result.freshness,
|
|
87
113
|
action: result.action,
|
package/dist/commands.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import ConfigGet from './commands/config/get.js';
|
|
2
|
+
import ConfigIndex from './commands/config/index.js';
|
|
2
3
|
import ConfigList from './commands/config/list.js';
|
|
3
4
|
import ConfigSet from './commands/config/set.js';
|
|
4
5
|
import ConfigUnset from './commands/config/unset.js';
|
|
@@ -17,6 +18,7 @@ export declare const commands: {
|
|
|
17
18
|
prune: typeof PruneCommand;
|
|
18
19
|
search: typeof SearchCommand;
|
|
19
20
|
status: typeof StatusCommand;
|
|
21
|
+
config: typeof ConfigIndex;
|
|
20
22
|
'config:get': typeof ConfigGet;
|
|
21
23
|
'config:list': typeof ConfigList;
|
|
22
24
|
'config:set': typeof ConfigSet;
|
package/dist/commands.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import ConfigGet from './commands/config/get.js';
|
|
2
|
+
import ConfigIndex from './commands/config/index.js';
|
|
2
3
|
import ConfigList from './commands/config/list.js';
|
|
3
4
|
import ConfigSet from './commands/config/set.js';
|
|
4
5
|
import ConfigUnset from './commands/config/unset.js';
|
|
@@ -17,6 +18,7 @@ export const commands = {
|
|
|
17
18
|
prune: PruneCommand,
|
|
18
19
|
search: SearchCommand,
|
|
19
20
|
status: StatusCommand,
|
|
21
|
+
config: ConfigIndex,
|
|
20
22
|
'config:get': ConfigGet,
|
|
21
23
|
'config:list': ConfigList,
|
|
22
24
|
'config:set': ConfigSet,
|