Skip to content
Open
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
2 changes: 1 addition & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -388,7 +388,7 @@ opencli generate https://example.com --goal "hot"
opencli cascade https://api.example.com/data
```

探索结果输出到 `.opencli/explore/<site>/`
探索结果输出到 `~/.opencli/explore/<site>/`

## 常见问题排查

Expand Down
4 changes: 2 additions & 2 deletions clis/douyin/_shared/tos-upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@

import * as crypto from 'node:crypto';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { CommandExecutionError } from '@jackwener/opencli/errors';
import { getUserOpenCliPath } from '@jackwener/opencli/user-opencli-paths';
import type { Sts2Credentials, TosUploadInfo } from './types.js';

export interface TosUploadOptions {
Expand All @@ -32,7 +32,7 @@ interface ResumeState {
}

const PART_SIZE = 5 * 1024 * 1024; // 5 MB minimum per TOS/S3 spec
const RESUME_DIR = path.join(os.homedir(), '.opencli', 'douyin-resume');
const RESUME_DIR = getUserOpenCliPath('douyin-resume');

// ── Resume file helpers ──────────────────────────────────────────────────────

Expand Down
7 changes: 2 additions & 5 deletions clis/linux-do/feed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import * as path from 'node:path';
import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
import { cli, Strategy, type Arg, type CommandArgs } from '@jackwener/opencli/registry';
import type { IPage } from '@jackwener/opencli/types';
import { getUserOpenCliPath } from '@jackwener/opencli/user-opencli-paths';

const LINUX_DO_HOME = 'https://linux.do';
const LINUX_DO_METADATA_TTL_MS = 24 * 60 * 60 * 1000;
Expand Down Expand Up @@ -95,12 +96,8 @@ function normalizeLookupValue(value: string): string {
return value.trim().replace(/\s+/g, ' ').toLowerCase();
}

function getHomeDir(): string {
return process.env.HOME || process.env.USERPROFILE || os.homedir();
}

function getLinuxDoCacheDir(): string {
return testCacheDirOverride ?? path.join(getHomeDir(), '.opencli', 'cache', 'linux-do');
return testCacheDirOverride ?? getUserOpenCliPath('cache', 'linux-do');
}

function getMetadataCachePath(name: 'tags' | 'categories'): string {
Expand Down
8 changes: 4 additions & 4 deletions clis/spotify/spotify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { cli, Strategy } from '@jackwener/opencli/registry';
import { CliError } from '@jackwener/opencli/errors';
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
import { createServer } from 'http';
import { homedir } from 'os';
import { join } from 'path';
import { exec } from 'child_process';
import {
Expand All @@ -12,14 +11,15 @@ import {
parseDotEnv,
resolveSpotifyCredentials,
} from './utils.js';
import { USER_OPENCLI_DIR, getUserOpenCliPath } from '@jackwener/opencli/user-opencli-paths';

// ── Credentials ───────────────────────────────────────────────────────────────
// Set SPOTIFY_CLIENT_ID and SPOTIFY_CLIENT_SECRET as environment variables,
// or place them in ~/.opencli/spotify.env:
// SPOTIFY_CLIENT_ID=your_id
// SPOTIFY_CLIENT_SECRET=your_secret

const ENV_FILE = join(homedir(), '.opencli', 'spotify.env');
const ENV_FILE = getUserOpenCliPath('spotify.env');

function loadEnv(): Record<string, string> {
if (!existsSync(ENV_FILE)) return {};
Expand All @@ -40,7 +40,7 @@ const SCOPES = [

// ── Token storage ─────────────────────────────────────────────────────────────

const TOKEN_FILE = join(homedir(), '.opencli', 'spotify-tokens.json');
const TOKEN_FILE = getUserOpenCliPath('spotify-tokens.json');

interface Tokens {
access_token: string;
Expand All @@ -53,7 +53,7 @@ function loadTokens(): Tokens | null {
}

function saveTokens(tokens: Tokens): void {
mkdirSync(join(homedir(), '.opencli'), { recursive: true });
mkdirSync(USER_OPENCLI_DIR, { recursive: true });
writeFileSync(TOKEN_FILE, JSON.stringify(tokens, null, 2));
}

Expand Down
2 changes: 1 addition & 1 deletion docs/developer/ai-workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ Discover APIs, infer capabilities, and detect framework:
opencli explore https://example.com --site mysite
```

