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
1 change: 1 addition & 0 deletions cli/.justfile
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ setup:
build: setup
npx tsc
cp src/viewer/index.html src/viewer/viewer.css src/viewer/empty-plugin-styles.css dist/viewer/
cp src/agents.template.md dist/
npx oclif manifest

# Run static checks
Expand Down
85 changes: 85 additions & 0 deletions cli/src/agents.template.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# Workday Everywhere Plugin — Agent Guide

This project is a **Workday Everywhere (WE) plugin** built with the `@workday/everywhere` SDK.

## Example applications

Reference implementations are available in the SDK repository:

- **hello** — minimal single-page plugin:
https://github.com/Workday/everywhere/tree/main/examples/hello
- **directory** — multi-page plugin with routes and data access:
https://github.com/Workday/everywhere/tree/main/examples/directory

## Entry point

`plugin.tsx` must default-export a `PluginDefinition` created with `plugin()`:

```typescript
import { plugin, route } from '@workday/everywhere';
export default plugin({ defaultRoute, routes, provider });
```

## Routes and navigation

Define routes with `route()`. Add a type parameter to enable type-safe params:

```typescript
const list = route('list', { component: ListPage });
const detail = route<{ id: string }>('detail', { component: DetailPage });
```

Navigate between routes with `useNavigate()` and read params with `useParams()`:

```typescript
import { useNavigate, useParams } from '@workday/everywhere';
const navigate = useNavigate();
navigate(detail, { id: '123' });
const { id } = useParams(detail);
```

## Data layer

This plugin runs inside a **data provider** context. Data access goes through a `DataResolver`
wrapped in `DataProvider`. Pass the provider to `plugin()` so every page can access it:

```typescript
import { DataProvider, GraphQLResolver } from '@workday/everywhere';
import { schema } from './everywhere/data/schema.js';

const resolver = new GraphQLResolver('your-app-referenceId', schema);

function Provider({ children }: { children: React.ReactNode }) {
return <DataProvider resolver={resolver}>{children}</DataProvider>;
}

export default plugin({ defaultRoute, routes, provider: Provider });
```

Data hooks are auto-generated by `npx everywhere bind` into `everywhere/data/` and follow this
pattern:

```typescript
import { useEmployees } from './everywhere/data/Employee.js';
const { data, loading, error } = useEmployees();
```

## Peer dependencies

`react` and `react-dom` (>=18) are **peer dependencies** — they are provided by the host environment
and must not be bundled with the plugin.

## Import conventions

All local imports must use `.js` extensions, even in `.ts`/`.tsx` source files:

```typescript
import { helper } from './utils.js'; // correct
import { helper } from './utils'; // incorrect — omitting .js breaks ESM resolution
```

## CLI reference

- `npx everywhere dev` — start the dev server with hot reload
- `npx everywhere build` — build the plugin bundle
- `npx everywhere bind <bundle>` — generate typed data hooks from a business object bundle
19 changes: 19 additions & 0 deletions cli/src/commands/everywhere/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,17 @@ export function writeTsConfigIfAbsent(pluginDir: string): boolean {
return true;
}

const AGENTS_TEMPLATE_PATH = path.resolve(THIS_DIR, '../../agents.template.md');

export function writeAgentsMdIfAbsent(pluginDir: string): boolean {
const agentsPath = path.join(pluginDir, 'AGENTS.md');
if (fs.existsSync(agentsPath)) {
return false;
}
fs.copyFileSync(AGENTS_TEMPLATE_PATH, agentsPath);
return true;
}

