scripts/COMPLETIONS.md
Karolis2011 df5df0ea92 Add remote script loader with intelligent completions
Features:
- Remote script execution with version control (name@ref)
- Background daemon for fast completions (< 15ms)
- HTTP caching with ETag/Last-Modified support
- Context-aware completions with async support
- Comprehensive documentation and type definitions

Changes:
- Add loader.ts with daemon mode
- Add completions.d.ts type definitions
- Add COMPLETIONS.md guide for developers
- Add README.md with usage examples
- Update configure_ssh_key.ts with async completions
- Use JSR imports for remote execution compatibility
2025-11-29 23:22:35 +02:00

11 KiB

Script Completions Guide

This guide explains how to add intelligent shell completions to your scripts in the remote script loader system.

Overview

Scripts can export a getCompletions function that provides context-aware suggestions for command-line arguments. The loader daemon calls this function when users press TAB in their shell.

Quick Start

Add this to your script:

import type { CompletionContext } from "./completions.d.ts";

export function getCompletions(ctx: CompletionContext): string[] {
  return ["--help", "--version", "--verbose"];
}

Or use async for API calls or async operations:

import type { CompletionContext } from "./completions.d.ts";

export async function getCompletions(ctx: CompletionContext): Promise<string[]> {
  // Can now use await for async operations
  return ["--help", "--version", "--verbose"];
}

That's it! The loader will automatically discover and use your completion function.

The CompletionContext

Your function receives a context object with three properties:

interface CompletionContext {
  args: string[];      // All arguments provided so far
  current: string;     // The word being completed
  position: number;    // Position in the args array
}

Example Context

If the user types:

scripts my-script --flag1 value1 --fl<TAB>

Your function receives:

{
  args: ["--flag1", "value1"],
  current: "--fl",
  position: 2
}

Common Patterns

1. Basic Flag Completions

Return all available flags:

export function getCompletions(ctx: CompletionContext): string[] {
  return [
    "--input=",
    "--output=",
    "--verbose",
    "--help"
  ];
}

2. Value Completions

Suggest values for flags with =:

export function getCompletions(ctx: CompletionContext): string[] {
  const { current } = ctx;
  
  if (current.startsWith("--format=")) {
    return ["--format=json", "--format=yaml", "--format=xml"];
  }
  
  if (current.startsWith("--level=")) {
    return ["--level=debug", "--level=info", "--level=warn", "--level=error"];
  }
  
  return ["--format=", "--level=", "--help"];
}

3. Avoiding Duplicate Flags

Filter out already-used flags:

export function getCompletions(ctx: CompletionContext): string[] {
  const { args } = ctx;
  
  // Check what's already provided
  const hasVerbose = args.includes("--verbose");
  const hasOutput = args.some(a => a.startsWith("--output"));
  
  const flags = [];
  if (!hasVerbose) flags.push("--verbose");
  if (!hasOutput) flags.push("--output=");
  flags.push("--help"); // Help can always be shown
  
  return flags;
}

4. Mutually Exclusive Options

export function getCompletions(ctx: CompletionContext): string[] {
  const { args } = ctx;
  
  const hasJson = args.includes("--json");
  const hasYaml = args.includes("--yaml");
  
  // Don't suggest both formats at once
  if (hasJson) {
    return ["--verbose", "--help"]; // No --yaml
  }
  if (hasYaml) {
    return ["--verbose", "--help"]; // No --json
  }
  
  return ["--json", "--yaml", "--verbose", "--help"];
}

5. Filesystem Completions

Discover files dynamically:

export function getCompletions(ctx: CompletionContext): string[] {
  const { current } = ctx;
  
  if (current.startsWith("--config=")) {
    try {
      const home = Deno.env.get("HOME") || "";
      const configDir = `${home}/.config/myapp`;
      const configs: string[] = [];
      
      for (const entry of Deno.readDirSync(configDir)) {
        if (entry.isFile && entry.name.endsWith(".json")) {
          configs.push(`--config=${entry.name}`);
        }
      }
      
      return configs.length > 0 ? configs : ["--config=config.json"];
    } catch {
      return ["--config=config.json"];
    }
  }
  
  return ["--config=", "--help"];
}

6. Environment-Based Completions

export function getCompletions(ctx: CompletionContext): string[] {
  const { current } = ctx;
  
  if (current.startsWith("--user=")) {
    // Suggest current user
    const currentUser = Deno.env.get("USER") || "user";
    return [`--user=${currentUser}`];
  }
  
  if (current.startsWith("--home=")) {
    const home = Deno.env.get("HOME") || "";
    return [`--home=${home}`];
  }
  
  return ["--user=", "--home=", "--help"];
}

7. Async Completions with API Calls

export async function getCompletions(ctx: CompletionContext): Promise<string[]> {
  const { current } = ctx;
  
  if (current.startsWith("--repo=")) {
    try {
      // Fetch repositories from GitHub API
      const response = await fetch("https://api.github.com/user/repos", {
        headers: {
          "Authorization": `token ${Deno.env.get("GITHUB_TOKEN")}`
        }
      });
      
      if (response.ok) {
        const repos = await response.json();
        return repos.map((r: { name: string }) => `--repo=${r.name}`);
      }
    } catch {
      // Fallback on error
    }
    return ["--repo=<repository-name>"];
  }
  
  return ["--repo=", "--help"];
}

Note: The loader daemon caches script modules, but your async operations run on each completion. For better performance, consider returning fast fallback values while keeping the function synchronous, or implement your own caching within the script.

8. Positional Argument Completions