Outputs to `.opencli/explore/<site>/`:
Outputs to `~/.opencli/explore/<site>/`:
- `manifest.json` — Site metadata
- `endpoints.json` — Discovered API endpoints
- `capabilities.json` — Inferred capabilities
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"./registry": "./dist/src/registry-api.js",
"./errors": "./dist/src/errors.js",
"./types": "./dist/src/types.js",
"./user-opencli-paths": "./dist/src/user-opencli-paths.js",
"./utils": "./dist/src/utils.js",
"./logger": "./dist/src/logger.js",
"./launcher": "./dist/src/launcher.js",
Expand Down
8 changes: 4 additions & 4 deletions skills/opencli-explorer/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ OpenCLI 内置 Deep Explore,自动分析网站网络请求:
opencli explore https://www.example.com --site mysite
```

输出到 `.opencli/explore/mysite/`:
输出到 `~/.opencli/explore/mysite/`:

| 文件 | 内容 |
|------|------|
Expand Down Expand Up @@ -727,7 +727,7 @@ opencli synthesize mysite # 生成候选
opencli verify mysite/hot --smoke # 冒烟测试
```

生成的候选 YAML 保存在 `.opencli/explore/mysite/candidates/`,可直接复制到 `clis/mysite/` 并微调。
生成的候选 YAML 保存在 `~/.opencli/explore/mysite/candidates/`,可直接复制到 `clis/mysite/` 并微调。

## Record Workflow

Expand Down Expand Up @@ -764,8 +764,8 @@ opencli record "https://example.com/page" --timeout 120000
# 3. 完成操作后按 Enter 停止(或等超时自动停止)