export function runNpmInstall(cwd: string): Promise<void> {
return new Promise((resolve, reject) => {
const child = spawn(NPM_BIN, ['install'], {
Expand Down Expand Up @@ -268,6 +279,14 @@ export default class InitCommand extends EverywhereBaseCommand {
this.log(`tsconfig.json already exists, skipping (${chalk.cyan(tsConfigPath)})`);
}

// Mutation 4: write AGENTS.md if not already present
const agentsPath = path.join(pluginDir, 'AGENTS.md');
if (writeAgentsMdIfAbsent(pluginDir)) {
this.log(chalk.green('Created AGENTS.md'));
} else if (verbose) {
this.log(`AGENTS.md already exists, skipping (${chalk.cyan(agentsPath)})`);
}

// Run npm install
this.log('Installing dependencies...');
await runNpmInstall(pluginDir);
Expand Down
46 changes: 46 additions & 0 deletions cli/tests/commands/everywhere/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import * as fs from 'node:fs';
import InitCommand, {
resolveTypeDevDependencies,
writeTsConfigIfAbsent,
writeAgentsMdIfAbsent,
} from '../../../src/commands/everywhere/init.js';
import EverywhereBaseCommand from '../../../src/lib/command.js';

Expand Down Expand Up @@ -330,6 +331,51 @@ describe('writeTsConfigIfAbsent', () => {
});
});

describe('writeAgentsMdIfAbsent', () => {
let tmpDir: string;

beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'we-init-test-'));
});

afterEach(() => {
fs.rmSync(tmpDir, { recursive: true });
});

describe('when AGENTS.md does not exist', () => {
it('writes AGENTS.md to the directory', () => {
writeAgentsMdIfAbsent(tmpDir);
expect(fs.existsSync(path.join(tmpDir, 'AGENTS.md'))).toBe(true);
});

it('writes non-empty content', () => {
writeAgentsMdIfAbsent(tmpDir);
const content = fs.readFileSync(path.join(tmpDir, 'AGENTS.md'), 'utf-8');
expect(content.length).toBeGreaterThan(0);
});

it('returns true', () => {
expect(writeAgentsMdIfAbsent(tmpDir)).toBe(true);
});
});

describe('when AGENTS.md already exists', () => {
beforeEach(() => {
fs.writeFileSync(path.join(tmpDir, 'AGENTS.md'), '# existing\n');
});

it('does not overwrite the existing file', () => {
writeAgentsMdIfAbsent(tmpDir);
const content = fs.readFileSync(path.join(tmpDir, 'AGENTS.md'), 'utf-8');
expect(content).toBe('# existing\n');
});

it('returns false', () => {
expect(writeAgentsMdIfAbsent(tmpDir)).toBe(false);
});
});
});

describe('runNpmInit', () => {
beforeEach(() => {
vi.resetModules();
Expand Down
78 changes: 78 additions & 0 deletions cli/tests/init-template.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
import { describe, it, expect } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { fileURLToPath } from 'node:url';
import { renderStub, renderTsConfig } from '../src/init-template.js';

const TEMPLATE_PATH = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'../src/agents.template.md'
);

describe('renderStub', () => {
describe('when called with a name', () => {
it('returns a string', () => {
Expand Down Expand Up @@ -104,3 +112,73 @@ describe('renderTsConfig', () => {
});
});
});

describe('agents.template.md', () => {
let content: string;

describe('when read from disk', () => {
it('exists', () => {
expect(fs.existsSync(TEMPLATE_PATH)).toBe(true);
});

it('ends with a newline', () => {
content = fs.readFileSync(TEMPLATE_PATH, 'utf-8');
expect(content.endsWith('\n')).toBe(true);
});
});

describe('project context', () => {
it('identifies this as a Workday Everywhere plugin project', () => {
content = fs.readFileSync(TEMPLATE_PATH, 'utf-8');
expect(content).toContain('Workday Everywhere');
});

it('names the entry point file', () => {
content = fs.readFileSync(TEMPLATE_PATH, 'utf-8');
expect(content).toContain('plugin.tsx');
});
});

describe('data provider guidance', () => {
it('mentions DataProvider', () => {
content = fs.readFileSync(TEMPLATE_PATH, 'utf-8');
expect(content).toContain('DataProvider');
});

it('mentions DataResolver', () => {
content = fs.readFileSync(TEMPLATE_PATH, 'utf-8');
expect(content).toContain('DataResolver');
});
});

describe('peer dependency guidance', () => {
it('mentions react as a peer dependency', () => {
content = fs.readFileSync(TEMPLATE_PATH, 'utf-8');
expect(content.toLowerCase()).toContain('peer');
});

it('mentions react', () => {
content = fs.readFileSync(TEMPLATE_PATH, 'utf-8');
expect(content).toContain('react');
});
});

describe('import convention guidance', () => {
it('calls out the .js extension requirement', () => {
content = fs.readFileSync(TEMPLATE_PATH, 'utf-8');
expect(content).toContain('.js');
});
});

describe('example references', () => {
it('links to the hello example', () => {
content = fs.readFileSync(TEMPLATE_PATH, 'utf-8');
expect(content).toContain('examples/hello');
});

it('links to the directory example', () => {
content = fs.readFileSync(TEMPLATE_PATH, 'utf-8');
expect(content).toContain('examples/directory');
});
});
});