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
5 changes: 5 additions & 0 deletions .changeset/add-remote-project-serve.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@inkeep/open-knowledge': patch
---

Open projects from saved macOS or Linux SSH machines in the OpenKnowledge desktop app, including remote browsing, editing, search, and terminals over a loopback-only SSH connection. Desktop installs its matching remote support automatically in the SSH user's home directory with no global `ok` install, `sudo`, or PATH changes, shows saved remotes from the SSH machine box, and asks before initializing a selected folder.
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
Highlights:
- Full true **WYSIWYG** so that editing markdown files feels like editing a Google Doc or Notion page.
- **macOS app** and **web UI** with file navigator, search, tabs, graph wiki link viewer, and more.
- **Remote projects over SSH** in the macOS app, with no separate web-editor tunnel to manage.
- Integrated **side-by-side AI-editing** with **Claude, Codex, OpenCode, Pi and others**. Can be used with any harness/agent via MCP/CLI.
- Out-of-the-box **MCP**, **skills**, and **agentic search** for LLM Wikis, second brains, and knowledge graphs.
- No-code **Team sharing** and **Auto-sync** powered by git/GitHub under the hood.
Expand Down Expand Up @@ -68,6 +69,14 @@ Either way, the app will walk you through installing the MCP and skills for agen

Git/GitHub based sync and sharing can optionally be enabled.

### Remote projects over SSH

The macOS app can open a project directly from a remote macOS or Linux machine with a POSIX-compatible login shell. The remote machine needs Node.js 24 or newer and Git 2.31.0 or newer. You do not need to install the `ok` CLI on the SSH machine: Desktop installs its matching, versioned remote support under the SSH user's `~/.ok/remote` directory without `sudo` or PATH changes. OpenKnowledge inspects the selected folder before changing it; if it is not initialized, the app shows the exact folder and project files and asks you to confirm before creating `.ok/config.yml`, `.ok/.gitignore`, and `.okignore`.

Make sure the machine is reachable non-interactively through your system SSH config or SSH agent. In the app's project Navigator, choose **Open over SSH**, add the SSH host or config alias, and select the remote folder.

OpenKnowledge starts its bundled support process on the remote loopback interface and reaches it through a local SSH tunnel. The project server is not exposed on the remote machine's network, and the app does not store passwords or private keys.

Docs for general usage: <https://openknowledge.ai/docs>.