# 4. 查看结果
cat .opencli/record/<site>/captured.json # 原始捕获
ls .opencli/record/<site>/candidates/ # 候选 YAML
cat ~/.opencli/record/<site>/captured.json # 原始捕获
ls ~/.opencli/record/<site>/candidates/ # 候选 YAML
```

### 页面类型与捕获预期
Expand Down
2 changes: 1 addition & 1 deletion skills/opencli-usage/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ opencli record <url> # 录制,site name 从域名
opencli record <url> --site mysite # 指定 site name
opencli record <url> --timeout 120000 # 自定义超时(毫秒,默认 60000)
opencli record <url> --poll 1000 # 缩短轮询间隔(毫秒,默认 2000)
opencli record <url> --out .opencli/record/x # 自定义输出目录
opencli record <url> --out ~/.opencli/record/x # 自定义输出目录

# Strategy Cascade: auto-probe PUBLIC → COOKIE → HEADER
opencli cascade <api-url>
Expand Down
10 changes: 6 additions & 4 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { loadExternalClis, executeExternalCli, installExternalCli, registerExter
import { registerAllCommands } from './commanderAdapter.js';
import { EXIT_CODES, getErrorMessage } from './errors.js';
import { daemonStatus, daemonStop, daemonRestart } from './commands/daemon.js';
import { getUserCliDir } from './user-opencli-paths.js';

const CLI_FILE = fileURLToPath(import.meta.url);

Expand Down Expand Up @@ -154,13 +155,15 @@ export function createProgram(BUILTIN_CLIS: string, USER_CLIS: string): Command
.option('--wait <s>', '', '3')
.option('--auto', 'Enable interactive fuzzing')
.option('--click <labels>', 'Comma-separated labels to click before fuzzing')
.option('--out <dir>', 'Output directory for artifacts')
.option('-v, --verbose', 'Debug output')
.action(async (url: string, opts: {
site?: string;
goal?: string;
wait: string;
auto?: boolean;
click?: string;
out?: string;
verbose?: boolean;
}) => {
applyVerbose(opts);
Expand All @@ -177,6 +180,7 @@ export function createProgram(BUILTIN_CLIS: string, USER_CLIS: string): Command
auto: opts.auto,
clickLabels,
workspace,
outDir: opts.out,
});
console.log(renderExploreSummary(result));
});
Expand Down Expand Up @@ -562,10 +566,9 @@ export function createProgram(BUILTIN_CLIS: string, USER_CLIS: string): Command
return;
}

const os = await import('node:os');
const fs = await import('node:fs');
const path = await import('node:path');
const dir = path.join(os.homedir(), '.opencli', 'clis', site);
const dir = getUserCliDir(site);
const filePath = path.join(dir, `${command}.ts`);

if (fs.existsSync(filePath)) {
Expand Down Expand Up @@ -629,8 +632,7 @@ cli({
}

const { execFileSync } = await import('node:child_process');
const os = await import('node:os');
const filePath = path.join(os.homedir(), '.opencli', 'clis', site, `${command}.ts`);
const filePath = path.join(getUserCliDir(site), `${command}.ts`);
if (!fs.existsSync(filePath)) {
console.error(`Adapter not found: ${filePath}`);
console.error(`Run "opencli operate init ${name}" to create it.`);
Expand Down
13 changes: 5 additions & 8 deletions src/discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,25 +9,22 @@
*/

import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import yaml from 'js-yaml';
import { type CliCommand, type InternalCliCommand, type Arg, Strategy, registerCommand } from './registry.js';
import { getErrorMessage } from './errors.js';
import { log } from './logger.js';
import type { ManifestEntry } from './build-manifest.js';

/** User runtime directory: ~/.opencli */
export const USER_OPENCLI_DIR = path.join(os.homedir(), '.opencli');
/** User CLIs directory: ~/.opencli/clis */
export const USER_CLIS_DIR = path.join(USER_OPENCLI_DIR, 'clis');
/** Plugins directory: ~/.opencli/plugins/ */
export const PLUGINS_DIR = path.join(USER_OPENCLI_DIR, 'plugins');
/** Matches files that register commands via cli() or lifecycle hooks */
const PLUGIN_MODULE_PATTERN = /\b(?:cli|onStartup|onBeforeExecute|onAfterExecute)\s*\(/;

import { type YamlCliDefinition, parseYamlArgs } from './yaml-schema.js';
import { USER_CLIS_DIR, USER_OPENCLI_DIR, USER_PLUGINS_DIR } from './user-opencli-paths.js';

export { USER_CLIS_DIR, USER_OPENCLI_DIR };
/** Plugins directory: ~/.opencli/plugins/ */
export const PLUGINS_DIR = USER_PLUGINS_DIR;

function parseStrategy(rawStrategy: string | undefined, fallback: Strategy = Strategy.COOKIE): Strategy {
if (!rawStrategy) return fallback;
Expand Down
5 changes: 2 additions & 3 deletions src/electron-apps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,8 @@
*/

import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import yaml from 'js-yaml';
import { getUserAppsConfigPath } from './user-opencli-paths.js';

export interface ElectronAppEntry {
/** CDP debug port (unique per app) */
Expand Down Expand Up @@ -64,7 +63,7 @@ function ensureLoaded(): Record<string, ElectronAppEntry> {

let userApps: Record<string, ElectronAppEntry> | undefined;
try {
const yamlPath = path.join(os.homedir(), '.opencli', 'apps.yaml');
const yamlPath = getUserAppsConfigPath();
if (fs.existsSync(yamlPath)) {
const content = fs.readFileSync(yamlPath, 'utf-8');
const parsed = yaml.load(content) as { apps?: Record<string, ElectronAppEntry> };
Expand Down
4 changes: 3 additions & 1 deletion src/explore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { discoverStores } from './scripts/store.js';
import { interactFuzz } from './scripts/interact.js';
import type { IPage } from './types.js';
import { log } from './logger.js';
import { getUserExploreDir } from './user-opencli-paths.js';
import {
urlToPattern,
findArrayPath,
Expand Down Expand Up @@ -447,7 +448,8 @@ export async function exploreUrl(

// Step 9: Assemble result and write artifacts
const siteName = opts.site ?? detectSiteName(metadata.url || url);
const targetDir = opts.outDir ?? path.join('.opencli', 'explore', siteName);
// Default to ~/.opencli/explore/<site>/ so we never pollute cwd (#711)
const targetDir = opts.outDir ?? getUserExploreDir(siteName);

const result = {
site: siteName, target_url: url, final_url: metadata.url, title: metadata.title,
Expand Down
4 changes: 2 additions & 2 deletions src/external.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import yaml from 'js-yaml';
import chalk from 'chalk';
import { log } from './logger.js';
import { EXIT_CODES, getErrorMessage } from './errors.js';
import { getUserExternalClisConfigPath } from './user-opencli-paths.js';

const __dirname = path.dirname(fileURLToPath(import.meta.url));

Expand All @@ -27,8 +28,7 @@ export interface ExternalCliConfig {
}

function getUserRegistryPath(): string {
const home = os.homedir();
return path.join(home, '.opencli', 'external-clis.yaml');
return getUserExternalClisConfigPath();
}

let _cachedExternalClis: ExternalCliConfig[] | null = null;
Expand Down
10 changes: 3 additions & 7 deletions src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,23 +22,19 @@ import {
checkCompatibility,
type PluginManifest,
} from './plugin-manifest.js';
import { getUserPluginLockFilePath, USER_MONOREPOS_DIR } from './user-opencli-paths.js';

const isWindows = process.platform === 'win32';
const LOCAL_PLUGIN_SOURCE_PREFIX = 'local:';

/** Get home directory, respecting HOME environment variable for test isolation. */
function getHomeDir(): string {
return process.env.HOME || process.env.USERPROFILE || os.homedir();
}

/** Path to the lock file that tracks installed plugin versions. */
export function getLockFilePath(): string {
return path.join(getHomeDir(), '.opencli', 'plugins.lock.json');
return getUserPluginLockFilePath();
}

/** Monorepo clones directory: ~/.opencli/monorepos/ */
export function getMonoreposDir(): string {
return path.join(getHomeDir(), '.opencli', 'monorepos');
return USER_MONOREPOS_DIR;
}

export type PluginSourceRecord =
Expand Down
4 changes: 3 additions & 1 deletion src/record.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import chalk from 'chalk';
import yaml from 'js-yaml';
import { sendCommand } from './browser/daemon-client.js';
import type { IPage } from './types.js';
import { getUserRecordDir } from './user-opencli-paths.js';
import { SEARCH_PARAMS, PAGINATION_PARAMS, FIELD_ROLES } from './constants.js';
import {
urlToPattern,
Expand Down Expand Up @@ -734,7 +735,8 @@ function analyzeAndWrite(
requests: RecordedRequest[],
outDir?: string,
): RecordResult {
const targetDir = outDir ?? path.join('.opencli', 'record', site);
// Default to ~/.opencli/record/<site>/ so we never pollute cwd (#711)
const targetDir = outDir ?? getUserRecordDir(site);
fs.mkdirSync(targetDir, { recursive: true });

if (requests.length === 0) {
Expand Down
64 changes: 64 additions & 0 deletions src/synthesize.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import * as path from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';

const { mockExistsSync, mockGetUserExploreDir } = vi.hoisted(() => ({
mockExistsSync: vi.fn<(candidate: string) => boolean>(),
mockGetUserExploreDir: vi.fn((site: string) => `/mock-home/.opencli/explore/${site}`),
}));

vi.mock('node:fs', async () => {
const actual = await vi.importActual<typeof import('node:fs')>('node:fs');
return {
...actual,
existsSync: mockExistsSync,
};
});

vi.mock('./user-opencli-paths.js', async () => {
const actual = await vi.importActual<typeof import('./user-opencli-paths.js')>('./user-opencli-paths.js');
return {
...actual,
getUserExploreDir: mockGetUserExploreDir,
};
});

import { resolveExploreDir } from './synthesize.js';

describe('resolveExploreDir', () => {
afterEach(() => {
vi.clearAllMocks();
});

it('returns the explicit target path when it already exists', () => {
mockExistsSync.mockImplementation((candidate) => candidate === '/tmp/explore-dir');

expect(resolveExploreDir('/tmp/explore-dir')).toBe('/tmp/explore-dir');
expect(mockExistsSync).toHaveBeenCalledWith('/tmp/explore-dir');
});

it('prefers ~/.opencli explore artifacts before the legacy cwd fallback', () => {
const target = 'mysite';
const homeCandidate = `/mock-home/.opencli/explore/${target}`;
const cwdCandidate = path.join('.opencli', 'explore', target);
mockExistsSync.mockImplementation((candidate) => candidate === homeCandidate || candidate === cwdCandidate);

expect(resolveExploreDir(target)).toBe(homeCandidate);
expect(mockGetUserExploreDir).toHaveBeenCalledWith(target);
});

it('falls back to the legacy cwd artifact path when the home artifact is missing', () => {
const target = 'mysite';
const cwdCandidate = path.join('.opencli', 'explore', target);
mockExistsSync.mockImplementation((candidate) => candidate === cwdCandidate);

expect(resolveExploreDir(target)).toBe(cwdCandidate);
});

it('shows the improved error hint when no explore artifact exists', () => {
mockExistsSync.mockReturnValue(false);

expect(() => resolveExploreDir('missing-site')).toThrowError(
'Explore directory not found: missing-site. If artifacts were created elsewhere, pass the full path.',
);
});
});
Loading