export function getCompletions(ctx: CompletionContext): string[] {
  const { current, args, position } = ctx;
  
  // Get non-flag arguments
  const positionalArgs = args.filter(a => !a.startsWith("-"));
  
  // First positional: suggest commands
  if (positionalArgs.length === 0 && !current.startsWith("-")) {
    return ["start", "stop", "restart", "status"];
  }
  
  // Second positional: suggest targets
  if (positionalArgs.length === 1 && !current.startsWith("-")) {
    return ["production", "staging", "development"];
  }
  
  // Otherwise suggest flags
  return ["--force", "--verbose", "--help"];
}

Best Practices

Performance

  • Keep completion functions fast (< 50ms recommended)
  • Cache expensive operations if possible
  • Use async sparingly: Async completions can make UX slower
  • For API calls, implement caching or provide immediate fallback
  • Use try-catch for filesystem operations
export function getCompletions(ctx: CompletionContext): string[] {
  try {
    // Fast filesystem lookup
    const files = [...Deno.readDirSync(".")];
    return files.map(f => f.name);
  } catch {
    // Fallback to safe defaults
    return ["file.txt", "config.json"];
  }
}

User Experience

  • Return empty array if no completions available
  • Include both complete and partial suggestions
  • Add = suffix for flags that need values
  • Provide sensible defaults when dynamic lookup fails
export function getCompletions(ctx: CompletionContext): string[] {
  const { current } = ctx;
  
  // Partial match - suggests user needs to provide a value
  if (current.startsWith("--port=")) {
    return ["--port=3000", "--port=8080", "--port=8443"];
  }
  
  // Complete flags
  return ["--port=", "--host=", "--help"];
}

Documentation

  • Add JSDoc comments to your completion function
  • Document expected flag values
  • Explain any complex completion logic
/**
 * Provides completions for the configure script.
 * 
 * Supports:
 * - --key=<path>: SSH key paths from ~/.ssh/
 * - --port=<num>: Common SSH ports (22, 2222, 2200)
 * - --alias=<name>: Auto-generated from target
 */
export function getCompletions(ctx: CompletionContext): string[] {
  // ... implementation
}

Testing Completions Locally

Test your completion function locally before pushing:

# Import and test directly
deno eval '
const m = await import("file:///path/to/your/script.ts");
const result = m.getCompletions({
  args: ["--flag1"],
  current: "--",
  position: 1
});
console.log(result.join("\n"));
'

Caching and Performance

The loader daemon caches:

  • Script modules: 5 minutes (respects HTTP Cache-Control)
  • HTTP responses: Uses ETag/Last-Modified for conditional requests
  • Metadata (refs, script lists): 60 seconds

Your completion function is called on every TAB press, but the script import is cached, so only your function execution time matters.

Advanced Example

Complete example from configure_ssh_key.ts:

import type { CompletionContext } from "./completions.d.ts";

export function getCompletions(ctx: CompletionContext): string[] {
  const { current, args } = ctx;
  
  // Complete SSH key paths
  if (current.startsWith("--key=")) {
    try {
      const home = Deno.env.get("HOME") || "";
      const sshDir = `${home}/.ssh`;
      const keys: string[] = [];
      
      for (const entry of Deno.readDirSync(sshDir)) {
        if (entry.isFile && !entry.name.endsWith(".pub") && 
            (entry.name.startsWith("id_") || entry.name.includes("key"))) {
          keys.push(`--key=~/.ssh/${entry.name}`);
        }
      }
      
      return keys.length > 0 ? keys : ["--key=~/.ssh/id_rsa"];
    } catch {
      return ["--key=~/.ssh/id_rsa", "--key=~/.ssh/id_ed25519"];
    }
  }
  
  // Smart alias based on target
  if (current.startsWith("--alias=")) {
    const target = args.find(a => !a.startsWith("-"));
    if (target && target.includes("@")) {
      const [user, host] = target.split("@");
      const hostPart = host.split(":")[0];
      return [`--alias=${user}-${hostPart.replace(/\./g, "-")}`];
    }
    return ["--alias=<host-alias>"];
  }
  
  // Common ports
  if (current.startsWith("--port=")) {
    return ["--port=22", "--port=2222", "--port=2200"];
  }
  
  // Track what's been used
  const hasGenerate = args.includes("--generate");
  const hasKey = args.some(a => a.startsWith("--key"));
  
  const flags: string[] = [];
  
  // Mutually exclusive: --key and --generate
  if (!hasGenerate && !hasKey) {
    flags.push("--key=", "--generate");
  }
  
  flags.push("--alias=", "--port=", "--force", "--dry-run", "--help");
  
  return flags;
}

Troubleshooting

Completions not working?

  1. Check script exports getCompletions:

    export function getCompletions(ctx: CompletionContext): string[] { ... }
    
  2. Verify the function signature matches:

    (ctx: { args: string[]; current: string; position: number }) => string[]
    
  3. Test locally with the eval command above

  4. Check daemon logs: cat /tmp/daemon.log

Completions are slow?

  • Profile your function execution time
  • Avoid expensive I/O operations
  • Cache results when possible
  • Return early for invalid states

Completions are incorrect?

  • Log the context to understand what's being passed:
    export function getCompletions(ctx: CompletionContext): string[] {
      console.error("DEBUG:", JSON.stringify(ctx));
      return [...];
    }
    
  • Check daemon logs for your debug output

Type Checking

Use the provided type definitions:

import type { CompletionContext, CompletionFunction } from "./completions.d.ts";

export const getCompletions: CompletionFunction = (ctx) => {
  // TypeScript will enforce correct parameter and return types
  return ["--help"];
};