## Contributions
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
*
* Runs under `bun run test:dom` (jsdom substrate per precedent #43).
*/
import { afterEach, describe, expect, mock, test } from 'bun:test';
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
import { cleanup, fireEvent, render } from '@testing-library/react';

// Stub dispatchAssetClick so the button's onClick does not attempt a
Expand All @@ -46,9 +46,14 @@ mock.module('@/lib/config-provider', () => ({
const { AssetPreview } = await import('./AssetPreview.tsx');

describe('AssetPreview — text-viewer dispatch', () => {
beforeEach(() => {
Reflect.deleteProperty(window, 'okDesktop');
});

afterEach(() => {
cleanup();
dispatchAssetClickStub.mockClear();
Reflect.deleteProperty(window, 'okDesktop');
});

test('mediaKind=text on a json asset mounts TextViewer (not the fallback)', () => {
Expand Down Expand Up @@ -101,6 +106,34 @@ describe('AssetPreview — text-viewer dispatch', () => {
});
});

test('remote assets keep the in-app text view and hide the local native-open action', () => {
Object.defineProperty(window, 'okDesktop', {
configurable: true,
value: {
config: {
remote: {
kind: 'ssh',
machineId: 'machine-1',
machineName: 'Build box',
path: '/srv/knowledge',
platform: 'linux',
pathSeparator: '/',
},
apiOrigin: 'http://127.0.0.1:43123',
},
},
});

const { container } = render(<AssetPreview assetPath="docs/report.docx" mediaKind={null} />);
expect(container.querySelector('[data-testid="asset-preview-open-as-text"]')).not.toBeNull();
expect(
Array.from(container.querySelectorAll('button')).find((button) =>
/open file/i.test(button.textContent ?? ''),
),
).toBeUndefined();
expect(dispatchAssetClickStub).not.toHaveBeenCalled();
});

test('clicking "View as text" flips into the text branch', () => {
const { container } = render(<AssetPreview assetPath="docs/report.pdf" mediaKind={null} />);
const btn = container.querySelector(
Expand Down
36 changes: 20 additions & 16 deletions packages/app/src/components/AssetPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ function AssetPreviewBody({ assetPath, mediaKind }: AssetPreviewProps) {
// so the user can override an image / video / pdf / no-viewer asset
// into the text-editor pane and back via the assetPath-keyed reset.
const effectiveMediaKind: InlineAssetMediaKind | null = forceText ? 'text' : mediaKind;
const canOpenWithLocalApplication =
typeof window === 'undefined' || window.okDesktop?.config.remote == null;

// PDFs render edge-to-edge via the bundled `<Pdf>` viewer — toolbar +
// page-scroll fill the available height. `fillContainer` makes the
Expand Down Expand Up @@ -176,22 +178,24 @@ function AssetPreviewBody({ assetPath, mediaKind }: AssetPreviewProps) {
>
<Trans>View as text</Trans>
</Button>
<Button
variant="outline"
size="sm"
className="font-mono uppercase"
onClick={() => {
void dispatchAssetClick({
url: src,
projectRelPath: assetPath,
ext: rawExtension.toLowerCase(),
title: fileName,
forceOsDelegation: false,
});
}}
>
<Trans>Open file</Trans>
</Button>
{canOpenWithLocalApplication ? (
<Button
variant="outline"
size="sm"
className="font-mono uppercase"
onClick={() => {
void dispatchAssetClick({
url: src,
projectRelPath: assetPath,
ext: rawExtension.toLowerCase(),
title: fileName,
forceOsDelegation: false,
});
}}
>
<Trans>Open file</Trans>
</Button>
) : null}
</div>
</div>
)}
Expand Down
55 changes: 55 additions & 0 deletions packages/app/src/components/CommandPalette.dom.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,28 @@ describe('CommandPalette DOM behavior', () => {
);
});

test('uses the SSH server icon for a remote recent project', async () => {
const bridge = createBridge();
bridge.project.listRecent = mock(() =>
Promise.resolve([
{
name: 'Remote Notes',
path: 'ssh:machine-1:%2Fsrv%2Fnotes',
lastOpenedAt: '2026-07-14T00:00:00.000Z',
remote: {
machineId: 'machine-1',
machineName: 'Build box',
path: '/srv/notes',
},
},
]),
);

await renderPalette({ bridge });
const row = await screen.findByTestId('command-palette-recent-ssh:machine-1:%2Fsrv%2Fnotes');
expect(row.querySelector('svg.lucide-server')).not.toBeNull();
});

test('settings command is searchable by preferences/config, closes the palette, and routes through the canonical hash', async () => {
const { onOpenChange } = await renderPalette({ bridge: null });

Expand Down Expand Up @@ -641,6 +663,39 @@ describe('CommandPalette DOM behavior', () => {
});
expect(refreshWorktreesMock).toHaveBeenCalled();
});

test('does not expose local worktrees from an SSH-backed project window', async () => {
worktreeModelMock = {
mainRoot: '/projects/current',
currentBranch: 'main',
entries: [
{
branch: 'local-only',
worktreePath: '/projects/current/.ok/worktrees/local-only',
isCurrent: false,
isMain: false,
locked: false,
},
],
};
const bridge = createBridge();
Object.assign(bridge.config, {
projectPath: 'ssh:machine-1:%2Fsrv%2Fknowledge',
remote: {
kind: 'ssh',
machineId: 'machine-1',
machineName: 'Build box',
path: '/srv/knowledge',
platform: 'linux',
pathSeparator: '/',
},
});

await renderPalette({ bridge });

expect(screen.queryByTestId('command-palette-worktree-local-only')).toBeNull();
expect(screen.queryByRole('heading', { name: 'Worktrees' })).toBeNull();
});
});

describe('NavigationItem path subtitle', () => {
Expand Down
23 changes: 14 additions & 9 deletions packages/app/src/components/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
Network,
Package,
Plus,
Server,
Settings,
Sparkles,
} from 'lucide-react';
Expand Down Expand Up @@ -97,7 +98,7 @@ import { cn } from '@/lib/utils.ts';
import { refreshWorktrees } from '@/lib/worktree-store';
import { buildHandoffInput, useHandoffDispatch } from './handoff/useHandoffDispatch';
import { useInstalledAgents } from './handoff/useInstalledAgents';
import { basenameOf } from './project-switcher-recents';
import { basenameOf, recentProjectDisplayPath } from './project-switcher-recents';

const COMMAND_PALETTE_SEARCH_TIMEOUT_MS = 3000;
// Re-poll cadence while the server reports the search index is still warming
Expand Down Expand Up @@ -439,7 +440,7 @@ export function CommandPalette({ bridge = null, open, onOpenChange }: CommandPal
// in switching to yourself. `null` off-desktop / until the first fetch lands.
const worktreeModel = useWorktrees();
const switchableWorktrees =
bridge && worktreeModel
bridge && bridge.config.remote == null && worktreeModel
? worktreeModel.entries.filter((entry) => entry.branch !== null && !entry.isCurrent)
: [];
const initialCreateDir = resolveCreateInitialDir(activeTarget, activeDocName);
Expand Down Expand Up @@ -856,7 +857,9 @@ export function CommandPalette({ bridge = null, open, onOpenChange }: CommandPal
switchableProjects.length > 0 &&
(trimmedDeferredQuery === '' ||
switchableProjects.some((row) =>
matchesCommandQuery(`${row.name} ${row.path}`, deferredQuery, ['open recent project']),
matchesCommandQuery(`${row.name} ${recentProjectDisplayPath(row)}`, deferredQuery, [
'open recent project',
]),
));
const matchedWorktrees = switchableWorktrees.filter(
(entry) =>
Expand Down Expand Up @@ -1568,9 +1571,11 @@ export function CommandPalette({ bridge = null, open, onOpenChange }: CommandPal
<CommandGroup heading={t`Open recent project`}>
{switchableProjects
.filter((row) =>
matchesCommandQuery(`${row.name} ${row.path}`, deferredQuery, [
'open recent project',
]),
matchesCommandQuery(
`${row.name} ${recentProjectDisplayPath(row)}`,
deferredQuery,
['open recent project'],
),
)
.slice(0, 10)
.map((row) => {
Expand All @@ -1579,13 +1584,13 @@ export function CommandPalette({ bridge = null, open, onOpenChange }: CommandPal
// a branch; every project uses the same plain folder. The
// base-project note names the repo a worktree belongs to
// (e.g. "worktree of pnw-fishing").
const RowIcon = isWorktree ? GitBranch : Folder;
const RowIcon = isWorktree ? GitBranch : row.remote ? Server : Folder;
const worktreeOf =
isWorktree && row.mainRoot !== undefined ? basenameOf(row.mainRoot) : null;
return (
<CommandItem
key={row.path}
value={`${row.name} ${row.path} recent project`}
value={`${row.name} ${recentProjectDisplayPath(row)} recent project`}
disabled={row.missing}
onSelect={() =>
runAction(
Expand All @@ -1610,7 +1615,7 @@ export function CommandPalette({ bridge = null, open, onOpenChange }: CommandPal
</span>
) : null}
<span className="truncate text-muted-foreground text-xs">
{row.path}
{recentProjectDisplayPath(row)}
{row.missing ? (
<>
{' '}
Expand Down
36 changes: 34 additions & 2 deletions packages/app/src/components/FileSidebar.dom.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,24 +103,43 @@ const projectLocalPatch = mock((_patch: unknown) => projectPatchResult);
const showItemInFolderMock = mock((_path: string) => Promise.resolve());
const notifyViewMenuStateChangedMock = mock((_snapshot: unknown) => {});
const onOpenSearch = mock(() => {});
let menuActionListener: ((action: string) => void) | null = null;

function setFolderState(next: FolderState) {
folderState = next;
for (const listener of treeListeners) listener();
}

function installBridge() {
function installBridge({ remote = false }: { remote?: boolean } = {}) {
Object.defineProperty(window, 'okDesktop', {
configurable: true,
value: {
platform: 'darwin',
config: {
projectPath: remote ? 'ssh:machine-1:%2Fsrv%2Fwiki' : '/tmp/open-knowledge',
remote: remote
? {
machineId: 'machine-1',
machineLabel: 'Build box',
projectPath: '/srv/wiki',
}
: undefined,
},
editor: {
notifyViewMenuStateChanged: notifyViewMenuStateChangedMock,
},
project: {
listRecent: () => Promise.resolve([{ path: '/tmp/another-project' }]),
},
shell: {
showItemInFolder: showItemInFolderMock,
},
onMenuAction: () => () => {},
onMenuAction: (listener: (action: string) => void) => {
menuActionListener = listener;
return () => {
if (menuActionListener === listener) menuActionListener = null;
};
},
},
});
}
Expand Down Expand Up @@ -442,6 +461,7 @@ describe('FileSidebar runtime behavior', () => {
toastSuccesses = [];
toastErrors = [];
pillRenderErrors = [];
menuActionListener = null;
treeListeners.clear();
for (const fn of [
treeCalls.collapseAll,
Expand Down Expand Up @@ -782,6 +802,18 @@ describe('FileSidebar runtime behavior', () => {
});
});

test('SSH projects suppress Finder-only actions from renderer and native menus', async () => {
installBridge({ remote: true });
await renderSidebar();

expect(screen.queryByTestId('empty-space-menu-reveal-in-finder')).toBeNull();
expect(screen.getByTestId('empty-space-menu-copy-full-path')).toBeTruthy();
await waitFor(() => expect(menuActionListener).not.toBeNull());

act(() => menuActionListener?.('reveal-in-finder'));
expect(showItemInFolderMock).not.toHaveBeenCalled();
});

test('empty-space visibility toggles carry full-form labels and patch their own leaves', async () => {
mergedConfig = { appearance: { sidebar: { showOnlyMarkdownFiles: true } } };
await renderSidebar();
Expand Down
4 changes: 2 additions & 2 deletions packages/app/src/components/FileSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -576,7 +576,7 @@ function FileSidebarInner({ onOpenSearch }: FileSidebarProps) {
return;
}
case 'reveal-in-finder': {
if (!bridge || !workspace) return;
if (!bridge || !workspace || bridge.config.remote) return;
const absPath = resolveActiveTargetAbsPath(activeTarget, workspace);
void bridge.shell.showItemInFolder(absPath);
return;
Expand Down Expand Up @@ -1215,7 +1215,7 @@ function FileSidebarInner({ onOpenSearch }: FileSidebarProps) {
<Trans>New folder</Trans>
</ContextMenuItem>
<ContextMenuSeparator />
{bridge ? (
{bridge && !bridge.config.remote ? (
<ContextMenuItem
disabled={!workspace}
onSelect={handleEmptySpaceReveal}
Expand Down
Loading